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
parent
9a3ba54974
commit
917445ade4
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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",
|
)
|
||||||
)
|
except Exception as e:
|
||||||
def clickup_reset_all(ctx: dict | None = None) -> str:
|
return f"Error resetting task '{task_id}': {e}"
|
||||||
"""Delete all clickup task states and legacy active_ids from kv_store."""
|
finally:
|
||||||
db = ctx["db"]
|
client.close()
|
||||||
states = _get_clickup_states(db)
|
|
||||||
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."
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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))
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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():
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue