"""ClickUp + Claude Code automation runner -- entry point. Usage: uv run python -m clickup_runner """ from __future__ import annotations import logging import signal import sys import time from datetime import datetime, timezone from .claude_runner import ( RunResult, build_prompt, copy_to_nas, notify, read_skill_file, run_claude, ) from .clickup_client import ClickUpClient, ClickUpTask from .config import Config, load_config from .skill_map import SkillRoute, get_route, get_supported_task_types, get_valid_stages from .state import StateDB log = logging.getLogger("clickup_runner") # Flag for graceful shutdown _shutdown = False def _handle_signal(signum, frame): global _shutdown log.info("Received signal %d -- shutting down after current cycle", signum) _shutdown = True def _setup_logging(): """Configure logging: console + file.""" fmt = logging.Formatter( "[%(asctime)s] %(levelname)-7s %(name)s: %(message)s", datefmt="%Y-%m-%d %H:%M:%S", ) console = logging.StreamHandler(sys.stdout) console.setFormatter(fmt) console.setLevel(logging.INFO) root = logging.getLogger() root.setLevel(logging.INFO) root.addHandler(console) # File handler for persistent logs try: from pathlib import Path log_dir = Path(__file__).resolve().parent.parent / "logs" log_dir.mkdir(exist_ok=True) file_handler = logging.FileHandler( log_dir / "clickup_runner.log", encoding="utf-8" ) file_handler.setFormatter(fmt) file_handler.setLevel(logging.DEBUG) root.addHandler(file_handler) except Exception as e: log.warning("Could not set up file logging: %s", e) def _due_date_cutoff_ms() -> int: """Return end-of-today as Unix milliseconds for due_date_lt filter.""" now = datetime.now(timezone.utc) end_of_day = now.replace(hour=23, minute=59, second=59, microsecond=999999) return int(end_of_day.timestamp() * 1000) def _is_due_today_or_earlier(task: ClickUpTask) -> bool: """Check if a task's due date is today or earlier.""" if not task.due_date: return False try: due_ms = int(task.due_date) cutoff_ms = _due_date_cutoff_ms() return due_ms <= cutoff_ms except (ValueError, TypeError): return False def poll_cycle( client: ClickUpClient, cfg: Config, db: StateDB, ) -> int: """Run one poll cycle. Returns the number of tasks dispatched.""" space_id = cfg.clickup.space_id if not space_id: log.error("No space_id configured -- skipping poll cycle") return 0 # Fetch all tasks from Overall lists with due date <= today cutoff_ms = _due_date_cutoff_ms() tasks = client.get_tasks_from_overall_lists(space_id, due_date_lt=cutoff_ms) dispatched = 0 for task in tasks: # 1. Check "Delegate to Claude" checkbox if not client.is_checkbox_checked(task, cfg.clickup.delegate_field_name): continue # 2. Verify due date <= today if not _is_due_today_or_earlier(task): continue # 3. Read task type and stage task_type = task.task_type stage = client.get_stage(task, cfg.clickup.stage_field_name) log.info( "Found delegated task: %s (id=%s, type=%s, stage=%s)", task.name, task.id, task_type, stage, ) # 4. Look up skill route if not task_type: _handle_no_mapping( client, cfg, task, "Task has no Work Category set. " "Set the Work Category field, then re-check Delegate to Claude.", ) continue if not stage: _handle_no_mapping( client, cfg, task, "Task has no Stage set. " "Valid stages for %s: %s. " "Set the Stage field, then re-check Delegate to Claude." % (task_type, ", ".join(get_valid_stages(task_type)) or "none"), ) continue # 5. Check for .xlsx attachment on run_cora stage route = get_route(task_type, stage) if route and route.handler == "autocora": # If .xlsx is already attached, skip Cora and advance attachments = client.get_task_attachments(task.id) task.attachments = attachments if task.has_xlsx_attachment(): log.info( "Task %s has .xlsx attached -- skipping run_cora, advancing to %s", task.id, route.next_stage, ) client.set_stage( task.id, task.list_id, route.next_stage, cfg.clickup.stage_field_name, ) # Re-read stage and re-route stage = route.next_stage route = get_route(task_type, stage) if route is None: valid = get_valid_stages(task_type) if not valid: msg = ( "Task type '%s' is not supported. " "Supported types: %s. " "Fix the Work Category field, then re-check Delegate to Claude." % (task_type, ", ".join(get_supported_task_types())) ) else: msg = ( "Stage '%s' is not valid for task type '%s'. " "Valid stages: %s. " "Fix the Stage field, then re-check Delegate to Claude." % (stage, task_type, ", ".join(valid)) ) _handle_no_mapping(client, cfg, task, msg) continue # 6. Dispatch log.info( "Dispatching task %s: type=%s, stage=%s, handler=%s", task.id, task_type, stage, route.handler, ) run_id = db.log_run_start(task.id, task.name, task_type, stage) if route.handler == "autocora": _dispatch_autocora(client, cfg, db, task, route, run_id) else: _dispatch_claude(client, cfg, db, task, route, run_id) dispatched += 1 return dispatched def _handle_no_mapping( client: ClickUpClient, cfg: Config, task: ClickUpTask, message: str, ): """Handle a task that can't be routed: post comment, set error, uncheck.""" comment = "[ERROR] Cannot process task\n--\n%s" % message client.add_comment(task.id, comment) client.set_checkbox( task.id, task.list_id, cfg.clickup.error_field_name, True ) client.set_checkbox( task.id, task.list_id, cfg.clickup.delegate_field_name, False ) log.warning("Task %s: %s", task.id, message) def _dispatch_autocora( client: ClickUpClient, cfg: Config, db: StateDB, task: ClickUpTask, route, run_id: int, ): """Submit an AutoCora job for a task.""" # TODO: Phase 3 -- implement AutoCora job submission log.info("AutoCora dispatch for task %s -- NOT YET IMPLEMENTED", task.id) db.log_run_finish(run_id, "skipped", result="AutoCora not yet implemented") # For now, post a comment and uncheck client.add_comment( task.id, "[WARNING] AutoCora dispatch not yet implemented. " "Attach the .xlsx manually and re-check Delegate to Claude.", ) client.set_checkbox( task.id, task.list_id, cfg.clickup.delegate_field_name, False ) def _dispatch_claude( client: ClickUpClient, cfg: Config, db: StateDB, task: ClickUpTask, route: SkillRoute, run_id: int, ): """Run Claude Code headless for a task.""" # 1. Set status to "ai working" client.update_task_status(task.id, cfg.clickup.ai_working_status) # 2. Read skill file try: skill_content = read_skill_file(route, cfg.skills_dir) except FileNotFoundError as e: _handle_dispatch_error( client, cfg, db, task, run_id, error=str(e), fix="Create the skill file at skills/%s, then re-check Delegate to Claude." % route.skill_file, ) return # 3. Gather .xlsx attachment URLs for the prompt xlsx_urls = [ a.get("url", "") for a in task.attachments if a.get("title", "").lower().endswith(".xlsx") or a.get("url", "").lower().endswith(".xlsx") ] # 4. Build prompt prompt = build_prompt(task, route, skill_content, xlsx_urls or None) # 5. Run Claude log.info("Starting Claude for task %s (%s)", task.id, task.name) result = run_claude(prompt, route, cfg) if not result.success: _handle_dispatch_error( client, cfg, db, task, run_id, error=result.error, fix="Check logs/clickup_runner.log for details. " "Fix the issue, then re-check Delegate to Claude.", ) # Clean up temp dir _cleanup_work_dir(result.work_dir) return # 6. Upload output files to ClickUp uploaded = 0 for f in result.output_files: if client.upload_attachment(task.id, f): uploaded += 1 # 7. Copy to NAS (best-effort) customer = task.get_field_value("Customer") or "" if customer and cfg.nas.generated_dir: copy_to_nas(result.output_files, customer, cfg.nas.generated_dir) # 8. Advance stage + status client.set_stage( task.id, task.list_id, route.next_stage, cfg.clickup.stage_field_name ) client.update_task_status(task.id, route.next_status) # 9. Post success comment summary = "Stage complete. %d file(s) attached." % uploaded if result.output: # Include first 500 chars of Claude's output as context truncated = result.output[:500] if len(result.output) > 500: truncated += "..." summary += "\n\n---\nClaude output:\n%s" % truncated client.add_comment(task.id, summary) # 10. Uncheck delegate + clear error client.set_checkbox( task.id, task.list_id, cfg.clickup.delegate_field_name, False ) client.set_checkbox( task.id, task.list_id, cfg.clickup.error_field_name, False ) # 11. Log success db.log_run_finish( run_id, "completed", result="%d files uploaded" % uploaded, ) # 12. Notify notify(cfg, "Task complete: %s" % task.name, summary) log.info( "Task %s completed: stage -> %s, %d file(s) uploaded", task.id, route.next_stage, uploaded, ) # 13. Clean up temp dir _cleanup_work_dir(result.work_dir) def _handle_dispatch_error( client: ClickUpClient, cfg: Config, db: StateDB, task: ClickUpTask, run_id: int, error: str, fix: str, ): """Handle a failed Claude dispatch: set error state, comment, notify.""" comment = ( "[ERROR] Claude processing failed\n" "--\n" "What failed: %s\n" "\n" "How to fix: %s" ) % (error, fix) client.add_comment(task.id, comment) client.set_checkbox( task.id, task.list_id, cfg.clickup.error_field_name, True ) client.set_checkbox( task.id, task.list_id, cfg.clickup.delegate_field_name, False ) client.update_task_status(task.id, cfg.clickup.review_status) db.log_run_finish(run_id, "failed", error=error) notify( cfg, "FAILED: %s" % task.name, "Error: %s\nFix: %s" % (error, fix), is_error=True, ) log.error("Task %s failed: %s", task.id, error) def _cleanup_work_dir(work_dir): """Remove temporary work directory.""" if work_dir is None: return try: import shutil shutil.rmtree(str(work_dir), ignore_errors=True) except Exception: pass def main(): _setup_logging() log.info("ClickUp Runner starting up") cfg = load_config() if not cfg.clickup.api_token: log.error("CLICKUP_API_TOKEN not set -- exiting") sys.exit(1) if not cfg.clickup.space_id: log.error("CLICKUP_SPACE_ID not set -- exiting") sys.exit(1) client = ClickUpClient( api_token=cfg.clickup.api_token, task_type_field_name=cfg.clickup.task_type_field_name, ) db = StateDB(cfg.db_path) # Graceful shutdown on SIGINT/SIGTERM signal.signal(signal.SIGINT, _handle_signal) signal.signal(signal.SIGTERM, _handle_signal) log.info( "Runner ready. Polling every %ds. Space: %s", cfg.runner.poll_interval_seconds, cfg.clickup.space_id, ) try: while not _shutdown: try: count = poll_cycle(client, cfg, db) if count: log.info("Dispatched %d task(s) this cycle", count) except Exception: log.exception("Error in poll cycle") # Sleep in small increments so we can catch shutdown signal for _ in range(cfg.runner.poll_interval_seconds): if _shutdown: break time.sleep(1) finally: client.close() log.info("ClickUp Runner shut down") if __name__ == "__main__": main()