Implement plans

This commit is contained in:
2026-03-04 02:19:22 +00:00
parent 34ee49410d
commit 0718a06c19
31 changed files with 3987 additions and 181 deletions

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import json
import hashlib
from urllib.parse import urlencode
from asgiref.sync import async_to_sync
@@ -11,12 +12,15 @@ from django.db.models import Count
from django.urls import reverse
from django.http import JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone
from django.views import View
from core.clients.transport import send_message_raw
from core.models import (
AnswerSuggestionEvent,
ChatTaskSource,
CodexPermissionRequest,
CodexRun,
DerivedTask,
DerivedTaskEvent,
ExternalSyncEvent,
@@ -30,6 +34,7 @@ from core.models import (
Chat,
ExternalChatLink,
)
from core.tasks.codex_support import resolve_external_chat_id
from core.tasks.providers import get_provider
SAFE_TASK_FLAGS_DEFAULTS = {
@@ -268,11 +273,47 @@ def _creator_label_for_message(user, service: str, message) -> str:
def _apply_task_creator_labels(user, task_rows):
rows = list(task_rows or [])
person_identifier_cache: dict[tuple[str, str], PersonIdentifier | None] = {}
def _resolve_person_identifier(service_key: str, sender_identifier: str):
key = (str(service_key or "").strip().lower(), str(sender_identifier or "").strip())
if key in person_identifier_cache:
return person_identifier_cache[key]
variants = _person_identifier_scope_variants(key[0], key[1])
row = (
PersonIdentifier.objects.filter(
user=user,
service=key[0],
identifier__in=variants or [key[1]],
)
.select_related("person")
.first()
)
person_identifier_cache[key] = row
return row
for row in rows:
origin = getattr(row, "origin_message", None)
service_key = str(getattr(row, "source_service", "") or "").strip().lower()
sender_identifier = str(getattr(origin, "sender_uuid", "") or "").strip()
row.creator_label = _creator_label_for_message(user, service_key, origin)
row.creator_identifier = str(getattr(origin, "sender_uuid", "") or "").strip()
row.creator_identifier = sender_identifier
row.creator_compose_href = ""
if sender_identifier and service_key:
person_identifier = _resolve_person_identifier(service_key, sender_identifier)
compose_service = service_key
compose_identifier = sender_identifier
compose_person_id = ""
if person_identifier is not None:
compose_identifier = str(getattr(person_identifier, "identifier", "") or "").strip() or sender_identifier
compose_person_id = str(getattr(person_identifier, "person_id", "") or "")
query = {
"service": compose_service,
"identifier": compose_identifier,
}
if compose_person_id:
query["person"] = compose_person_id
row.creator_compose_href = f"{reverse('compose_page')}?{urlencode(query)}"
return rows
@@ -283,6 +324,96 @@ def _provider_row_map(user):
}
def _codex_settings_with_defaults(raw: dict | None) -> dict:
row = dict(raw or {})
timeout_raw = str(row.get("timeout_seconds") or "60").strip()
try:
timeout_seconds = max(1, int(timeout_raw))
except Exception:
timeout_seconds = 60
return {
"command": str(row.get("command") or "codex").strip() or "codex",
"workspace_root": str(row.get("workspace_root") or "").strip(),
"default_profile": str(row.get("default_profile") or "").strip(),
"timeout_seconds": timeout_seconds,
"chat_link_mode": "task-sync",
"instance_label": str(row.get("instance_label") or "default").strip() or "default",
"approver_service": str(row.get("approver_service") or "").strip().lower(),
"approver_identifier": str(row.get("approver_identifier") or "").strip(),
"approver_mode": "channel",
}
def _enqueue_codex_task_submission(
*,
user,
task: DerivedTask,
source_service: str,
source_channel: str,
mode: str = "default",
command_text: str = "",
source_message=None,
) -> CodexRun:
external_chat_id = resolve_external_chat_id(
user=user,
provider="codex_cli",
service=source_service,
channel=source_channel,
)
provider_payload = {
"task_id": str(task.id),
"reference_code": str(task.reference_code or ""),
"title": str(task.title or ""),
"external_key": str(task.external_key or ""),
"project_name": str(getattr(task.project, "name", "") or ""),
"epic_name": str(getattr(task.epic, "name", "") or ""),
"source_service": str(source_service or ""),
"source_channel": str(source_channel or ""),
"external_chat_id": external_chat_id,
"origin_message_id": str(getattr(task, "origin_message_id", "") or ""),
"trigger_message_id": str(getattr(source_message, "id", "") or ""),
"mode": str(mode or "default"),
}
if command_text:
provider_payload["command_text"] = str(command_text)
run = CodexRun.objects.create(
user=user,
task=task,
source_message=source_message,
project=task.project,
epic=task.epic,
source_service=str(source_service or ""),
source_channel=str(source_channel or ""),
external_chat_id=external_chat_id,
status="queued",
request_payload={"action": "append_update", "provider_payload": dict(provider_payload)},
result_payload={},
error="",
)
provider_payload["codex_run_id"] = str(run.id)
run.request_payload = {"action": "append_update", "provider_payload": dict(provider_payload)}
run.save(update_fields=["request_payload", "updated_at"])
idempotency_key = (
f"codex_submit:{task.id}:{mode}:{hashlib.sha1(str(command_text or '').encode('utf-8')).hexdigest()[:10]}:{run.id}"
)
ExternalSyncEvent.objects.update_or_create(
idempotency_key=idempotency_key,
defaults={
"user": user,
"task": task,
"task_event": None,
"provider": "codex_cli",
"status": "pending",
"payload": {
"action": "append_update",
"provider_payload": dict(provider_payload),
},
"error": "",
},
)
return run
def _normalize_channel_identifier(service: str, identifier: str) -> str:
service_key = str(service or "").strip().lower()
value = str(identifier or "").strip()
@@ -337,6 +468,41 @@ def _upsert_group_source(*, user, service: str, channel_identifier: str, project
return source
def _notify_epic_created_in_project_chats(*, project: TaskProject, epic: TaskEpic) -> None:
rows = (
ChatTaskSource.objects.filter(project=project, enabled=True)
.order_by("service", "channel_identifier")
.values_list("service", "channel_identifier")
)
seen: set[tuple[str, str]] = set()
for service, channel_identifier in rows:
svc = str(service or "").strip().lower()
chan = str(channel_identifier or "").strip()
if not svc or not chan:
continue
key = (svc, chan)
if key in seen:
continue
seen.add(key)
try:
async_to_sync(send_message_raw)(
svc,
chan,
text=(
f"[epic] Created '{epic.name}' in project '{project.name}'.\n"
"WhatsApp usage:\n"
"- create epic: epic: <Epic name> (or .epic <Epic name>)\n"
"- add task to epic: task: <description> [epic:<Epic name>]\n"
"- list tasks: .l list tasks\n"
"- undo latest task: .undo"
),
attachments=[],
metadata={"origin": "task_epic_announce"},
)
except Exception:
continue
def _person_identifier_scope_variants(service: str, identifier: str) -> list[str]:
service_key = str(service or "").strip().lower()
raw_identifier = str(identifier or "").strip()
@@ -690,6 +856,7 @@ class TaskProjectDetail(LoginRequiredMixin, View):
return redirect("tasks_project", project_id=str(project.id))
if created:
messages.success(request, f"Created epic '{epic.name}'.")
_notify_epic_created_in_project_chats(project=project, epic=epic)
else:
messages.info(request, f"Epic '{epic.name}' already exists.")
return redirect("tasks_project", project_id=str(project.id))
@@ -701,6 +868,28 @@ class TaskProjectDetail(LoginRequiredMixin, View):
messages.success(request, f"Deleted epic '{deleted_name}'.")
return redirect("tasks_project", project_id=str(project.id))
if action == "task_set_epic":
task = get_object_or_404(
DerivedTask,
id=request.POST.get("task_id"),
user=request.user,
project=project,
)
epic_id = str(request.POST.get("epic_id") or "").strip()
epic = None
if epic_id:
epic = get_object_or_404(TaskEpic, id=epic_id, project=project)
task.epic = epic
task.save(update_fields=["epic"])
if epic is None:
messages.success(request, f"Cleared epic for task #{task.reference_code}.")
else:
messages.success(
request,
f"Assigned task #{task.reference_code} to epic '{epic.name}'.",
)
return redirect("tasks_project", project_id=str(project.id))
if action == "project_delete":
deleted_name = str(project.name or "").strip() or "Project"
project.delete()
@@ -862,6 +1051,10 @@ class TaskDetail(LoginRequiredMixin, View):
getattr(task, "origin_message", None),
)
sync_events = task.external_sync_events.order_by("-created_at")
codex_runs = task.codex_runs.select_related("source_message").order_by("-created_at")
permission_requests = CodexPermissionRequest.objects.filter(codex_run__task=task).select_related(
"codex_run", "external_sync_event"
).order_by("-requested_at")
return render(
request,
self.template_name,
@@ -869,6 +1062,8 @@ class TaskDetail(LoginRequiredMixin, View):
"task": task,
"events": events,
"sync_events": sync_events,
"codex_runs": codex_runs,
"permission_requests": permission_requests,
},
)
@@ -895,8 +1090,39 @@ class TaskSettings(LoginRequiredMixin, View):
row.allowed_prefixes_csv = ",".join(row.settings_effective["allowed_prefixes"])
provider_map = _provider_row_map(request.user)
codex_cfg = provider_map.get("codex_cli")
codex_settings = dict(getattr(codex_cfg, "settings", {}) or {})
codex_settings = _codex_settings_with_defaults(dict(getattr(codex_cfg, "settings", {}) or {}))
mock_cfg = provider_map.get("mock")
codex_provider = get_provider("codex_cli")
codex_healthcheck = codex_provider.healthcheck(codex_settings) if codex_cfg else None
codex_queue_counts = {
"pending": ExternalSyncEvent.objects.filter(
user=request.user, provider="codex_cli", status="pending"
).count(),
"waiting_approval": ExternalSyncEvent.objects.filter(
user=request.user, provider="codex_cli", status="waiting_approval"
).count(),
"failed": ExternalSyncEvent.objects.filter(
user=request.user, provider="codex_cli", status="failed"
).count(),
"ok": ExternalSyncEvent.objects.filter(
user=request.user, provider="codex_cli", status="ok"
).count(),
}
codex_recent_runs = CodexRun.objects.filter(user=request.user).order_by("-created_at")[:10]
latest_worker_event = (
ExternalSyncEvent.objects.filter(
user=request.user,
provider="codex_cli",
)
.filter(status__in=["ok", "failed", "waiting_approval", "retrying"])
.order_by("-updated_at")
.first()
)
worker_heartbeat_at = getattr(latest_worker_event, "updated_at", None)
worker_heartbeat_age = ""
if worker_heartbeat_at is not None:
delta_seconds = max(0, int((timezone.now() - worker_heartbeat_at).total_seconds()))
worker_heartbeat_age = f"{delta_seconds}s ago"
external_chat_links = list(
ExternalChatLink.objects.filter(user=request.user).select_related(
"person", "person_identifier"
@@ -932,6 +1158,19 @@ class TaskSettings(LoginRequiredMixin, View):
"default_profile": str(codex_settings.get("default_profile") or ""),
"timeout_seconds": int(codex_settings.get("timeout_seconds") or 60),
"chat_link_mode": str(codex_settings.get("chat_link_mode") or "task-sync"),
"instance_label": str(codex_settings.get("instance_label") or "default"),
"approver_service": str(codex_settings.get("approver_service") or ""),
"approver_identifier": str(codex_settings.get("approver_identifier") or ""),
"approver_mode": "channel",
},
"codex_compact_summary": {
"healthcheck_ok": bool(getattr(codex_healthcheck, "ok", False)),
"healthcheck_error": str(getattr(codex_healthcheck, "error", "") or ""),
"healthcheck_payload": dict(getattr(codex_healthcheck, "payload", {}) or {}),
"worker_heartbeat_at": worker_heartbeat_at,
"worker_heartbeat_age": worker_heartbeat_age,
"queue_counts": codex_queue_counts,
"recent_runs": codex_recent_runs,
},
"person_identifiers": person_identifiers,
"external_link_person_identifiers": external_link_person_identifiers,
@@ -961,12 +1200,13 @@ class TaskSettings(LoginRequiredMixin, View):
if action == "epic_create":
project = get_object_or_404(TaskProject, id=request.POST.get("project_id"), user=request.user)
TaskEpic.objects.create(
epic = TaskEpic.objects.create(
project=project,
name=str(request.POST.get("name") or "Epic").strip() or "Epic",
external_key=str(request.POST.get("external_key") or "").strip(),
active=bool(request.POST.get("active") or "1"),
)
_notify_epic_created_in_project_chats(project=project, epic=epic)
return _settings_redirect(request)
if action == "source_create":
@@ -1063,18 +1303,18 @@ class TaskSettings(LoginRequiredMixin, View):
row.enabled = bool(request.POST.get("enabled"))
settings_payload = dict(row.settings or {})
if provider == "codex_cli":
timeout_raw = str(request.POST.get("timeout_seconds") or "60").strip()
try:
timeout_value = max(1, int(timeout_raw))
except Exception:
timeout_value = 60
settings_payload = {
"command": str(request.POST.get("command") or "codex").strip() or "codex",
"workspace_root": str(request.POST.get("workspace_root") or "").strip(),
"default_profile": str(request.POST.get("default_profile") or "").strip(),
"timeout_seconds": timeout_value,
"chat_link_mode": "task-sync",
}
settings_payload = _codex_settings_with_defaults(
{
"command": request.POST.get("command"),
"workspace_root": request.POST.get("workspace_root"),
"default_profile": request.POST.get("default_profile"),
"timeout_seconds": request.POST.get("timeout_seconds"),
"instance_label": request.POST.get("instance_label"),
"approver_service": request.POST.get("approver_service"),
"approver_identifier": request.POST.get("approver_identifier"),
"approver_mode": "channel",
}
)
row.settings = settings_payload
row.save(update_fields=["enabled", "settings", "updated_at"])
return _settings_redirect(request)
@@ -1159,6 +1399,196 @@ class TaskSettings(LoginRequiredMixin, View):
return _settings_redirect(request)
class TaskCodexSubmit(LoginRequiredMixin, View):
def post(self, request):
task_id = str(request.POST.get("task_id") or "").strip()
next_url = str(request.POST.get("next") or reverse("tasks_hub")).strip()
task = get_object_or_404(
DerivedTask.objects.select_related("project", "epic", "origin_message"),
id=task_id,
user=request.user,
)
cfg = TaskProviderConfig.objects.filter(
user=request.user,
provider="codex_cli",
enabled=True,
).first()
if cfg is None:
messages.error(
request,
"Codex provider is disabled. Enable it in Task Settings first.",
)
return redirect(next_url)
run = _enqueue_codex_task_submission(
user=request.user,
task=task,
source_service=str(task.source_service or ""),
source_channel=str(task.source_channel or ""),
mode="default",
source_message=getattr(task, "origin_message", None),
)
messages.success(request, f"Sent task #{task.reference_code} to Codex (run {run.id}).")
return redirect(next_url)
class CodexSettingsPage(LoginRequiredMixin, View):
template_name = "pages/codex-settings.html"
def _context(self, request):
cfg = TaskProviderConfig.objects.filter(user=request.user, provider="codex_cli").first()
settings_payload = _codex_settings_with_defaults(dict(getattr(cfg, "settings", {}) or {}))
provider = get_provider("codex_cli")
health = provider.healthcheck(settings_payload) if cfg else None
status_filter = str(request.GET.get("status") or "").strip().lower()
service_filter = str(request.GET.get("service") or "").strip().lower()
channel_filter = str(request.GET.get("channel") or "").strip()
project_filter = str(request.GET.get("project") or "").strip()
date_from = str(request.GET.get("date_from") or "").strip()
runs = CodexRun.objects.filter(user=request.user).select_related("task", "project", "epic").order_by("-created_at")
if status_filter:
runs = runs.filter(status=status_filter)
if service_filter:
runs = runs.filter(source_service=service_filter)
if channel_filter:
runs = runs.filter(source_channel=channel_filter)
if project_filter:
runs = runs.filter(project_id=project_filter)
if date_from:
runs = runs.filter(created_at__date__gte=date_from)
runs = runs[:200]
queue_counts = {
"pending": ExternalSyncEvent.objects.filter(
user=request.user, provider="codex_cli", status="pending"
).count(),
"waiting_approval": ExternalSyncEvent.objects.filter(
user=request.user, provider="codex_cli", status="waiting_approval"
).count(),
"failed": ExternalSyncEvent.objects.filter(
user=request.user, provider="codex_cli", status="failed"
).count(),
"ok": ExternalSyncEvent.objects.filter(
user=request.user, provider="codex_cli", status="ok"
).count(),
}
permission_requests = (
CodexPermissionRequest.objects.filter(user=request.user)
.select_related("codex_run", "codex_run__task", "external_sync_event")
.order_by("-requested_at")[:200]
)
return {
"provider_config": cfg,
"provider_settings": settings_payload,
"health": health,
"runs": runs,
"queue_counts": queue_counts,
"permission_requests": permission_requests,
"projects": TaskProject.objects.filter(user=request.user).order_by("name"),
"filters": {
"status": status_filter,
"service": service_filter,
"channel": channel_filter,
"project": project_filter,
"date_from": date_from,
},
}
def get(self, request):
return render(request, self.template_name, self._context(request))
class CodexApprovalAction(LoginRequiredMixin, View):
def post(self, request):
request_id = str(request.POST.get("request_id") or "").strip()
decision = str(request.POST.get("decision") or "").strip().lower()
row = get_object_or_404(
CodexPermissionRequest.objects.select_related("codex_run", "external_sync_event"),
id=request_id,
user=request.user,
)
if row.status != "pending":
return redirect("codex_settings")
now = timezone.now()
if decision == "approve":
row.status = "approved"
row.resolved_at = now
row.resolved_by_identifier = "settings_ui"
row.resolution_note = "approved via settings ui"
row.save(
update_fields=[
"status",
"resolved_at",
"resolved_by_identifier",
"resolution_note",
]
)
run = row.codex_run
run.status = "approved_waiting_resume"
run.error = ""
run.save(update_fields=["status", "error", "updated_at"])
provider_payload = dict(run.request_payload.get("provider_payload") or {})
provider_payload.update(
{
"mode": "approval_response",
"approval_key": row.approval_key,
"resume_payload": dict(row.resume_payload or {}),
"codex_run_id": str(run.id),
}
)
ExternalSyncEvent.objects.update_or_create(
idempotency_key=f"codex_approval:{row.approval_key}:approved",
defaults={
"user": request.user,
"task": run.task,
"task_event": run.derived_task_event,
"provider": "codex_cli",
"status": "pending",
"payload": {"action": "append_update", "provider_payload": provider_payload},
"error": "",
},
)
messages.success(request, f"Approved {row.approval_key}. Resume event queued.")
return redirect("codex_settings")
row.status = "denied"
row.resolved_at = now
row.resolved_by_identifier = "settings_ui"
row.resolution_note = "denied via settings ui"
row.save(update_fields=["status", "resolved_at", "resolved_by_identifier", "resolution_note"])
run = row.codex_run
run.status = "denied"
run.error = "approval_denied"
run.save(update_fields=["status", "error", "updated_at"])
ExternalSyncEvent.objects.update_or_create(
idempotency_key=f"codex_approval:{row.approval_key}:denied",
defaults={
"user": request.user,
"task": run.task,
"task_event": run.derived_task_event,
"provider": "codex_cli",
"status": "failed",
"payload": {
"action": "append_update",
"provider_payload": {
"mode": "approval_response",
"approval_key": row.approval_key,
"codex_run_id": str(run.id),
},
},
"error": "approval_denied",
},
)
if row.external_sync_event_id:
ExternalSyncEvent.objects.filter(id=row.external_sync_event_id).update(
status="failed",
error="approval_denied",
)
messages.warning(request, f"Denied {row.approval_key}.")
return redirect("codex_settings")
class AnswerSuggestionSend(LoginRequiredMixin, View):
def post(self, request):
event = get_object_or_404(