Steps 7-8: Eliminate KV store from task pipelines and update tests

Remove all KV store reads/writes from task pipeline code. ClickUp is now
the single source of truth for task state. File location (processed/
subfolder) tracks file processing state. Loop timestamps use in-memory
dict on Scheduler.

Source changes:
- scheduler.py: Remove KV dedup, fallback sync path, docx extraction;
  tools own their ClickUp sync; in-memory timestamps
- press_release.py: Remove KV state writes, log-only _set_status
- linkbuilding.py: Remove KV state writes, processed/ subfolder check
- content_creation.py: Phase detection via ClickUp API status, remove
  KV phase/state tracking, _update_kv_state removed
- clickup_tool.py: Rewrite to query ClickUp API directly
- ui.py: Pipeline status polling is now a no-op

Test changes:
- test_scheduler.py: Remove KV dedup tests, remove fallback path test,
  verify ClickUp API calls instead of KV state
- test_content_creation.py: Mock _get_clickup_client for phase detection,
  verify ClickUp sync calls instead of KV assertions
- test_linkbuilding.py: Remove KV status test, verify ClickUp API calls
- test_clickup_tools.py: Rewrite for API-backed tools
- test_scheduler_helpers.py: Test in-memory timestamps

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
fix/customer-field-migration
PeninsulaInd 2026-02-27 16:26:44 -06:00
parent 9a3ba54974
commit 917445ade4
11 changed files with 458 additions and 920 deletions

View File

