summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitmessage26
-rw-r--r--.tasks/tasks.jsonl48
-rw-r--r--AGENTS.md39
-rw-r--r--Biz/PodcastItLater/Admin.py23
-rw-r--r--Biz/PodcastItLater/Core.py182
-rw-r--r--Biz/PodcastItLater/Test.py49
-rw-r--r--Biz/PodcastItLater/TestMetricsView.py48
-rw-r--r--Biz/PodcastItLater/UI.py704
-rw-r--r--Biz/PodcastItLater/Web.py607
-rw-r--r--Biz/PodcastItLater/Worker.py144
-rw-r--r--Omni/Agent/Log.hs96
-rw-r--r--Omni/Agent/LogTest.hs78
-rw-r--r--Omni/Agent/Worker.hs109
-rwxr-xr-xOmni/Bild/Audit.py176
-rw-r--r--Omni/Task.hs32
-rw-r--r--Omni/Task/Core.hs56
-rw-r--r--worker.log29
17 files changed, 1826 insertions, 620 deletions
diff --git a/.gitmessage b/.gitmessage
new file mode 100644
index 0000000..1eb44e6
--- /dev/null
+++ b/.gitmessage
@@ -0,0 +1,26 @@
+
+# Summarize change in 50 characters or less
+#
+# More detailed explanatory text, if necessary. Wrap it to about 72
+# characters or so. In some contexts, the first line is treated as the
+# subject of the email and the rest of the text as the body. The
+# blank line separating the summary from the body is critical (unless
+# you omit the body entirely); various tools like `log`, `shortlog`
+# and `rebase` can get confused if you run the two together.
+#
+# Explain the problem that this commit solves. Focus on why you are
+# making this change as opposed to how (the code explains that).
+# Are there side effects or other unintuitive consequences of this
+# change? Here's the place to explain them.
+#
+# Further paragraphs come after blank lines.
+#
+# - Bullet points are okay, too
+#
+# - Typically a hyphen or asterisk is used for the bullet, preceded
+# by a single space, with blank lines in between, but conventions
+# vary here
+#
+# If applied, this commit will...
+# Why was this change made?
+# Any references to tickets, articles, etc?
diff --git a/.tasks/tasks.jsonl b/.tasks/tasks.jsonl
index 41789b7..4ceb9d7 100644
--- a/.tasks/tasks.jsonl
+++ b/.tasks/tasks.jsonl
@@ -29,7 +29,7 @@
{"taskCreatedAt":"2025-11-09T16:48:47.388960509Z","taskDependencies":[],"taskDescription":null,"taskId":"t-144eKR1","taskNamespace":"Biz/PodcastItLater.hs","taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Implement usage tracking and limits","taskType":"WorkTask","taskUpdatedAt":"2025-11-19T03:27:25.707745105Z"}
{"taskCreatedAt":"2025-11-09T16:48:47.589181852Z","taskDependencies":[],"taskDescription":null,"taskId":"t-144fAWn","taskNamespace":"Biz/PodcastItLater.hs","taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Add email notifications (transactional)","taskType":"WorkTask","taskUpdatedAt":"2025-11-13T01:35:54.519545888Z"}
{"taskCreatedAt":"2025-11-09T16:48:47.737218185Z","taskDependencies":[],"taskDescription":null,"taskId":"t-144gds4","taskNamespace":"Biz/PodcastItLater.hs","taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Migrate from SQLite to PostgreSQL","taskType":"WorkTask","taskUpdatedAt":"2025-11-13T01:35:54.70061831Z"}
-{"taskCreatedAt":"2025-11-09T16:48:47.887102357Z","taskDependencies":[],"taskDescription":null,"taskId":"t-144gQry","taskNamespace":"Biz/PodcastItLater.hs","taskParent":null,"taskPriority":"P2","taskStatus":"Review","taskTitle":"Create basic admin dashboard","taskType":"WorkTask","taskUpdatedAt":"2025-11-21T04:38:19.992989496Z"}
+{"taskCreatedAt":"2025-11-09T16:48:47.887102357Z","taskDependencies":[],"taskDescription":null,"taskId":"t-144gQry","taskNamespace":"Biz/PodcastItLater.hs","taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Create basic admin dashboard","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T13:50:19.733612558Z"}
{"taskCreatedAt":"2025-11-09T16:48:48.072927212Z","taskDependencies":[],"taskDescription":null,"taskId":"t-144hCMJ","taskNamespace":"Biz/PodcastItLater.hs","taskParent":null,"taskPriority":"P2","taskStatus":"Open","taskTitle":"Complete comprehensive test suite","taskType":"Epic","taskUpdatedAt":"2025-11-09T16:48:48.072927212Z"}
{"taskCreatedAt":"2025-11-09T17:48:34.522286485Z","taskDependencies":[],"taskDescription":null,"taskId":"t-17Z0069","taskNamespace":"Biz/PodcastItLater.hs","taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Fix Recent Episodes refresh to prepend instead of reload (interrupts audio playback)","taskType":"WorkTask","taskUpdatedAt":"2025-11-09T19:42:22.105902786Z"}
{"taskCreatedAt":"2025-11-09T22:19:27.303689497Z","taskDependencies":[],"taskDescription":null,"taskId":"t-1pIV0ZF","taskNamespace":"Biz/PodcastItLater.hs","taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Implement billing page UI component with pricing and upgrade options","taskType":"WorkTask","taskUpdatedAt":"2025-11-09T23:04:20.974801117Z"}
@@ -45,9 +45,9 @@
{"taskCreatedAt":"2025-11-13T16:32:17.411379982Z","taskDependencies":[],"taskDescription":null,"taskId":"t-12ZeUsG","taskNamespace":null,"taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Update success/cancel URLs to redirect to / instead of /billing","taskType":"WorkTask","taskUpdatedAt":"2025-11-13T16:36:41.808119038Z"}
{"taskCreatedAt":"2025-11-13T16:32:17.557115348Z","taskDependencies":[],"taskDescription":null,"taskId":"t-12Zfwnf","taskNamespace":null,"taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Remove 'Billing' button from navbar (paid users will use Stripe portal link in callout)","taskType":"WorkTask","taskUpdatedAt":"2025-11-13T16:34:44.628587871Z"}
{"taskCreatedAt":"2025-11-13T16:32:17.738052991Z","taskDependencies":[],"taskDescription":null,"taskId":"t-12ZghrB","taskNamespace":null,"taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Test the complete flow and verify all changes","taskType":"WorkTask","taskUpdatedAt":"2025-11-13T16:37:49.356932049Z"}
-{"taskCreatedAt":"2025-11-13T19:38:08.01779309Z","taskDependencies":[],"taskDescription":null,"taskId":"t-1f9RIzd","taskNamespace":"Biz/PodcastItLater.hs","taskParent":"t-1vIPJYG","taskPriority":"P2","taskStatus":"Review","taskTitle":"Account Management Page","taskType":"WorkTask","taskUpdatedAt":"2025-11-21T04:27:07.637122837Z"}
-{"taskCreatedAt":"2025-11-13T19:38:08.176692694Z","taskDependencies":[],"taskDescription":null,"taskId":"t-1f9SnU7","taskNamespace":"Biz/PodcastItLater.hs","taskParent":"t-1vIPJYG","taskPriority":"P2","taskStatus":"Review","taskTitle":"Queue Status Improvements","taskType":"WorkTask","taskUpdatedAt":"2025-11-21T04:30:19.474773695Z"}
-{"taskCreatedAt":"2025-11-13T19:38:08.37344762Z","taskDependencies":[],"taskDescription":null,"taskId":"t-1f9Td4U","taskNamespace":"Biz/PodcastItLater.hs","taskParent":"t-1vIPJYG","taskPriority":"P2","taskStatus":"Review","taskTitle":"Navbar Styling Cleanup","taskType":"WorkTask","taskUpdatedAt":"2025-11-21T04:43:03.725680217Z"}
+{"taskCreatedAt":"2025-11-13T19:38:08.01779309Z","taskDependencies":[],"taskDescription":null,"taskId":"t-1f9RIzd","taskNamespace":"Biz/PodcastItLater.hs","taskParent":"t-1vIPJYG","taskPriority":"P2","taskStatus":"Done","taskTitle":"Account Management Page","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T13:50:19.815116309Z"}
+{"taskCreatedAt":"2025-11-13T19:38:08.176692694Z","taskDependencies":[],"taskDescription":null,"taskId":"t-1f9SnU7","taskNamespace":"Biz/PodcastItLater.hs","taskParent":"t-1vIPJYG","taskPriority":"P2","taskStatus":"Done","taskTitle":"Queue Status Improvements","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T13:50:19.89665814Z"}
+{"taskCreatedAt":"2025-11-13T19:38:08.37344762Z","taskDependencies":[],"taskDescription":null,"taskId":"t-1f9Td4U","taskNamespace":"Biz/PodcastItLater.hs","taskParent":"t-1vIPJYG","taskPriority":"P2","taskStatus":"Done","taskTitle":"Navbar Styling Cleanup","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T13:50:19.977778598Z"}
{"taskCreatedAt":"2025-11-13T19:38:32.95559213Z","taskDependencies":[],"taskDescription":null,"taskId":"t-1fbym1M","taskNamespace":null,"taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Remove BLE001 noqa for bare Exception catches - use specific exceptions","taskType":"WorkTask","taskUpdatedAt":"2025-11-13T19:43:29.049855419Z"}
{"taskCreatedAt":"2025-11-13T19:38:33.139120541Z","taskDependencies":[],"taskDescription":null,"taskId":"t-1fbz7LV","taskNamespace":null,"taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Fix PLR0913 violations - refactor functions with too many parameters","taskType":"WorkTask","taskUpdatedAt":"2025-11-13T19:44:09.820023426Z"}
{"taskCreatedAt":"2025-11-13T19:38:33.309222802Z","taskDependencies":[],"taskDescription":null,"taskId":"t-1fbzQ1v","taskNamespace":null,"taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Extract format_duration utility to shared UI or Core module (used only in Web.py)","taskType":"WorkTask","taskUpdatedAt":"2025-11-13T19:45:49.402934404Z"}
@@ -55,8 +55,8 @@
{"taskCreatedAt":"2025-11-13T19:38:33.674140035Z","taskDependencies":[],"taskDescription":null,"taskId":"t-1fbBmXa","taskNamespace":null,"taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Review and fix type: ignore comments - improve type safety","taskType":"WorkTask","taskUpdatedAt":"2025-11-13T19:47:09.583640045Z"}
{"taskCreatedAt":"2025-11-13T19:38:33.85804778Z","taskDependencies":[],"taskDescription":null,"taskId":"t-1fbC8Nq","taskNamespace":null,"taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Remove PLR2004 magic number - use constant for month check","taskType":"WorkTask","taskUpdatedAt":"2025-11-13T19:47:45.120428021Z"}
{"taskCreatedAt":"2025-11-13T19:38:34.035597081Z","taskDependencies":[],"taskDescription":null,"taskId":"t-1fbCSZd","taskNamespace":null,"taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Implement cancel subscription functionality","taskType":"WorkTask","taskUpdatedAt":"2025-11-13T20:22:51.709672316Z"}
-{"taskCreatedAt":"2025-11-13T19:38:34.194926176Z","taskDependencies":[],"taskDescription":null,"taskId":"t-1fbDyr2","taskNamespace":"Biz/PodcastItLater.hs","taskParent":null,"taskPriority":"P2","taskStatus":"Review","taskTitle":"Implement delete account functionality","taskType":"WorkTask","taskUpdatedAt":"2025-11-21T04:57:46.437836107Z"}
-{"taskCreatedAt":"2025-11-13T19:38:34.384489707Z","taskDependencies":[],"taskDescription":null,"taskId":"t-1fbElKv","taskNamespace":"Biz/PodcastItLater.hs","taskParent":null,"taskPriority":"P2","taskStatus":"Review","taskTitle":"Implement change email address functionality","taskType":"WorkTask","taskUpdatedAt":"2025-11-21T05:06:38.53919732Z"}
+{"taskCreatedAt":"2025-11-13T19:38:34.194926176Z","taskDependencies":[],"taskDescription":null,"taskId":"t-1fbDyr2","taskNamespace":"Biz/PodcastItLater.hs","taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Implement delete account functionality","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T14:14:24.645486426Z"}
+{"taskCreatedAt":"2025-11-13T19:38:34.384489707Z","taskDependencies":[],"taskDescription":null,"taskId":"t-1fbElKv","taskNamespace":"Biz/PodcastItLater.hs","taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Implement change email address functionality","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T14:14:24.726951592Z"}
{"taskCreatedAt":"2025-11-13T19:38:34.561871604Z","taskDependencies":[],"taskDescription":null,"taskId":"t-1fbF5Tv","taskNamespace":null,"taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Add logout button to account page","taskType":"WorkTask","taskUpdatedAt":"2025-11-13T20:22:51.65796855Z"}
{"taskCreatedAt":"2025-11-13T19:38:34.777721397Z","taskDependencies":[],"taskDescription":null,"taskId":"t-1fbG02X","taskNamespace":null,"taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Replace Coming Soon placeholder with full account management UI","taskType":"WorkTask","taskUpdatedAt":"2025-11-13T20:22:51.606196024Z"}
{"taskCreatedAt":"2025-11-13T19:38:34.962196629Z","taskDependencies":[],"taskDescription":null,"taskId":"t-1fbGM2m","taskNamespace":null,"taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Add remove button to queue status items","taskType":"WorkTask","taskUpdatedAt":"2025-11-13T20:20:10.941908917Z"}
@@ -120,10 +120,10 @@
{"taskCreatedAt":"2025-11-20T18:44:29.330834039Z","taskDependencies":[{"depId":"t-Uumhrq","depType":"DiscoveredFrom"}],"taskDescription":null,"taskId":"t-1bE2r3q","taskNamespace":"Omni/Task.hs","taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Document TASK_TEST_MODE in AGENTS.md","taskType":"WorkTask","taskUpdatedAt":"2025-11-20T18:53:22.852670919Z"}
{"taskCreatedAt":"2025-11-20T19:46:53.636713383Z","taskDependencies":[],"taskDescription":null,"taskId":"t-1fJra3K","taskNamespace":"Omni/Bild.hs","taskParent":null,"taskPriority":"P1","taskStatus":"Done","taskTitle":"Fix bild --plan to output only JSON without logging","taskType":"WorkTask","taskUpdatedAt":"2025-11-20T19:51:46.854882315Z"}
{"taskCreatedAt":"2025-11-20T21:41:12.7461675Z","taskDependencies":[],"taskDescription":null,"taskId":"t-1ndDhLo","taskNamespace":"Biz/PodcastItLater.hs","taskParent":"t-143KQl2","taskPriority":"P2","taskStatus":"Done","taskTitle":"PodcastItLater: Add Pricing Page UI","taskType":"WorkTask","taskUpdatedAt":"2025-11-21T00:25:09.131891321Z"}
-{"taskCreatedAt":"2025-11-20T21:41:12.764720659Z","taskDependencies":[],"taskDescription":null,"taskId":"t-1ndDmAD","taskNamespace":"Biz/PodcastItLater.hs","taskParent":"t-143KQl2","taskPriority":"P2","taskStatus":"Review","taskTitle":"PodcastItLater: Add Stripe Checkout Route","taskType":"WorkTask","taskUpdatedAt":"2025-11-21T05:09:49.904682771Z"}
-{"taskCreatedAt":"2025-11-20T21:41:12.783999704Z","taskDependencies":[],"taskDescription":null,"taskId":"t-1ndDrBA","taskNamespace":"Biz/PodcastItLater.hs","taskParent":"t-143KQl2","taskPriority":"P2","taskStatus":"Review","taskTitle":"PodcastItLater: Add Stripe Portal Route","taskType":"WorkTask","taskUpdatedAt":"2025-11-21T05:15:42.436876306Z"}
-{"taskCreatedAt":"2025-11-20T21:41:12.802988426Z","taskDependencies":[],"taskDescription":null,"taskId":"t-1ndDwxQ","taskNamespace":"Biz/PodcastItLater.hs","taskParent":"t-143KQl2","taskPriority":"P2","taskStatus":"Review","taskTitle":"PodcastItLater: Add Stripe Webhook Handler","taskType":"WorkTask","taskUpdatedAt":"2025-11-21T05:19:49.882551659Z"}
-{"taskCreatedAt":"2025-11-20T21:41:12.821995769Z","taskDependencies":[],"taskDescription":null,"taskId":"t-1ndDBuq","taskNamespace":"Biz/PodcastItLater.hs","taskParent":"t-143KQl2","taskPriority":"P2","taskStatus":"Review","taskTitle":"PodcastItLater: Enforce Paid Limits in UI","taskType":"WorkTask","taskUpdatedAt":"2025-11-21T05:23:39.337972299Z"}
+{"taskCreatedAt":"2025-11-20T21:41:12.764720659Z","taskDependencies":[],"taskDescription":null,"taskId":"t-1ndDmAD","taskNamespace":"Biz/PodcastItLater.hs","taskParent":"t-143KQl2","taskPriority":"P2","taskStatus":"Done","taskTitle":"PodcastItLater: Add Stripe Checkout Route","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T14:16:02.758048988Z"}
+{"taskCreatedAt":"2025-11-20T21:41:12.783999704Z","taskDependencies":[],"taskDescription":null,"taskId":"t-1ndDrBA","taskNamespace":"Biz/PodcastItLater.hs","taskParent":"t-143KQl2","taskPriority":"P2","taskStatus":"Done","taskTitle":"PodcastItLater: Add Stripe Portal Route","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T14:16:02.82972272Z"}
+{"taskCreatedAt":"2025-11-20T21:41:12.802988426Z","taskDependencies":[],"taskDescription":null,"taskId":"t-1ndDwxQ","taskNamespace":"Biz/PodcastItLater.hs","taskParent":"t-143KQl2","taskPriority":"P2","taskStatus":"Done","taskTitle":"PodcastItLater: Add Stripe Webhook Handler","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T14:16:02.911223697Z"}
+{"taskCreatedAt":"2025-11-20T21:41:12.821995769Z","taskDependencies":[],"taskDescription":null,"taskId":"t-1ndDBuq","taskNamespace":"Biz/PodcastItLater.hs","taskParent":"t-143KQl2","taskPriority":"P2","taskStatus":"Done","taskTitle":"PodcastItLater: Enforce Paid Limits in UI","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T14:16:02.993133469Z"}
{"taskCreatedAt":"2025-11-20T21:41:32.113815607Z","taskDependencies":[],"taskDescription":null,"taskId":"t-1neWyaO","taskNamespace":"Biz/PodcastItLater.hs","taskParent":"t-144hCMJ","taskPriority":"P2","taskStatus":"Review","taskTitle":"Add tests for Admin dashboard","taskType":"WorkTask","taskUpdatedAt":"2025-11-21T05:27:20.741813376Z"}
{"taskCreatedAt":"2025-11-20T21:41:32.132888832Z","taskDependencies":[],"taskDescription":null,"taskId":"t-1neWD8r","taskNamespace":"Biz/PodcastItLater.hs","taskParent":"t-144hCMJ","taskPriority":"P2","taskStatus":"Review","taskTitle":"Add error handling tests for Worker","taskType":"WorkTask","taskUpdatedAt":"2025-11-21T05:41:19.218858972Z"}
{"taskCreatedAt":"2025-11-20T22:42:03.728732682Z","taskDependencies":[],"taskDescription":null,"taskId":"t-1rcIr6X","taskNamespace":"Omni/Task.hs","taskParent":"t-PpXWsU","taskPriority":"P2","taskStatus":"Done","taskTitle":"Implement 'task progress <epic-id>' command","taskType":"WorkTask","taskUpdatedAt":"2025-11-21T08:59:47.987586572Z"}
@@ -164,15 +164,39 @@
{"taskCreatedAt":"2025-11-21T22:31:20.872934097Z","taskDependencies":[],"taskDescription":null,"taskId":"t-rWblzNdp4.3","taskNamespace":null,"taskParent":"t-rWblzNdp4","taskPriority":"P2","taskStatus":"Done","taskTitle":"Implement smart base branch selection in Worker","taskType":"WorkTask","taskUpdatedAt":"2025-11-21T22:36:36.614180518Z"}
{"taskCreatedAt":"2025-11-21T23:01:48.224051611Z","taskDependencies":[],"taskDescription":null,"taskId":"t-rWbnAjCJH","taskNamespace":null,"taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Update start-worker.sh to use Haskell agent","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T01:34:02.545292575Z"}
{"taskCreatedAt":"2025-11-22T01:34:07.407341455Z","taskDependencies":[],"taskDescription":"Omni/Bild.hs:776 has a TODO: wrapper should just be removed, instead rely on upstream nixpkgs builders to make wrappers. This simplifies the codebase by removing manual bash script generation.","taskId":"t-rWbMpcV4v","taskNamespace":"Omni/Bild.hs","taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Remove manual wrapper generation in Omni/Bild","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T03:21:49.357422745Z"}
-{"taskCreatedAt":"2025-11-22T01:34:12.233596517Z","taskDependencies":[],"taskDescription":"Implement a metrics view in the Admin dashboard (Biz/PodcastItLater/Admin.py). Show total users, active subscriptions, and recent submission counts. Ref: Biz/PodcastItLater/DESIGN.md","taskId":"t-rWbMpxaBk","taskNamespace":"Biz/PodcastItLater.hs","taskParent":null,"taskPriority":"P2","taskStatus":"InProgress","taskTitle":"Implement metrics view in Admin dashboard","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T10:08:07.856723065Z"}
+{"taskCreatedAt":"2025-11-22T01:34:12.233596517Z","taskDependencies":[],"taskDescription":"Implement a metrics view in the Admin dashboard (Biz/PodcastItLater/Admin.py). Show total users, active subscriptions, and recent submission counts. Ref: Biz/PodcastItLater/DESIGN.md","taskId":"t-rWbMpxaBk","taskNamespace":"Biz/PodcastItLater.hs","taskParent":null,"taskPriority":"P2","taskStatus":"Review","taskTitle":"Implement metrics view in Admin dashboard","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T10:39:55.344152609Z"}
{"taskCreatedAt":"2025-11-22T01:34:19.451799517Z","taskDependencies":[],"taskDescription":"Update Omni/Agent/start-worker.sh to invoke the new Haskell-based agent binary ('agent start <name>') instead of running the legacy bash loop. Ensure it still sets up the environment correctly. The agent binary handles the loop internally.","taskId":"t-rWbMq1snX","taskNamespace":"Omni/Agent.hs","taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Update start-worker.sh to use Haskell agent","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T01:57:09.161716208Z"}
{"taskCreatedAt":"2025-11-22T02:13:44.805917094Z","taskDependencies":[],"taskDescription":"Modify Omni/Agent/Git.hs to proactively clean up stale rebase/merge states before attempting operations. The worker should attempt 'git rebase --abort' (ignoring errors) before syncing to prevent 'already rebase-merge' errors.","taskId":"t-rWbP06f2O","taskNamespace":null,"taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Make worker agent robust to stale git states","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T02:14:40.413090556Z"}
{"taskCreatedAt":"2025-11-22T02:26:44.02456019Z","taskDependencies":[],"taskDescription":"Modify Omni/Agent/Git.hs to check for .git/rebase-merge or .git/rebase-apply before running git rebase --abort. This avoids blindly running abort commands.","taskId":"t-rWbPQPLps","taskNamespace":null,"taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Detect in-progress rebase before aborting in Agent","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T02:27:45.377866012Z"}
{"taskCreatedAt":"2025-11-22T03:01:36.84628158Z","taskDependencies":[],"taskDescription":"Modify Omni/Agent/Worker.hs to check if the task branch already exists before trying to create it. If it exists, simply checkout the branch. This prevents 'fatal: a branch named ... already exists' errors when restarting the worker.","taskId":"t-rWbS8t1Wv","taskNamespace":"Omni/Agent.hs","taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Handle existing task branch in Worker Agent","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T03:02:31.746506652Z"}
{"taskCreatedAt":"2025-11-22T03:09:54.022974779Z","taskDependencies":[],"taskDescription":"Implement the 2-line status UI described in Omni/Agent/DESIGN.md (Section 4.3). It should reserve 2 lines at the bottom for Meta (Task ID, Time) and Activity (current thought/action), allowing history to scroll above. Use ANSI codes for cursor management.","taskId":"t-rWbSG78jq","taskNamespace":"Omni/Agent/Log.hs","taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Implement 2-line Agent Status UI","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T03:21:54.480763142Z"}
+{"taskCreatedAt":"2025-11-22T11:31:50.378377038Z","taskDependencies":[],"taskDescription":null,"taskId":"t-rWcpygi7d","taskNamespace":null,"taskParent":null,"taskPriority":"P2","taskStatus":"Open","taskTitle":"Test Lowercase","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T11:31:50.378377038Z"}
+{"taskCreatedAt":"2025-11-22T11:34:17.854509264Z","taskDependencies":[],"taskDescription":null,"taskId":"t-rWcpIf5ov","taskNamespace":null,"taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"--help","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T11:39:43.304029721Z"}
{"taskCreatedAt":"2025-11-22T04:02:16.914288868Z","taskDependencies":[{"depId":"t-rWbMpcV4v","depType":"Blocks"},{"depId":"t-rWbMpxaBk","depType":"Blocks"},{"depId":"t-rWbS8t1Wv","depType":"Blocks"}],"taskDescription":"Update Omni/Agent/Worker.hs to spawn a background thread that tails '_/llm/amp.log' while the Amp agent is running. For each new line in the log: 1. Parse it (it's JSON). 2. Extract a user-friendly summary (e.g. 'Thinking...', 'Tool: Bash'). 3. Update the status bar activity line (AgentLog.updateActivity) with this summary. This provides real-time visibility into what the agent is doing.","taskId":"t-rWbW6OnUO","taskNamespace":"Omni/Agent/Worker.hs","taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Stream Amp logs to Agent status bar","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T10:05:14.217613978Z"}
{"taskCreatedAt":"2025-11-22T09:41:06.786529414Z","taskDependencies":[],"taskDescription":"Replace 'git rebase live' with 'git sync' (which maps to git-branchless sync) in Omni.Agent.Git.syncWithLive. This aligns with the branchless workflow and handles stack rebasing automatically.","taskId":"t-rWciiEsnZ","taskNamespace":"Omni/Agent/Git.hs","taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Use 'git sync' instead of 'git rebase' in Agent","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T09:42:37.875643446Z"}
{"taskCreatedAt":"2025-11-22T09:50:59.154884329Z","taskDependencies":[],"taskDescription":"1. Add Thread ID to the status bar (requires log parsing later, but add field now). 2. Make the status layout responsive or vertical (4 lines) to fit on small screens (iPhone). 3. Reserve more lines in init.","taskId":"t-rWciWJYsi","taskNamespace":"Omni/Agent/Log.hs","taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Improve Agent Status UI for mobile & debugging","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T09:52:36.176467065Z"}
{"taskCreatedAt":"2025-11-22T10:09:23.249166289Z","taskDependencies":[],"taskDescription":null,"taskId":"t-rWck9sDOA","taskNamespace":"Omni/Agent.hs","taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Split Thread and Credits in Worker status bar","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T10:10:17.800528662Z"}
{"taskCreatedAt":"2025-11-22T10:12:35.129294132Z","taskDependencies":[{"depId":"t-rWck9sDOA","depType":"DiscoveredFrom"}],"taskDescription":null,"taskId":"t-rWckmrKBm","taskNamespace":null,"taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Fix Worker status bar activity not updating","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T10:14:43.612634394Z"}
-{"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":"Open","taskTitle":"Fix failing tests in Biz/PodcastItLater/Web.py (UsageLimits and EpisodeDetail)","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T10:39:11.364170862Z"}
+{"taskCreatedAt":"2025-11-22T10:24:04.441689132Z","taskDependencies":[{"depId":"t-rWckmrKBm","depType":"DiscoveredFrom"}],"taskDescription":null,"taskId":"t-rWcl762fd","taskNamespace":null,"taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Fix credit calculation in Worker status bar","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T10:25:51.468062833Z"}
+{"taskCreatedAt":"2025-11-22T10:32:31.370216711Z","taskDependencies":[],"taskDescription":"Map raw Amp log messages to human-friendly status updates (e.g. 'READ: ...', 'TOOL: ...'), similar to monitor-worker.sh, but WITHOUT using emojis as they are unnecessary.","taskId":"t-rWclFp3vN","taskNamespace":"Omni/Agent.hs","taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Improve Worker status bar activity formatting (No Emojis)","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T13:40:28.250551154Z"}
+{"taskCreatedAt":"2025-11-22T10:35:13.559736706Z","taskDependencies":[{"depId":"t-rWcl762fd","depType":"DiscoveredFrom"}],"taskDescription":null,"taskId":"t-rWclQnApM","taskNamespace":null,"taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Verify credit units in amp logs","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T10:41:51.876980566Z"}
+{"taskCreatedAt":"2025-11-22T10:41:55.215833393Z","taskDependencies":[],"taskDescription":"The credits in usage-ledger logs are in cents, but we display them as dollars. We need to divide by 100.","taskId":"t-rWcmhyTvV","taskNamespace":"Omni/Agent.hs","taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Divide usage-ledger credits by 100 to get dollars","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T10:42:42.156523503Z"}
+{"taskCreatedAt":"2025-11-22T10:50:50.329217484Z","taskDependencies":[],"taskDescription":"Collection of tasks to improve the robustness of the codebase (builds), the usability of the 'task' tool, and the accuracy of the agent's status reporting.","taskId":"t-rWcmRMaWX","taskNamespace":"Omni.hs","taskParent":null,"taskPriority":"P2","taskStatus":"Open","taskTitle":"Codebase Health and Tooling Improvements","taskType":"Epic","taskUpdatedAt":"2025-11-22T10:50:50.329217484Z"}
+{"taskCreatedAt":"2025-11-22T10:50:57.552875891Z","taskDependencies":[],"taskDescription":"Implement a 'task edit <id>' command (or 'task update' extension) that allows modifying a task's title, description, priority, and other fields in-place. Currently 'task update' only changes status.","taskId":"t-rWcmRMaWX.1","taskNamespace":"Omni/Task.hs","taskParent":"t-rWcmRMaWX","taskPriority":"P2","taskStatus":"Done","taskTitle":"Add 'task edit' command","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T11:16:49.365516683Z"}
+{"taskCreatedAt":"2025-11-22T10:51:01.309897479Z","taskDependencies":[],"taskDescription":"Update the Worker Agent status bar logic to round the displayed credit usage to 2 decimal places (nearest cent). Currently it may show long floating point numbers.","taskId":"t-rWcmRMaWX.2","taskNamespace":"Omni/Agent.hs","taskParent":"t-rWcmRMaWX","taskPriority":"P2","taskStatus":"Done","taskTitle":"Round credits to nearest cent in Agent status","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T13:40:28.332806178Z"}
+{"taskCreatedAt":"2025-11-22T10:51:04.73629995Z","taskDependencies":[],"taskDescription":"Update Omni/Task/Core.hs to handle task IDs case-insensitively for lookups and normalize them to lowercase when storing/creating. This improves user experience when typing IDs manually.","taskId":"t-rWcmRMaWX.3","taskNamespace":"Omni/Task.hs","taskParent":"t-rWcmRMaWX","taskPriority":"P2","taskStatus":"Done","taskTitle":"Case-insensitive task IDs","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T11:54:45.741575622Z"}
+{"taskCreatedAt":"2025-11-22T10:51:08.813653444Z","taskDependencies":[],"taskDescription":"Create an agent or script that iterates through every namespace in the project and runs 'bild' (e.g. 'bild --time 0 **/*'). For every build failure encountered, it should automatically create a new task with the error details and link it to this epic (or the discovery context).","taskId":"t-rWcmRMaWX.4","taskNamespace":"Omni/Bild.hs","taskParent":"t-rWcmRMaWX","taskPriority":"P2","taskStatus":"Done","taskTitle":"Audit codebase builds and file repair tasks","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T11:51:55.014259557Z"}
+{"taskCreatedAt":"2025-11-22T11:27:59.621730567Z","taskDependencies":[],"taskDescription":"Update Omni/Agent/Worker.hs to read the content of AGENTS.md and include a relevant summary or the full content in the initial system prompt provided to the Amp agent. This ensures the worker knows about repository conventions, testing standards, and tool usage.","taskId":"t-rWcpiE3LO","taskNamespace":"Omni/Agent.hs","taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Include AGENTS.md context in Worker initial prompt","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T11:36:14.542146518Z"}
+{"taskCreatedAt":"2025-11-22T11:45:43.502171517Z","taskDependencies":[],"taskDescription":"Remove unused test files, migrate useful tests to the main suite, and remove legacy bash prototype scripts replaced by the Haskell implementation.","taskId":"t-rWcqsDZFM","taskNamespace":"Omni/Agent.hs","taskParent":null,"taskPriority":"P2","taskStatus":"Open","taskTitle":"Cleanup Omni/Agent files and tests","taskType":"Epic","taskUpdatedAt":"2025-11-22T11:45:43.502171517Z"}
+{"taskCreatedAt":"2025-11-22T11:45:49.548163416Z","taskDependencies":[],"taskDescription":"Omni/Agent/LogTest.hs is currently unused by the main 'bild --test Omni/Agent.hs' command. Review its contents, move any valuable tests to Omni/Agent.hs (or Omni/Agent/Log.hs's test section), and delete the file.","taskId":"t-rWcqsDZFM.1","taskNamespace":"Omni/Agent.hs","taskParent":"t-rWcqsDZFM","taskPriority":"P2","taskStatus":"Open","taskTitle":"Consolidate LogTest.hs into main test suite","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T11:45:49.548163416Z"}
+{"taskCreatedAt":"2025-11-22T11:45:57.926946967Z","taskDependencies":[],"taskDescription":"Remove bash scripts that have been superseded by the Haskell agent implementation. Candidates for removal: harvest-tasks.sh, merge-tasks.sh, sync-tasks.sh, setup-worker.sh. Ensure functionality is covered by Haskell code before deletion.","taskId":"t-rWcqsDZFM.2","taskNamespace":"Omni/Agent.hs","taskParent":"t-rWcqsDZFM","taskPriority":"P2","taskStatus":"Open","taskTitle":"Remove legacy bash prototype scripts","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T11:45:57.926946967Z"}
+{"taskCreatedAt":"2025-11-22T11:46:03.875940421Z","taskDependencies":[],"taskDescription":"We have both 'monitor.sh' and 'monitor-worker.sh'. Consolidate them into a single 'monitor.sh' script and remove the duplicate.","taskId":"t-rWcqsDZFM.3","taskNamespace":"Omni/Agent.hs","taskParent":"t-rWcqsDZFM","taskPriority":"P2","taskStatus":"Open","taskTitle":"Consolidate monitor scripts","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T11:46:03.875940421Z"}
+{"taskCreatedAt":"2025-11-22T12:42:35.9228659Z","taskDependencies":[],"taskDescription":null,"taskId":"t-1o2bk9tzanj","taskNamespace":"Omni/Agent.hs","taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Capture Amp summary for commit message","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T12:48:02.872211474Z"}
+{"taskCreatedAt":"2025-11-22T12:42:39.927855226Z","taskDependencies":[],"taskDescription":null,"taskId":"t-1o2bk9wd4x9","taskNamespace":"Omni/Agent.hs","taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Update Amp prompt to forbid git commits","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T12:48:07.355031023Z"}
+{"taskCreatedAt":"2025-11-22T12:57:29.984013645Z","taskDependencies":[],"taskDescription":null,"taskId":"t-1o2bkoma4nf","taskNamespace":"Omni.hs","taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Update AGENTS.md with commit message guidelines","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T12:59:00.994108608Z"}
+{"taskCreatedAt":"2025-11-22T12:57:52.859363726Z","taskDependencies":[{"depId":"t-1o2bkoma4nf","depType":"Related"}],"taskDescription":null,"taskId":"t-1o2bkozwfdt","taskNamespace":"Omni.hs","taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Configure git commit template (.gitmessage)","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T12:59:25.14786599Z"}
+{"taskCreatedAt":"2025-11-22T13:01:18.426816879Z","taskDependencies":[],"taskDescription":"Update repository setup scripts (e.g. Omni/Ide/hooks or task init) to automatically run 'git config commit.template .gitmessage' so all users get the template.","taskId":"t-1o2bkseag8u","taskNamespace":"Omni/Ide.hs","taskParent":null,"taskPriority":"P2","taskStatus":"Open","taskTitle":"Automate git commit template configuration","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T13:01:18.426816879Z"}
+{"taskCreatedAt":"2025-11-22T13:03:21.434586142Z","taskDependencies":[],"taskDescription":"Move detailed documentation (Task Manager, Bild, Git Workflow) to separate README files in their respective namespaces. Keep AGENTS.md focused on critical rules, cheat sheets, and pointers to the detailed docs. Goal is to reduce token usage.","taskId":"t-1o2bkufixnc","taskNamespace":"Omni.hs","taskParent":null,"taskPriority":"P2","taskStatus":"Open","taskTitle":"Refactor and condense AGENTS.md","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T13:04:41.243757221Z"}
+{"taskCreatedAt":"2025-11-21T04:37:55.163249193Z","taskDependencies":[{"depId":"t-144gqry","depType":"DiscoveredFrom"}],"taskDescription":null,"taskId":"t-rwadhwrzt","taskNamespace":null,"taskParent":null,"taskPriority":"P2","taskStatus":"Open","taskTitle":"Fix bild failure for Biz/PodcastItLater/Web.py","taskType":"WorkTask","taskUpdatedAt":"2025-11-21T04:37:55.163249193Z"}
+{"taskCreatedAt":"2025-11-21T05:28:31.973657907Z","taskDependencies":[],"taskDescription":null,"taskId":"t-rwagbsb6w","taskNamespace":"Biz/PodcastItLater.hs","taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Add error handling tests for Worker","taskType":"WorkTask","taskUpdatedAt":"2025-11-21T05:40:59.255645021Z"}
+{"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":"Open","taskTitle":"Fix failing tests in Biz/PodcastItLater/Web.py (UsageLimits and EpisodeDetail)","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T10:39:11.364170862Z"}
diff --git a/AGENTS.md b/AGENTS.md
index 6ff1ebf..6e3bbad 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -671,7 +671,44 @@ Key concepts:
git smartlog
```
-**Create a new commit:**
+### Commit Messages
+
+Follow these rules for good commit messages:
+
+1. **Separate subject from body with a blank line**
+2. **Limit the subject line to 50 characters**
+3. **Capitalize the subject line**
+4. **Do not end the subject line with a period**
+5. **Use the imperative mood in the subject line** (e.g., "Fix bug" not "Fixed bug")
+6. **Wrap the body at 72 characters**
+7. **Use the body to explain what and why vs. how**
+
+Template:
+```
+Summarize change in 50 characters or less
+
+More detailed explanatory text, if necessary. Wrap it to about 72
+characters or so. In some contexts, the first line is treated as the
+subject of the email and the rest of the text as the body. The
+blank line separating the summary from the body is critical (unless
+you omit the body entirely); various tools like `log`, `shortlog`
+and `rebase` can get confused if you run the two together.
+
+Explain the problem that this commit solves. Focus on why you are
+making this change as opposed to how (the code explains that).
+Are there side effects or other unintuitive consequences of this
+change? Here's the place to explain them.
+
+Further paragraphs come after blank lines.
+
+ - Bullet points are okay, too
+
+ - Typically a hyphen or asterisk is used for the bullet, preceded
+ by a single space, with blank lines in between, but conventions
+ vary here
+```
+
+### Create a new commit:
```bash
# Make your changes
git add .
diff --git a/Biz/PodcastItLater/Admin.py b/Biz/PodcastItLater/Admin.py
index f1dc3ab..6f60948 100644
--- a/Biz/PodcastItLater/Admin.py
+++ b/Biz/PodcastItLater/Admin.py
@@ -848,7 +848,7 @@ def admin_queue_status(request: Request) -> AdminView | Response | html.div:
def retry_queue_item(request: Request, job_id: int) -> Response:
"""Retry a failed queue item."""
try:
- # Check if user owns this job
+ # Check if user owns this job or is admin
user_id = request.session.get("user_id")
if not user_id:
return Response("Unauthorized", status_code=401)
@@ -856,15 +856,30 @@ def retry_queue_item(request: Request, job_id: int) -> Response:
job = Core.Database.get_job_by_id(
job_id,
)
- if job is None or job.get("user_id") != user_id:
+ if job is None:
+ return Response("Job not found", status_code=404)
+
+ # Check ownership or admin status
+ user = Core.Database.get_user_by_id(user_id)
+ if job.get("user_id") != user_id and not Core.is_admin(user):
return Response("Forbidden", status_code=403)
Core.Database.retry_job(job_id)
- # Redirect back to admin view
+
+ # Check if request is from admin page via referer header
+ is_from_admin = "/admin" in request.headers.get("referer", "")
+
+ # Redirect to admin if from admin page, trigger update otherwise
+ if is_from_admin:
+ return Response(
+ "",
+ status_code=200,
+ headers={"HX-Redirect": "/admin"},
+ )
return Response(
"",
status_code=200,
- headers={"HX-Redirect": "/admin"},
+ headers={"HX-Trigger": "queue-updated"},
)
except (ValueError, KeyError) as e:
return Response(
diff --git a/Biz/PodcastItLater/Core.py b/Biz/PodcastItLater/Core.py
index fc299cc..2f05db3 100644
--- a/Biz/PodcastItLater/Core.py
+++ b/Biz/PodcastItLater/Core.py
@@ -373,7 +373,10 @@ class Database: # noqa: PLR0904
SELECT id, url, email, status, created_at, error_message,
title, author
FROM queue
- WHERE status IN ('pending', 'processing', 'error')
+ WHERE status IN (
+ 'pending', 'processing', 'extracting',
+ 'synthesizing', 'uploading', 'error'
+ )
ORDER BY created_at DESC
LIMIT 20
""")
@@ -888,7 +891,10 @@ class Database: # noqa: PLR0904
title, author
FROM queue
WHERE user_id = ? AND
- status IN ('pending', 'processing', 'error')
+ status IN (
+ 'pending', 'processing', 'extracting',
+ 'synthesizing', 'uploading', 'error'
+ )
ORDER BY created_at DESC
LIMIT 20
""",
@@ -948,6 +954,76 @@ class Database: # noqa: PLR0904
logger.info("Updated user %s status to %s", user_id, status)
@staticmethod
+ def delete_user(user_id: int) -> None:
+ """Delete user and all associated data."""
+ with Database.get_connection() as conn:
+ cursor = conn.cursor()
+
+ # 1. Get owned episode IDs
+ cursor.execute(
+ "SELECT id FROM episodes WHERE user_id = ?",
+ (user_id,),
+ )
+ owned_episode_ids = [row[0] for row in cursor.fetchall()]
+
+ # 2. Delete references to owned episodes
+ if owned_episode_ids:
+ # Construct placeholders for IN clause
+ placeholders = ",".join("?" * len(owned_episode_ids))
+
+ # Delete from user_episodes where these episodes are referenced
+ query = f"DELETE FROM user_episodes WHERE episode_id IN ({placeholders})" # noqa: S608, E501
+ cursor.execute(query, tuple(owned_episode_ids))
+
+ # Delete metrics for these episodes
+ query = f"DELETE FROM episode_metrics WHERE episode_id IN ({placeholders})" # noqa: S608, E501
+ cursor.execute(query, tuple(owned_episode_ids))
+
+ # 3. Delete owned episodes
+ cursor.execute("DELETE FROM episodes WHERE user_id = ?", (user_id,))
+
+ # 4. Delete user's data referencing others or themselves
+ cursor.execute(
+ "DELETE FROM user_episodes WHERE user_id = ?",
+ (user_id,),
+ )
+ cursor.execute(
+ "DELETE FROM episode_metrics WHERE user_id = ?",
+ (user_id,),
+ )
+ cursor.execute("DELETE FROM queue WHERE user_id = ?", (user_id,))
+
+ # 5. Delete user
+ cursor.execute("DELETE FROM users WHERE id = ?", (user_id,))
+
+ conn.commit()
+ logger.info("Deleted user %s and all associated data", user_id)
+
+ @staticmethod
+ def update_user_email(user_id: int, new_email: str) -> None:
+ """Update user's email address.
+
+ Args:
+ user_id: ID of the user to update
+ new_email: New email address
+
+ Raises:
+ ValueError: If email is already taken by another user
+ """
+ with Database.get_connection() as conn:
+ cursor = conn.cursor()
+ try:
+ cursor.execute(
+ "UPDATE users SET email = ? WHERE id = ?",
+ (new_email, user_id),
+ )
+ conn.commit()
+ logger.info("Updated user %s email to %s", user_id, new_email)
+ except sqlite3.IntegrityError:
+ msg = f"Email {new_email} is already taken"
+ raise ValueError(msg) from None
+
+ @staticmethod
def mark_episode_public(episode_id: int) -> None:
"""Mark an episode as public."""
with Database.get_connection() as conn:
@@ -1519,7 +1595,7 @@ class TestDatabase(Test.TestCase):
1000,
user_id,
)
-
+
# Create a queue item
Database.add_to_queue(
"https://example.com",
@@ -1528,18 +1604,17 @@ class TestDatabase(Test.TestCase):
)
metrics = Database.get_metrics_summary()
-
+
self.assertIn("total_users", metrics)
self.assertIn("active_subscriptions", metrics)
self.assertIn("submissions_24h", metrics)
self.assertIn("submissions_7d", metrics)
-
+
self.assertEqual(metrics["total_users"], 1)
self.assertEqual(metrics["submissions_24h"], 1)
self.assertEqual(metrics["submissions_7d"], 1)
-
class TestUserManagement(Test.TestCase):
"""Test user management functionality."""
@@ -1635,6 +1710,67 @@ class TestUserManagement(Test.TestCase):
# All tokens should be unique
self.assertEqual(len(tokens), 10)
+ def test_delete_user(self) -> None:
+ """Test user deletion and cleanup."""
+ # Create user
+ user_id, _ = Database.create_user("delete_me@example.com")
+
+ # Create some data for the user
+ Database.add_to_queue(
+ "https://example.com/article",
+ "delete_me@example.com",
+ user_id,
+ )
+
+ ep_id = Database.create_episode(
+ title="Test Episode",
+ audio_url="url",
+ duration=100,
+ content_length=1000,
+ user_id=user_id,
+ )
+ Database.add_episode_to_user(user_id, ep_id)
+ Database.track_episode_metric(ep_id, "played", user_id)
+
+ # Delete user
+ Database.delete_user(user_id)
+
+ # Verify user is gone
+ self.assertIsNone(Database.get_user_by_id(user_id))
+
+ # Verify queue items are gone
+ queue = Database.get_user_queue_status(user_id)
+ self.assertEqual(len(queue), 0)
+
+ # Verify episodes are gone (direct lookup)
+ self.assertIsNone(Database.get_episode_by_id(ep_id))
+
+ def test_update_user_email(self) -> None:
+ """Update user email address."""
+ user_id, _ = Database.create_user("old@example.com")
+
+ # Update email
+ Database.update_user_email(user_id, "new@example.com")
+
+ # Verify update
+ user = Database.get_user_by_id(user_id)
+ self.assertIsNotNone(user)
+ if user:
+ self.assertEqual(user["email"], "new@example.com")
+
+ # Old email should not exist
+ self.assertIsNone(Database.get_user_by_email("old@example.com"))
+
+ @staticmethod
+ def test_update_user_email_duplicate() -> None:
+ """Cannot update to an existing email."""
+ user_id1, _ = Database.create_user("user1@example.com")
+ Database.create_user("user2@example.com")
+
+ # Try to update user1 to user2's email
+ with pytest.raises(ValueError, match="already taken"):
+ Database.update_user_email(user_id1, "user2@example.com")
+
class TestQueueOperations(Test.TestCase):
"""Test queue operations."""
@@ -1847,6 +1983,40 @@ class TestQueueOperations(Test.TestCase):
self.assertEqual(counts.get("processing", 0), 1)
self.assertEqual(counts.get("error", 0), 1)
+ def test_queue_position(self) -> None:
+ """Verify queue position calculation."""
+ # Add multiple pending jobs
+ job1 = Database.add_to_queue(
+ "https://example.com/1",
+ "test@example.com",
+ self.user_id,
+ )
+ time.sleep(0.01)
+ job2 = Database.add_to_queue(
+ "https://example.com/2",
+ "test@example.com",
+ self.user_id,
+ )
+ time.sleep(0.01)
+ job3 = Database.add_to_queue(
+ "https://example.com/3",
+ "test@example.com",
+ self.user_id,
+ )
+
+ # Check positions
+ self.assertEqual(Database.get_queue_position(job1), 1)
+ self.assertEqual(Database.get_queue_position(job2), 2)
+ self.assertEqual(Database.get_queue_position(job3), 3)
+
+ # Move job 2 to processing
+ Database.update_job_status(job2, "processing")
+
+ # Check positions (job 3 should now be 2nd pending job)
+ self.assertEqual(Database.get_queue_position(job1), 1)
+ self.assertIsNone(Database.get_queue_position(job2))
+ self.assertEqual(Database.get_queue_position(job3), 2)
+
class TestEpisodeManagement(Test.TestCase):
"""Test episode management functionality."""
diff --git a/Biz/PodcastItLater/Test.py b/Biz/PodcastItLater/Test.py
index b2a1d24..ee638f1 100644
--- a/Biz/PodcastItLater/Test.py
+++ b/Biz/PodcastItLater/Test.py
@@ -19,6 +19,7 @@
# : out podcastitlater-e2e-test
# : run ffmpeg
import Biz.PodcastItLater.Core as Core
+import Biz.PodcastItLater.UI as UI
import Biz.PodcastItLater.Web as Web
import Biz.PodcastItLater.Worker as Worker
import Omni.App as App
@@ -208,12 +209,60 @@ class TestEndToEnd(BaseWebTest):
self.assertIn("Other User's Article", response.text)
+class TestUI(Test.TestCase):
+ """Test UI components."""
+
+ def test_render_navbar(self) -> None:
+ """Test navbar rendering."""
+ user = {"email": "test@example.com", "id": 1}
+ layout = UI.PageLayout(
+ user=user,
+ current_page="home",
+ error=None,
+ page_title="Test",
+ meta_tags=[],
+ )
+ navbar = layout._render_navbar(user, "home") # noqa: SLF001
+ html_output = navbar.to_html()
+
+ # Check basic structure
+ self.assertIn("navbar", html_output)
+ self.assertIn("Home", html_output)
+ self.assertIn("Public Feed", html_output)
+ self.assertIn("Pricing", html_output)
+ self.assertIn("Manage Account", html_output)
+
+ # Check active state
+ self.assertIn("active", html_output)
+
+ # Check non-admin user doesn't see admin menu
+ self.assertNotIn("Admin", html_output)
+
+ def test_render_navbar_admin(self) -> None:
+ """Test navbar rendering for admin."""
+ user = {"email": "ben@bensima.com", "id": 1} # Admin email
+ layout = UI.PageLayout(
+ user=user,
+ current_page="admin",
+ error=None,
+ page_title="Test",
+ meta_tags=[],
+ )
+ navbar = layout._render_navbar(user, "admin") # noqa: SLF001
+ html_output = navbar.to_html()
+
+ # Check admin menu present
+ self.assertIn("Admin", html_output)
+ self.assertIn("Queue Status", html_output)
+
+
def test() -> None:
"""Run all end-to-end tests."""
Test.run(
App.Area.Test,
[
TestEndToEnd,
+ TestUI,
],
)
diff --git a/Biz/PodcastItLater/TestMetricsView.py b/Biz/PodcastItLater/TestMetricsView.py
index 9ca30c2..b452feb 100644
--- a/Biz/PodcastItLater/TestMetricsView.py
+++ b/Biz/PodcastItLater/TestMetricsView.py
@@ -1,3 +1,5 @@
+"""Tests for Admin metrics view."""
+
# : out podcastitlater-test-metrics
# : dep pytest
# : dep starlette
@@ -14,20 +16,28 @@ import Biz.PodcastItLater.Web as Web
import Omni.Test as Test
from starlette.testclient import TestClient
+
class BaseWebTest(Test.TestCase):
+ """Base class for web tests."""
+
def setUp(self) -> None:
+ """Set up test database and client."""
Core.Database.init_db()
self.client = TestClient(Web.app)
@staticmethod
def tearDown() -> None:
+ """Clean up test database."""
Core.Database.teardown()
+
class TestMetricsView(BaseWebTest):
- def test_admin_metrics_view_access(self):
+ """Test Admin Metrics View."""
+
+ def test_admin_metrics_view_access(self) -> None:
"""Admin user should be able to access metrics view."""
# Create admin user
- admin_id, _ = Core.Database.create_user("ben@bensima.com")
+ _admin_id, _ = Core.Database.create_user("ben@bensima.com")
self.client.post("/login", data={"email": "ben@bensima.com"})
response = self.client.get("/admin/metrics")
@@ -35,7 +45,7 @@ class TestMetricsView(BaseWebTest):
self.assertIn("Growth & Usage", response.text)
self.assertIn("Total Users", response.text)
- def test_admin_metrics_data(self):
+ def test_admin_metrics_data(self) -> None:
"""Metrics view should show correct data."""
# Create admin user
admin_id, _ = Core.Database.create_user("ben@bensima.com")
@@ -48,32 +58,40 @@ class TestMetricsView(BaseWebTest):
# 2. Subscriptions (simulate by setting subscription_status)
with Core.Database.get_connection() as conn:
- conn.execute("UPDATE users SET subscription_status = 'active' WHERE id = ?", (user2_id,))
- conn.commit()
+ conn.execute(
+ "UPDATE users SET subscription_status = 'active' WHERE id = ?",
+ (user2_id,),
+ )
+ conn.commit()
# 3. Submissions
- Core.Database.add_to_queue("http://example.com/1", "user1@example.com", admin_id)
-
+ Core.Database.add_to_queue(
+ "http://example.com/1",
+ "user1@example.com",
+ admin_id,
+ )
+
# Get metrics page
response = self.client.get("/admin/metrics")
self.assertEqual(response.status_code, 200)
-
+
# Check labels
self.assertIn("Total Users", response.text)
self.assertIn("Active Subs", response.text)
self.assertIn("Submissions (24h)", response.text)
-
- # Check values (metrics dict is passed to template, we check rendered HTML)
+
+ # Check values (metrics dict is passed to template,
+ # we check rendered HTML)
# Total users: 3 (admin + user1 + user2)
# Active subs: 1 (user2)
# Submissions 24h: 1
-
+
# Check for values in HTML
# Note: This is a bit brittle, but effective for quick verification
- self.assertIn('<h3 class="mb-0">3</h3>', response.text)
+ self.assertIn('<h3 class="mb-0">3</h3>', response.text)
self.assertIn('<h3 class="mb-0">1</h3>', response.text)
- def test_non_admin_access_denied(self):
+ def test_non_admin_access_denied(self) -> None:
"""Non-admin users should be denied access."""
# Create regular user
Core.Database.create_user("regular@example.com")
@@ -84,12 +102,13 @@ class TestMetricsView(BaseWebTest):
self.assertEqual(response.status_code, 302)
self.assertIn("error=forbidden", response.headers["Location"])
- def test_anonymous_access_redirect(self):
+ def test_anonymous_access_redirect(self) -> None:
"""Anonymous users should be redirected to login."""
response = self.client.get("/admin/metrics")
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], "/")
+
def test() -> None:
"""Run the tests."""
Test.run(
@@ -97,5 +116,6 @@ def test() -> None:
[TestMetricsView],
)
+
if __name__ == "__main__":
test()
diff --git a/Biz/PodcastItLater/UI.py b/Biz/PodcastItLater/UI.py
index 226a7af..6825fd7 100644
--- a/Biz/PodcastItLater/UI.py
+++ b/Biz/PodcastItLater/UI.py
@@ -6,12 +6,13 @@ Common UI components and utilities shared across web pages.
# : out podcastitlater-ui
# : dep ludic
+import Biz.PodcastItLater.Core as Core
import ludic.html as html
import typing
from ludic.attrs import Attrs
from ludic.components import Component
from ludic.types import AnyChildren
-from typing import Any, cast, override
+from typing import override
def format_duration(seconds: int | None) -> str:
@@ -90,7 +91,7 @@ def create_auto_dark_mode_style() -> html.style:
/* Navbar dark mode */
.navbar.bg-body-tertiary {
- background-color: #2b3035 !important;
+ background-color: #2b3035 !important;
}
.navbar .navbar-text {
@@ -127,16 +128,6 @@ def create_bootstrap_js() -> html.script:
)
-def is_admin(user: dict[str, typing.Any] | None) -> bool:
- """Check if user is an admin based on email whitelist."""
- if not user:
- return False
- admin_emails = ["ben@bensima.com", "admin@example.com"]
- return user.get("email", "").lower() in [
- email.lower() for email in admin_emails
- ]
-
-
class PageLayoutAttrs(Attrs):
"""Attributes for PageLayout component."""
@@ -151,6 +142,78 @@ class PageLayout(Component[AnyChildren, PageLayoutAttrs]):
"""Reusable page layout with header and navbar."""
@staticmethod
+ def _render_nav_item(
+ label: str,
+ href: str,
+ icon: str,
+ *,
+ is_active: bool,
+ ) -> html.li:
+ return html.li(
+ html.a(
+ html.i(classes=["bi", f"bi-{icon}", "me-1"]),
+ label,
+ href=href,
+ classes=[
+ "nav-link",
+ "active" if is_active else "",
+ ],
+ ),
+ classes=["nav-item"],
+ )
+
+ @staticmethod
+ def _render_admin_dropdown(
+ is_active_func: typing.Callable[[str], bool],
+ ) -> html.li:
+ is_active = is_active_func("admin") or is_active_func("admin-users")
+ return html.li(
+ html.a( # type: ignore[call-arg]
+ html.i(classes=["bi", "bi-gear-fill", "me-1"]),
+ "Admin",
+ href="#",
+ id="adminDropdown",
+ role="button",
+ data_bs_toggle="dropdown",
+ aria_expanded="false",
+ classes=[
+ "nav-link",
+ "dropdown-toggle",
+ "active" if is_active else "",
+ ],
+ ),
+ html.ul( # type: ignore[call-arg]
+ html.li(
+ html.a(
+ html.i(classes=["bi", "bi-list-task", "me-2"]),
+ "Queue Status",
+ href="/admin",
+ classes=["dropdown-item"],
+ ),
+ ),
+ html.li(
+ html.a(
+ html.i(classes=["bi", "bi-people-fill", "me-2"]),
+ "Manage Users",
+ href="/admin/users",
+ classes=["dropdown-item"],
+ ),
+ ),
+ html.li(
+ html.a(
+ html.i(classes=["bi", "bi-graph-up", "me-2"]),
+ "Metrics",
+ href="/admin/metrics",
+ classes=["dropdown-item"],
+ ),
+ ),
+ classes=["dropdown-menu"],
+ aria_labelledby="adminDropdown",
+ ),
+ classes=["nav-item", "dropdown"],
+ )
+
+ @staticmethod
def _render_navbar(
user: dict[str, typing.Any] | None,
current_page: str,
@@ -174,151 +237,32 @@ class PageLayout(Component[AnyChildren, PageLayoutAttrs]):
),
html.div(
html.ul(
- html.li(
- html.a(
- html.i(
- classes=[
- "bi",
- "bi-house-fill",
- "me-1",
- ],
- ),
- "Home",
- href="/",
- classes=[
- "nav-link",
- "active" if is_active("home") else "",
- ],
- ),
- classes=["nav-item"],
+ PageLayout._render_nav_item(
+ "Home",
+ "/",
+ "house-fill",
+ is_active=is_active("home"),
),
- html.li(
- html.a(
- html.i(
- classes=[
- "bi",
- "bi-globe",
- "me-1",
- ],
- ),
- "Public Feed",
- href="/public",
- classes=[
- "nav-link",
- "active" if is_active("public") else "",
- ],
- ),
- classes=["nav-item"],
+ PageLayout._render_nav_item(
+ "Public Feed",
+ "/public",
+ "globe",
+ is_active=is_active("public"),
),
- html.li(
- html.a(
- html.i(
- classes=[
- "bi",
- "bi-stars",
- "me-1",
- ],
- ),
- "Pricing",
- href="/pricing",
- classes=[
- "nav-link",
- "active" if is_active("pricing") else "",
- ],
- ),
- classes=["nav-item"],
+ PageLayout._render_nav_item(
+ "Pricing",
+ "/pricing",
+ "stars",
+ is_active=is_active("pricing"),
),
- html.li(
- html.a(
- html.i(
- classes=[
- "bi",
- "bi-person-circle",
- "me-1",
- ],
- ),
- "Manage Account",
- href="/account",
- classes=[
- "nav-link",
- "active" if is_active("account") else "",
- ],
- ),
- classes=["nav-item"],
+ PageLayout._render_nav_item(
+ "Manage Account",
+ "/account",
+ "person-circle",
+ is_active=is_active("account"),
),
- html.li(
- html.a( # type: ignore[call-arg]
- html.i(
- classes=[
- "bi",
- "bi-gear-fill",
- "me-1",
- ],
- ),
- "Admin",
- href="#",
- id="adminDropdown",
- role="button",
- data_bs_toggle="dropdown",
- aria_expanded="false",
- classes=[
- "nav-link",
- "dropdown-toggle",
- "active"
- if is_active("admin")
- or is_active("admin-users")
- else "",
- ],
- ),
- html.ul( # type: ignore[call-arg]
- html.li(
- html.a(
- html.i(
- classes=[
- "bi",
- "bi-list-task",
- "me-2",
- ],
- ),
- "Queue Status",
- href="/admin",
- classes=["dropdown-item"],
- ),
- ),
- html.li(
- html.a(
- html.i(
- classes=[
- "bi",
- "bi-people-fill",
- "me-2",
- ],
- ),
- "Manage Users",
- href="/admin/users",
- classes=["dropdown-item"],
- ),
- ),
- html.li(
- html.a(
- html.i(
- classes=[
- "bi",
- "bi-graph-up",
- "me-2",
- ],
- ),
- "Metrics",
- href="/admin/metrics",
- classes=["dropdown-item"],
- ),
- ),
- classes=["dropdown-menu"],
- aria_labelledby="adminDropdown",
- ),
- classes=["nav-item", "dropdown"],
- )
- if user and is_admin(user)
+ PageLayout._render_admin_dropdown(is_active)
+ if user and Core.is_admin(user)
else html.span(),
classes=["navbar-nav"],
),
@@ -407,140 +351,406 @@ class PageLayout(Component[AnyChildren, PageLayoutAttrs]):
)
-class PricingPageAttrs(Attrs):
- """Attributes for PricingPage component."""
+class AccountPageAttrs(Attrs):
+ """Attributes for AccountPage component."""
- user: dict[str, typing.Any] | None
+ user: dict[str, typing.Any]
+ usage: dict[str, int]
+ limits: dict[str, int | None]
+ portal_url: str | None
-class PricingPage(Component[AnyChildren, PricingPageAttrs]):
- """Pricing page component."""
+class AccountPage(Component[AnyChildren, AccountPageAttrs]):
+ """Account management page component."""
@override
def render(self) -> PageLayout:
- user = self.attrs.get("user")
- current_tier = user.get("plan_tier", "free") if user else "free"
+ user = self.attrs["user"]
+ usage = self.attrs["usage"]
+ limits = self.attrs["limits"]
+ portal_url = self.attrs["portal_url"]
+
+ plan_tier = user.get("plan_tier", "free")
+ is_paid = plan_tier == "paid"
+
+ article_limit = limits.get("articles_per_period")
+ article_usage = usage.get("articles", 0)
+
+ limit_text = (
+ "Unlimited" if article_limit is None else str(article_limit)
+ )
+
+ usage_percent = 0
+ if article_limit:
+ usage_percent = min(100, int((article_usage / article_limit) * 100))
+
+ progress_style = (
+ {"width": f"{usage_percent}%"} if article_limit else {"width": "0%"}
+ )
return PageLayout(
- cast(Any, html.div( # type: ignore[arg-type]
- html.h2("Simple Pricing", classes=["text-center", "mb-5"]),
+ html.div(
html.div(
- # Free Tier
html.div(
html.div(
html.div(
- html.h3("Free", classes=["card-title"]),
- html.h4(
- "$0",
- classes=[
- "card-subtitle",
- "mb-3",
- "text-muted",
- ],
+ html.h2(
+ html.i(
+ classes=[
+ "bi",
+ "bi-person-circle",
+ "me-2",
+ ],
+ ),
+ "My Account",
+ classes=["card-title", "mb-4"],
),
- html.p(
- "10 articles total",
- classes=["card-text"],
+ # User Info Section
+ html.div(
+ html.h5("Profile", classes=["mb-3"]),
+ html.div(
+ html.strong("Email: "),
+ html.span(user.get("email", "")),
+ html.button(
+ "Change",
+ classes=[
+ "btn",
+ "btn-sm",
+ "btn-outline-secondary",
+ "ms-2",
+ "py-0",
+ ],
+ hx_get="/settings/email/edit",
+ hx_target="closest div",
+ hx_swap="outerHTML",
+ ),
+ classes=[
+ "mb-2",
+ "d-flex",
+ "align-items-center",
+ ],
+ ),
+ html.p(
+ html.strong("Member since: "),
+ user.get("created_at", "").split("T")[
+ 0
+ ],
+ classes=["mb-4"],
+ ),
+ classes=["mb-5"],
),
- html.ul(
- html.li("Convert 10 articles"),
- html.li("Basic features"),
- classes=["list-unstyled", "mb-4"],
+ # Subscription Section
+ html.div(
+ html.h5("Subscription", classes=["mb-3"]),
+ html.div(
+ html.div(
+ html.strong("Current Plan"),
+ html.span(
+ plan_tier.title(),
+ classes=[
+ "badge",
+ "bg-success"
+ if is_paid
+ else "bg-secondary",
+ "ms-2",
+ ],
+ ),
+ classes=[
+ "d-flex",
+ "align-items-center",
+ "mb-3",
+ ],
+ ),
+ # Usage Stats
+ html.div(
+ html.p(
+ "Usage this period:",
+ classes=["mb-2", "text-muted"],
+ ),
+ html.div(
+ html.div(
+ f"{article_usage} / "
+ f"{limit_text}",
+ classes=["mb-1"],
+ ),
+ html.div(
+ html.div(
+ classes=[
+ "progress-bar",
+ ],
+ role="progressbar",
+ style=progress_style,
+ ),
+ classes=[
+ "progress",
+ "mb-3",
+ ],
+ style={"height": "10px"},
+ )
+ if article_limit
+ else html.div(),
+ classes=["mb-3"],
+ ),
+ ),
+ # Actions
+ html.div(
+ html.form(
+ html.button(
+ html.i(
+ classes=[
+ "bi",
+ "bi-credit-card",
+ "me-2",
+ ],
+ ),
+ "Manage Subscription",
+ type="submit",
+ classes=[
+ "btn",
+ "btn-outline-primary",
+ ],
+ ),
+ method="post",
+ action=portal_url,
+ )
+ if is_paid and portal_url
+ else html.a(
+ html.i(
+ classes=[
+ "bi",
+ "bi-star-fill",
+ "me-2",
+ ],
+ ),
+ "Upgrade to Pro",
+ href="/pricing",
+ classes=["btn", "btn-primary"],
+ ),
+ classes=["d-flex", "gap-2"],
+ ),
+ classes=[
+ "card",
+ "card-body",
+ "bg-light",
+ ],
+ ),
+ classes=["mb-5"],
+ ),
+ # Logout Section
+ html.div(
+ html.form(
+ html.button(
+ html.i(
+ classes=[
+ "bi",
+ "bi-box-arrow-right",
+ "me-2",
+ ],
+ ),
+ "Log Out",
+ type="submit",
+ classes=[
+ "btn",
+ "btn-outline-danger",
+ ],
+ ),
+ action="/logout",
+ method="post",
+ ),
+ classes=["border-top", "pt-4"],
+ ),
+ # Delete Account Section
+ html.div(
+ html.h5(
+ "Danger Zone",
+ classes=["text-danger", "mb-3"],
+ ),
+ html.div(
+ html.h6("Delete Account"),
+ html.p(
+ "Once you delete your account, "
+ "there is no going back. "
+ "Please be certain.",
+ classes=["card-text"],
+ ),
+ html.button(
+ html.i(
+ classes=[
+ "bi",
+ "bi-trash",
+ "me-2",
+ ],
+ ),
+ "Delete Account",
+ hx_delete="/account",
+ hx_confirm=(
+ "Are you absolutely sure you "
+ "want to delete your account? "
+ "This action cannot be undone."
+ ),
+ classes=["btn", "btn-danger"],
+ ),
+ classes=[
+ "card",
+ "card-body",
+ "border-danger",
+ ],
+ ),
+ classes=["mt-5", "pt-4", "border-top"],
),
- html.button(
- "Current Plan",
- classes=[
- "btn",
- "btn-outline-primary",
- "w-100",
- ],
- disabled=True,
- )
- if current_tier == "free"
- else html.div(),
- classes=["card-body"],
+ classes=["card-body", "p-4"],
),
- classes=["card", "mb-4", "shadow-sm", "h-100"],
+ classes=["card", "shadow-sm"],
),
- classes=["col-md-6"],
+ classes=["col-lg-8", "mx-auto"],
),
- # Paid Tier
+ classes=["row"],
+ ),
+ ),
+ user=user,
+ current_page="account",
+ page_title="Account - PodcastItLater",
+ error=None,
+ meta_tags=[],
+ )
+
+
+class PricingPageAttrs(Attrs):
+ """Attributes for PricingPage component."""
+
+ user: dict[str, typing.Any] | None
+
+
+class PricingPage(Component[AnyChildren, PricingPageAttrs]):
+ """Pricing page component."""
+
+ @override
+ def render(self) -> PageLayout:
+ user = self.attrs.get("user")
+ current_tier = user.get("plan_tier", "free") if user else "free"
+
+ return PageLayout(
+ html.div(
+ html.div(
+ html.h2("Simple Pricing", classes=["text-center", "mb-5"]),
html.div(
+ # Free Tier
html.div(
html.div(
- html.h3(
- "Unlimited",
- classes=["card-title"],
- ),
- html.h4(
- "$12/mo",
- classes=[
- "card-subtitle",
- "mb-3",
- "text-muted",
- ],
- ),
- html.p(
- "Unlimited articles",
- classes=["card-text"],
- ),
- html.ul(
- html.li("Unlimited conversions"),
- html.li("Priority processing"),
- html.li("Support independent software"),
- classes=["list-unstyled", "mb-4"],
- ),
- html.form( # type: ignore[arg-type]
- html.button(
- "Upgrade Now",
- type="submit",
+ html.div(
+ html.h3("Free", classes=["card-title"]),
+ html.h4(
+ "$0",
classes=[
- "btn",
- "btn-primary",
- "w-100",
+ "card-subtitle",
+ "mb-3",
+ "text-muted",
],
),
- action="/upgrade",
- attrs={"method": "post"}, # type: ignore[arg-type]
- )
- if user and current_tier == "free"
- else (
+ html.p(
+ "10 articles total",
+ classes=["card-text"],
+ ),
+ html.ul(
+ html.li("Convert 10 articles"),
+ html.li("Basic features"),
+ classes=["list-unstyled", "mb-4"],
+ ),
html.button(
"Current Plan",
classes=[
"btn",
- "btn-success",
+ "btn-outline-primary",
"w-100",
],
disabled=True,
)
- if current_tier == "paid"
- else html.a(
- "Login to Upgrade",
- href="/",
+ if current_tier == "free"
+ else html.div(),
+ classes=["card-body"],
+ ),
+ classes=["card", "mb-4", "shadow-sm", "h-100"],
+ ),
+ classes=["col-md-6"],
+ ),
+ # Paid Tier
+ html.div(
+ html.div(
+ html.div(
+ html.h3(
+ "Unlimited",
+ classes=["card-title"],
+ ),
+ html.h4(
+ "$12/mo",
classes=[
- "btn",
- "btn-primary",
- "w-100",
+ "card-subtitle",
+ "mb-3",
+ "text-muted",
],
+ ),
+ html.p(
+ "Unlimited articles",
+ classes=["card-text"],
+ ),
+ html.ul(
+ html.li("Unlimited conversions"),
+ html.li("Priority processing"),
+ html.li("Support independent software"),
+ classes=["list-unstyled", "mb-4"],
+ ),
+ html.form(
+ html.button(
+ "Upgrade Now",
+ type="submit",
+ classes=[
+ "btn",
+ "btn-primary",
+ "w-100",
+ ],
+ ),
+ action="/upgrade",
+ method="POST",
)
+ if user and current_tier == "free"
+ else (
+ html.button(
+ "Current Plan",
+ classes=[
+ "btn",
+ "btn-success",
+ "w-100",
+ ],
+ disabled=True,
+ )
+ if user and current_tier == "paid"
+ else html.a(
+ "Login to Upgrade",
+ href="/",
+ classes=[
+ "btn",
+ "btn-primary",
+ "w-100",
+ ],
+ )
+ ),
+ classes=["card-body"],
),
- classes=["card-body"],
+ classes=[
+ "card",
+ "mb-4",
+ "shadow-sm",
+ "border-primary",
+ "h-100",
+ ],
),
- classes=[
- "card",
- "mb-4",
- "shadow-sm",
- "border-primary",
- "h-100",
- ],
+ classes=["col-md-6"],
),
- classes=["col-md-6"],
+ classes=["row"],
),
- classes=["row"],
+ classes=["container", "py-3"],
),
- classes=["container", "py-3"],
- )),
+ ),
user=user,
current_page="pricing",
page_title="Pricing - PodcastItLater",
diff --git a/Biz/PodcastItLater/Web.py b/Biz/PodcastItLater/Web.py
index e41b980..06145d9 100644
--- a/Biz/PodcastItLater/Web.py
+++ b/Biz/PodcastItLater/Web.py
@@ -54,6 +54,7 @@ from starlette.middleware.sessions import SessionMiddleware
from starlette.responses import RedirectResponse
from starlette.testclient import TestClient
from typing import override
+from unittest.mock import patch
logger = logging.getLogger(__name__)
Log.setup(logger)
@@ -362,6 +363,9 @@ class QueueStatus(Component[AnyChildren, QueueStatusAttrs]):
status_classes = {
"pending": "bg-warning text-dark",
"processing": "bg-primary",
+ "extracting": "bg-info text-dark",
+ "synthesizing": "bg-primary",
+ "uploading": "bg-success",
"error": "bg-danger",
"cancelled": "bg-secondary",
}
@@ -369,6 +373,9 @@ class QueueStatus(Component[AnyChildren, QueueStatusAttrs]):
status_icons = {
"pending": "bi-clock",
"processing": "bi-arrow-repeat",
+ "extracting": "bi-file-text",
+ "synthesizing": "bi-mic",
+ "uploading": "bi-cloud-arrow-up",
"error": "bi-exclamation-triangle",
"cancelled": "bi-x-circle",
}
@@ -378,6 +385,11 @@ class QueueStatus(Component[AnyChildren, QueueStatusAttrs]):
badge_class = status_classes.get(item["status"], "bg-secondary")
icon_class = status_icons.get(item["status"], "bi-question-circle")
+ # Get queue position for pending items
+ queue_pos = None
+ if item["status"] == "pending":
+ queue_pos = Core.Database.get_queue_position(item["id"])
+
queue_items.append(
html.div(
html.div(
@@ -429,6 +441,16 @@ class QueueStatus(Component[AnyChildren, QueueStatusAttrs]):
f"Created: {item['created_at']}",
classes=["text-muted", "d-block", "mt-1"],
),
+ # Display queue position if available
+ html.small(
+ html.i(
+ classes=["bi", "bi-hourglass-split", "me-1"],
+ ),
+ f"Position in queue: #{queue_pos}",
+ classes=["text-info", "d-block", "mt-1"],
+ )
+ if queue_pos
+ else html.span(),
*(
[
html.div(
@@ -456,6 +478,33 @@ class QueueStatus(Component[AnyChildren, QueueStatusAttrs]):
),
# Add cancel button for pending jobs, remove for others
html.div(
+ # Retry button for error items
+ html.button(
+ html.i(
+ classes=[
+ "bi",
+ "bi-arrow-clockwise",
+ "me-1",
+ ],
+ ),
+ "Retry",
+ hx_post=f"/queue/{item['id']}/retry",
+ hx_trigger="click",
+ hx_on=(
+ "htmx:afterRequest: "
+ "if(event.detail.successful) "
+ "htmx.trigger('body', 'queue-updated')"
+ ),
+ classes=[
+ "btn",
+ "btn-sm",
+ "btn-outline-primary",
+ "mt-2",
+ "me-2",
+ ],
+ )
+ if item["status"] == "error"
+ else html.span(),
html.button(
html.i(classes=["bi", "bi-x-lg", "me-1"]),
"Cancel",
@@ -1003,6 +1052,29 @@ def upgrade(request: Request) -> RedirectResponse:
return RedirectResponse(url="/pricing?error=checkout_failed")
+@app.post("/logout")
+def logout(request: Request) -> RedirectResponse:
+ """Log out user."""
+ request.session.clear()
+ return RedirectResponse(url="/", status_code=303)
+
+
+@app.post("/billing/portal")
+def billing_portal(request: Request) -> RedirectResponse:
+ """Redirect to Stripe billing portal."""
+ user_id = request.session.get("user_id")
+ if not user_id:
+ return RedirectResponse(url="/?error=login_required")
+
+ try:
+ portal_url = Billing.create_portal_session(user_id, BASE_URL)
+ return RedirectResponse(url=portal_url, status_code=303)
+ except ValueError as e:
+ logger.warning("Failed to create portal session: %s", e)
+ # If user has no customer ID (e.g. free tier), redirect to pricing
+ return RedirectResponse(url="/pricing")
+
+
def _handle_test_login(email: str, request: Request) -> Response:
"""Handle login in test mode."""
# Special handling for demo account
@@ -1147,187 +1219,187 @@ def verify_magic_link(request: Request) -> Response:
return RedirectResponse("/?error=expired_link")
-@app.get("/account")
-def account_page(request: Request) -> UI.PageLayout | RedirectResponse:
- """Account management page."""
+@app.get("/settings/email/edit")
+def edit_email_form(request: Request) -> Response:
+ """Return form to edit email."""
user_id = request.session.get("user_id")
if not user_id:
- return RedirectResponse(url="/?error=login_required")
+ return Response("Unauthorized", status_code=401)
user = Core.Database.get_user_by_id(user_id)
if not user:
- return RedirectResponse(url="/?error=user_not_found")
-
- # Get subscription details
- tier = user.get("plan_tier", "free")
- tier_info = Billing.get_tier_info(tier)
- subscription_status = user.get("subscription_status", "")
- cancel_at_period_end = user.get("cancel_at_period_end", 0) == 1
-
- return UI.PageLayout(
- html.h2(
- html.i(
- classes=["bi", "bi-person-circle", "me-2"],
+ return Response("User not found", status_code=404)
+
+ return html.div(
+ html.form(
+ html.strong("Email: ", classes=["me-2"]),
+ html.input(
+ type="email",
+ name="email",
+ value=user["email"],
+ required=True,
+ classes=[
+ "form-control",
+ "form-control-sm",
+ "d-inline-block",
+ "w-auto",
+ "me-2",
+ ],
),
- "Account Management",
- classes=["mb-4"],
- ),
- html.div(
- html.h4(
- html.i(classes=["bi", "bi-envelope-fill", "me-2"]),
- "Account Information",
- classes=["card-header", "bg-transparent"],
+ html.button(
+ "Save",
+ type="submit",
+ classes=["btn", "btn-sm", "btn-primary", "me-1"],
),
- html.div(
- html.div(
- html.strong("Email: "),
- user["email"],
- classes=["mb-2"],
- ),
- html.div(
- html.strong("Account Created: "),
- user["created_at"],
- classes=["mb-2"],
- ),
- classes=["card-body"],
+ html.button(
+ "Cancel",
+ hx_get="/settings/email/cancel",
+ hx_target="closest div",
+ hx_swap="outerHTML",
+ classes=["btn", "btn-sm", "btn-secondary"],
),
- classes=["card", "mb-4"],
+ hx_post="/settings/email",
+ hx_target="closest div",
+ hx_swap="outerHTML",
+ classes=["d-flex", "align-items-center"],
),
- html.div(
- html.h4(
- html.i(
- classes=["bi", "bi-credit-card-fill", "me-2"],
- ),
- "Subscription",
- classes=["card-header", "bg-transparent"],
- ),
- html.div(
- html.div(
- html.strong("Plan: "),
- tier_info["name"],
- f" ({tier_info['price']})",
- classes=["mb-2"],
- ),
- html.div(
- html.strong("Status: "),
- subscription_status.title()
- if subscription_status
- else "Active",
- classes=["mb-2"],
- )
- if tier == "paid"
- else html.div(),
- html.div(
- html.i(
- classes=[
- "bi",
- "bi-info-circle",
- "me-1",
- ],
- ),
- "Your subscription will cancel at the end "
- "of the billing period.",
- classes=[
- "alert",
- "alert-warning",
- "mt-2",
- "mb-2",
- ],
- )
- if cancel_at_period_end
- else html.div(),
- html.div(
- html.strong("Features: "),
- tier_info["description"],
- classes=["mb-3"],
- ),
- html.div(
- html.a(
- html.i(
- classes=[
- "bi",
- "bi-arrow-up-circle",
- "me-1",
- ],
- ),
- "Upgrade to Paid Plan",
- href="#",
- hx_post="/billing/checkout",
- hx_vals='{"tier": "paid"}',
- classes=[
- "btn",
- "btn-success",
- "me-2",
- ],
- )
- if tier == "free"
- else html.form(
- html.button(
- html.i(
- classes=[
- "bi",
- "bi-gear-fill",
- "me-1",
- ],
- ),
- "Manage Subscription",
- type="submit",
- classes=[
- "btn",
- "btn-primary",
- "me-2",
- ],
- ),
- method="post",
- action="/billing/portal",
- ),
- ),
- classes=["card-body"],
- ),
- classes=["card", "mb-4"],
+ classes=["mb-2"],
+ )
+
+
+@app.get("/settings/email/cancel")
+def cancel_edit_email(request: Request) -> Response:
+ """Cancel email editing and show original view."""
+ user_id = request.session.get("user_id")
+ if not user_id:
+ return Response("Unauthorized", status_code=401)
+
+ user = Core.Database.get_user_by_id(user_id)
+ if not user:
+ return Response("User not found", status_code=404)
+
+ return html.div(
+ html.strong("Email: "),
+ html.span(user["email"]),
+ html.button(
+ "Change",
+ classes=[
+ "btn",
+ "btn-sm",
+ "btn-outline-secondary",
+ "ms-2",
+ "py-0",
+ ],
+ hx_get="/settings/email/edit",
+ hx_target="closest div",
+ hx_swap="outerHTML",
),
- html.div(
- html.h4(
- html.i(classes=["bi", "bi-sliders", "me-2"]),
- "Actions",
- classes=["card-header", "bg-transparent"],
- ),
- html.div(
- html.a(
- html.i(
- classes=[
- "bi",
- "bi-box-arrow-right",
- "me-1",
- ],
- ),
- "Logout",
- href="/logout",
+ classes=["mb-2", "d-flex", "align-items-center"],
+ )
+
+
+@app.post("/settings/email")
+def update_email(request: Request, data: FormData) -> Response:
+ """Update user email."""
+ user_id = request.session.get("user_id")
+ if not user_id:
+ return Response("Unauthorized", status_code=401)
+
+ new_email_raw = data.get("email", "")
+ new_email = (
+ new_email_raw.strip().lower() if isinstance(new_email_raw, str) else ""
+ )
+
+ if not new_email:
+ return Response("Email required", status_code=400)
+
+ try:
+ Core.Database.update_user_email(user_id, new_email)
+ return cancel_edit_email(request)
+ except ValueError as e:
+ # Return form with error
+ return html.div(
+ html.form(
+ html.strong("Email: ", classes=["me-2"]),
+ html.input(
+ type="email",
+ name="email",
+ value=new_email,
+ required=True,
classes=[
- "btn",
- "btn-outline-secondary",
- "mb-2",
+ "form-control",
+ "form-control-sm",
+ "d-inline-block",
+ "w-auto",
"me-2",
+ "is-invalid",
],
),
- classes=["card-body"],
+ html.button(
+ "Save",
+ type="submit",
+ classes=["btn", "btn-sm", "btn-primary", "me-1"],
+ ),
+ html.button(
+ "Cancel",
+ hx_get="/settings/email/cancel",
+ hx_target="closest div",
+ hx_swap="outerHTML",
+ classes=["btn", "btn-sm", "btn-secondary"],
+ ),
+ html.div(
+ str(e),
+ classes=["invalid-feedback", "d-block", "ms-2"],
+ ),
+ hx_post="/settings/email",
+ hx_target="closest div",
+ hx_swap="outerHTML",
+ classes=["d-flex", "align-items-center", "flex-wrap"],
),
- classes=["card", "mb-4"],
- ),
+ classes=["mb-2"],
+ )
+
+
+@app.get("/account")
+def account_page(request: Request) -> UI.AccountPage | RedirectResponse:
+ """Account management page."""
+ user_id = request.session.get("user_id")
+ if not user_id:
+ return RedirectResponse(url="/?error=login_required")
+
+ user = Core.Database.get_user_by_id(user_id)
+ if not user:
+ return RedirectResponse(url="/?error=user_not_found")
+
+ # Get usage stats
+ period_start, period_end = Billing.get_period_boundaries(user)
+ usage = Billing.get_usage(user["id"], period_start, period_end)
+
+ # Get limits
+ tier = user.get("plan_tier", "free")
+ limits = Billing.TIER_LIMITS.get(tier, Billing.TIER_LIMITS["free"])
+
+ return UI.AccountPage(
user=user,
- current_page="account",
- error=None,
+ usage=usage,
+ limits=limits,
+ portal_url="/billing/portal" if tier == "paid" else None,
)
-@app.get("/logout")
-def logout(request: Request) -> Response:
- """Handle logout."""
+@app.delete("/account")
+def delete_account(request: Request) -> Response:
+ """Delete user account."""
+ user_id = request.session.get("user_id")
+ if not user_id:
+ return RedirectResponse(url="/?error=login_required")
+
+ Core.Database.delete_user(user_id)
request.session.clear()
+
return Response(
- "",
- status_code=302,
- headers={"Location": "/"},
+ "Account deleted",
+ headers={"HX-Redirect": "/?message=account_deleted"},
)
@@ -1705,21 +1777,6 @@ def billing_checkout(request: Request, data: FormData) -> Response:
return Response(f"Error: {e!s}", status_code=400)
-@app.post("/billing/portal")
-def billing_portal(request: Request) -> Response | RedirectResponse:
- """Create Stripe Billing Portal session."""
- user_id = request.session.get("user_id")
- if not user_id:
- return Response("Unauthorized", status_code=401)
-
- try:
- portal_url = Billing.create_portal_session(user_id, BASE_URL)
- return RedirectResponse(url=portal_url, status_code=303)
- except Exception:
- logger.exception("Portal error - ensure Stripe portal is configured")
- return Response("Portal not configured", status_code=500)
-
-
@app.post("/stripe/webhook")
async def stripe_webhook(request: Request) -> Response:
"""Handle Stripe webhook events."""
@@ -2412,7 +2469,7 @@ class TestMetricsDashboard(BaseWebTest):
tier="paid",
cancel_at_period_end=False,
)
-
+
# Create a queue item
Core.Database.add_to_queue(
"https://example.com/new",
@@ -3195,6 +3252,202 @@ class TestUsageLimits(BaseWebTest):
self.assertEqual(usage["articles"], 20)
+class TestAccountPage(BaseWebTest):
+ """Test account page functionality."""
+
+ def setUp(self) -> None:
+ """Set up test with user."""
+ super().setUp()
+ self.user_id, _ = Core.Database.create_user(
+ "test@example.com",
+ status="active",
+ )
+ self.client.post("/login", data={"email": "test@example.com"})
+
+ def test_account_page_logged_in(self) -> None:
+ """Account page should render for logged-in users."""
+ # Create some usage to verify stats are shown
+ ep_id = Core.Database.create_episode(
+ title="Test Episode",
+ audio_url="https://example.com/audio.mp3",
+ duration=300,
+ content_length=1000,
+ user_id=self.user_id,
+ author="Test Author",
+ original_url="https://example.com/article",
+ original_url_hash=Core.hash_url("https://example.com/article"),
+ )
+ Core.Database.add_episode_to_user(self.user_id, ep_id)
+
+ response = self.client.get("/account")
+
+ self.assertEqual(response.status_code, 200)
+ self.assertIn("My Account", response.text)
+ self.assertIn("test@example.com", response.text)
+ self.assertIn("1 / 10", response.text) # Usage / Limit for free tier
+
+ def test_account_page_login_required(self) -> None:
+ """Should redirect to login if not logged in."""
+ self.client.post("/logout")
+ response = self.client.get("/account", follow_redirects=False)
+ self.assertEqual(response.status_code, 307)
+ self.assertEqual(response.headers["location"], "/?error=login_required")
+
+ def test_logout(self) -> None:
+ """Logout should clear session."""
+ response = self.client.post("/logout", follow_redirects=False)
+ self.assertEqual(response.status_code, 303)
+ self.assertEqual(response.headers["location"], "/")
+
+ # Verify session cleared
+ response = self.client.get("/account", follow_redirects=False)
+ self.assertEqual(response.status_code, 307)
+
+ def test_billing_portal_redirect(self) -> None:
+ """Billing portal should redirect to Stripe."""
+ # First set a customer ID
+ Core.Database.set_user_stripe_customer(self.user_id, "cus_test")
+
+ # Mock the create_portal_session method
+ with patch(
+ "Biz.PodcastItLater.Billing.create_portal_session",
+ ) as mock_portal:
+ mock_portal.return_value = "https://billing.stripe.com/test"
+
+ response = self.client.post(
+ "/billing/portal",
+ follow_redirects=False,
+ )
+
+ self.assertEqual(response.status_code, 303)
+ self.assertEqual(
+ response.headers["location"],
+ "https://billing.stripe.com/test",
+ )
+
+ def test_update_email_success(self) -> None:
+ """Should allow updating email."""
+ # POST new email
+ response = self.client.post(
+ "/settings/email",
+ data={"email": "new@example.com"},
+ )
+ self.assertEqual(response.status_code, 200)
+
+ # Verify update in DB
+ user = Core.Database.get_user_by_id(self.user_id)
+ self.assertEqual(user["email"], "new@example.com") # type: ignore[index]
+
+ def test_update_email_duplicate(self) -> None:
+ """Should prevent updating to existing email."""
+ # Create another user
+ Core.Database.create_user("other@example.com")
+
+ # Try to update to their email
+ response = self.client.post(
+ "/settings/email",
+ data={"email": "other@example.com"},
+ )
+
+ # Should show error (return 200 with error message in form)
+ self.assertEqual(response.status_code, 200)
+ self.assertIn("already taken", response.text.lower())
+
+ def test_delete_account(self) -> None:
+ """Should allow user to delete their account."""
+ # Delete account
+ response = self.client.delete("/account")
+ self.assertEqual(response.status_code, 200)
+ self.assertIn("HX-Redirect", response.headers)
+
+ # Verify user gone
+ user = Core.Database.get_user_by_id(self.user_id)
+ self.assertIsNone(user)
+
+ # Verify session cleared
+ response = self.client.get("/account", follow_redirects=False)
+ self.assertEqual(response.status_code, 307)
+
+
+class TestAdminUsers(BaseWebTest):
+ """Test admin user management functionality."""
+
+ def setUp(self) -> None:
+ """Set up test client with logged-in admin user."""
+ super().setUp()
+
+ # Create and login admin user
+ self.user_id, _ = Core.Database.create_user(
+ "ben@bensima.com",
+ )
+ Core.Database.update_user_status(
+ self.user_id,
+ "active",
+ )
+ self.client.post("/login", data={"email": "ben@bensima.com"})
+
+ # Create another regular user
+ self.other_user_id, _ = Core.Database.create_user("user@example.com")
+ Core.Database.update_user_status(self.other_user_id, "active")
+
+ def test_admin_users_page_access(self) -> None:
+ """Admin can access users page."""
+ response = self.client.get("/admin/users")
+ self.assertEqual(response.status_code, 200)
+ self.assertIn("User Management", response.text)
+ self.assertIn("user@example.com", response.text)
+
+ def test_non_admin_users_page_access(self) -> None:
+ """Non-admin cannot access users page."""
+ # Login as regular user
+ self.client.get("/logout")
+ self.client.post("/login", data={"email": "user@example.com"})
+
+ response = self.client.get("/admin/users")
+ self.assertEqual(response.status_code, 302)
+ self.assertIn("error=forbidden", response.headers["Location"])
+
+ def test_admin_can_update_user_status(self) -> None:
+ """Admin can update user status."""
+ response = self.client.post(
+ f"/admin/users/{self.other_user_id}/status",
+ data={"status": "disabled"},
+ )
+ self.assertEqual(response.status_code, 200)
+
+ user = Core.Database.get_user_by_id(self.other_user_id)
+ assert user is not None # noqa: S101
+ self.assertEqual(user["status"], "disabled")
+
+ def test_non_admin_cannot_update_user_status(self) -> None:
+ """Non-admin cannot update user status."""
+ # Login as regular user
+ self.client.get("/logout")
+ self.client.post("/login", data={"email": "user@example.com"})
+
+ response = self.client.post(
+ f"/admin/users/{self.other_user_id}/status",
+ data={"status": "disabled"},
+ )
+ self.assertEqual(response.status_code, 403)
+
+ user = Core.Database.get_user_by_id(self.other_user_id)
+ assert user is not None # noqa: S101
+ self.assertEqual(user["status"], "active")
+
+ def test_update_user_status_invalid_status(self) -> None:
+ """Invalid status validation."""
+ response = self.client.post(
+ f"/admin/users/{self.other_user_id}/status",
+ data={"status": "invalid_status"},
+ )
+ self.assertEqual(response.status_code, 400)
+
+ user = Core.Database.get_user_by_id(self.other_user_id)
+ assert user is not None # noqa: S101
+ self.assertEqual(user["status"], "active")
+
+
def test() -> None:
"""Run all tests for the web module."""
Test.run(
@@ -3211,6 +3464,8 @@ def test() -> None:
TestEpisodeDeduplication,
TestMetricsTracking,
TestUsageLimits,
+ TestAccountPage,
+ TestAdminUsers,
],
)
diff --git a/Biz/PodcastItLater/Worker.py b/Biz/PodcastItLater/Worker.py
index 5203490..251f614 100644
--- a/Biz/PodcastItLater/Worker.py
+++ b/Biz/PodcastItLater/Worker.py
@@ -60,6 +60,8 @@ MAX_RETRIES = 3
TTS_MODEL = "tts-1"
TTS_VOICE = "alloy"
MEMORY_THRESHOLD = 80 # Percentage threshold for memory usage
+CROSSFADE_DURATION = 500 # ms for crossfading segments
+PAUSE_DURATION = 1000 # ms for silence between segments
class ShutdownHandler:
@@ -358,7 +360,7 @@ class ArticleProcessor:
content_audio: bytes,
outro_audio: bytes,
) -> bytes:
- """Combine intro, content, and outro with 1-second pauses.
+ """Combine intro, content, and outro with crossfades.
Args:
intro_audio: MP3 bytes for intro
@@ -373,11 +375,27 @@ class ArticleProcessor:
content = AudioSegment.from_mp3(io.BytesIO(content_audio))
outro = AudioSegment.from_mp3(io.BytesIO(outro_audio))
- # Create 1-second silence
- pause = AudioSegment.silent(duration=1000) # milliseconds
+ # Create bridge silence (pause + 2 * crossfade to account for overlap)
+ bridge = AudioSegment.silent(duration=PAUSE_DURATION + 2 * CROSSFADE_DURATION)
- # Combine segments with pauses
- combined = intro + pause + content + pause + outro
+ def safe_append(seg1: AudioSegment, seg2: AudioSegment, crossfade: int) -> AudioSegment:
+ if len(seg1) < crossfade or len(seg2) < crossfade:
+ logger.warning(
+ "Segment too short for crossfade (%dms vs %dms/%dms), using concatenation",
+ crossfade,
+ len(seg1),
+ len(seg2),
+ )
+ return seg1 + seg2
+ return seg1.append(seg2, crossfade=crossfade)
+
+ # Combine segments with crossfades
+ # Intro -> Bridge -> Content -> Bridge -> Outro
+ # This effectively fades out the previous segment and fades in the next one
+ combined = safe_append(intro, bridge, CROSSFADE_DURATION)
+ combined = safe_append(combined, content, CROSSFADE_DURATION)
+ combined = safe_append(combined, bridge, CROSSFADE_DURATION)
+ combined = safe_append(combined, outro, CROSSFADE_DURATION)
# Export to bytes
output = io.BytesIO()
@@ -620,6 +638,7 @@ class ArticleProcessor:
return
# Step 1: Extract article content
+ Core.Database.update_job_status(job_id, "extracting")
title, content, author, pub_date = (
ArticleProcessor.extract_article_content(url)
)
@@ -630,6 +649,7 @@ class ArticleProcessor:
return
# Step 2: Generate audio with metadata
+ Core.Database.update_job_status(job_id, "synthesizing")
audio_data = self.text_to_speech(content, title, author, pub_date)
if self.shutdown_handler.is_shutdown_requested():
@@ -638,6 +658,7 @@ class ArticleProcessor:
return
# Step 3: Upload to S3
+ Core.Database.update_job_status(job_id, "uploading")
filename = ArticleProcessor.generate_filename(job_id, title)
audio_url = self.upload_to_s3(audio_data, filename)
@@ -2039,6 +2060,117 @@ class TestJobProcessing(Test.TestCase):
mock_update.assert_not_called()
+class TestWorkerErrorHandling(Test.TestCase):
+ """Test worker error handling and recovery."""
+
+ def setUp(self) -> None:
+ """Set up test environment."""
+ Core.Database.init_db()
+ self.user_id, _ = Core.Database.create_user("test@example.com")
+ self.job_id = Core.Database.add_to_queue(
+ "https://example.com",
+ "test@example.com",
+ self.user_id,
+ )
+ self.shutdown_handler = ShutdownHandler()
+ self.processor = ArticleProcessor(self.shutdown_handler)
+
+ @staticmethod
+ def tearDown() -> None:
+ """Clean up."""
+ Core.Database.teardown()
+
+ def test_process_pending_jobs_exception_handling(self) -> None:
+ """Test that process_pending_jobs handles exceptions."""
+
+ def side_effect(job: dict[str, Any]) -> None:
+ # Simulate process_job starting and setting status to processing
+ Core.Database.update_job_status(job["id"], "processing")
+ msg = "Unexpected Error"
+ raise ValueError(msg)
+
+ with (
+ unittest.mock.patch.object(
+ self.processor,
+ "process_job",
+ side_effect=side_effect,
+ ),
+ unittest.mock.patch(
+ "Biz.PodcastItLater.Core.Database.update_job_status",
+ side_effect=Core.Database.update_job_status,
+ ) as _mock_update,
+ ):
+ process_pending_jobs(self.processor)
+
+ # Job should be marked as error
+ job = Core.Database.get_job_by_id(self.job_id)
+ self.assertIsNotNone(job)
+ if job:
+ self.assertEqual(job["status"], "error")
+ self.assertIn("Unexpected Error", job["error_message"])
+
+ def test_process_retryable_jobs_success(self) -> None:
+ """Test processing of retryable jobs."""
+ # Set up a retryable job
+ Core.Database.update_job_status(self.job_id, "error", "Fail 1")
+
+ # Modify created_at to be in the past to satisfy backoff
+ with Core.Database.get_connection() as conn:
+ conn.execute(
+ "UPDATE queue SET created_at = ? WHERE id = ?",
+ (
+ (
+ datetime.now(tz=timezone.utc) - timedelta(minutes=5)
+ ).isoformat(),
+ self.job_id,
+ ),
+ )
+ conn.commit()
+
+ process_retryable_jobs()
+
+ job = Core.Database.get_job_by_id(self.job_id)
+ self.assertIsNotNone(job)
+ if job:
+ self.assertEqual(job["status"], "pending")
+
+ def test_process_retryable_jobs_not_ready(self) -> None:
+ """Test that jobs are not retried before backoff period."""
+ # Set up a retryable job that just failed
+ Core.Database.update_job_status(self.job_id, "error", "Fail 1")
+
+ # created_at is now, so backoff should prevent retry
+ process_retryable_jobs()
+
+ job = Core.Database.get_job_by_id(self.job_id)
+ self.assertIsNotNone(job)
+ if job:
+ self.assertEqual(job["status"], "error")
+
+
+class TestTextChunking(Test.TestCase):
+ """Test text chunking edge cases."""
+
+ def test_split_text_single_long_word(self) -> None:
+ """Handle text with a single word exceeding limit."""
+ long_word = "a" * 4000
+ chunks = split_text_into_chunks(long_word, max_chars=3000)
+
+ # Should keep it as one chunk or split?
+ # The current implementation does not split words
+ self.assertEqual(len(chunks), 1)
+ self.assertEqual(len(chunks[0]), 4000)
+
+ def test_split_text_no_sentence_boundaries(self) -> None:
+ """Handle long text with no sentence boundaries."""
+ text = "word " * 1000 # 5000 chars
+ chunks = split_text_into_chunks(text, max_chars=3000)
+
+ # Should keep it as one chunk as it can't split by ". "
+ self.assertEqual(len(chunks), 1)
+ self.assertGreater(len(chunks[0]), 3000)
+
+
def test() -> None:
"""Run the tests."""
Test.run(
@@ -2048,6 +2180,8 @@ def test() -> None:
TestTextToSpeech,
TestMemoryEfficiency,
TestJobProcessing,
+ TestWorkerErrorHandling,
+ TestTextChunking,
],
)
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
diff --git a/worker.log b/worker.log
new file mode 100644
index 0000000..cd8b451
--- /dev/null
+++ b/worker.log
@@ -0,0 +1,29 @@
+Starting Worker Agent Loop
+ Worker Path: /home/ben/omni-worker-1
+ Amp Binary: /home/ben/omni/node_modules/.bin/amp
+ Log File: /home/ben/omni-worker-1/_/llm/amp.log
+ Monitor: tail -f /home/ben/omni-worker-1/_/llm/amp.log
+ Press Ctrl+C to stop.
+----------------------------------------------------------------
+Thu Nov 20 11:30:48 PM EST 2025: Syncing and checking for work...
+Warning: Rebase conflict at start of loop. Aborting rebase and proceeding with local state.
+branchless: processing 1 update: branch omni-worker-1
+branchless: processing 1 update: ref HEAD
+Syncing tasks...
+Importing tasks from live branch...
+Imported tasks from .tasks/live-tasks.jsonl
+Exported and consolidated tasks to .tasks/tasks.jsonl
+Thu Nov 20 11:30:51 PM EST 2025: Claiming task t-144gQry: Create basic admin dashboard
+info: version: .tasks/tasks.jsonl: 1231
+Exported and consolidated tasks to .tasks/tasks.jsonl
+gitlint: checking commit message...
+gitlint: OK (no violations in commit message)
+branchless: processing 1 update: branch omni-worker-1
+branchless: processed commit: b6af81e task: sync database
+Resuming existing branch task/t-144gQry
+branchless: processing 1 update: ref HEAD
+Switched to branch 'task/t-144gQry'
+Imported tasks from .tasks/tasks.jsonl
+branchless: processing checkout
+Launching Amp to implement task...
+./Omni/Agent/start-worker.sh: line 190: 917703 Killed "$AMP_BIN" --log-level debug --log-file "_/llm/amp.log" --dangerously-allow-all -x "$PROMPT"