788 lines
24 KiB
Python
788 lines
24 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 .blm import find_cora_xlsx, run_generate, run_ingest
|
|
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)
|
|
elif route.handler == "blm":
|
|
_dispatch_blm(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)
|
|
|
|
if not job_data:
|
|
# Orphaned result from a previous run -- archive and skip.
|
|
# Without the KV entry we don't have keyword or run context.
|
|
log.warning(
|
|
"Result %s has no matching state DB entry -- archiving as orphan",
|
|
result.job_id,
|
|
)
|
|
archive_result(result)
|
|
continue
|
|
|
|
task_id = job_data["task_id"]
|
|
|
|
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
|
|
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 _dispatch_blm(
|
|
client: ClickUpClient,
|
|
cfg: Config,
|
|
db: StateDB,
|
|
task: ClickUpTask,
|
|
route: SkillRoute,
|
|
run_id: int,
|
|
):
|
|
"""Run BLM ingest-cora + generate-batch directly (no Claude needed)."""
|
|
keyword = task.get_field_value("Keyword") or ""
|
|
url = task.get_field_value("IMSURL") or ""
|
|
cli_flags = task.get_field_value("CLIFlags") or ""
|
|
bp_ratio = task.get_field_value("BrandedPlusRatio") or ""
|
|
custom_anchors = task.get_field_value("CustomAnchors") or ""
|
|
|
|
if not keyword:
|
|
_handle_dispatch_error(
|
|
client, cfg, db, task, run_id,
|
|
error="Missing Keyword field",
|
|
fix="Set the Keyword custom field, then re-check Delegate to Claude.",
|
|
)
|
|
return
|
|
|
|
# 1. Set status to AI Working
|
|
client.update_task_status(task.id, cfg.clickup.ai_working_status)
|
|
|
|
# 2. Find the Cora xlsx
|
|
xlsx_path = find_cora_xlsx(keyword, cfg.autocora.xlsx_dir)
|
|
if not xlsx_path:
|
|
_handle_dispatch_error(
|
|
client, cfg, db, task, run_id,
|
|
error="No Cora xlsx found for keyword '%s' in %s"
|
|
% (keyword, cfg.autocora.xlsx_dir),
|
|
fix="Check that the Cora report exists in %s, then re-check Delegate to Claude."
|
|
% cfg.autocora.xlsx_dir,
|
|
)
|
|
return
|
|
|
|
log.info("Found Cora xlsx: %s", xlsx_path)
|
|
client.add_comment(task.id, "Starting BLM pipeline for '%s'.\nCora file: %s" % (keyword, xlsx_path))
|
|
|
|
# 3. Run ingest-cora
|
|
log.info("Running ingest-cora for task %s (keyword=%s)", task.id, keyword)
|
|
ingest = run_ingest(
|
|
xlsx_path=xlsx_path,
|
|
keyword=keyword,
|
|
money_site_url=url,
|
|
blm_dir=cfg.blm.blm_dir,
|
|
timeout=cfg.blm.timeout_seconds,
|
|
branded_plus_ratio=bp_ratio,
|
|
custom_anchors=custom_anchors,
|
|
cli_flags=cli_flags,
|
|
)
|
|
|
|
if not ingest.success:
|
|
_handle_dispatch_error(
|
|
client, cfg, db, task, run_id,
|
|
error="ingest-cora failed: %s" % ingest.error,
|
|
fix="Check BLM logs, fix the issue, then re-check Delegate to Claude.",
|
|
)
|
|
return
|
|
|
|
log.info(
|
|
"ingest-cora OK: project=%s (ID=%s), job_file=%s",
|
|
ingest.project_name, ingest.project_id, ingest.job_file,
|
|
)
|
|
|
|
# 4. Run generate-batch
|
|
log.info("Running generate-batch for task %s (job=%s)", task.id, ingest.job_file)
|
|
gen = run_generate(
|
|
job_file=ingest.job_file,
|
|
blm_dir=cfg.blm.blm_dir,
|
|
timeout=cfg.blm.timeout_seconds,
|
|
)
|
|
|
|
if not gen.success:
|
|
_handle_dispatch_error(
|
|
client, cfg, db, task, run_id,
|
|
error="generate-batch failed: %s" % gen.error,
|
|
fix="Check BLM logs, fix the issue, then re-check Delegate to Claude.",
|
|
)
|
|
return
|
|
|
|
log.info("generate-batch OK: job moved to %s", gen.job_moved_to)
|
|
|
|
# 5. Advance stage + post summary
|
|
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)
|
|
|
|
summary = (
|
|
"BLM pipeline completed for '%s'.\n\n"
|
|
"- Project: %s (ID: %s)\n"
|
|
"- Keyword: %s\n"
|
|
"- Job file: %s"
|
|
) % (keyword, ingest.project_name, ingest.project_id,
|
|
ingest.main_keyword, gen.job_moved_to or ingest.job_file)
|
|
|
|
client.add_comment(task.id, summary)
|
|
|
|
# 6. Clear error + uncheck delegate
|
|
client.set_checkbox(
|
|
task.id, task.list_id, cfg.clickup.error_field_name, False
|
|
)
|
|
client.set_checkbox(
|
|
task.id, task.list_id, cfg.clickup.delegate_field_name, False
|
|
)
|
|
|
|
db.log_run_finish(run_id, "completed", result="BLM pipeline done")
|
|
notify(cfg, "BLM done: %s" % keyword, "Task %s completed" % task.id)
|
|
log.info("BLM pipeline completed for task %s (keyword=%s)", 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()
|