@ -3,7 +3,6 @@
from __future__ import annotations from __future__ import annotations
import contextlib import contextlib
import json
import logging import logging
import re import re
import shutil import shutil
@ -24,15 +23,6 @@ log = logging.getLogger(__name__)
HEARTBEAT_OK = "HEARTBEAT_OK" HEARTBEAT_OK = "HEARTBEAT_OK"
# Matches **Docx:** `path/to/file.docx` patterns in tool output
_DOCX_PATH_RE = re.compile(r"\*\*Docx:\*\*\s*`([^`]+\.docx)`")
def _extract_docx_paths(result: str) -> list[str]:
"""Extract .docx file paths from a tool result string."""
return _DOCX_PATH_RE.findall(result)
class Scheduler: class Scheduler:
# Tasks due within this window are eligible for execution # Tasks due within this window are eligible for execution
DUE_DATE_WINDOW_WEEKS = 3 DUE_DATE_WINDOW_WEEKS = 3
@ -60,6 +50,14 @@ class Scheduler:
self._force_autocora = threading.Event() self._force_autocora = threading.Event()
self._clickup_client = None self._clickup_client = None
self._field_filter_cache: dict | None = None self._field_filter_cache: dict | None = None
self._loop_timestamps: dict[str, str | None] = {
"heartbeat": None,
"poll": None,
"clickup": None,
"folder_watch": None,
"autocora": None,
"content_watch": None,
}
def start(self): def start(self):
"""Start the scheduler, heartbeat, and ClickUp threads.""" """Start the scheduler, heartbeat, and ClickUp threads."""
@ -169,15 +167,8 @@ class Scheduler:
self._force_autocora.set() self._force_autocora.set()
def get_loop_timestamps(self) -> dict[str, str | None]: def get_loop_timestamps(self) -> dict[str, str | None]:
"""Return last_run timestamps for all loops.""" """Return last_run timestamps for all loops (in-memory)."""
return { return dict(self._loop_timestamps)
"heartbeat": self.db.kv_get("system:loop:heartbeat:last_run"),
"poll": self.db.kv_get("system:loop:poll:last_run"),
"clickup": self.db.kv_get("system:loop:clickup:last_run"),
"folder_watch": self.db.kv_get("system:loop:folder_watch:last_run"),
"autocora": self.db.kv_get("system:loop:autocora:last_run"),
"content_watch": self.db.kv_get("system:loop:content_watch:last_run"),
}
# ── Scheduled Tasks ── # ── Scheduled Tasks ──
@ -185,9 +176,7 @@ class Scheduler:
while not self._stop_event.is_set(): while not self._stop_event.is_set():
try: try:
self._run_due_tasks() self._run_due_tasks()
self.db.kv_set( self._loop_timestamps["poll"] = datetime.now(UTC).isoformat()
"system:loop:poll:last_run", datetime.now(UTC).isoformat()
)
except Exception as e: except Exception as e:
log.error("Scheduler poll error: %s", e) log.error("Scheduler poll error: %s", e)
self._interruptible_wait( self._interruptible_wait(
@ -227,9 +216,7 @@ class Scheduler:
while not self._stop_event.is_set(): while not self._stop_event.is_set():
try: try:
self._run_heartbeat() self._run_heartbeat()
self.db.kv_set( self._loop_timestamps["heartbeat"] = datetime.now(UTC).isoformat()
"system:loop:heartbeat:last_run", datetime.now(UTC).isoformat()
)
except Exception as e: except Exception as e:
log.error("Heartbeat error: %s", e) log.error("Heartbeat error: %s", e)
self._interruptible_wait(interval, self._force_heartbeat) self._interruptible_wait(interval, self._force_heartbeat)
@ -281,9 +268,7 @@ class Scheduler:
try: try:
self._poll_clickup() self._poll_clickup()
self._recover_stale_tasks() self._recover_stale_tasks()
self.db.kv_set( self._loop_timestamps["clickup"] = datetime.now(UTC).isoformat()
"system:loop:clickup:last_run", datetime.now(UTC).isoformat()
)
except Exception as e: except Exception as e:
log.error("ClickUp poll error: %s", e) log.error("ClickUp poll error: %s", e)
self._interruptible_wait(interval) self._interruptible_wait(interval)
@ -357,15 +342,9 @@ class Scheduler:
) )
for task in tasks: for task in tasks:
# Skip tasks already processed in kv_store # ClickUp status filtering is the dedup: tasks in poll_statuses
raw = self.db.kv_get(f"clickup:task:{task.id}:state") # are eligible; once moved to "automation underway", they won't
if raw: # appear in the next poll.
try:
existing = json.loads(raw)
if existing.get("state") in ("executing", "completed", "failed"):
continue
except json.JSONDecodeError:
pass
# Client-side verify: Work Category must be in skill_map # Client-side verify: Work Category must be in skill_map
if task.task_type not in skill_map: if task.task_type not in skill_map:
@ -394,7 +373,11 @@ class Scheduler:
self._execute_task(task) self._execute_task(task)
def _execute_task(self, task): def _execute_task(self, task):
"""Execute a single ClickUp task immediately.""" """Execute a single ClickUp task immediately.
Tools own their own ClickUp sync (status, comments, attachments).
The scheduler just calls the tool and handles errors.
"""
skill_map = self.config.clickup.skill_map skill_map = self.config.clickup.skill_map
mapping = skill_map.get(task.task_type, {}) mapping = skill_map.get(task.task_type, {})
tool_name = mapping.get("tool", "") tool_name = mapping.get("tool", "")
@ -403,35 +386,17 @@ class Scheduler:
return return
task_id = task.id task_id = task.id
kv_key = f"clickup:task:{task_id}:state"
now = datetime.now(UTC).isoformat()
client = self._get_clickup_client() client = self._get_clickup_client()
# Build state object — starts at "executing"
state = {
"state": "executing",
"clickup_task_id": task_id,
"clickup_task_name": task.name,
"task_type": task.task_type,
"skill_name": tool_name,
"discovered_at": now,
"started_at": now,
"completed_at": None,
"error": None,
"deliverable_paths": [],
"custom_fields": task.custom_fields,
}
# Move to "automation underway" on ClickUp immediately # Move to "automation underway" on ClickUp immediately
client.update_task_status(task_id, self.config.clickup.automation_status) client.update_task_status(task_id, self.config.clickup.automation_status)
self.db.kv_set(kv_key, json.dumps(state))
log.info("Executing ClickUp task: %s%s", task.name, tool_name) log.info("Executing ClickUp task: %s%s", task.name, tool_name)
self._notify(f"Executing ClickUp task: **{task.name}** → Skill: `{tool_name}`") self._notify(f"Executing ClickUp task: **{task.name}** → Skill: `{tool_name}`")
try: try:
# Build tool arguments from field mapping # Build tool arguments from field mapping
args = self._build_tool_args(state) args = self._build_tool_args_from_task(task, mapping)
args["clickup_task_id"] = task_id args["clickup_task_id"] = task_id
# Execute the skill via the tool registry # Execute the skill via the tool registry
@ -440,21 +405,15 @@ class Scheduler:
else: else:
result = self.agent.execute_task( result = self.agent.execute_task(
f"Execute the '{tool_name}' tool for ClickUp task '{task.name}'. " f"Execute the '{tool_name}' tool for ClickUp task '{task.name}'. "
f"Task description: {state.get('custom_fields', {})}" f"Task description: {task.custom_fields}"
) )
# Check if the tool skipped or reported an error without doing work # Check if the tool skipped or reported an error without doing work
if result.startswith("Skipped:") or result.startswith("Error:"): if result.startswith("Skipped:") or result.startswith("Error:"):
state["state"] = "failed"
state["error"] = result[:500]
state["completed_at"] = datetime.now(UTC).isoformat()
self.db.kv_set(kv_key, json.dumps(state))
client.add_comment( client.add_comment(
task_id, task_id,
f"⚠️ CheddahBot could not execute this task.\n\n{result[:2000]}", f"⚠️ CheddahBot could not execute this task.\n\n{result[:2000]}",
) )
# Move to "error" so Bryan can see what happened
client.update_task_status(task_id, self.config.clickup.error_status) client.update_task_status(task_id, self.config.clickup.error_status)
self._notify( self._notify(
@ -464,54 +423,17 @@ class Scheduler:
log.info("ClickUp task skipped: %s%s", task.name, result[:200]) log.info("ClickUp task skipped: %s%s", task.name, result[:200])
return return
# Check if the tool already handled ClickUp sync internally # Tool handled its own ClickUp sync — just log success
tool_handled_sync = "## ClickUp Sync" in result
if tool_handled_sync:
state["state"] = "completed"
state["completed_at"] = datetime.now(UTC).isoformat()
self.db.kv_set(kv_key, json.dumps(state))
else:
# Scheduler handles sync (fallback path)
docx_paths = _extract_docx_paths(result)
state["deliverable_paths"] = docx_paths
uploaded_count = 0
for path in docx_paths:
if client.upload_attachment(task_id, path):
uploaded_count += 1
else:
log.warning("Failed to upload %s for task %s", path, task_id)
state["state"] = "completed"
state["completed_at"] = datetime.now(UTC).isoformat()
self.db.kv_set(kv_key, json.dumps(state))
client.update_task_status(task_id, self.config.clickup.review_status)
attach_note = f"\n📎 {uploaded_count} file(s) attached." if uploaded_count else ""
comment = (
f"✅ CheddahBot completed this task.\n\n"
f"Skill: {tool_name}\n"
f"Result:\n{result[:3000]}{attach_note}"
)
client.add_comment(task_id, comment)
self._notify( self._notify(
f"ClickUp task completed: **{task.name}**\n" f"ClickUp task completed: **{task.name}**\n"
f"Skill: `{tool_name}` | Status set to '{self.config.clickup.review_status}'" f"Skill: `{tool_name}`"
) )
log.info("ClickUp task completed: %s", task.name) log.info("ClickUp task completed: %s", task.name)
except Exception as e: except Exception as e:
# Failure — move back to "to do" on ClickUp
state["state"] = "failed"
state["error"] = str(e)
state["completed_at"] = datetime.now(UTC).isoformat()
self.db.kv_set(kv_key, json.dumps(state))
client.add_comment( client.add_comment(
task_id, f"❌ CheddahBot failed to complete this task.\n\nError: {str(e)[:2000]}" task_id, f"❌ CheddahBot failed to complete this task.\n\nError: {str(e)[:2000]}"
) )
# Move to "error" so Bryan can see what happened
client.update_task_status(task_id, self.config.clickup.error_status) client.update_task_status(task_id, self.config.clickup.error_status)
self._notify( self._notify(
@ -554,7 +476,8 @@ class Scheduler:
age_ms = now_ms - updated_ms age_ms = now_ms - updated_ms
if age_ms > threshold_ms: if age_ms > threshold_ms:
reset_status = self.config.clickup.poll_statuses[0] if self.config.clickup.poll_statuses else "to do" poll_sts = self.config.clickup.poll_statuses
reset_status = poll_sts[0] if poll_sts else "to do"
log.warning( log.warning(
"Recovering stale task %s (%s) — stuck in '%s' for %.1f hours", "Recovering stale task %s (%s) — stuck in '%s' for %.1f hours",
task.id, task.name, automation_status, age_ms / 3_600_000, task.id, task.name, automation_status, age_ms / 3_600_000,
@ -572,22 +495,19 @@ class Scheduler:
category="clickup", category="clickup",
) )
def _build_tool_args(self, state: dict) -> dict: def _build_tool_args_from_task(self, task, mapping: dict) -> dict:
"""Build tool arguments from ClickUp task fields using the field mapping.""" """Build tool arguments from a ClickUp task using the field mapping."""
skill_map = self.config.clickup.skill_map
task_type = state.get("task_type", "")
mapping = skill_map.get(task_type, {})
field_mapping = mapping.get("field_mapping", {}) field_mapping = mapping.get("field_mapping", {})
args = {} args = {}
for tool_param, source in field_mapping.items(): for tool_param, source in field_mapping.items():
if source == "task_name": if source == "task_name":
args[tool_param] = state.get("clickup_task_name", "") args[tool_param] = task.name
elif source == "task_description": elif source == "task_description":
args[tool_param] = state.get("custom_fields", {}).get("description", "") args[tool_param] = task.custom_fields.get("description", "")
else: else:
# Look up custom field by name # Look up custom field by name
args[tool_param] = state.get("custom_fields", {}).get(source, "") args[tool_param] = task.custom_fields.get(source, "")
return args return args
@ -604,9 +524,7 @@ class Scheduler:
try: try:
self._auto_submit_cora_jobs() self._auto_submit_cora_jobs()
self._poll_autocora_results() self._poll_autocora_results()
self.db.kv_set( self._loop_timestamps["autocora"] = datetime.now(UTC).isoformat()
"system:loop:autocora:last_run", datetime.now(UTC).isoformat()
)
except Exception as e: except Exception as e:
log.error("AutoCora poll error: %s", e) log.error("AutoCora poll error: %s", e)
self._interruptible_wait(interval, self._force_autocora) self._interruptible_wait(interval, self._force_autocora)
@ -707,9 +625,7 @@ class Scheduler:
while not self._stop_event.is_set(): while not self._stop_event.is_set():
try: try:
self._scan_watch_folder() self._scan_watch_folder()
self.db.kv_set( self._loop_timestamps["folder_watch"] = datetime.now(UTC).isoformat()
"system:loop:folder_watch:last_run", datetime.now(UTC).isoformat()
)
except Exception as e: except Exception as e:
log.error("Folder watcher error: %s", e) log.error("Folder watcher error: %s", e)
self._interruptible_wait(interval) self._interruptible_wait(interval)
@ -726,30 +642,25 @@ class Scheduler:
log.debug("No .xlsx files in watch folder") log.debug("No .xlsx files in watch folder")
return return
# Check processed/ subfolder for already-handled files
processed_dir = watch_folder / "processed"
processed_names = set()
if processed_dir.exists():
processed_names = {f.name for f in processed_dir.glob("*.xlsx")}
for xlsx_path in xlsx_files: for xlsx_path in xlsx_files:
filename = xlsx_path.name filename = xlsx_path.name
# Skip Office temp/lock files (e.g. ~$insert_molding.xlsx) # Skip Office temp/lock files (e.g. ~$insert_molding.xlsx)
if filename.startswith("~$"): if filename.startswith("~$"):
continue continue
kv_key = f"linkbuilding:watched:{filename}" # Skip files already in processed/
if filename in processed_names:
# Skip completed/failed; retry "processing" (killed run) and "blocked" (missing field)
existing = self.db.kv_get(kv_key)
if existing:
try:
state = json.loads(existing)
if state.get("status") in ("completed", "failed"):
continue
if state.get("status") in ("processing", "blocked", "unmatched"):
log.info("Retrying '%s' state for %s", state["status"], filename)
self.db.kv_delete(kv_key)
except json.JSONDecodeError:
continue continue
log.info("Folder watcher: new .xlsx found: %s", filename) log.info("Folder watcher: new .xlsx found: %s", filename)
self._process_watched_file(xlsx_path, kv_key) self._process_watched_file(xlsx_path)
def _process_watched_file(self, xlsx_path: Path, kv_key: str): def _process_watched_file(self, xlsx_path: Path):
"""Try to match a watched .xlsx file to a ClickUp task and run the pipeline.""" """Try to match a watched .xlsx file to a ClickUp task and run the pipeline."""
filename = xlsx_path.name filename = xlsx_path.name
# Normalize filename stem for matching # Normalize filename stem for matching
@ -757,12 +668,6 @@ class Scheduler:
stem = xlsx_path.stem.lower().replace("-", " ").replace("_", " ") stem = xlsx_path.stem.lower().replace("-", " ").replace("_", " ")
stem = re.sub(r"\s+", " ", stem).strip() stem = re.sub(r"\s+", " ", stem).strip()
# Mark as processing
self.db.kv_set(
kv_key,
json.dumps({"status": "processing", "started_at": datetime.now(UTC).isoformat()}),
)
# Try to find matching ClickUp task # Try to find matching ClickUp task
matched_task = None matched_task = None
if self.config.clickup.enabled: if self.config.clickup.enabled:
@ -770,17 +675,6 @@ class Scheduler:
if not matched_task: if not matched_task:
log.warning("No ClickUp task match for '%s' — skipping", filename) log.warning("No ClickUp task match for '%s' — skipping", filename)
self.db.kv_set(
kv_key,
json.dumps(
{
"status": "unmatched",
"filename": filename,
"stem": stem,
"checked_at": datetime.now(UTC).isoformat(),
}
),
)
self._notify( self._notify(
f"Folder watcher: no ClickUp match for **{filename}**.\n" f"Folder watcher: no ClickUp match for **{filename}**.\n"
f"Create a Link Building task with Keyword " f"Create a Link Building task with Keyword "
@ -807,20 +701,6 @@ class Scheduler:
money_site_url = matched_task.custom_fields.get("IMSURL", "") or "" money_site_url = matched_task.custom_fields.get("IMSURL", "") or ""
if not money_site_url: if not money_site_url:
log.warning("Task %s (%s) missing IMSURL — skipping", task_id, matched_task.name) log.warning("Task %s (%s) missing IMSURL — skipping", task_id, matched_task.name)
self.db.kv_set(
kv_key,
json.dumps(
{
"status": "blocked",
"reason": "missing_imsurl",
"filename": filename,
"task_id": task_id,
"task_name": matched_task.name,
"checked_at": datetime.now(UTC).isoformat(),
}
),
)
# Set ClickUp status to "error" so it's visible on the board
client.update_task_status(task_id, self.config.clickup.error_status) client.update_task_status(task_id, self.config.clickup.error_status)
self._notify( self._notify(
f"Folder watcher: **{filename}** matched task **{matched_task.name}** " f"Folder watcher: **{filename}** matched task **{matched_task.name}** "
@ -853,20 +733,7 @@ class Scheduler:
result = "Error: tool registry not available" result = "Error: tool registry not available"
if "Error" in result and "## Step" not in result: if "Error" in result and "## Step" not in result:
# Pipeline failed # Pipeline failed — tool handles its own ClickUp error status
self.db.kv_set(
kv_key,
json.dumps(
{
"status": "failed",
"filename": filename,
"task_id": task_id,
"error": result[:500],
"failed_at": datetime.now(UTC).isoformat(),
}
),
)
client.update_task_status(task_id, self.config.clickup.error_status)
self._notify( self._notify(
f"Folder watcher: pipeline **failed** for **{filename}**.\n" f"Folder watcher: pipeline **failed** for **{filename}**.\n"
f"Error: {result[:200]}", f"Error: {result[:200]}",
@ -883,18 +750,6 @@ class Scheduler:
except OSError as e: except OSError as e:
log.warning("Could not move %s to processed: %s", filename, e) log.warning("Could not move %s to processed: %s", filename, e)
self.db.kv_set(
kv_key,
json.dumps(
{
"status": "completed",
"filename": filename,
"task_id": task_id,
"completed_at": datetime.now(UTC).isoformat(),
}
),
)
client.update_task_status(task_id, "complete")
self._notify( self._notify(
f"Folder watcher: pipeline **completed** for **{filename}**.\n" f"Folder watcher: pipeline **completed** for **{filename}**.\n"
f"ClickUp task: {matched_task.name}", f"ClickUp task: {matched_task.name}",
@ -903,18 +758,6 @@ class Scheduler:
except Exception as e: except Exception as e:
log.error("Folder watcher pipeline error for %s: %s", filename, e) log.error("Folder watcher pipeline error for %s: %s", filename, e)
self.db.kv_set(
kv_key,
json.dumps(
{
"status": "failed",
"filename": filename,
"task_id": task_id,
"error": str(e)[:500],
"failed_at": datetime.now(UTC).isoformat(),
}
),
)
client.update_task_status(task_id, self.config.clickup.error_status) client.update_task_status(task_id, self.config.clickup.error_status)
def _match_xlsx_to_clickup(self, normalized_stem: str): def _match_xlsx_to_clickup(self, normalized_stem: str):
@ -965,9 +808,7 @@ class Scheduler:
while not self._stop_event.is_set(): while not self._stop_event.is_set():
try: try:
self._scan_content_folder() self._scan_content_folder()
self.db.kv_set( self._loop_timestamps["content_watch"] = datetime.now(UTC).isoformat()
"system:loop:content_watch:last_run", datetime.now(UTC).isoformat()
)
except Exception as e: except Exception as e:
log.error("Content folder watcher error: %s", e) log.error("Content folder watcher error: %s", e)
self._interruptible_wait(interval) self._interruptible_wait(interval)
@ -984,41 +825,30 @@ class Scheduler:
log.debug("No .xlsx files in content Cora inbox") log.debug("No .xlsx files in content Cora inbox")
return return
# Check processed/ subfolder for already-handled files
processed_dir = inbox / "processed"
processed_names = set()
if processed_dir.exists():
processed_names = {f.name for f in processed_dir.glob("*.xlsx")}
for xlsx_path in xlsx_files: for xlsx_path in xlsx_files:
filename = xlsx_path.name filename = xlsx_path.name
# Skip Office temp/lock files # Skip Office temp/lock files
if filename.startswith("~$"): if filename.startswith("~$"):
continue continue
kv_key = f"content:watched:{filename}" # Skip files already in processed/
if filename in processed_names:
# Skip completed/failed; retry processing/blocked/unmatched
existing = self.db.kv_get(kv_key)
if existing:
try:
state = json.loads(existing)
if state.get("status") in ("completed", "failed"):
continue
if state.get("status") in ("processing", "blocked", "unmatched"):
log.info("Retrying '%s' state for %s", state["status"], filename)
self.db.kv_delete(kv_key)
except json.JSONDecodeError:
continue continue
log.info("Content watcher: new .xlsx found: %s", filename) log.info("Content watcher: new .xlsx found: %s", filename)
self._process_content_file(xlsx_path, kv_key) self._process_content_file(xlsx_path)
def _process_content_file(self, xlsx_path: Path, kv_key: str): def _process_content_file(self, xlsx_path: Path):
"""Match a content Cora .xlsx to a ClickUp task and run create_content.""" """Match a content Cora .xlsx to a ClickUp task and run create_content."""
filename = xlsx_path.name filename = xlsx_path.name
stem = xlsx_path.stem.lower().replace("-", " ").replace("_", " ") stem = xlsx_path.stem.lower().replace("-", " ").replace("_", " ")
stem = re.sub(r"\s+", " ", stem).strip() stem = re.sub(r"\s+", " ", stem).strip()
# Mark as processing
self.db.kv_set(
kv_key,
json.dumps({"status": "processing", "started_at": datetime.now(UTC).isoformat()}),
)
# Try to find matching ClickUp task # Try to find matching ClickUp task
matched_task = None matched_task = None
if self.config.clickup.enabled: if self.config.clickup.enabled:
@ -1026,17 +856,6 @@ class Scheduler:
if not matched_task: if not matched_task:
log.warning("No ClickUp content task match for '%s' — skipping", filename) log.warning("No ClickUp content task match for '%s' — skipping", filename)
self.db.kv_set(
kv_key,
json.dumps(
{
"status": "unmatched",
"filename": filename,
"stem": stem,
"checked_at": datetime.now(UTC).isoformat(),
}
),
)
self._notify( self._notify(
f"Content watcher: no ClickUp match for **{filename}**.\n" f"Content watcher: no ClickUp match for **{filename}**.\n"
f"Create a Content Creation or On Page Optimization task with Keyword " f"Create a Content Creation or On Page Optimization task with Keyword "
@ -1077,18 +896,6 @@ class Scheduler:
result = "Error: tool registry not available" result = "Error: tool registry not available"
if result.startswith("Error:"): if result.startswith("Error:"):
self.db.kv_set(
kv_key,
json.dumps(
{
"status": "failed",
"filename": filename,
"task_id": task_id,
"error": result[:500],
"failed_at": datetime.now(UTC).isoformat(),
}
),
)
self._notify( self._notify(
f"Content watcher: pipeline **failed** for **{filename}**.\n" f"Content watcher: pipeline **failed** for **{filename}**.\n"
f"Error: {result[:200]}", f"Error: {result[:200]}",
@ -1105,17 +912,6 @@ class Scheduler:
except OSError as e: except OSError as e:
log.warning("Could not move %s to processed: %s", filename, e) log.warning("Could not move %s to processed: %s", filename, e)
self.db.kv_set(
kv_key,
json.dumps(
{
"status": "completed",
"filename": filename,
"task_id": task_id,
"completed_at": datetime.now(UTC).isoformat(),
}
),
)
self._notify( self._notify(
f"Content watcher: pipeline **completed** for **{filename}**.\n" f"Content watcher: pipeline **completed** for **{filename}**.\n"
f"ClickUp task: {matched_task.name}", f"ClickUp task: {matched_task.name}",
@ -1124,18 +920,6 @@ class Scheduler:
except Exception as e: except Exception as e:
log.error("Content watcher pipeline error for %s: %s", filename, e) log.error("Content watcher pipeline error for %s: %s", filename, e)
self.db.kv_set(
kv_key,
json.dumps(
{
"status": "failed",
"filename": filename,
"task_id": task_id,
"error": str(e)[:500],
"failed_at": datetime.now(UTC).isoformat(),
}
),
)
def _match_xlsx_to_content_task(self, normalized_stem: str): def _match_xlsx_to_content_task(self, normalized_stem: str):
"""Find a ClickUp content task whose Keyword matches the file stem. """Find a ClickUp content task whose Keyword matches the file stem.

View File

@ -1,8 +1,7 @@
"""ClickUp chat-facing tools for listing, approving, and declining tasks.""" """ClickUp chat-facing tools for listing, querying, and resetting tasks."""
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
from . import tool from . import tool
@ -24,22 +23,6 @@ def _get_clickup_client(ctx: dict):
) )
def _get_clickup_states(db) -> dict[str, dict]:
"""Load all tracked ClickUp task states from kv_store."""
pairs = db.kv_scan("clickup:task:")
states = {}
for key, value in pairs:
# keys look like clickup:task:{id}:state
parts = key.split(":")
if len(parts) == 4 and parts[3] == "state":
task_id = parts[2]
try: # noqa: SIM105
states[task_id] = json.loads(value)
except json.JSONDecodeError:
pass
return states
@tool( @tool(
"clickup_query_tasks", "clickup_query_tasks",
"Query ClickUp live for tasks. Optionally filter by status (e.g. 'to do', 'in progress') " "Query ClickUp live for tasks. Optionally filter by status (e.g. 'to do', 'in progress') "
@ -94,112 +77,116 @@ def clickup_query_tasks(status: str = "", task_type: str = "", ctx: dict | None
@tool( @tool(
"clickup_list_tasks", "clickup_list_tasks",
"List ClickUp tasks that Cheddah is tracking. Optionally filter by internal state " "List ClickUp tasks in automation-related statuses (automation underway, "
"(executing, completed, failed).", "outline review, internal review, error). Shows tasks currently being processed.",
category="clickup", category="clickup",
) )
def clickup_list_tasks(status: str = "", ctx: dict | None = None) -> str: def clickup_list_tasks(status: str = "", ctx: dict | None = None) -> str:
"""List tracked ClickUp tasks, optionally filtered by state.""" """List ClickUp tasks in automation-related statuses."""
db = ctx["db"] client = _get_clickup_client(ctx)
states = _get_clickup_states(db) if not client:
return "Error: ClickUp API token not configured."
if not states: cfg = ctx["config"].clickup
return "No ClickUp tasks are currently being tracked." if not cfg.space_id:
return "Error: ClickUp space_id not configured."
# Query tasks in automation-related statuses
automation_statuses = [
cfg.automation_status,
"outline review",
cfg.review_status,
cfg.error_status,
]
if status: if status:
states = {tid: s for tid, s in states.items() if s.get("state") == status} automation_statuses = [status]
if not states:
return f"No ClickUp tasks with state '{status}'." try:
tasks = client.get_tasks_from_space(cfg.space_id, statuses=automation_statuses)
except Exception as e:
return f"Error querying ClickUp: {e}"
finally:
client.close()
if not tasks:
filter_note = f" with status '{status}'" if status else " in automation statuses"
return f"No tasks found{filter_note}."
lines = [] lines = []
for task_id, state in sorted(states.items(), key=lambda x: x[1].get("discovered_at", "")): for t in tasks:
name = state.get("clickup_task_name", "Unknown") parts = [f"**{t.name}** (ID: {t.id})"]
task_type = state.get("task_type", "") parts.append(f" Status: {t.status} | Type: {t.task_type or ''}")
task_state = state.get("state", "unknown") fields = {k: v for k, v in t.custom_fields.items() if v}
skill = state.get("skill_name", "") if fields:
lines.append( field_strs = [f"{k}: {v}" for k, v in fields.items()]
f"• **{name}** (ID: {task_id})\n" parts.append(f" Fields: {', '.join(field_strs)}")
f" Type: {task_type} | State: {task_state} | Skill: {skill}" lines.append("\n".join(parts))
)
return f"**Tracked ClickUp Tasks ({len(lines)}):**\n\n" + "\n\n".join(lines) return f"**Automation Tasks ({len(lines)}):**\n\n" + "\n\n".join(lines)
@tool( @tool(
"clickup_task_status", "clickup_task_status",
"Check the detailed internal processing state of a ClickUp task by its ID.", "Check the current status and details of a ClickUp task by its ID.",
category="clickup", category="clickup",
) )
def clickup_task_status(task_id: str, ctx: dict | None = None) -> str: def clickup_task_status(task_id: str, ctx: dict | None = None) -> str:
"""Get detailed state for a specific tracked task.""" """Get current status for a specific ClickUp task from the API."""
db = ctx["db"] client = _get_clickup_client(ctx)
raw = db.kv_get(f"clickup:task:{task_id}:state") if not client:
if not raw: return "Error: ClickUp API token not configured."
return f"No tracked state found for task ID '{task_id}'."
try: try:
state = json.loads(raw) task = client.get_task(task_id)
except json.JSONDecodeError: except Exception as e:
return f"Corrupted state data for task '{task_id}'." return f"Error fetching task '{task_id}': {e}"
finally:
client.close()
lines = [f"**Task: {state.get('clickup_task_name', 'Unknown')}** (ID: {task_id})"] lines = [f"**Task: {task.name}** (ID: {task.id})"]
lines.append(f"State: {state.get('state', 'unknown')}") lines.append(f"Status: {task.status}")
lines.append(f"Task Type: {state.get('task_type', '')}") lines.append(f"Type: {task.task_type or ''}")
lines.append(f"Mapped Skill: {state.get('skill_name', '')}") if task.url:
lines.append(f"Discovered: {state.get('discovered_at', '')}") lines.append(f"URL: {task.url}")
if state.get("started_at"): if task.due_date:
lines.append(f"Started: {state['started_at']}") lines.append(f"Due: {task.due_date}")
if state.get("completed_at"): if task.date_updated:
lines.append(f"Completed: {state['completed_at']}") lines.append(f"Updated: {task.date_updated}")
if state.get("error"): fields = {k: v for k, v in task.custom_fields.items() if v}
lines.append(f"Error: {state['error']}") if fields:
if state.get("deliverable_paths"): field_strs = [f"{k}: {v}" for k, v in fields.items()]
lines.append(f"Deliverables: {', '.join(state['deliverable_paths'])}") lines.append(f"Fields: {', '.join(field_strs)}")
if state.get("custom_fields"):
fields_str = ", ".join(f"{k}: {v}" for k, v in state["custom_fields"].items() if v)
if fields_str:
lines.append(f"Custom Fields: {fields_str}")
return "\n".join(lines) return "\n".join(lines)
@tool( @tool(
"clickup_reset_task", "clickup_reset_task",
"Reset a ClickUp task's internal tracking state so it can be retried on the next poll. " "Reset a ClickUp task to 'to do' status so it can be retried on the next poll. "
"Use this when a task has failed or completed and you want to re-run it.", "Use this when a task is stuck in an error or automation state.",
category="clickup", category="clickup",
) )
def clickup_reset_task(task_id: str, ctx: dict | None = None) -> str: def clickup_reset_task(task_id: str, ctx: dict | None = None) -> str:
"""Delete the kv_store state for a single task so it can be retried.""" """Reset a ClickUp task status to 'to do' for retry."""
db = ctx["db"] client = _get_clickup_client(ctx)
key = f"clickup:task:{task_id}:state" if not client:
raw = db.kv_get(key) return "Error: ClickUp API token not configured."
if not raw:
return f"No tracked state found for task ID '{task_id}'. Nothing to reset."
db.kv_delete(key) cfg = ctx["config"].clickup
return f"Task '{task_id}' state cleared. It will be picked up on the next scheduler poll." reset_status = cfg.poll_statuses[0] if cfg.poll_statuses else "to do"
try:
@tool( client.update_task_status(task_id, reset_status)
"clickup_reset_all", client.add_comment(
"Clear ALL internal ClickUp task tracking state. Use this to wipe the slate clean " task_id,
"so all eligible tasks can be retried on the next poll cycle.", f"Task reset to '{reset_status}' via chat command.",
category="clickup",
) )
def clickup_reset_all(ctx: dict | None = None) -> str: except Exception as e:
"""Delete all clickup task states and legacy active_ids from kv_store.""" return f"Error resetting task '{task_id}': {e}"
db = ctx["db"] finally:
states = _get_clickup_states(db) client.close()
count = 0
for task_id in states:
db.kv_delete(f"clickup:task:{task_id}:state")
count += 1
# Also clean up legacy active_ids key
if db.kv_get("clickup:active_task_ids"):
db.kv_delete("clickup:active_task_ids")
return ( return (
f"Cleared {count} task state(s) from tracking. Next poll will re-discover eligible tasks." f"Task '{task_id}' reset to '{reset_status}'. "
f"It will be picked up on the next scheduler poll."
) )

View File

@ -9,10 +9,8 @@ The content-researcher skill in the execution brain is triggered by keywords lik
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
import re import re
from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from . import tool from . import tool
@ -361,7 +359,8 @@ def _build_phase2_prompt(
@tool( @tool(
"create_content", "create_content",
"Two-phase SEO content creation: Phase 1 researches + outlines, Phase 2 writes " "Two-phase SEO content creation: Phase 1 researches + outlines, Phase 2 writes "
"full content from the approved outline. Auto-detects phase from kv_store state. " "full content from the approved outline. Auto-detects phase from ClickUp task "
"status ('outline approved' → Phase 2). "
"Auto-detects content type from URL presence if not specified.", "Auto-detects content type from URL presence if not specified.",
category="content", category="content",
) )
@ -396,20 +395,20 @@ def create_content(
config = ctx.get("config") config = ctx.get("config")
db = ctx.get("db") db = ctx.get("db")
task_id = ctx.get("clickup_task_id", "") task_id = ctx.get("clickup_task_id", "")
kv_key = f"clickup:task:{task_id}:state" if task_id else ""
# Determine phase from kv_store state # Determine phase from ClickUp task status
phase = 1 phase = 1
existing_state = {} if task_id and ctx:
if kv_key and db: client = _get_clickup_client(ctx)
raw = db.kv_get(kv_key) if client:
if raw:
try: try:
existing_state = json.loads(raw) task = client.get_task(task_id)
if existing_state.get("state") == "outline_review": if task.status.lower() == "outline approved":
phase = 2 phase = 2
except json.JSONDecodeError: except Exception as e:
pass log.warning("Could not check ClickUp status for phase detection: %s", e)
finally:
client.close()
# Find Cora report # Find Cora report
cora_inbox = config.content.cora_inbox if config else "" cora_inbox = config.content.cora_inbox if config else ""
@ -426,7 +425,6 @@ def create_content(
db=db, db=db,
ctx=ctx, ctx=ctx,
task_id=task_id, task_id=task_id,
kv_key=kv_key,
url=url, url=url,
keyword=keyword, keyword=keyword,
content_type=content_type, content_type=content_type,
@ -441,11 +439,10 @@ def create_content(
db=db, db=db,
ctx=ctx, ctx=ctx,
task_id=task_id, task_id=task_id,
kv_key=kv_key,
url=url, url=url,
keyword=keyword, keyword=keyword,
cora_path=cora_path, cora_path=cora_path,
existing_state=existing_state, existing_state={},
is_service_page=is_service_page, is_service_page=is_service_page,
capabilities_default=capabilities_default, capabilities_default=capabilities_default,
) )
@ -463,7 +460,6 @@ def _run_phase1(
db, db,
ctx, ctx,
task_id: str, task_id: str,
kv_key: str,
url: str, url: str,
keyword: str, keyword: str,
content_type: str, content_type: str,
@ -471,8 +467,6 @@ def _run_phase1(
capabilities_default: str, capabilities_default: str,
is_service_page: bool = False, is_service_page: bool = False,
) -> str: ) -> str:
now = datetime.now(UTC).isoformat()
# ClickUp: move to automation underway # ClickUp: move to automation underway
if task_id: if task_id:
_sync_clickup_start(ctx, task_id) _sync_clickup_start(ctx, task_id)
@ -492,13 +486,11 @@ def _run_phase1(
error_msg = f"Phase 1 execution failed: {e}" error_msg = f"Phase 1 execution failed: {e}"
log.error(error_msg) log.error(error_msg)
if task_id: if task_id:
_update_kv_state(db, kv_key, "failed", error=str(e))
_sync_clickup_fail(ctx, task_id, str(e)) _sync_clickup_fail(ctx, task_id, str(e))
return f"Error: {error_msg}" return f"Error: {error_msg}"
if result.startswith("Error:"): if result.startswith("Error:"):
if task_id: if task_id:
_update_kv_state(db, kv_key, "failed", error=result)
_sync_clickup_fail(ctx, task_id, result) _sync_clickup_fail(ctx, task_id, result)
return result return result
@ -506,23 +498,7 @@ def _run_phase1(
outline_path = _save_content(result, keyword, "outline.md", config) outline_path = _save_content(result, keyword, "outline.md", config)
log.info("Outline saved to: %s", outline_path) log.info("Outline saved to: %s", outline_path)
# Update kv_store # ClickUp: move to outline review + store OutlinePath
if kv_key and db:
state = {
"state": "outline_review",
"clickup_task_id": task_id,
"url": url,
"keyword": keyword,
"content_type": content_type,
"cora_path": cora_path,
"outline_path": outline_path,
"phase1_completed_at": now,
"completed_at": None,
"error": None,
}
db.kv_set(kv_key, json.dumps(state))
# ClickUp: move to outline review
if task_id: if task_id:
_sync_clickup_outline_ready(ctx, task_id, outline_path) _sync_clickup_outline_ready(ctx, task_id, outline_path)
@ -585,7 +561,6 @@ def _run_phase2(
db, db,
ctx, ctx,
task_id: str, task_id: str,
kv_key: str,
url: str, url: str,
keyword: str, keyword: str,
cora_path: str, cora_path: str,
@ -593,11 +568,8 @@ def _run_phase2(
is_service_page: bool = False, is_service_page: bool = False,
capabilities_default: str = "", capabilities_default: str = "",
) -> str: ) -> str:
# Resolve outline path: ClickUp field → convention → state fallback # Resolve outline path: ClickUp field → convention
outline_path = _resolve_outline_path(ctx, task_id, keyword, config) outline_path = _resolve_outline_path(ctx, task_id, keyword, config)
if not outline_path:
# Last resort: check existing_state (for continue_content calls)
outline_path = existing_state.get("outline_path", "")
outline_text = "" outline_text = ""
if outline_path: if outline_path:
@ -612,7 +584,8 @@ def _run_phase2(
client = _get_clickup_client(ctx) client = _get_clickup_client(ctx)
if client: if client:
try: try:
reset_status = config.clickup.poll_statuses[0] if config.clickup.poll_statuses else "to do" poll_sts = config.clickup.poll_statuses
reset_status = poll_sts[0] if poll_sts else "to do"
client.update_task_status(task_id, reset_status) client.update_task_status(task_id, reset_status)
client.add_comment( client.add_comment(
task_id, task_id,
@ -653,13 +626,11 @@ def _run_phase2(
error_msg = f"Phase 2 execution failed: {e}" error_msg = f"Phase 2 execution failed: {e}"
log.error(error_msg) log.error(error_msg)
if task_id: if task_id:
_update_kv_state(db, kv_key, "failed", error=str(e))
_sync_clickup_fail(ctx, task_id, str(e)) _sync_clickup_fail(ctx, task_id, str(e))
return f"Error: {error_msg}" return f"Error: {error_msg}"
if result.startswith("Error:"): if result.startswith("Error:"):
if task_id: if task_id:
_update_kv_state(db, kv_key, "failed", error=result)
_sync_clickup_fail(ctx, task_id, result) _sync_clickup_fail(ctx, task_id, result)
return result return result
@ -667,16 +638,6 @@ def _run_phase2(
content_path = _save_content(result, keyword, "final-content.md", config) content_path = _save_content(result, keyword, "final-content.md", config)
log.info("Final content saved to: %s", content_path) log.info("Final content saved to: %s", content_path)
# Update kv_store
if kv_key and db:
now = datetime.now(UTC).isoformat()
state = existing_state.copy()
state["state"] = "completed"
state["content_path"] = content_path
state["completed_at"] = now
state["error"] = None
db.kv_set(kv_key, json.dumps(state))
# ClickUp: move to internal review # ClickUp: move to internal review
if task_id: if task_id:
_sync_clickup_complete(ctx, task_id, content_path) _sync_clickup_complete(ctx, task_id, content_path)
@ -714,29 +675,31 @@ def continue_content(
""" """
if not keyword: if not keyword:
return "Error: 'keyword' is required." return "Error: 'keyword' is required."
if not ctx or "agent" not in ctx or "db" not in ctx: if not ctx or "agent" not in ctx:
return "Error: Tool context with agent and db is required." return "Error: Tool context with agent is required."
db = ctx["db"]
config = ctx.get("config") config = ctx.get("config")
db = ctx.get("db")
# Scan kv_store for outline_review entries matching keyword # Query ClickUp for tasks in "outline approved" or "outline review" status
entries = db.kv_scan("clickup:task:") # matching the keyword
keyword_lower = keyword.lower().strip() client = _get_clickup_client(ctx)
if client:
for key, raw in entries:
try: try:
state = json.loads(raw) space_id = config.clickup.space_id if config else ""
except (json.JSONDecodeError, TypeError): if space_id:
continue tasks = client.get_tasks_from_space(
if state.get("state") != "outline_review": space_id,
continue statuses=["outline approved", "outline review"],
if state.get("keyword", "").lower().strip() == keyword_lower: )
# Found a matching entry — run Phase 2 keyword_lower = keyword.lower().strip()
task_id = state.get("clickup_task_id", "") for task in tasks:
kv_key = key task_keyword = task.custom_fields.get("Keyword", "")
url = state.get("url", "") if str(task_keyword).lower().strip() == keyword_lower:
cora_path = state.get("cora_path", "") task_id = task.id
url = task.custom_fields.get("IMSURL", "") or ""
cora_inbox = config.content.cora_inbox if config else ""
cora_path = _find_cora_report(keyword, cora_inbox)
return _run_phase2( return _run_phase2(
agent=ctx["agent"], agent=ctx["agent"],
@ -744,35 +707,32 @@ def continue_content(
db=db, db=db,
ctx=ctx, ctx=ctx,
task_id=task_id, task_id=task_id,
kv_key=kv_key, url=str(url),
url=url,
keyword=keyword, keyword=keyword,
cora_path=cora_path, cora_path=cora_path or "",
existing_state=state, existing_state={},
)
except Exception as e:
log.warning("ClickUp query failed in continue_content: %s", e)
finally:
client.close()
# Fallback: try to run Phase 2 without a ClickUp task (outline must exist locally)
outline_path = _resolve_outline_path(ctx, "", keyword, config)
if outline_path:
return _run_phase2(
agent=ctx["agent"],
config=config,
db=db,
ctx=ctx,
task_id="",
url="",
keyword=keyword,
cora_path="",
existing_state={},
) )
return ( return (
f"No outline awaiting review found for keyword '{keyword}'. " f"No outline awaiting review found for keyword '{keyword}'. "
f"Use create_content to start Phase 1 first." f"Use create_content to start Phase 1 first."
) )
# ---------------------------------------------------------------------------
# KV state helper
# ---------------------------------------------------------------------------
def _update_kv_state(db, kv_key: str, state_val: str, error: str = "") -> None:
"""Update kv_store state without losing existing data."""
if not db or not kv_key:
return
raw = db.kv_get(kv_key)
try:
state = json.loads(raw) if raw else {}
except json.JSONDecodeError:
state = {}
state["state"] = state_val
if error:
state["error"] = error[:2000]
state["completed_at"] = datetime.now(UTC).isoformat()
db.kv_set(kv_key, json.dumps(state))

View File

@ -6,12 +6,10 @@ Primary workflow: ingest CORA .xlsx → generate content batch.
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
import os import os
import re import re
import subprocess import subprocess
from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from . import tool from . import tool
@ -163,9 +161,9 @@ def _parse_generate_output(stdout: str) -> dict:
def _set_status(ctx: dict | None, message: str) -> None: def _set_status(ctx: dict | None, message: str) -> None:
"""Write pipeline progress to KV store for UI polling.""" """Log pipeline progress. Previously wrote to KV; now just logs."""
if ctx and "db" in ctx: if message:
ctx["db"].kv_set("linkbuilding:status", message) log.info("[LB Pipeline] %s", message)
def _get_clickup_client(ctx: dict | None): def _get_clickup_client(ctx: dict | None):
@ -187,25 +185,10 @@ def _get_clickup_client(ctx: dict | None):
def _sync_clickup(ctx: dict | None, task_id: str, step: str, message: str) -> None: def _sync_clickup(ctx: dict | None, task_id: str, step: str, message: str) -> None:
"""Post a comment to ClickUp and update KV state.""" """Post a progress comment to ClickUp."""
if not task_id or not ctx: if not task_id or not ctx:
return return
# Update KV store
db = ctx.get("db")
if db:
kv_key = f"clickup:task:{task_id}:state"
raw = db.kv_get(kv_key)
if raw:
try:
state = json.loads(raw)
state["last_step"] = step
state["last_message"] = message
db.kv_set(kv_key, json.dumps(state))
except json.JSONDecodeError:
pass
# Post comment to ClickUp
cu_client = _get_clickup_client(ctx) cu_client = _get_clickup_client(ctx)
if cu_client: if cu_client:
try: try:
@ -254,26 +237,8 @@ def _find_clickup_task(ctx: dict, keyword: str) -> str:
continue continue
if _fuzzy_keyword_match(keyword_norm, _normalize_for_match(str(task_keyword))): if _fuzzy_keyword_match(keyword_norm, _normalize_for_match(str(task_keyword))):
# Found a match — create executing state # Found a match — move to "automation underway"
task_id = task.id task_id = task.id
now = datetime.now(UTC).isoformat()
state = {
"state": "executing",
"clickup_task_id": task_id,
"clickup_task_name": task.name,
"task_type": task.task_type,
"skill_name": "run_link_building",
"discovered_at": now,
"started_at": now,
"completed_at": None,
"error": None,
"deliverable_paths": [],
"custom_fields": task.custom_fields,
}
db = ctx.get("db")
if db:
db.kv_set(f"clickup:task:{task_id}:state", json.dumps(state))
# Move to "automation underway" # Move to "automation underway"
cu_client2 = _get_clickup_client(ctx) cu_client2 = _get_clickup_client(ctx)
@ -322,7 +287,7 @@ def _fuzzy_keyword_match(a: str, b: str) -> bool:
def _complete_clickup_task(ctx: dict | None, task_id: str, message: str, status: str = "") -> None: def _complete_clickup_task(ctx: dict | None, task_id: str, message: str, status: str = "") -> None:
"""Mark a ClickUp task as completed and update KV state.""" """Mark a ClickUp task as completed."""
if not task_id or not ctx: if not task_id or not ctx:
return return
@ -331,19 +296,6 @@ def _complete_clickup_task(ctx: dict | None, task_id: str, message: str, status:
lb_map = skill_map.get("Link Building", {}) lb_map = skill_map.get("Link Building", {})
complete_status = status or lb_map.get("complete_status", "complete") complete_status = status or lb_map.get("complete_status", "complete")
db = ctx.get("db")
if db:
kv_key = f"clickup:task:{task_id}:state"
raw = db.kv_get(kv_key)
if raw:
try:
state = json.loads(raw)
state["state"] = "completed"
state["completed_at"] = datetime.now(UTC).isoformat()
db.kv_set(kv_key, json.dumps(state))
except json.JSONDecodeError:
pass
cu_client = _get_clickup_client(ctx) cu_client = _get_clickup_client(ctx)
if cu_client: if cu_client:
try: try:
@ -356,27 +308,13 @@ def _complete_clickup_task(ctx: dict | None, task_id: str, message: str, status:
def _fail_clickup_task(ctx: dict | None, task_id: str, error_msg: str) -> None: def _fail_clickup_task(ctx: dict | None, task_id: str, error_msg: str) -> None:
"""Mark a ClickUp task as failed and update KV state.""" """Mark a ClickUp task as failed."""
if not task_id or not ctx: if not task_id or not ctx:
return return
config = ctx.get("config") config = ctx.get("config")
error_status = config.clickup.error_status if config else "error" error_status = config.clickup.error_status if config else "error"
db = ctx.get("db")
if db:
kv_key = f"clickup:task:{task_id}:state"
raw = db.kv_get(kv_key)
if raw:
try:
state = json.loads(raw)
state["state"] = "failed"
state["error"] = error_msg
state["completed_at"] = datetime.now(UTC).isoformat()
db.kv_set(kv_key, json.dumps(state))
except json.JSONDecodeError:
pass
cu_client = _get_clickup_client(ctx) cu_client = _get_clickup_client(ctx)
if cu_client: if cu_client:
try: try:
@ -749,7 +687,6 @@ def scan_cora_folder(ctx: dict | None = None) -> str:
if not watch_path.exists(): if not watch_path.exists():
return f"Watch folder does not exist: {watch_folder}" return f"Watch folder does not exist: {watch_folder}"
db = ctx.get("db")
xlsx_files = sorted(watch_path.glob("*.xlsx")) xlsx_files = sorted(watch_path.glob("*.xlsx"))
if not xlsx_files: if not xlsx_files:
@ -757,18 +694,16 @@ def scan_cora_folder(ctx: dict | None = None) -> str:
lines = [f"## Cora Inbox: {watch_folder}\n"] lines = [f"## Cora Inbox: {watch_folder}\n"]
processed_dir = watch_path / "processed"
processed_names = set()
if processed_dir.exists():
processed_names = {f.name for f in processed_dir.glob("*.xlsx")}
for f in xlsx_files: for f in xlsx_files:
filename = f.name filename = f.name
status = "new" if filename.startswith("~$"):
if db: continue
kv_val = db.kv_get(f"linkbuilding:watched:{filename}") status = "processed" if filename in processed_names else "new"
if kv_val:
try:
watched = json.loads(kv_val)
status = watched.get("status", "unknown")
except json.JSONDecodeError:
status = "tracked"
lines.append(f"- **{filename}** — status: {status}") lines.append(f"- **{filename}** — status: {status}")
# Check processed subfolder # Check processed subfolder

View File

@ -14,7 +14,7 @@ import json
import logging import logging
import re import re
import time import time
from datetime import UTC, datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from ..docx_export import text_to_docx from ..docx_export import text_to_docx
@ -38,9 +38,9 @@ SONNET_CLI_MODEL = "sonnet"
def _set_status(ctx: dict | None, message: str) -> None: def _set_status(ctx: dict | None, message: str) -> None:
"""Write pipeline progress to the DB so the UI can poll it.""" """Log pipeline progress. Previously wrote to KV; now just logs."""
if ctx and "db" in ctx: if message:
ctx["db"].kv_set("pipeline:status", message) log.info("[PR Pipeline] %s", message)
def _fuzzy_company_match(name: str, candidate: str) -> bool: def _fuzzy_company_match(name: str, candidate: str) -> bool:
@ -95,26 +95,8 @@ def _find_clickup_task(ctx: dict, company_name: str) -> str:
): ):
continue continue
# Found a match — create kv_store entry and move to "in progress" # Found a match — move to "automation underway" on ClickUp
task_id = task.id task_id = task.id
now = datetime.now(UTC).isoformat()
state = {
"state": "executing",
"clickup_task_id": task_id,
"clickup_task_name": task.name,
"task_type": task.task_type,
"skill_name": "write_press_releases",
"discovered_at": now,
"started_at": now,
"completed_at": None,
"error": None,
"deliverable_paths": [],
"custom_fields": task.custom_fields,
}
db = ctx.get("db")
if db:
db.kv_set(f"clickup:task:{task_id}:state", json.dumps(state))
# Move to "automation underway" on ClickUp # Move to "automation underway" on ClickUp
cu_client2 = _get_clickup_client(ctx) cu_client2 = _get_clickup_client(ctx)
@ -809,18 +791,6 @@ def write_press_releases(
# Set status to internal review # Set status to internal review
cu_client.update_task_status(clickup_task_id, config.clickup.review_status) cu_client.update_task_status(clickup_task_id, config.clickup.review_status)
# Update kv_store state if one exists
db = ctx.get("db")
if db:
kv_key = f"clickup:task:{clickup_task_id}:state"
existing = db.kv_get(kv_key)
if existing:
state = json.loads(existing)
state["state"] = "completed"
state["completed_at"] = datetime.now(UTC).isoformat()
state["deliverable_paths"] = docx_files
db.kv_set(kv_key, json.dumps(state))
output_parts.append("\n## ClickUp Sync\n") output_parts.append("\n## ClickUp Sync\n")
output_parts.append(f"- Task `{clickup_task_id}` updated") output_parts.append(f"- Task `{clickup_task_id}` updated")
output_parts.append(f"- {uploaded_count} file(s) uploaded") output_parts.append(f"- {uploaded_count} file(s) uploaded")

View File

@ -454,13 +454,7 @@ def create_ui(
return agent_name, agent_name, chatbot_msgs, convs, new_browser return agent_name, agent_name, chatbot_msgs, convs, new_browser
def poll_pipeline_status(agent_name): def poll_pipeline_status(agent_name):
"""Poll the DB for pipeline progress updates.""" """Pipeline status indicator (no longer used — kept for UI timer)."""
agent = _get_agent(agent_name)
if not agent:
return gr.update(value="", visible=False)
status = agent.db.kv_get("pipeline:status")
if status:
return gr.update(value=f"{status}", visible=True)
return gr.update(value="", visible=False) return gr.update(value="", visible=False)
def poll_notifications(): def poll_notifications():

View File

@ -1,147 +1,144 @@
"""Tests for the ClickUp chat tools.""" """Tests for the ClickUp chat tools (API-backed, no KV store)."""
from __future__ import annotations from __future__ import annotations
import json from dataclasses import dataclass, field
from unittest.mock import MagicMock, patch
from cheddahbot.tools.clickup_tool import ( from cheddahbot.tools.clickup_tool import (
clickup_list_tasks, clickup_list_tasks,
clickup_reset_all, clickup_query_tasks,
clickup_reset_task, clickup_reset_task,
clickup_task_status, clickup_task_status,
) )
def _make_ctx(db): @dataclass
return {"db": db} class FakeTask:
id: str = "t1"
name: str = "Test Task"
status: str = "to do"
task_type: str = "Press Release"
url: str = "https://app.clickup.com/t/t1"
due_date: str = ""
date_updated: str = ""
tags: list = field(default_factory=list)
custom_fields: dict = field(default_factory=dict)
def _seed_task(db, task_id, state, **overrides): def _make_ctx():
"""Insert a task state into kv_store.""" config = MagicMock()
data = { config.clickup.api_token = "test-token"
"state": state, config.clickup.workspace_id = "ws1"
"clickup_task_id": task_id, config.clickup.space_id = "sp1"
"clickup_task_name": f"Task {task_id}", config.clickup.task_type_field_name = "Work Category"
"task_type": "Press Release", config.clickup.automation_status = "automation underway"
"skill_name": "write_press_releases", config.clickup.review_status = "internal review"
"discovered_at": "2026-01-01T00:00:00", config.clickup.error_status = "error"
"started_at": None, config.clickup.poll_statuses = ["to do"]
"completed_at": None, return {"config": config, "db": MagicMock()}
"error": None,
"deliverable_paths": [],
"custom_fields": {}, class TestClickupQueryTasks:
} @patch("cheddahbot.tools.clickup_tool._get_clickup_client")
data.update(overrides) def test_returns_tasks(self, mock_client_fn):
db.kv_set(f"clickup:task:{task_id}:state", json.dumps(data)) mock_client = MagicMock()
mock_client.get_tasks_from_space.return_value = [
FakeTask(id="t1", name="PR Task", task_type="Press Release"),
]
mock_client_fn.return_value = mock_client
result = clickup_query_tasks(ctx=_make_ctx())
assert "PR Task" in result
assert "t1" in result
@patch("cheddahbot.tools.clickup_tool._get_clickup_client")
def test_no_tasks_found(self, mock_client_fn):
mock_client = MagicMock()
mock_client.get_tasks_from_space.return_value = []
mock_client_fn.return_value = mock_client
result = clickup_query_tasks(ctx=_make_ctx())
assert "No tasks found" in result
class TestClickupListTasks: class TestClickupListTasks:
def test_empty_when_no_tasks(self, tmp_db): @patch("cheddahbot.tools.clickup_tool._get_clickup_client")
result = clickup_list_tasks(ctx=_make_ctx(tmp_db)) def test_lists_automation_tasks(self, mock_client_fn):
assert "No ClickUp tasks" in result mock_client = MagicMock()
mock_client.get_tasks_from_space.return_value = [
FakeTask(id="t1", name="Active Task", status="automation underway"),
]
mock_client_fn.return_value = mock_client
def test_lists_all_tracked_tasks(self, tmp_db): result = clickup_list_tasks(ctx=_make_ctx())
_seed_task(tmp_db, "a1", "discovered") assert "Active Task" in result
_seed_task(tmp_db, "a2", "approved") assert "t1" in result
result = clickup_list_tasks(ctx=_make_ctx(tmp_db)) @patch("cheddahbot.tools.clickup_tool._get_clickup_client")
def test_no_automation_tasks(self, mock_client_fn):
mock_client = MagicMock()
mock_client.get_tasks_from_space.return_value = []
mock_client_fn.return_value = mock_client
assert "a1" in result result = clickup_list_tasks(ctx=_make_ctx())
assert "a2" in result assert "No tasks found" in result
assert "2" in result # count
def test_filter_by_status(self, tmp_db): @patch("cheddahbot.tools.clickup_tool._get_clickup_client")
_seed_task(tmp_db, "a1", "discovered") def test_filter_by_status(self, mock_client_fn):
_seed_task(tmp_db, "a2", "approved") mock_client = MagicMock()
_seed_task(tmp_db, "a3", "completed") mock_client.get_tasks_from_space.return_value = [
FakeTask(id="t1", name="Error Task", status="error"),
]
mock_client_fn.return_value = mock_client
result = clickup_list_tasks(status="approved", ctx=_make_ctx(tmp_db)) result = clickup_list_tasks(status="error", ctx=_make_ctx())
assert "Error Task" in result
assert "a2" in result
assert "a1" not in result
assert "a3" not in result
def test_filter_returns_empty_message(self, tmp_db):
_seed_task(tmp_db, "a1", "discovered")
result = clickup_list_tasks(status="completed", ctx=_make_ctx(tmp_db))
assert "No ClickUp tasks with state" in result
class TestClickupTaskStatus: class TestClickupTaskStatus:
def test_shows_details(self, tmp_db): @patch("cheddahbot.tools.clickup_tool._get_clickup_client")
_seed_task(tmp_db, "a1", "executing", started_at="2026-01-01T12:00:00") def test_shows_details(self, mock_client_fn):
mock_client = MagicMock()
mock_client.get_task.return_value = FakeTask(
id="t1",
name="My Task",
status="automation underway",
task_type="Press Release",
)
mock_client_fn.return_value = mock_client
result = clickup_task_status(task_id="a1", ctx=_make_ctx(tmp_db)) result = clickup_task_status(task_id="t1", ctx=_make_ctx())
assert "My Task" in result
assert "Task a1" in result assert "automation underway" in result
assert "executing" in result
assert "Press Release" in result assert "Press Release" in result
assert "2026-01-01T12:00:00" in result
def test_unknown_task(self, tmp_db): @patch("cheddahbot.tools.clickup_tool._get_clickup_client")
result = clickup_task_status(task_id="nonexistent", ctx=_make_ctx(tmp_db)) def test_api_error(self, mock_client_fn):
mock_client = MagicMock()
mock_client.get_task.side_effect = Exception("Not found")
mock_client_fn.return_value = mock_client
assert "No tracked state" in result result = clickup_task_status(task_id="bad", ctx=_make_ctx())
assert "Error" in result
def test_shows_error_when_failed(self, tmp_db):
_seed_task(tmp_db, "f1", "failed", error="API timeout")
result = clickup_task_status(task_id="f1", ctx=_make_ctx(tmp_db))
assert "API timeout" in result
def test_shows_deliverables(self, tmp_db):
_seed_task(tmp_db, "c1", "completed", deliverable_paths=["/data/pr1.txt", "/data/pr2.txt"])
result = clickup_task_status(task_id="c1", ctx=_make_ctx(tmp_db))
assert "/data/pr1.txt" in result
class TestClickupResetTask: class TestClickupResetTask:
def test_resets_failed_task(self, tmp_db): @patch("cheddahbot.tools.clickup_tool._get_clickup_client")
_seed_task(tmp_db, "f1", "failed") def test_resets_task(self, mock_client_fn):
mock_client = MagicMock()
mock_client_fn.return_value = mock_client
result = clickup_reset_task(task_id="f1", ctx=_make_ctx(tmp_db)) result = clickup_reset_task(task_id="t1", ctx=_make_ctx())
assert "reset" in result.lower()
mock_client.update_task_status.assert_called_once_with("t1", "to do")
mock_client.add_comment.assert_called_once()
assert "cleared" in result.lower() @patch("cheddahbot.tools.clickup_tool._get_clickup_client")
assert tmp_db.kv_get("clickup:task:f1:state") is None def test_api_error(self, mock_client_fn):
mock_client = MagicMock()
mock_client.update_task_status.side_effect = Exception("API error")
mock_client_fn.return_value = mock_client
def test_resets_completed_task(self, tmp_db): result = clickup_reset_task(task_id="t1", ctx=_make_ctx())
_seed_task(tmp_db, "c1", "completed") assert "Error" in result
result = clickup_reset_task(task_id="c1", ctx=_make_ctx(tmp_db))
assert "cleared" in result.lower()
assert tmp_db.kv_get("clickup:task:c1:state") is None
def test_unknown_task(self, tmp_db):
result = clickup_reset_task(task_id="nope", ctx=_make_ctx(tmp_db))
assert "Nothing to reset" in result
class TestClickupResetAll:
def test_clears_all_states(self, tmp_db):
_seed_task(tmp_db, "a1", "completed")
_seed_task(tmp_db, "a2", "failed")
_seed_task(tmp_db, "a3", "executing")
result = clickup_reset_all(ctx=_make_ctx(tmp_db))
assert "3" in result
assert tmp_db.kv_get("clickup:task:a1:state") is None
assert tmp_db.kv_get("clickup:task:a2:state") is None
assert tmp_db.kv_get("clickup:task:a3:state") is None
def test_clears_legacy_active_ids(self, tmp_db):
tmp_db.kv_set("clickup:active_task_ids", json.dumps(["a1", "a2"]))
clickup_reset_all(ctx=_make_ctx(tmp_db))
assert tmp_db.kv_get("clickup:active_task_ids") is None
def test_empty_returns_zero(self, tmp_db):
result = clickup_reset_all(ctx=_make_ctx(tmp_db))
assert "0" in result

View File

@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
import json
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
@ -261,20 +260,22 @@ class TestCreateContentPhase1:
saved = (outline_dir / "outline.md").read_text(encoding="utf-8") saved = (outline_dir / "outline.md").read_text(encoding="utf-8")
assert saved == "## Generated Outline\nSection 1..." assert saved == "## Generated Outline\nSection 1..."
def test_phase1_sets_kv_state(self, tmp_db, tmp_path): @patch("cheddahbot.tools.content_creation._get_clickup_client")
def test_phase1_syncs_clickup(self, mock_get_client, tmp_db, tmp_path):
mock_client = MagicMock()
mock_get_client.return_value = mock_client
ctx = self._make_ctx(tmp_db, tmp_path) ctx = self._make_ctx(tmp_db, tmp_path)
create_content( create_content(
url="https://example.com", url="https://example.com",
keyword="plumbing services", keyword="plumbing services",
ctx=ctx, ctx=ctx,
) )
raw = tmp_db.kv_get("clickup:task:task123:state") # Verify outline review status was set and OutlinePath was stored
assert raw is not None mock_client.update_task_status.assert_any_call("task123", "outline review")
state = json.loads(raw) mock_client.set_custom_field_by_name.assert_called_once()
assert state["state"] == "outline_review" call_args = mock_client.set_custom_field_by_name.call_args
assert state["keyword"] == "plumbing services" assert call_args[0][0] == "task123"
assert state["url"] == "https://example.com" assert call_args[0][1] == "OutlinePath"
assert "outline_path" in state
def test_phase1_includes_clickup_sync_marker(self, tmp_db, tmp_path): def test_phase1_includes_clickup_sync_marker(self, tmp_db, tmp_path):
ctx = self._make_ctx(tmp_db, tmp_path) ctx = self._make_ctx(tmp_db, tmp_path)
@ -293,7 +294,7 @@ class TestCreateContentPhase1:
class TestCreateContentPhase2: class TestCreateContentPhase2:
def _setup_phase2(self, tmp_db, tmp_path): def _setup_phase2(self, tmp_db, tmp_path):
"""Set up an outline_review state and outline file, return ctx.""" """Set up outline file and return (ctx, outline_path)."""
cfg = Config() cfg = Config()
cfg.content = ContentConfig(outline_dir=str(tmp_path / "outlines")) cfg.content = ContentConfig(outline_dir=str(tmp_path / "outlines"))
@ -303,29 +304,30 @@ class TestCreateContentPhase2:
outline_file = outline_dir / "outline.md" outline_file = outline_dir / "outline.md"
outline_file.write_text("## Approved Outline\nSection content here.", encoding="utf-8") outline_file.write_text("## Approved Outline\nSection content here.", encoding="utf-8")
# Set kv_store to outline_review
state = {
"state": "outline_review",
"clickup_task_id": "task456",
"url": "https://example.com/plumbing",
"keyword": "plumbing services",
"content_type": "service page",
"cora_path": "",
"outline_path": str(outline_file),
}
tmp_db.kv_set("clickup:task:task456:state", json.dumps(state))
agent = MagicMock() agent = MagicMock()
agent.execute_task.return_value = "# Full Content\nParagraph..." agent.execute_task.return_value = "# Full Content\nParagraph..."
return { ctx = {
"agent": agent, "agent": agent,
"config": cfg, "config": cfg,
"db": tmp_db, "db": tmp_db,
"clickup_task_id": "task456", "clickup_task_id": "task456",
} }
return ctx, str(outline_file)
def _make_phase2_client(self, outline_path):
"""Create a mock ClickUp client that triggers Phase 2 detection."""
mock_client = MagicMock()
mock_task = MagicMock()
mock_task.status = "outline approved"
mock_client.get_task.return_value = mock_task
mock_client.get_custom_field_by_name.return_value = outline_path
return mock_client
@patch("cheddahbot.tools.content_creation._get_clickup_client")
def test_phase2_detects_outline_approved_status(self, mock_get_client, tmp_db, tmp_path):
ctx, outline_path = self._setup_phase2(tmp_db, tmp_path)
mock_get_client.return_value = self._make_phase2_client(outline_path)
def test_phase2_detects_outline_review_state(self, tmp_db, tmp_path):
ctx = self._setup_phase2(tmp_db, tmp_path)
result = create_content( result = create_content(
url="https://example.com/plumbing", url="https://example.com/plumbing",
keyword="plumbing services", keyword="plumbing services",
@ -333,8 +335,11 @@ class TestCreateContentPhase2:
) )
assert "Phase 2 Complete" in result assert "Phase 2 Complete" in result
def test_phase2_reads_outline(self, tmp_db, tmp_path): @patch("cheddahbot.tools.content_creation._get_clickup_client")
ctx = self._setup_phase2(tmp_db, tmp_path) def test_phase2_reads_outline(self, mock_get_client, tmp_db, tmp_path):
ctx, outline_path = self._setup_phase2(tmp_db, tmp_path)
mock_get_client.return_value = self._make_phase2_client(outline_path)
create_content( create_content(
url="https://example.com/plumbing", url="https://example.com/plumbing",
keyword="plumbing services", keyword="plumbing services",
@ -344,8 +349,11 @@ class TestCreateContentPhase2:
prompt = call_args.args[0] if call_args.args else call_args.kwargs.get("prompt", "") prompt = call_args.args[0] if call_args.args else call_args.kwargs.get("prompt", "")
assert "Approved Outline" in prompt assert "Approved Outline" in prompt
def test_phase2_saves_content_file(self, tmp_db, tmp_path): @patch("cheddahbot.tools.content_creation._get_clickup_client")
ctx = self._setup_phase2(tmp_db, tmp_path) def test_phase2_saves_content_file(self, mock_get_client, tmp_db, tmp_path):
ctx, outline_path = self._setup_phase2(tmp_db, tmp_path)
mock_get_client.return_value = self._make_phase2_client(outline_path)
create_content( create_content(
url="https://example.com/plumbing", url="https://example.com/plumbing",
keyword="plumbing services", keyword="plumbing services",
@ -355,20 +363,26 @@ class TestCreateContentPhase2:
assert content_file.exists() assert content_file.exists()
assert content_file.read_text(encoding="utf-8") == "# Full Content\nParagraph..." assert content_file.read_text(encoding="utf-8") == "# Full Content\nParagraph..."
def test_phase2_sets_completed_state(self, tmp_db, tmp_path): @patch("cheddahbot.tools.content_creation._get_clickup_client")
ctx = self._setup_phase2(tmp_db, tmp_path) def test_phase2_syncs_clickup_complete(self, mock_get_client, tmp_db, tmp_path):
ctx, outline_path = self._setup_phase2(tmp_db, tmp_path)
mock_client = self._make_phase2_client(outline_path)
mock_get_client.return_value = mock_client
create_content( create_content(
url="https://example.com/plumbing", url="https://example.com/plumbing",
keyword="plumbing services", keyword="plumbing services",
ctx=ctx, ctx=ctx,
) )
raw = tmp_db.kv_get("clickup:task:task456:state") # Verify ClickUp was synced to internal review
state = json.loads(raw) mock_client.update_task_status.assert_any_call("task456", "internal review")
assert state["state"] == "completed" mock_client.add_comment.assert_called()
assert "content_path" in state
@patch("cheddahbot.tools.content_creation._get_clickup_client")
def test_phase2_includes_clickup_sync_marker(self, mock_get_client, tmp_db, tmp_path):
ctx, outline_path = self._setup_phase2(tmp_db, tmp_path)
mock_get_client.return_value = self._make_phase2_client(outline_path)
def test_phase2_includes_clickup_sync_marker(self, tmp_db, tmp_path):
ctx = self._setup_phase2(tmp_db, tmp_path)
result = create_content( result = create_content(
url="https://example.com/plumbing", url="https://example.com/plumbing",
keyword="plumbing services", keyword="plumbing services",
@ -392,9 +406,11 @@ class TestContinueContent:
result = continue_content(keyword="nonexistent", ctx=ctx) result = continue_content(keyword="nonexistent", ctx=ctx)
assert "No outline awaiting review" in result assert "No outline awaiting review" in result
def test_finds_and_runs_phase2(self, tmp_db, tmp_path): @patch("cheddahbot.tools.content_creation._get_clickup_client")
def test_finds_and_runs_phase2(self, mock_get_client, tmp_db, tmp_path):
cfg = Config() cfg = Config()
cfg.content = ContentConfig(outline_dir=str(tmp_path / "outlines")) cfg.content = ContentConfig(outline_dir=str(tmp_path / "outlines"))
cfg.clickup.space_id = "sp1"
# Create outline file # Create outline file
outline_dir = tmp_path / "outlines" / "plumbing-services" outline_dir = tmp_path / "outlines" / "plumbing-services"
@ -402,16 +418,17 @@ class TestContinueContent:
outline_file = outline_dir / "outline.md" outline_file = outline_dir / "outline.md"
outline_file.write_text("## Outline", encoding="utf-8") outline_file.write_text("## Outline", encoding="utf-8")
# Set kv state # Mock ClickUp client — returns a task matching the keyword
state = { mock_client = MagicMock()
"state": "outline_review", mock_task = MagicMock()
"clickup_task_id": "task789", mock_task.id = "task789"
"url": "https://example.com", mock_task.custom_fields = {
"keyword": "plumbing services", "Keyword": "plumbing services",
"outline_path": str(outline_file), "IMSURL": "https://example.com",
"cora_path": "",
} }
tmp_db.kv_set("clickup:task:task789:state", json.dumps(state)) mock_client.get_tasks_from_space.return_value = [mock_task]
mock_client.get_custom_field_by_name.return_value = str(outline_file)
mock_get_client.return_value = mock_client
agent = MagicMock() agent = MagicMock()
agent.execute_task.return_value = "# Full content" agent.execute_task.return_value = "# Full content"
@ -426,7 +443,11 @@ class TestContinueContent:
class TestErrorPropagation: class TestErrorPropagation:
def test_phase1_execution_error_sets_failed_state(self, tmp_db, tmp_path): @patch("cheddahbot.tools.content_creation._get_clickup_client")
def test_phase1_execution_error_syncs_clickup(self, mock_get_client, tmp_db, tmp_path):
mock_client = MagicMock()
mock_get_client.return_value = mock_client
cfg = Config() cfg = Config()
cfg.content = ContentConfig(outline_dir=str(tmp_path / "outlines")) cfg.content = ContentConfig(outline_dir=str(tmp_path / "outlines"))
agent = MagicMock() agent = MagicMock()
@ -443,11 +464,14 @@ class TestErrorPropagation:
ctx=ctx, ctx=ctx,
) )
assert "Error:" in result assert "Error:" in result
raw = tmp_db.kv_get("clickup:task:task_err:state") # Verify ClickUp was notified of the failure
state = json.loads(raw) mock_client.update_task_status.assert_any_call("task_err", "error")
assert state["state"] == "failed"
@patch("cheddahbot.tools.content_creation._get_clickup_client")
def test_phase1_error_return_syncs_clickup(self, mock_get_client, tmp_db, tmp_path):
mock_client = MagicMock()
mock_get_client.return_value = mock_client
def test_phase1_error_return_sets_failed(self, tmp_db, tmp_path):
cfg = Config() cfg = Config()
cfg.content = ContentConfig(outline_dir=str(tmp_path / "outlines")) cfg.content = ContentConfig(outline_dir=str(tmp_path / "outlines"))
agent = MagicMock() agent = MagicMock()
@ -464,6 +488,5 @@ class TestErrorPropagation:
ctx=ctx, ctx=ctx,
) )
assert result.startswith("Error:") assert result.startswith("Error:")
raw = tmp_db.kv_get("clickup:task:task_err2:state") # Verify ClickUp was notified of the failure
state = json.loads(raw) mock_client.update_task_status.assert_any_call("task_err2", "error")
assert state["state"] == "failed"

View File

@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
import json
import subprocess import subprocess
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
@ -548,16 +547,6 @@ class TestScanCoraFolder:
assert "Processed" in result assert "Processed" in result
assert "old.xlsx" in result assert "old.xlsx" in result
def test_shows_kv_status(self, mock_ctx, tmp_path):
mock_ctx["config"].link_building.watch_folder = str(tmp_path)
(tmp_path / "tracked.xlsx").write_text("fake")
db = mock_ctx["db"]
db.kv_set("linkbuilding:watched:tracked.xlsx", json.dumps({"status": "completed"}))
result = scan_cora_folder(ctx=mock_ctx)
assert "completed" in result
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# ClickUp state machine tests # ClickUp state machine tests
@ -582,12 +571,6 @@ class TestClickUpStateMachine:
mock_ctx["clickup_task_id"] = "task_abc" mock_ctx["clickup_task_id"] = "task_abc"
mock_ctx["config"].clickup.enabled = True mock_ctx["config"].clickup.enabled = True
# Pre-set executing state
mock_ctx["db"].kv_set(
"clickup:task:task_abc:state",
json.dumps({"state": "executing"}),
)
ingest_proc = subprocess.CompletedProcess( ingest_proc = subprocess.CompletedProcess(
args=[], returncode=0, stdout=ingest_success_stdout, stderr="" args=[], returncode=0, stdout=ingest_success_stdout, stderr=""
) )
@ -603,10 +586,9 @@ class TestClickUpStateMachine:
assert "ClickUp Sync" in result assert "ClickUp Sync" in result
# Verify KV state was updated # Verify ClickUp API was called for completion
raw = mock_ctx["db"].kv_get("clickup:task:task_abc:state") cu.add_comment.assert_called()
state = json.loads(raw) cu.update_task_status.assert_called()
assert state["state"] == "completed"
@patch("cheddahbot.tools.linkbuilding._run_blm_command") @patch("cheddahbot.tools.linkbuilding._run_blm_command")
@patch("cheddahbot.tools.linkbuilding._get_clickup_client") @patch("cheddahbot.tools.linkbuilding._get_clickup_client")
@ -619,14 +601,6 @@ class TestClickUpStateMachine:
mock_ctx["clickup_task_id"] = "task_fail" mock_ctx["clickup_task_id"] = "task_fail"
mock_ctx["config"].clickup.enabled = True mock_ctx["config"].clickup.enabled = True
mock_ctx["config"].clickup.skill_map = {
"Link Building": {"error_status": "internal review"}
}
mock_ctx["db"].kv_set(
"clickup:task:task_fail:state",
json.dumps({"state": "executing"}),
)
mock_cmd.return_value = subprocess.CompletedProcess( mock_cmd.return_value = subprocess.CompletedProcess(
args=[], returncode=1, stdout="Error", stderr="crash" args=[], returncode=1, stdout="Error", stderr="crash"
@ -638,6 +612,6 @@ class TestClickUpStateMachine:
) )
assert "Error" in result assert "Error" in result
raw = mock_ctx["db"].kv_get("clickup:task:task_fail:state") # Verify ClickUp API was called for failure
state = json.loads(raw) cu.add_comment.assert_called()
assert state["state"] == "failed" cu.update_task_status.assert_called()

View File

@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
import json
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import UTC, datetime from datetime import UTC, datetime
from unittest.mock import MagicMock from unittest.mock import MagicMock
@ -104,55 +103,6 @@ class TestPollClickup:
mock_client.discover_field_filter.return_value = field_filter mock_client.discover_field_filter.return_value = field_filter
return mock_client return mock_client
def test_skips_task_already_completed(self, tmp_db):
"""Tasks with completed state should be skipped."""
config = _FakeConfig()
agent = MagicMock()
scheduler = Scheduler(config, tmp_db, agent)
state = {"state": "completed", "clickup_task_id": "t1"}
tmp_db.kv_set("clickup:task:t1:state", json.dumps(state))
due = str(_now_ms() + 86400000)
task = _make_task(
"t1",
"PR for Acme",
"Press Release",
due_date=due,
custom_fields=_FIELDS,
)
scheduler._clickup_client = self._make_mock_client(
tasks=[task],
)
scheduler._poll_clickup()
scheduler._clickup_client.update_task_status.assert_not_called()
def test_skips_task_already_failed(self, tmp_db):
"""Tasks with failed state should be skipped."""
config = _FakeConfig()
agent = MagicMock()
scheduler = Scheduler(config, tmp_db, agent)
state = {"state": "failed", "clickup_task_id": "t1"}
tmp_db.kv_set("clickup:task:t1:state", json.dumps(state))
due = str(_now_ms() + 86400000)
task = _make_task(
"t1",
"PR for Acme",
"Press Release",
due_date=due,
)
scheduler._clickup_client = self._make_mock_client(
tasks=[task],
)
scheduler._poll_clickup()
scheduler._clickup_client.update_task_status.assert_not_called()
def test_skips_task_with_no_due_date(self, tmp_db): def test_skips_task_with_no_due_date(self, tmp_db):
"""Tasks with no due date should be skipped.""" """Tasks with no due date should be skipped."""
config = _FakeConfig() config = _FakeConfig()
@ -199,11 +149,11 @@ class TestExecuteTask:
"""Test the simplified _execute_task method.""" """Test the simplified _execute_task method."""
def test_success_flow(self, tmp_db): def test_success_flow(self, tmp_db):
"""Successful execution: state=completed.""" """Successful execution: tool called, automation underway set."""
config = _FakeConfig() config = _FakeConfig()
agent = MagicMock() agent = MagicMock()
agent._tools = MagicMock() agent._tools = MagicMock()
agent._tools.execute.return_value = "## ClickUp Sync\nDone" agent._tools.execute.return_value = "Pipeline completed successfully"
scheduler = Scheduler(config, tmp_db, agent) scheduler = Scheduler(config, tmp_db, agent)
mock_client = MagicMock() mock_client = MagicMock()
@ -224,51 +174,10 @@ class TestExecuteTask:
"t1", "t1",
"automation underway", "automation underway",
) )
agent._tools.execute.assert_called_once()
raw = tmp_db.kv_get("clickup:task:t1:state")
state = json.loads(raw)
assert state["state"] == "completed"
def test_success_fallback_path(self, tmp_db):
"""Scheduler uploads docx and sets review status."""
config = _FakeConfig()
agent = MagicMock()
agent._tools = MagicMock()
agent._tools.execute.return_value = "Press releases done.\n**Docx:** `output/pr.docx`"
scheduler = Scheduler(config, tmp_db, agent)
mock_client = MagicMock()
mock_client.update_task_status.return_value = True
mock_client.upload_attachment.return_value = True
mock_client.add_comment.return_value = True
scheduler._clickup_client = mock_client
due = str(_now_ms() + 86400000)
task = _make_task(
"t1",
"PR for Acme",
"Press Release",
due_date=due,
custom_fields=_FIELDS,
)
scheduler._execute_task(task)
mock_client.update_task_status.assert_any_call(
"t1",
"internal review",
)
mock_client.upload_attachment.assert_called_once_with(
"t1",
"output/pr.docx",
)
raw = tmp_db.kv_get("clickup:task:t1:state")
state = json.loads(raw)
assert state["state"] == "completed"
assert "output/pr.docx" in state["deliverable_paths"]
def test_failure_flow(self, tmp_db): def test_failure_flow(self, tmp_db):
"""Failed: state=failed, error comment, status set to 'error'.""" """Failed: error comment posted, status set to 'error'."""
config = _FakeConfig() config = _FakeConfig()
agent = MagicMock() agent = MagicMock()
agent._tools = MagicMock() agent._tools = MagicMock()
@ -295,11 +204,6 @@ class TestExecuteTask:
comment_text = mock_client.add_comment.call_args[0][1] comment_text = mock_client.add_comment.call_args[0][1]
assert "failed" in comment_text.lower() assert "failed" in comment_text.lower()
raw = tmp_db.kv_get("clickup:task:t1:state")
state = json.loads(raw)
assert state["state"] == "failed"
assert "API timeout" in state["error"]
class TestFieldFilterDiscovery: class TestFieldFilterDiscovery:
"""Test _discover_field_filter caching.""" """Test _discover_field_filter caching."""

View File

@ -1,32 +1,42 @@
"""Tests for scheduler helper functions.""" """Tests for scheduler helper functions.
Note: _extract_docx_paths was removed as part of KV store elimination.
The scheduler no longer handles docx extraction tools own their own sync.
"""
from __future__ import annotations from __future__ import annotations
from cheddahbot.scheduler import _extract_docx_paths
class TestLoopTimestamps:
"""Test that loop timestamps use in-memory storage."""
class TestExtractDocxPaths: def test_initial_timestamps_are_none(self):
def test_extracts_paths_from_realistic_output(self): from unittest.mock import MagicMock
result = (
"Press releases generated successfully!\n\n"
"**Docx:** `output/press_releases/acme-corp-launch.docx`\n"
"**Docx:** `output/press_releases/acme-corp-expansion.docx`\n"
"Files saved to output/press_releases/"
)
paths = _extract_docx_paths(result)
assert len(paths) == 2 from cheddahbot.scheduler import Scheduler
assert paths[0] == "output/press_releases/acme-corp-launch.docx"
assert paths[1] == "output/press_releases/acme-corp-expansion.docx"
def test_returns_empty_list_when_no_paths(self): config = MagicMock()
result = "Task completed successfully. No files generated." db = MagicMock()
paths = _extract_docx_paths(result) agent = MagicMock()
sched = Scheduler(config, db, agent)
assert paths == [] timestamps = sched.get_loop_timestamps()
assert timestamps["heartbeat"] is None
assert timestamps["poll"] is None
assert timestamps["clickup"] is None
def test_only_matches_docx_extension(self): def test_timestamps_update_in_memory(self):
result = "**Docx:** `report.docx`\n**PDF:** `report.pdf`\n**Docx:** `summary.txt`\n" from unittest.mock import MagicMock
paths = _extract_docx_paths(result)
assert paths == ["report.docx"] from cheddahbot.scheduler import Scheduler
config = MagicMock()
db = MagicMock()
agent = MagicMock()
sched = Scheduler(config, db, agent)
sched._loop_timestamps["heartbeat"] = "2026-02-27T12:00:00+00:00"
timestamps = sched.get_loop_timestamps()
assert timestamps["heartbeat"] == "2026-02-27T12:00:00+00:00"
# Ensure db.kv_set was never called
db.kv_set.assert_not_called()