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

@@ -129,7 +129,10 @@ class CommandRoutingSettings(LoginRequiredMixin, View):
"channel_services": ("web", "xmpp", "signal", "whatsapp"),
"directions": ("ingress", "egress", "scratchpad_mirror"),
"action_types": ("extract_bp", "post_result", "save_document"),
"command_choices": (("bp", "Business Plan (bp)"),),
"command_choices": (
("bp", "Business Plan (bp)"),
("codex", "Codex (codex)"),
),
"scope_service": scope_service,
"scope_identifier": scope_identifier,
"scope_variants": scope_variants,
@@ -153,40 +156,55 @@ class CommandRoutingSettings(LoginRequiredMixin, View):
user=request.user,
slug=slug,
defaults={
"name": str(request.POST.get("name") or "Business Plan").strip()
or "Business Plan",
"name": str(request.POST.get("name") or ("Codex" if slug == "codex" else "Business Plan")).strip()
or ("Codex" if slug == "codex" else "Business Plan"),
"enabled": True,
"trigger_token": str(
request.POST.get("trigger_token") or "#bp#"
request.POST.get("trigger_token")
or (".codex" if slug == "codex" else ".bp")
).strip()
or "#bp#",
or (".codex" if slug == "codex" else ".bp"),
"template_text": str(request.POST.get("template_text") or ""),
},
)
profile.name = str(request.POST.get("name") or profile.name).strip() or profile.name
if slug == "bp":
profile.trigger_token = "#bp#"
profile.trigger_token = ".bp"
profile.template_text = str(request.POST.get("template_text") or profile.template_text or "")
profile.save(update_fields=["name", "trigger_token", "template_text", "updated_at"])
CommandAction.objects.get_or_create(
profile=profile,
action_type="extract_bp",
defaults={"enabled": True, "position": 0},
if slug == "codex":
profile.trigger_token = ".codex"
profile.reply_required = False
profile.exact_match_only = False
profile.save(
update_fields=[
"name",
"trigger_token",
"template_text",
"reply_required",
"exact_match_only",
"updated_at",
]
)
# Keep legacy action rows in storage for compatibility and for
# potential reuse by non-bp commands; bp UI now relies on
# variant policies instead of exposing the generic action matrix.
CommandAction.objects.get_or_create(
profile=profile,
action_type="save_document",
defaults={"enabled": True, "position": 1},
)
CommandAction.objects.get_or_create(
profile=profile,
action_type="post_result",
defaults={"enabled": True, "position": 2},
)
ensure_variant_policies_for_profile(profile)
if slug == "bp":
CommandAction.objects.get_or_create(
profile=profile,
action_type="extract_bp",
defaults={"enabled": True, "position": 0},
)
# Keep legacy action rows in storage for compatibility and for
# potential reuse by non-bp commands; bp UI now relies on
# variant policies instead of exposing the generic action matrix.
CommandAction.objects.get_or_create(
profile=profile,
action_type="save_document",
defaults={"enabled": True, "position": 1},
)
CommandAction.objects.get_or_create(
profile=profile,
action_type="post_result",
defaults={"enabled": True, "position": 2},
)
ensure_variant_policies_for_profile(profile)
return self._redirect_with_scope(request)
if action == "profile_update":
@@ -199,7 +217,7 @@ class CommandRoutingSettings(LoginRequiredMixin, View):
profile.enabled = bool(request.POST.get("enabled"))
profile.trigger_token = (
str(request.POST.get("trigger_token") or profile.trigger_token).strip()
or "#bp#"
or ".bp"
)
profile.reply_required = bool(request.POST.get("reply_required"))
profile.exact_match_only = bool(request.POST.get("exact_match_only"))

View File

@@ -26,10 +26,12 @@ from django.utils import timezone as dj_timezone
from django.views import View
from core.clients import transport
from core.assist.engine import process_inbound_assist
from core.commands.base import CommandContext
from core.commands.engine import process_inbound_message
from core.commands.policies import ensure_variant_policies_for_profile
from core.messaging import ai as ai_runner
from core.messaging import history
from core.messaging import media_bridge
from core.messaging.utils import messages_to_string
from core.models import (
@@ -1792,6 +1794,86 @@ def _build_signal_reply_metadata(reply_to: Message | None, channel_identifier: s
return payload
def _parse_bool(value, default: bool = False) -> bool:
if value is None:
return bool(default)
raw = str(value).strip().lower()
if raw in {"1", "true", "yes", "on"}:
return True
if raw in {"0", "false", "no", "off"}:
return False
return bool(default)
def _reaction_actor_key(user_id, service: str) -> str:
return f"web:{int(user_id)}:{str(service or '').strip().lower()}"
def _resolve_reaction_target(message: Message, service: str, channel_identifier: str) -> dict:
service_key = _default_service(service)
source_message_id = str(getattr(message, "source_message_id", "") or "").strip()
sender_uuid = str(getattr(message, "sender_uuid", "") or "").strip()
source_chat_id = str(getattr(message, "source_chat_id", "") or "").strip()
delivered_ts = int(getattr(message, "delivered_ts", 0) or 0)
local_ts = int(getattr(message, "ts", 0) or 0)
if service_key == "signal":
target_ts = 0
if source_message_id.isdigit():
target_ts = int(source_message_id)
if not target_ts:
bridge_ref = _latest_signal_bridge_ref(message)
upstream_id = str(bridge_ref.get("upstream_message_id") or "").strip()
if upstream_id.isdigit():
target_ts = int(upstream_id)
if not target_ts:
target_ts = int(bridge_ref.get("upstream_ts") or 0)
if not target_ts:
target_ts = delivered_ts or local_ts
if target_ts <= 0:
return {"error": "signal_target_unresolvable"}
target_author = sender_uuid
if not target_author:
bridge_ref = _latest_signal_bridge_ref(message)
target_author = str(bridge_ref.get("upstream_author") or "").strip()
if (
str(getattr(message, "custom_author", "") or "").strip().upper()
in {"USER", "BOT"}
):
target_author = (
str(getattr(settings, "SIGNAL_NUMBER", "") or "").strip() or target_author
)
if not target_author:
target_author = source_chat_id or str(channel_identifier or "").strip()
if not target_author:
return {"error": "signal_target_author_unresolvable"}
return {
"target_message_id": "",
"target_ts": int(target_ts),
"target_author": target_author,
}
if service_key == "whatsapp":
target_message_id = source_message_id
target_ts = delivered_ts or local_ts
if not target_message_id:
bridge_ref = _latest_whatsapp_bridge_ref(message)
target_message_id = str(bridge_ref.get("upstream_message_id") or "").strip()
if not target_ts:
target_ts = int(bridge_ref.get("upstream_ts") or 0)
if not target_message_id:
return {"error": "whatsapp_target_unresolvable"}
return {
"target_message_id": target_message_id,
"target_ts": int(target_ts or 0),
"target_author": "",
}
return {"error": "service_not_supported"}
def _canonical_command_channel_identifier(service: str, identifier: str) -> str:
value = str(identifier or "").strip()
if not value:
@@ -1818,7 +1900,7 @@ def _ensure_bp_profile_and_actions(user) -> CommandProfile:
defaults={
"name": "Business Plan",
"enabled": True,
"trigger_token": "#bp#",
"trigger_token": ".bp",
"reply_required": True,
"exact_match_only": True,
"window_scope": "conversation",
@@ -1828,6 +1910,9 @@ def _ensure_bp_profile_and_actions(user) -> CommandProfile:
if not profile.enabled:
profile.enabled = True
profile.save(update_fields=["enabled", "updated_at"])
if str(profile.trigger_token or "").strip() != ".bp":
profile.trigger_token = ".bp"
profile.save(update_fields=["trigger_token", "updated_at"])
for action_type, position in (
("extract_bp", 0),
("save_document", 1),
@@ -1845,6 +1930,29 @@ def _ensure_bp_profile_and_actions(user) -> CommandProfile:
return profile
def _ensure_codex_profile(user) -> CommandProfile:
profile, _ = CommandProfile.objects.get_or_create(
user=user,
slug="codex",
defaults={
"name": "Codex",
"enabled": True,
"trigger_token": ".codex",
"reply_required": False,
"exact_match_only": False,
"window_scope": "conversation",
"visibility_mode": "status_in_source",
},
)
if not profile.enabled:
profile.enabled = True
profile.save(update_fields=["enabled", "updated_at"])
if str(profile.trigger_token or "").strip() != ".codex":
profile.trigger_token = ".codex"
profile.save(update_fields=["trigger_token", "updated_at"])
return profile
def _toggle_command_for_channel(
*,
user,
@@ -1860,6 +1968,8 @@ def _toggle_command_for_channel(
if slug == "bp":
profile = _ensure_bp_profile_and_actions(user)
elif slug == "codex":
profile = _ensure_codex_profile(user)
else:
profile = (
CommandProfile.objects.filter(user=user, slug=slug)
@@ -1916,7 +2026,15 @@ def _command_options_for_channel(user, service: str, identifier: str) -> list[di
user=user,
slug="bp",
name="Business Plan",
trigger_token="#bp#",
trigger_token=".bp",
enabled=True,
)
if "codex" not in by_slug:
by_slug["codex"] = CommandProfile(
user=user,
slug="codex",
name="Codex",
trigger_token=".codex",
enabled=True,
)
slugs = sorted(by_slug.keys())
@@ -1945,7 +2063,7 @@ def _command_options_for_channel(user, service: str, identifier: str) -> list[di
"slug": "bp",
"toggle_slug": "bp",
"name": "bp",
"trigger_token": "#bp#",
"trigger_token": ".bp",
"enabled_here": bool(enabled_here),
"profile_enabled": bool(profile.enabled),
"mode_label": str(
@@ -1956,7 +2074,7 @@ def _command_options_for_channel(user, service: str, identifier: str) -> list[di
"slug": "bp_set",
"toggle_slug": "bp",
"name": "bp set",
"trigger_token": "#bp set#",
"trigger_token": ".bp set",
"enabled_here": bool(enabled_here),
"profile_enabled": bool(profile.enabled),
"mode_label": str(
@@ -1967,7 +2085,7 @@ def _command_options_for_channel(user, service: str, identifier: str) -> list[di
"slug": "bp_set_range",
"toggle_slug": "bp",
"name": "bp set range",
"trigger_token": "#bp set range#",
"trigger_token": ".bp set range",
"enabled_here": bool(enabled_here),
"profile_enabled": bool(profile.enabled),
"mode_label": str(
@@ -4484,6 +4602,7 @@ class ComposeSend(LoginRequiredMixin, View):
payload={},
)
)
async_to_sync(process_inbound_assist)(created_message)
async_to_sync(process_inbound_translation)(created_message)
# Notify XMPP clients from runtime so cross-platform sends appear there too.
if base["service"] in {"signal", "whatsapp"}:
@@ -4516,3 +4635,116 @@ class ComposeSend(LoginRequiredMixin, View):
level="success",
panel_id=panel_id,
)
class ComposeReact(LoginRequiredMixin, View):
def post(self, request):
service, identifier, person = _request_scope(request, "POST")
service_key = _default_service(service)
if service_key not in {"signal", "whatsapp"}:
return JsonResponse({"ok": False, "error": "service_not_supported"})
if not identifier and person is None:
return JsonResponse({"ok": False, "error": "missing_scope"})
message_id = str(request.POST.get("message_id") or "").strip()
emoji = str(request.POST.get("emoji") or "").strip()
remove_raw = request.POST.get("remove")
remove_forced = remove_raw is not None and str(remove_raw).strip() != ""
if not message_id:
return JsonResponse({"ok": False, "error": "message_id_required"})
if not emoji:
return JsonResponse({"ok": False, "error": "emoji_required"})
base = _context_base(request.user, service_key, identifier, person)
person_identifier = base.get("person_identifier")
if person_identifier is None:
return JsonResponse({"ok": False, "error": "message_scope_unresolvable"})
session = ChatSession.objects.filter(
user=request.user,
identifier=person_identifier,
).first()
if session is None:
return JsonResponse({"ok": False, "error": "session_not_found"})
message = Message.objects.filter(
user=request.user,
session=session,
id=message_id,
).first()
if message is None:
return JsonResponse({"ok": False, "error": "message_not_found"})
target = _resolve_reaction_target(
message=message,
service=service_key,
channel_identifier=str(base.get("identifier") or "").strip(),
)
if target.get("error"):
return JsonResponse({"ok": False, "error": str(target.get("error"))})
actor_key = _reaction_actor_key(request.user.id, service_key)
remove = _parse_bool(remove_raw, default=False)
if not remove_forced:
existing_rows = list((message.receipt_payload or {}).get("reactions") or [])
has_same_reaction = False
for row in existing_rows:
item = dict(row or {})
if bool(item.get("removed")):
continue
if str(item.get("emoji") or "").strip() != emoji:
continue
if str(item.get("source_service") or "").strip().lower() != service_key:
continue
if str(item.get("actor") or "").strip() != actor_key:
continue
has_same_reaction = True
break
remove = bool(has_same_reaction)
target_ts = int(target.get("target_ts") or 0)
target_message_id = str(target.get("target_message_id") or "").strip()
sent = async_to_sync(transport.send_reaction)(
service_key,
str(base.get("identifier") or "").strip(),
emoji=emoji,
target_message_id=target_message_id,
target_timestamp=target_ts if target_ts > 0 else None,
target_author=str(target.get("target_author") or "").strip(),
remove=bool(remove),
)
if not sent:
return JsonResponse({"ok": False, "error": "reaction_send_failed"})
updated = async_to_sync(history.apply_reaction)(
request.user,
person_identifier,
target_message_id=target_message_id,
target_ts=target_ts,
emoji=emoji,
source_service=service_key,
actor=actor_key,
remove=bool(remove),
payload={
"source": "compose_react",
"message_id": str(message.id),
},
)
if updated is None:
updated = Message.objects.filter(
user=request.user,
session=session,
id=message_id,
).first()
serialized = _serialize_message(updated or message)
return JsonResponse(
{
"ok": True,
"message_id": str(message.id),
"emoji": emoji,
"remove": bool(remove),
"target_upstream_ts": target_ts,
"target_upstream_id": target_message_id,
"reactions": list(serialized.get("reactions") or []),
}
)

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(