675 lines
20 KiB
Python
675 lines
20 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 tempfile
|
|
import time
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
from .autocora import archive_result, scan_results, submit_job
|
|
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
|
|
|
|
# Check for completed AutoCora jobs before dispatching new tasks
|
|
_check_autocora_results(client, cfg, db)
|
|
|
|
# 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 _check_autocora_results(
|
|
client: ClickUpClient,
|
|
cfg: Config,
|
|
db: StateDB,
|
|
):
|
|
"""Poll for completed AutoCora jobs and update ClickUp accordingly."""
|
|
results = scan_results(cfg.autocora.results_dir)
|
|
if not results:
|
|
return
|
|
|
|
log.info("Found %d AutoCora result(s) to process", len(results))
|
|
|
|
for result in results:
|
|
# Look up the pending job in the state DB
|
|
job_data = db.kv_get_json("autocora:job:%s" % result.job_id)
|
|
|
|
# Also check task_ids from the result file itself
|
|
task_ids = result.task_ids
|
|
if job_data:
|
|
# Prefer state DB data -- it always has the task_id
|
|
task_ids = [job_data["task_id"]]
|
|
|
|
if not task_ids:
|
|
log.warning(
|
|
"Result %s has no task_ids and no matching state DB entry -- skipping",
|
|
result.job_id,
|
|
)
|
|
archive_result(result)
|
|
continue
|
|
|
|
for task_id in task_ids:
|
|
if result.status == "SUCCESS":
|
|
_handle_autocora_success(client, cfg, db, task_id, result, job_data)
|
|
else:
|
|
_handle_autocora_failure(client, cfg, db, task_id, result, job_data)
|
|
|
|
# Clean up state DB entry
|
|
if job_data:
|
|
db.kv_delete("autocora:job:%s" % result.job_id)
|
|
|
|
archive_result(result)
|
|
|
|
|
|
def _handle_autocora_success(
|
|
client: ClickUpClient,
|
|
cfg: Config,
|
|
db: StateDB,
|
|
task_id: str,
|
|
result,
|
|
job_data: dict | None,
|
|
):
|
|
"""Handle a successful AutoCora result for one task."""
|
|
keyword = result.keyword or (job_data or {}).get("keyword", "unknown")
|
|
|
|
# Advance stage -- need list_id from task or job_data
|
|
try:
|
|
task = client.get_task(task_id)
|
|
except Exception as e:
|
|
log.error("Failed to fetch task %s for AutoCora result: %s", task_id, e)
|
|
return
|
|
|
|
# Look up the route to get next_stage
|
|
task_type = task.task_type
|
|
stage = client.get_stage(task, cfg.clickup.stage_field_name)
|
|
route = get_route(task_type, stage)
|
|
|
|
if route:
|
|
client.set_stage(
|
|
task_id, task.list_id, route.next_stage, cfg.clickup.stage_field_name
|
|
)
|
|
next_stage = route.next_stage
|
|
else:
|
|
# Fallback -- just note it in the comment
|
|
next_stage = "(unknown)"
|
|
|
|
client.update_task_status(task_id, cfg.clickup.review_status)
|
|
client.add_comment(
|
|
task_id,
|
|
"Cora report generated for \"%s\". Stage advanced to %s.\n"
|
|
"Review the .xlsx in %s, then re-check Delegate to Claude for the next stage."
|
|
% (keyword, next_stage, cfg.autocora.xlsx_dir),
|
|
)
|
|
client.set_checkbox(
|
|
task_id, task.list_id, cfg.clickup.error_field_name, False
|
|
)
|
|
|
|
# Finish the run log if we have a run_id
|
|
run_id = (job_data or {}).get("run_id")
|
|
if run_id:
|
|
db.log_run_finish(run_id, "completed", result="Cora report ready")
|
|
|
|
notify(cfg, "Cora done: %s" % keyword, "Task %s ready for review" % task_id)
|
|
log.info("AutoCora SUCCESS for task %s (keyword=%s)", task_id, keyword)
|
|
|
|
|
|
def _handle_autocora_failure(
|
|
client: ClickUpClient,
|
|
cfg: Config,
|
|
db: StateDB,
|
|
task_id: str,
|
|
result,
|
|
job_data: dict | None,
|
|
):
|
|
"""Handle a failed AutoCora result for one task."""
|
|
keyword = result.keyword or (job_data or {}).get("keyword", "unknown")
|
|
reason = result.reason or "Unknown error"
|
|
|
|
try:
|
|
task = client.get_task(task_id)
|
|
except Exception as e:
|
|
log.error("Failed to fetch task %s for AutoCora result: %s", task_id, e)
|
|
return
|
|
|
|
comment = (
|
|
"[ERROR] Cora report failed for keyword: \"%s\"\n"
|
|
"--\n"
|
|
"What failed: %s\n"
|
|
"\n"
|
|
"How to fix: Check the AutoCora worker logs, fix the issue, "
|
|
"then re-check Delegate to Claude."
|
|
) % (keyword, reason)
|
|
|
|
client.add_comment(task_id, comment)
|
|
client.set_checkbox(
|
|
task_id, task.list_id, cfg.clickup.error_field_name, True
|
|
)
|
|
client.update_task_status(task_id, cfg.clickup.review_status)
|
|
|
|
run_id = (job_data or {}).get("run_id")
|
|
if run_id:
|
|
db.log_run_finish(run_id, "failed", error="Cora failed: %s" % reason)
|
|
|
|
notify(
|
|
cfg,
|
|
"Cora FAILED: %s" % keyword,
|
|
"Task %s -- %s" % (task_id, reason),
|
|
is_error=True,
|
|
)
|
|
log.error("AutoCora FAILURE for task %s: %s", task_id, reason)
|
|
|
|
|
|
def _dispatch_autocora(
|
|
client: ClickUpClient,
|
|
cfg: Config,
|
|
db: StateDB,
|
|
task: ClickUpTask,
|
|
route: SkillRoute,
|
|
run_id: int,
|
|
):
|
|
"""Submit an AutoCora job for a task."""
|
|
keyword = task.get_field_value("Keyword") or ""
|
|
url = task.get_field_value("IMSURL") or ""
|
|
|
|
if not keyword:
|
|
_handle_no_mapping(
|
|
client, cfg, task,
|
|
"Task has no Keyword field set. "
|
|
"Set the Keyword custom field, then re-check Delegate to Claude.",
|
|
)
|
|
db.log_run_finish(run_id, "failed", error="Missing Keyword field")
|
|
return
|
|
|
|
# 1. Set status to "ai working"
|
|
client.update_task_status(task.id, cfg.clickup.ai_working_status)
|
|
|
|
# 2. Submit the job to the NAS queue
|
|
job_id = submit_job(keyword, url, task.id, cfg.autocora.jobs_dir)
|
|
|
|
if not job_id:
|
|
_handle_dispatch_error(
|
|
client, cfg, db, task, run_id,
|
|
error="Failed to write AutoCora job file to %s" % cfg.autocora.jobs_dir,
|
|
fix="Check that the NAS is mounted and accessible, "
|
|
"then re-check Delegate to Claude.",
|
|
)
|
|
return
|
|
|
|
# 3. Store job metadata in state DB for result polling
|
|
db.kv_set_json("autocora:job:%s" % job_id, {
|
|
"task_id": task.id,
|
|
"task_name": task.name,
|
|
"keyword": keyword,
|
|
"url": url,
|
|
"run_id": run_id,
|
|
})
|
|
|
|
# 4. Post comment + uncheck delegate
|
|
client.add_comment(
|
|
task.id,
|
|
"Cora job submitted for keyword: \"%s\" (job: %s).\n"
|
|
"The runner will check for results automatically."
|
|
% (keyword, job_id),
|
|
)
|
|
client.set_checkbox(
|
|
task.id, task.list_id, cfg.clickup.delegate_field_name, False
|
|
)
|
|
|
|
# 5. Log as submitted (not completed -- that happens when results arrive)
|
|
db.log_run_finish(run_id, "submitted", result="Job: %s" % job_id)
|
|
|
|
notify(cfg, "Cora submitted: %s" % keyword, "Task: %s" % task.name)
|
|
|
|
log.info(
|
|
"AutoCora job submitted: %s (task=%s, keyword=%s)",
|
|
job_id, task.id, keyword,
|
|
)
|
|
|
|
|
|
def _download_attachments(
|
|
client: ClickUpClient,
|
|
task: ClickUpTask,
|
|
work_dir: Path,
|
|
) -> list[str]:
|
|
"""Download all task attachments to the work directory.
|
|
|
|
Returns list of local filenames (not full paths) that were downloaded.
|
|
"""
|
|
downloaded = []
|
|
for att in task.attachments:
|
|
title = att.get("title", "")
|
|
url = att.get("url", "")
|
|
if not title or not url:
|
|
continue
|
|
|
|
dest = work_dir / title
|
|
if client.download_attachment(url, dest):
|
|
downloaded.append(title)
|
|
else:
|
|
log.warning("Skipping attachment %s -- download failed", title)
|
|
|
|
if downloaded:
|
|
log.info(
|
|
"Downloaded %d attachment(s) to %s: %s",
|
|
len(downloaded), work_dir, ", ".join(downloaded),
|
|
)
|
|
|
|
return downloaded
|
|
|
|
|
|
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. Create work dir and download all attachments into it
|
|
work_dir = Path(tempfile.mkdtemp(prefix="clickup_runner_"))
|
|
downloaded_files = _download_attachments(client, task, work_dir)
|
|
|
|
# 4. Build prompt (reference local filenames, not URLs)
|
|
prompt = build_prompt(task, route, skill_content, downloaded_files)
|
|
|
|
# 5. Run Claude
|
|
log.info("Starting Claude for task %s (%s)", task.id, task.name)
|
|
result = run_claude(
|
|
prompt, route, cfg,
|
|
work_dir=work_dir,
|
|
exclude_files=set(downloaded_files),
|
|
)
|
|
|
|
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("Client") 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()
|