CheddahBot/clickup_runner/__main__.py

454 lines
13 KiB
Python

"""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()