Spaces:
Sleeping
Sleeping
Implement task notification system with cron job and ntfy integration
Browse files- .env.example +24 -0
- .gitignore +4 -1
- README.md +111 -7
- app.py +10 -0
- cron.py +264 -0
- pyproject.toml +1 -0
- requirements.txt +4 -0
- uv.lock +26 -0
.env.example
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Rowmind Environment Variables
|
| 2 |
+
# Copy this file to .env and fill in your values
|
| 3 |
+
|
| 4 |
+
# ===================
|
| 5 |
+
# ntfy Configuration
|
| 6 |
+
# ===================
|
| 7 |
+
# Server URL (default: https://ntfy.sh, or use your self-hosted instance)
|
| 8 |
+
NTFY_SERVER=https://ntfy.sh
|
| 9 |
+
|
| 10 |
+
# Topic name - USE A RANDOM/UNGUESSABLE NAME for security!
|
| 11 |
+
# Example: rowmind-a3f8b2c1-notifications
|
| 12 |
+
# Generate one with: python -c "import uuid; print(f'rowmind-{uuid.uuid4()}')"
|
| 13 |
+
NTFY_TOPIC=rowmind-your-random-topic-here
|
| 14 |
+
|
| 15 |
+
# Optional: Access token for authentication (recommended for public repos)
|
| 16 |
+
# Create one at https://ntfy.sh/account or your self-hosted instance
|
| 17 |
+
# This prevents others from sending notifications to your topic
|
| 18 |
+
NTFY_TOKEN=
|
| 19 |
+
|
| 20 |
+
# ===================
|
| 21 |
+
# Cron Configuration
|
| 22 |
+
# ===================
|
| 23 |
+
# How often to check for due tasks (in seconds)
|
| 24 |
+
CRON_INTERVAL_SECONDS=60
|
.gitignore
CHANGED
|
@@ -1,4 +1,7 @@
|
|
| 1 |
data/*
|
| 2 |
plan.md
|
| 3 |
__pycache__/
|
| 4 |
-
*.log
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
data/*
|
| 2 |
plan.md
|
| 3 |
__pycache__/
|
| 4 |
+
*.log
|
| 5 |
+
|
| 6 |
+
# Environment variables (contains secrets)
|
| 7 |
+
.env
|
README.md
CHANGED
|
@@ -20,6 +20,43 @@ tags:
|
|
| 20 |
|
| 21 |
## Rowmind MCP Server
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
### Idea
|
| 24 |
|
| 25 |
Rowmind is a small local “memory service” that you plug into any MCP‑compatible chat client (ChatGPT, Claude, Cursor, etc.).
|
|
@@ -80,15 +117,82 @@ Rowmind exposes a small set of tools and one resource over MCP:
|
|
| 80 |
|
| 81 |
The same pattern works for expenses, mood logs, trips, or any other table you define.
|
| 82 |
|
| 83 |
-
### Reminders
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
|
| 85 |
-
|
| 86 |
|
| 87 |
-
-
|
| 88 |
-
-
|
| 89 |
-
-
|
| 90 |
-
-
|
| 91 |
-
- If `schedule_type="one_time"` or `interval_seconds=0`, mark the task as `done`; otherwise add `interval_seconds` to `scheduled_at_epoch` and keep it `pending`.
|
| 92 |
|
| 93 |
Because everything is stored as epoch timestamps, the worker only does integer comparisons and additions.
|
| 94 |
|
|
|
|
| 20 |
|
| 21 |
## Rowmind MCP Server
|
| 22 |
|
| 23 |
+
### 🧠 How it Works
|
| 24 |
+
|
| 25 |
+
```mermaid
|
| 26 |
+
graph LR
|
| 27 |
+
User[User / Chat Client] <-->|MCP Protocol| Server[Rowmind Server]
|
| 28 |
+
Server <-->|Reads/Writes| CSVs[(Local CSV Files)]
|
| 29 |
+
|
| 30 |
+
subgraph "Your Machine / Private Space"
|
| 31 |
+
Server
|
| 32 |
+
CSVs
|
| 33 |
+
end
|
| 34 |
+
|
| 35 |
+
style User fill:#f9f,stroke:#333,stroke-width:2px
|
| 36 |
+
style Server fill:#bbf,stroke:#333,stroke-width:2px
|
| 37 |
+
style CSVs fill:#bfb,stroke:#333,stroke-width:2px
|
| 38 |
+
```
|
| 39 |
+
|
| 40 |
+
### 🚀 Why Rowmind?
|
| 41 |
+
|
| 42 |
+
```mermaid
|
| 43 |
+
mindmap
|
| 44 |
+
root((Rowmind))
|
| 45 |
+
Privacy
|
| 46 |
+
Data lives locally
|
| 47 |
+
You own your memory
|
| 48 |
+
No external DB
|
| 49 |
+
Simplicity
|
| 50 |
+
Plain text CSVs
|
| 51 |
+
Easy to backup
|
| 52 |
+
Easy to edit
|
| 53 |
+
Control
|
| 54 |
+
Custom Tables
|
| 55 |
+
Your own Schema
|
| 56 |
+
Hackable
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
|
| 60 |
### Idea
|
| 61 |
|
| 62 |
Rowmind is a small local “memory service” that you plug into any MCP‑compatible chat client (ChatGPT, Claude, Cursor, etc.).
|
|
|
|
| 117 |
|
| 118 |
The same pattern works for expenses, mood logs, trips, or any other table you define.
|
| 119 |
|
| 120 |
+
### Reminders & Notifications 🔔
|
| 121 |
+
|
| 122 |
+
> **🧪 Hackathon Experiment**: This feature explores how an MCP server can go beyond just storing data—it can actively reach out to you when something needs attention. Instead of you asking "do I have any pending tasks?", Rowmind tells you.
|
| 123 |
+
|
| 124 |
+
#### Why ntfy?
|
| 125 |
+
|
| 126 |
+
We wanted the simplest possible way to send push notifications without building a mobile app or dealing with Firebase/APNs complexity. [ntfy](https://ntfy.sh) is perfect for this:
|
| 127 |
+
|
| 128 |
+
- **No account required** — just pick a topic name and subscribe
|
| 129 |
+
- **Works everywhere** — Android, iOS, web, CLI
|
| 130 |
+
- **Self-hostable** — if you need full control
|
| 131 |
+
- **HTTP-based** — a simple POST request sends a notification
|
| 132 |
+
|
| 133 |
+
This makes ntfy ideal for hackathon projects where you want real push notifications without the usual infrastructure overhead.
|
| 134 |
+
|
| 135 |
+
#### How it works
|
| 136 |
+
|
| 137 |
+
Rowmind runs a **background worker** (using APScheduler) that:
|
| 138 |
+
|
| 139 |
+
1. Checks every 60 seconds for tasks where `scheduled_at_epoch <= now`
|
| 140 |
+
2. Sends a push notification via ntfy with the task description
|
| 141 |
+
3. Marks one-time tasks as `done`, or reschedules recurring tasks
|
| 142 |
+
|
| 143 |
+
```text
|
| 144 |
+
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
| 145 |
+
│ tasks.csv │ --> │ Cron Worker │ --> │ ntfy │ --> 📱 Your phone
|
| 146 |
+
│ (pending) │ │ (every 60s) │ │ (HTTP) │
|
| 147 |
+
└─────────────┘ └─────────────┘ └─────────────┘
|
| 148 |
+
```
|
| 149 |
+
|
| 150 |
+
#### Quick setup
|
| 151 |
+
|
| 152 |
+
1. **Copy the environment template:**
|
| 153 |
+
|
| 154 |
+
```bash
|
| 155 |
+
cp .env.example .env
|
| 156 |
+
```
|
| 157 |
+
|
| 158 |
+
2. **Generate a random topic name** (important for security):
|
| 159 |
+
|
| 160 |
+
```bash
|
| 161 |
+
python -c "import uuid; print(f'rowmind-{uuid.uuid4()}')"
|
| 162 |
+
```
|
| 163 |
+
|
| 164 |
+
3. **Configure your `.env` file:**
|
| 165 |
+
|
| 166 |
+
```env
|
| 167 |
+
# Required: Use a random topic name for security
|
| 168 |
+
NTFY_TOPIC=rowmind-your-random-topic-here
|
| 169 |
+
|
| 170 |
+
# Optional: ntfy server (defaults to https://ntfy.sh)
|
| 171 |
+
NTFY_SERVER=https://ntfy.sh
|
| 172 |
+
|
| 173 |
+
# Optional but recommended: Access token for authentication
|
| 174 |
+
NTFY_TOKEN=tk_your_access_token
|
| 175 |
+
|
| 176 |
+
# Optional: Check interval in seconds (default: 60)
|
| 177 |
+
CRON_INTERVAL_SECONDS=60
|
| 178 |
+
```
|
| 179 |
+
|
| 180 |
+
4. **Subscribe to your topic** in the ntfy app ([Android](https://play.google.com/store/apps/details?id=io.heckel.ntfy), [iOS](https://apps.apple.com/app/ntfy/id1625396347), or [web](https://ntfy.sh))
|
| 181 |
+
|
| 182 |
+
5. **Run the app** — the scheduler starts automatically:
|
| 183 |
+
|
| 184 |
+
```bash
|
| 185 |
+
python app.py
|
| 186 |
+
```
|
| 187 |
+
|
| 188 |
+
#### Security considerations
|
| 189 |
|
| 190 |
+
Since this is a public repository:
|
| 191 |
|
| 192 |
+
- **Never commit your `.env` file** (it's in `.gitignore`)
|
| 193 |
+
- Use a **random/unguessable topic name** — anyone who knows your topic can send you notifications
|
| 194 |
+
- Consider using **ntfy access tokens** for authentication
|
| 195 |
+
- For production use, run your own [self-hosted ntfy server](https://docs.ntfy.sh/install/)
|
|
|
|
| 196 |
|
| 197 |
Because everything is stored as epoch timestamps, the worker only does integer comparisons and additions.
|
| 198 |
|
app.py
CHANGED
|
@@ -2,6 +2,9 @@ import os
|
|
| 2 |
from typing import List, Dict
|
| 3 |
|
| 4 |
import gradio as gr
|
|
|
|
|
|
|
|
|
|
| 5 |
from helper import (
|
| 6 |
get_csv_path,
|
| 7 |
get_current_time_epoch,
|
|
@@ -411,5 +414,12 @@ with gr.Blocks(title="Rowmind MCP Server") as demo:
|
|
| 411 |
api_name="update_row",
|
| 412 |
)
|
| 413 |
|
|
|
|
|
|
|
|
|
|
| 414 |
if __name__ == "__main__":
|
|
|
|
|
|
|
|
|
|
|
|
|
| 415 |
demo.launch(mcp_server=True)
|
|
|
|
| 2 |
from typing import List, Dict
|
| 3 |
|
| 4 |
import gradio as gr
|
| 5 |
+
from dotenv import load_dotenv
|
| 6 |
+
|
| 7 |
+
from cron import start_scheduler, is_scheduler_running
|
| 8 |
from helper import (
|
| 9 |
get_csv_path,
|
| 10 |
get_current_time_epoch,
|
|
|
|
| 414 |
api_name="update_row",
|
| 415 |
)
|
| 416 |
|
| 417 |
+
# Load environment variables
|
| 418 |
+
load_dotenv()
|
| 419 |
+
|
| 420 |
if __name__ == "__main__":
|
| 421 |
+
# Start the background cron scheduler for task notifications
|
| 422 |
+
if not is_scheduler_running():
|
| 423 |
+
start_scheduler()
|
| 424 |
+
|
| 425 |
demo.launch(mcp_server=True)
|
cron.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Cron worker for Rowmind task notifications.
|
| 3 |
+
|
| 4 |
+
This module provides a background scheduler that:
|
| 5 |
+
1. Checks for tasks with scheduled_at_epoch <= current time
|
| 6 |
+
2. Sends notifications via ntfy
|
| 7 |
+
3. Updates task status (done for one-time, recalculates next run for recurring)
|
| 8 |
+
|
| 9 |
+
Uses APScheduler as recommended by Gradio for background tasks.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import logging
|
| 13 |
+
import os
|
| 14 |
+
import time
|
| 15 |
+
from typing import Optional
|
| 16 |
+
|
| 17 |
+
import httpx
|
| 18 |
+
from dotenv import load_dotenv
|
| 19 |
+
from apscheduler.schedulers.background import BackgroundScheduler
|
| 20 |
+
|
| 21 |
+
from helper import (
|
| 22 |
+
execute_sql_query,
|
| 23 |
+
get_csv_path,
|
| 24 |
+
update_row_in_csv,
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
# Load environment variables
|
| 28 |
+
load_dotenv()
|
| 29 |
+
|
| 30 |
+
# Configure logging
|
| 31 |
+
logging.basicConfig(level=logging.INFO)
|
| 32 |
+
logger = logging.getLogger(__name__)
|
| 33 |
+
|
| 34 |
+
# ntfy Configuration
|
| 35 |
+
NTFY_SERVER = os.getenv("NTFY_SERVER", "https://ntfy.sh")
|
| 36 |
+
NTFY_TOPIC = os.getenv("NTFY_TOPIC")
|
| 37 |
+
NTFY_TOKEN = os.getenv("NTFY_TOKEN") # Optional, for authentication
|
| 38 |
+
|
| 39 |
+
# Cron Configuration
|
| 40 |
+
CRON_INTERVAL_SECONDS = int(os.getenv("CRON_INTERVAL_SECONDS", "60"))
|
| 41 |
+
|
| 42 |
+
# Global scheduler instance
|
| 43 |
+
_scheduler: Optional[BackgroundScheduler] = None
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def send_ntfy_notification(
|
| 47 |
+
message: str, priority: str = "default", tags: str = "bell"
|
| 48 |
+
) -> bool:
|
| 49 |
+
"""
|
| 50 |
+
Send a notification via ntfy.
|
| 51 |
+
|
| 52 |
+
Args:
|
| 53 |
+
message: The notification message body
|
| 54 |
+
priority: Priority level (min, low, default, high, urgent)
|
| 55 |
+
tags: Comma-separated emoji tags (e.g., "bell,warning")
|
| 56 |
+
|
| 57 |
+
Returns:
|
| 58 |
+
True if notification was sent successfully, False otherwise
|
| 59 |
+
"""
|
| 60 |
+
if not NTFY_TOPIC:
|
| 61 |
+
logger.warning("NTFY_TOPIC not configured. Skipping notification.")
|
| 62 |
+
return False
|
| 63 |
+
|
| 64 |
+
url = f"{NTFY_SERVER.rstrip('/')}/{NTFY_TOPIC}"
|
| 65 |
+
|
| 66 |
+
headers = {
|
| 67 |
+
"Title": "Rowmind Task",
|
| 68 |
+
"Priority": priority,
|
| 69 |
+
"Tags": tags,
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
# Add authentication if token is configured
|
| 73 |
+
if NTFY_TOKEN:
|
| 74 |
+
headers["Authorization"] = f"Bearer {NTFY_TOKEN}"
|
| 75 |
+
|
| 76 |
+
try:
|
| 77 |
+
response = httpx.post(
|
| 78 |
+
url,
|
| 79 |
+
content=message.encode("utf-8"),
|
| 80 |
+
headers=headers,
|
| 81 |
+
timeout=10.0,
|
| 82 |
+
)
|
| 83 |
+
response.raise_for_status()
|
| 84 |
+
logger.info("Notification sent successfully")
|
| 85 |
+
return True
|
| 86 |
+
except httpx.HTTPStatusError as e:
|
| 87 |
+
logger.error(
|
| 88 |
+
f"Failed to send notification (HTTP {e.response.status_code}): {e}"
|
| 89 |
+
)
|
| 90 |
+
return False
|
| 91 |
+
except httpx.RequestError as e:
|
| 92 |
+
logger.error(f"Failed to send notification (network error): {e}")
|
| 93 |
+
return False
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def get_due_tasks() -> list[dict]:
|
| 97 |
+
"""
|
| 98 |
+
Get all pending tasks that are due for notification.
|
| 99 |
+
|
| 100 |
+
Returns:
|
| 101 |
+
List of task dictionaries with rowid included
|
| 102 |
+
"""
|
| 103 |
+
now = int(time.time())
|
| 104 |
+
|
| 105 |
+
query = f"""
|
| 106 |
+
SELECT *, rowid
|
| 107 |
+
FROM tasks
|
| 108 |
+
WHERE status = 'pending'
|
| 109 |
+
AND scheduled_at_epoch > 0
|
| 110 |
+
AND scheduled_at_epoch <= {now}
|
| 111 |
+
ORDER BY scheduled_at_epoch ASC
|
| 112 |
+
"""
|
| 113 |
+
|
| 114 |
+
result = execute_sql_query(query)
|
| 115 |
+
|
| 116 |
+
# Filter out error responses
|
| 117 |
+
if result and "error" in result[0]:
|
| 118 |
+
logger.error(f"SQL error getting due tasks: {result[0]['error']}")
|
| 119 |
+
return []
|
| 120 |
+
|
| 121 |
+
return result
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def process_task(task: dict) -> None:
|
| 125 |
+
"""
|
| 126 |
+
Process a due task: send notification and update status.
|
| 127 |
+
|
| 128 |
+
Args:
|
| 129 |
+
task: Task dictionary from the database
|
| 130 |
+
"""
|
| 131 |
+
description = task.get("description", "Task reminder")
|
| 132 |
+
task_id = task.get("task_id", "unknown")
|
| 133 |
+
schedule_type = task.get("schedule_type", "one_time")
|
| 134 |
+
interval_seconds = int(task.get("interval_seconds", 0) or 0)
|
| 135 |
+
rowid = task.get("rowid")
|
| 136 |
+
|
| 137 |
+
logger.info(f"Processing task {task_id}: {description}")
|
| 138 |
+
|
| 139 |
+
# Send notification
|
| 140 |
+
if not send_ntfy_notification(message=description, tags="memo,bell"):
|
| 141 |
+
logger.warning(f"Failed to notify for task {task_id}, will retry next cycle")
|
| 142 |
+
return
|
| 143 |
+
|
| 144 |
+
# Update task status
|
| 145 |
+
tasks_csv = get_csv_path("tasks")
|
| 146 |
+
|
| 147 |
+
if schedule_type == "recurring" and interval_seconds > 0:
|
| 148 |
+
# Recurring task: update next scheduled time
|
| 149 |
+
current_scheduled = int(task.get("scheduled_at_epoch", 0) or 0)
|
| 150 |
+
next_scheduled = current_scheduled + interval_seconds
|
| 151 |
+
|
| 152 |
+
update_row_in_csv(
|
| 153 |
+
tasks_csv,
|
| 154 |
+
key_field="rowid",
|
| 155 |
+
key_value=rowid,
|
| 156 |
+
update_data={"scheduled_at_epoch": str(next_scheduled)},
|
| 157 |
+
)
|
| 158 |
+
logger.info(f"Task {task_id} rescheduled for epoch {next_scheduled}")
|
| 159 |
+
else:
|
| 160 |
+
# One-time task: mark as done
|
| 161 |
+
update_row_in_csv(
|
| 162 |
+
tasks_csv,
|
| 163 |
+
key_field="rowid",
|
| 164 |
+
key_value=rowid,
|
| 165 |
+
update_data={"status": "done"},
|
| 166 |
+
)
|
| 167 |
+
logger.info(f"Task {task_id} marked as done")
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def run_cron_job() -> None:
|
| 171 |
+
"""
|
| 172 |
+
Main cron job function. Checks for due tasks and processes them.
|
| 173 |
+
"""
|
| 174 |
+
logger.debug("Running cron job...")
|
| 175 |
+
|
| 176 |
+
due_tasks = get_due_tasks()
|
| 177 |
+
|
| 178 |
+
if not due_tasks:
|
| 179 |
+
logger.debug("No due tasks found")
|
| 180 |
+
return
|
| 181 |
+
|
| 182 |
+
logger.info(f"Found {len(due_tasks)} due task(s)")
|
| 183 |
+
|
| 184 |
+
for task in due_tasks:
|
| 185 |
+
try:
|
| 186 |
+
process_task(task)
|
| 187 |
+
except Exception as e:
|
| 188 |
+
logger.error(f"Error processing task {task.get('task_id', 'unknown')}: {e}")
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def start_scheduler() -> BackgroundScheduler:
|
| 192 |
+
"""
|
| 193 |
+
Start the background scheduler for the cron job.
|
| 194 |
+
|
| 195 |
+
Returns:
|
| 196 |
+
The started BackgroundScheduler instance
|
| 197 |
+
"""
|
| 198 |
+
global _scheduler
|
| 199 |
+
|
| 200 |
+
if _scheduler is not None and _scheduler.running:
|
| 201 |
+
logger.warning("Scheduler already running")
|
| 202 |
+
return _scheduler
|
| 203 |
+
|
| 204 |
+
if not NTFY_TOPIC:
|
| 205 |
+
logger.warning(
|
| 206 |
+
"NTFY_TOPIC not configured. Set it in .env to enable notifications. "
|
| 207 |
+
"Scheduler will still run but notifications won't be sent."
|
| 208 |
+
)
|
| 209 |
+
|
| 210 |
+
_scheduler = BackgroundScheduler()
|
| 211 |
+
_scheduler.add_job(
|
| 212 |
+
func=run_cron_job,
|
| 213 |
+
trigger="interval",
|
| 214 |
+
seconds=CRON_INTERVAL_SECONDS,
|
| 215 |
+
id="rowmind_task_notifier",
|
| 216 |
+
replace_existing=True,
|
| 217 |
+
)
|
| 218 |
+
_scheduler.start()
|
| 219 |
+
|
| 220 |
+
logger.info(
|
| 221 |
+
f"Cron scheduler started. Checking for due tasks every {CRON_INTERVAL_SECONDS} seconds."
|
| 222 |
+
)
|
| 223 |
+
|
| 224 |
+
return _scheduler
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def stop_scheduler() -> None:
|
| 228 |
+
"""Stop the background scheduler if running."""
|
| 229 |
+
global _scheduler
|
| 230 |
+
|
| 231 |
+
if _scheduler is not None and _scheduler.running:
|
| 232 |
+
_scheduler.shutdown(wait=False)
|
| 233 |
+
logger.info("Cron scheduler stopped")
|
| 234 |
+
|
| 235 |
+
_scheduler = None
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def is_scheduler_running() -> bool:
|
| 239 |
+
"""Check if the scheduler is currently running."""
|
| 240 |
+
return _scheduler is not None and _scheduler.running
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
# For standalone testing
|
| 244 |
+
if __name__ == "__main__":
|
| 245 |
+
import time
|
| 246 |
+
|
| 247 |
+
print("Starting cron worker in standalone mode...")
|
| 248 |
+
print(f"NTFY_SERVER: {NTFY_SERVER}")
|
| 249 |
+
print(f"NTFY_TOPIC: {NTFY_TOPIC or '(not configured)'}")
|
| 250 |
+
print(f"CRON_INTERVAL_SECONDS: {CRON_INTERVAL_SECONDS}")
|
| 251 |
+
|
| 252 |
+
scheduler = start_scheduler()
|
| 253 |
+
|
| 254 |
+
try:
|
| 255 |
+
# Run immediately once for testing
|
| 256 |
+
run_cron_job()
|
| 257 |
+
|
| 258 |
+
# Keep the main thread alive
|
| 259 |
+
while True:
|
| 260 |
+
time.sleep(1)
|
| 261 |
+
except KeyboardInterrupt:
|
| 262 |
+
print("\nStopping scheduler...")
|
| 263 |
+
stop_scheduler()
|
| 264 |
+
print("Done.")
|
pyproject.toml
CHANGED
|
@@ -5,6 +5,7 @@ description = "MCP server"
|
|
| 5 |
readme = "README.md"
|
| 6 |
requires-python = ">=3.11"
|
| 7 |
dependencies = [
|
|
|
|
| 8 |
"duckdb>=1.4.2",
|
| 9 |
"gradio[mcp,oauth]>=5.49.1",
|
| 10 |
"huggingface-hub>=1.1.4",
|
|
|
|
| 5 |
readme = "README.md"
|
| 6 |
requires-python = ">=3.11"
|
| 7 |
dependencies = [
|
| 8 |
+
"apscheduler>=3.11.0",
|
| 9 |
"duckdb>=1.4.2",
|
| 10 |
"gradio[mcp,oauth]>=5.49.1",
|
| 11 |
"huggingface-hub>=1.1.4",
|
requirements.txt
CHANGED
|
@@ -14,6 +14,8 @@ anyio==4.11.0
|
|
| 14 |
# openai
|
| 15 |
# sse-starlette
|
| 16 |
# starlette
|
|
|
|
|
|
|
| 17 |
attrs==25.4.0
|
| 18 |
# via
|
| 19 |
# jsonschema
|
|
@@ -260,6 +262,8 @@ typing-inspection==0.4.2
|
|
| 260 |
# pydantic-settings
|
| 261 |
tzdata==2025.2
|
| 262 |
# via pandas
|
|
|
|
|
|
|
| 263 |
urllib3==2.5.0
|
| 264 |
# via requests
|
| 265 |
uvicorn==0.38.0
|
|
|
|
| 14 |
# openai
|
| 15 |
# sse-starlette
|
| 16 |
# starlette
|
| 17 |
+
apscheduler==3.11.1
|
| 18 |
+
# via rowmind (pyproject.toml)
|
| 19 |
attrs==25.4.0
|
| 20 |
# via
|
| 21 |
# jsonschema
|
|
|
|
| 262 |
# pydantic-settings
|
| 263 |
tzdata==2025.2
|
| 264 |
# via pandas
|
| 265 |
+
tzlocal==5.3.1
|
| 266 |
+
# via apscheduler
|
| 267 |
urllib3==2.5.0
|
| 268 |
# via requests
|
| 269 |
uvicorn==0.38.0
|
uv.lock
CHANGED
|
@@ -48,6 +48,18 @@ wheels = [
|
|
| 48 |
{ url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" },
|
| 49 |
]
|
| 50 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
[[package]]
|
| 52 |
name = "attrs"
|
| 53 |
version = "25.4.0"
|
|
@@ -1727,6 +1739,7 @@ name = "rowmind"
|
|
| 1727 |
version = "0.1.0"
|
| 1728 |
source = { virtual = "." }
|
| 1729 |
dependencies = [
|
|
|
|
| 1730 |
{ name = "duckdb" },
|
| 1731 |
{ name = "gradio", extra = ["mcp", "oauth"] },
|
| 1732 |
{ name = "huggingface-hub" },
|
|
@@ -1736,6 +1749,7 @@ dependencies = [
|
|
| 1736 |
|
| 1737 |
[package.metadata]
|
| 1738 |
requires-dist = [
|
|
|
|
| 1739 |
{ name = "duckdb", specifier = ">=1.4.2" },
|
| 1740 |
{ name = "gradio", extras = ["mcp", "oauth"], specifier = ">=5.49.1" },
|
| 1741 |
{ name = "huggingface-hub", specifier = ">=1.1.4" },
|
|
@@ -2070,6 +2084,18 @@ wheels = [
|
|
| 2070 |
{ url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" },
|
| 2071 |
]
|
| 2072 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2073 |
[[package]]
|
| 2074 |
name = "urllib3"
|
| 2075 |
version = "2.5.0"
|
|
|
|
| 48 |
{ url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" },
|
| 49 |
]
|
| 50 |
|
| 51 |
+
[[package]]
|
| 52 |
+
name = "apscheduler"
|
| 53 |
+
version = "3.11.1"
|
| 54 |
+
source = { registry = "https://pypi.org/simple" }
|
| 55 |
+
dependencies = [
|
| 56 |
+
{ name = "tzlocal" },
|
| 57 |
+
]
|
| 58 |
+
sdist = { url = "https://files.pythonhosted.org/packages/d0/81/192db4f8471de5bc1f0d098783decffb1e6e69c4f8b4bc6711094691950b/apscheduler-3.11.1.tar.gz", hash = "sha256:0db77af6400c84d1747fe98a04b8b58f0080c77d11d338c4f507a9752880f221", size = 108044, upload-time = "2025-10-31T18:55:42.819Z" }
|
| 59 |
+
wheels = [
|
| 60 |
+
{ url = "https://files.pythonhosted.org/packages/58/9f/d3c76f76c73fcc959d28e9def45b8b1cc3d7722660c5003b19c1022fd7f4/apscheduler-3.11.1-py3-none-any.whl", hash = "sha256:6162cb5683cb09923654fa9bdd3130c4be4bfda6ad8990971c9597ecd52965d2", size = 64278, upload-time = "2025-10-31T18:55:41.186Z" },
|
| 61 |
+
]
|
| 62 |
+
|
| 63 |
[[package]]
|
| 64 |
name = "attrs"
|
| 65 |
version = "25.4.0"
|
|
|
|
| 1739 |
version = "0.1.0"
|
| 1740 |
source = { virtual = "." }
|
| 1741 |
dependencies = [
|
| 1742 |
+
{ name = "apscheduler" },
|
| 1743 |
{ name = "duckdb" },
|
| 1744 |
{ name = "gradio", extra = ["mcp", "oauth"] },
|
| 1745 |
{ name = "huggingface-hub" },
|
|
|
|
| 1749 |
|
| 1750 |
[package.metadata]
|
| 1751 |
requires-dist = [
|
| 1752 |
+
{ name = "apscheduler", specifier = ">=3.11.0" },
|
| 1753 |
{ name = "duckdb", specifier = ">=1.4.2" },
|
| 1754 |
{ name = "gradio", extras = ["mcp", "oauth"], specifier = ">=5.49.1" },
|
| 1755 |
{ name = "huggingface-hub", specifier = ">=1.1.4" },
|
|
|
|
| 2084 |
{ url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" },
|
| 2085 |
]
|
| 2086 |
|
| 2087 |
+
[[package]]
|
| 2088 |
+
name = "tzlocal"
|
| 2089 |
+
version = "5.3.1"
|
| 2090 |
+
source = { registry = "https://pypi.org/simple" }
|
| 2091 |
+
dependencies = [
|
| 2092 |
+
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
| 2093 |
+
]
|
| 2094 |
+
sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" }
|
| 2095 |
+
wheels = [
|
| 2096 |
+
{ url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" },
|
| 2097 |
+
]
|
| 2098 |
+
|
| 2099 |
[[package]]
|
| 2100 |
name = "urllib3"
|
| 2101 |
version = "2.5.0"
|