Implement executing tasks

This commit is contained in:
2026-03-03 16:41:28 +00:00
parent d6bd56dace
commit 9c14e51b43
42 changed files with 3410 additions and 121 deletions

147
core/views/availability.py Normal file
View File

@@ -0,0 +1,147 @@
from __future__ import annotations
from datetime import datetime, timezone
from django.contrib.auth.mixins import LoginRequiredMixin
from django.shortcuts import render
from django.views import View
from core.models import (
ContactAvailabilityEvent,
ContactAvailabilitySettings,
ContactAvailabilitySpan,
Person,
)
def _to_int(value, default=0):
try:
return int(value)
except Exception:
return int(default)
def _to_bool(value, default=False):
if value is None:
return bool(default)
text = str(value).strip().lower()
if text in {"1", "true", "yes", "on", "y"}:
return True
if text in {"0", "false", "no", "off", "n"}:
return False
return bool(default)
def _iso_to_ms(value: str) -> int:
raw = str(value or "").strip()
if not raw:
return 0
try:
dt = datetime.fromisoformat(raw)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return int(dt.timestamp() * 1000)
except Exception:
return 0
class AvailabilitySettingsPage(LoginRequiredMixin, View):
template_name = "pages/availability-settings.html"
def _settings(self, request):
row, _ = ContactAvailabilitySettings.objects.get_or_create(user=request.user)
return row
def post(self, request):
row = self._settings(request)
row.enabled = _to_bool(request.POST.get("enabled"), row.enabled)
row.show_in_chat = _to_bool(request.POST.get("show_in_chat"), row.show_in_chat)
row.show_in_groups = _to_bool(
request.POST.get("show_in_groups"), row.show_in_groups
)
row.inference_enabled = _to_bool(
request.POST.get("inference_enabled"), row.inference_enabled
)
row.retention_days = max(1, _to_int(request.POST.get("retention_days"), 90))
row.fade_threshold_seconds = max(
30, _to_int(request.POST.get("fade_threshold_seconds"), 900)
)
row.save(
update_fields=[
"enabled",
"show_in_chat",
"show_in_groups",
"inference_enabled",
"retention_days",
"fade_threshold_seconds",
"updated_at",
]
)
return self.get(request)
def get(self, request):
settings_row = self._settings(request)
person_id = str(request.GET.get("person") or "").strip()
service = str(request.GET.get("service") or "").strip().lower()
state = str(request.GET.get("state") or "").strip().lower()
source_kind = str(request.GET.get("source_kind") or "").strip().lower()
start_ts = _iso_to_ms(request.GET.get("start"))
end_ts = _iso_to_ms(request.GET.get("end"))
if end_ts <= 0:
end_ts = int(datetime.now(tz=timezone.utc).timestamp() * 1000)
if start_ts <= 0:
start_ts = max(0, end_ts - (14 * 24 * 60 * 60 * 1000))
events_qs = ContactAvailabilityEvent.objects.filter(user=request.user)
spans_qs = ContactAvailabilitySpan.objects.filter(user=request.user)
if person_id:
events_qs = events_qs.filter(person_id=person_id)
spans_qs = spans_qs.filter(person_id=person_id)
if service:
events_qs = events_qs.filter(service=service)
spans_qs = spans_qs.filter(service=service)
if state:
events_qs = events_qs.filter(availability_state=state)
spans_qs = spans_qs.filter(state=state)
if source_kind:
events_qs = events_qs.filter(source_kind=source_kind)
events_qs = events_qs.filter(ts__gte=start_ts, ts__lte=end_ts)
spans_qs = spans_qs.filter(start_ts__lte=end_ts, end_ts__gte=start_ts)
events = list(
events_qs.select_related("person", "person_identifier").order_by("-ts")[:500]
)
spans = list(
spans_qs.select_related("person", "person_identifier").order_by("-end_ts")[:500]
)
people = list(Person.objects.filter(user=request.user).order_by("name"))
context = {
"settings_row": settings_row,
"people": people,
"events": events,
"spans": spans,
"filters": {
"person": person_id,
"service": service,
"state": state,
"source_kind": source_kind,
"start": request.GET.get("start") or "",
"end": request.GET.get("end") or "",
},
"service_choices": ["signal", "whatsapp", "xmpp", "instagram", "web"],
"state_choices": ["available", "fading", "unavailable", "unknown"],
"source_kind_choices": [
"native_presence",
"read_receipt",
"typing_start",
"typing_stop",
"message_in",
"message_out",
"inferred_timeout",
],
}
return render(request, self.template_name, context)

View File

@@ -48,6 +48,8 @@ from core.models import (
PlatformChatLink,
WorkspaceConversation,
)
from core.presence import get_settings as get_availability_settings
from core.presence import spans_for_range
from core.realtime.typing_state import get_person_typing_state
from core.translation.engine import process_inbound_translation
from core.views.workspace import (
@@ -101,6 +103,36 @@ def _default_service(service: str | None) -> str:
return "signal"
def _identifier_variants(service: str, identifier: str) -> list[str]:
value = str(identifier or "").strip()
if not value:
return []
service_key = _default_service(service)
variants = [value]
bare = value.split("@", 1)[0].strip()
if bare and bare not in variants:
variants.append(bare)
if service_key == "signal":
digits = re.sub(r"[^0-9]", "", value)
if digits and digits not in variants:
variants.append(digits)
if digits:
plus = f"+{digits}"
if plus not in variants:
variants.append(plus)
elif service_key == "whatsapp":
if bare:
direct = f"{bare}@s.whatsapp.net"
group = f"{bare}@g.us"
if direct not in variants:
variants.append(direct)
if group not in variants:
variants.append(group)
return variants
def _safe_limit(raw) -> int:
try:
value = int(raw or 40)
@@ -136,6 +168,24 @@ def _format_ts_label(ts_value: int) -> str:
return str(ts_value or "")
def _serialize_availability_spans(spans):
rows = []
for row in list(spans or []):
rows.append(
{
"id": int(getattr(row, "id", 0) or 0),
"service": str(getattr(row, "service", "") or ""),
"state": str(getattr(row, "state", "unknown") or "unknown"),
"start_ts": int(getattr(row, "start_ts", 0) or 0),
"end_ts": int(getattr(row, "end_ts", 0) or 0),
"confidence_start": float(getattr(row, "confidence_start", 0.0) or 0.0),
"confidence_end": float(getattr(row, "confidence_end", 0.0) or 0.0),
"payload": dict(getattr(row, "payload", {}) or {}),
}
)
return rows
def _is_outgoing(msg: Message) -> bool:
is_outgoing = str(msg.custom_author or "").upper() in {"USER", "BOT"}
if not is_outgoing:
@@ -672,14 +722,19 @@ THREAD_METRIC_COPY_OVERRIDES = {
def _workspace_conversation_for_person(user, person):
if person is None:
return None
return (
WorkspaceConversation.objects.filter(
user=user,
participants=person,
try:
from core.views.workspace import _conversation_for_person
return _conversation_for_person(user, person)
except Exception:
return (
WorkspaceConversation.objects.filter(
user=user,
participants=person,
)
.order_by("-last_event_ts", "-created_at")
.first()
)
.order_by("-last_event_ts", "-created_at")
.first()
)
def _counterpart_identifiers_for_person(user, person):
@@ -1522,14 +1577,15 @@ def _engage_source_from_ref(plan, source_ref):
def _context_base(user, service, identifier, person):
identifier_variants = _identifier_variants(service, identifier)
person_identifier = None
if person is not None:
if identifier:
if identifier_variants:
person_identifier = PersonIdentifier.objects.filter(
user=user,
person=person,
service=service,
identifier=identifier,
identifier__in=identifier_variants,
).first()
if person_identifier is None:
person_identifier = (
@@ -1544,7 +1600,7 @@ def _context_base(user, service, identifier, person):
person_identifier = PersonIdentifier.objects.filter(
user=user,
service=service,
identifier=identifier,
identifier__in=identifier_variants or [identifier],
).first()
if person_identifier:
@@ -2496,6 +2552,35 @@ def _panel_context(
counterpart_identifiers=counterpart_identifiers,
conversation=conversation,
)
availability_slices = []
availability_enabled = False
availability_settings = get_availability_settings(request.user)
if (
base["person"] is not None
and availability_settings.enabled
and availability_settings.show_in_chat
):
range_start = (
int(session_bundle["messages"][0].ts or 0) if session_bundle["messages"] else 0
)
range_end = (
int(session_bundle["messages"][-1].ts or 0) if session_bundle["messages"] else 0
)
if range_start <= 0 or range_end <= 0:
now_ts = int(time.time() * 1000)
range_start = now_ts - (24 * 60 * 60 * 1000)
range_end = now_ts
availability_enabled = True
availability_slices = _serialize_availability_spans(
spans_for_range(
user=request.user,
person=base["person"],
start_ts=range_start,
end_ts=range_end,
service=base["service"],
limit=200,
)
)
glance_items = _build_glance_items(
serialized_messages,
person_id=(base["person"].id if base["person"] else None),
@@ -2665,6 +2750,22 @@ def _panel_context(
if base["person"]
else reverse("ai_workspace")
),
"ai_workspace_graphs_url": (
reverse(
"ai_workspace_insight_graphs",
kwargs={"type": "page", "person_id": base["person"].id},
)
if base["person"]
else ""
),
"ai_workspace_info_url": (
reverse(
"ai_workspace_information",
kwargs={"type": "page", "person_id": base["person"].id},
)
if base["person"]
else ""
),
"ai_workspace_widget_url": (
(
f"{reverse('ai_workspace_person', kwargs={'type': 'widget', 'person_id': base['person'].id})}"
@@ -2676,6 +2777,9 @@ def _panel_context(
"manual_icon_class": "fa-solid fa-paper-plane",
"panel_id": f"compose-panel-{unique}",
"typing_state_json": json.dumps(typing_state),
"availability_enabled": availability_enabled,
"availability_slices": availability_slices,
"availability_slices_json": json.dumps(availability_slices),
"command_options": command_options,
"bp_binding_summary": bp_binding_summary,
"platform_options": platform_options,
@@ -3133,6 +3237,31 @@ class ComposeThread(LoginRequiredMixin, View):
counterpart_identifiers = _counterpart_identifiers_for_person(
request.user, base["person"]
)
availability_slices = []
availability_settings = get_availability_settings(request.user)
if (
base["person"] is not None
and availability_settings.enabled
and availability_settings.show_in_chat
):
range_start = (
int(messages[0].ts or 0) if messages else max(0, int(after_ts or 0))
)
range_end = int(latest_ts or 0)
if range_start <= 0 or range_end <= 0:
now_ts = int(time.time() * 1000)
range_start = now_ts - (24 * 60 * 60 * 1000)
range_end = now_ts
availability_slices = _serialize_availability_spans(
spans_for_range(
user=request.user,
person=base["person"],
start_ts=range_start,
end_ts=range_end,
service=base["service"],
limit=200,
)
)
payload = {
"messages": _serialize_messages_with_artifacts(
messages,
@@ -3141,6 +3270,7 @@ class ComposeThread(LoginRequiredMixin, View):
seed_previous=seed_previous,
),
"last_ts": latest_ts,
"availability_slices": availability_slices,
"typing": get_person_typing_state(
user_id=request.user.id,
person_id=base["person"].id if base["person"] else None,

View File

@@ -12,6 +12,8 @@ from mixins.views import ObjectList, ObjectRead
from core.clients import transport
from core.models import Chat, PersonIdentifier, PlatformChatLink
from core.presence import get_settings as get_availability_settings
from core.presence import latest_state_for_people
from core.views.manage.permissions import SuperUserRequiredMixin
@@ -211,6 +213,10 @@ class SignalChatsList(SuperUserRequiredMixin, ObjectList):
def get_queryset(self, *args, **kwargs):
pk = self.kwargs.get("pk", "")
availability_settings = get_availability_settings(self.request.user)
show_availability = bool(
availability_settings.enabled and availability_settings.show_in_groups
)
chats = list(
Chat.objects.filter(
Q(account=pk) | Q(account__isnull=True) | Q(account="")
@@ -265,6 +271,9 @@ class SignalChatsList(SuperUserRequiredMixin, ObjectList):
"person_name": (
person_identifier.person.name if person_identifier else ""
),
"person_id": (
str(person_identifier.person_id) if person_identifier else ""
),
"manual_icon_class": "fa-solid fa-paper-plane",
"can_compose": bool(compose_page_url),
"match_url": (
@@ -300,6 +309,56 @@ class SignalChatsList(SuperUserRequiredMixin, ObjectList):
}
)
if show_availability:
person_ids = [
str(item.get("person_id") or "").strip()
for item in rows
if str(item.get("person_id") or "").strip()
]
person_ids = [pid for pid in person_ids if pid]
state_map = latest_state_for_people(
user=self.request.user,
person_ids=person_ids,
service="signal",
)
for row in rows:
pid = str(row.get("person_id") or "").strip()
if pid and pid in state_map:
state_row = state_map.get(pid) or {}
row["availability_state"] = str(state_row.get("state") or "unknown")
row["availability_label"] = (
f"{str(state_row.get('state') or 'unknown').title()} "
f"({float(state_row.get('confidence') or 0.0):.2f})"
)
signal_person_ids = list(
PersonIdentifier.objects.filter(
user=self.request.user,
service="signal",
)
.exclude(person_id__isnull=True)
.values_list("person_id", flat=True)
.distinct()
)
group_states = latest_state_for_people(
user=self.request.user,
person_ids=[str(pid) for pid in signal_person_ids if str(pid)],
service="signal",
)
aggregate_counts = {"available": 0, "fading": 0}
for state_row in group_states.values():
state_text = str((state_row or {}).get("state") or "").strip().lower()
if state_text in aggregate_counts:
aggregate_counts[state_text] += 1
aggregate_label = (
f"{aggregate_counts['available']} available · {aggregate_counts['fading']} fading"
if (aggregate_counts["available"] or aggregate_counts["fading"])
else ""
)
if aggregate_label:
for row in rows:
if row.get("is_group"):
row["availability_label"] = aggregate_label
return rows

View File

@@ -24,8 +24,9 @@ from core.models import (
PersonIdentifier,
PlatformChatLink,
Chat,
ExternalChatLink,
)
from core.tasks.providers.mock import get_provider
from core.tasks.providers import get_provider
SAFE_TASK_FLAGS_DEFAULTS = {
"derive_enabled": True,
@@ -170,6 +171,13 @@ def _service_label(service: str) -> str:
return labels.get(key, key.title() if key else "Unknown")
def _provider_row_map(user):
return {
str(row.provider or "").strip().lower(): row
for row in TaskProviderConfig.objects.filter(user=user).order_by("provider")
}
def _resolve_channel_display(user, service: str, identifier: str) -> dict:
service_key = str(service or "").strip().lower()
raw_identifier = str(identifier or "").strip()
@@ -408,12 +416,33 @@ class TaskSettings(LoginRequiredMixin, View):
for row in sources:
row.settings_effective = _flags_with_defaults(row.settings)
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 {})
mock_cfg = provider_map.get("mock")
external_chat_links = list(
ExternalChatLink.objects.filter(user=request.user).select_related(
"person", "person_identifier"
).order_by("-updated_at")[:200]
)
return {
"projects": projects,
"epics": TaskEpic.objects.filter(project__user=request.user).select_related("project").order_by("project__name", "name"),
"sources": sources,
"patterns": TaskCompletionPattern.objects.filter(user=request.user).order_by("position", "created_at"),
"provider_configs": TaskProviderConfig.objects.filter(user=request.user).order_by("provider"),
"provider_configs": list(provider_map.values()),
"mock_provider_config": mock_cfg,
"codex_provider_config": codex_cfg,
"codex_provider_settings": {
"command": str(codex_settings.get("command") or "codex"),
"workspace_root": str(codex_settings.get("workspace_root") or ""),
"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"),
},
"person_identifiers": PersonIdentifier.objects.filter(user=request.user).select_related("person").order_by("person__name", "service", "identifier")[:600],
"external_chat_links": external_chat_links,
"sync_events": ExternalSyncEvent.objects.filter(user=request.user).order_by("-updated_at")[:100],
"prefill_service": prefill_service,
"prefill_identifier": prefill_identifier,
@@ -537,18 +566,81 @@ class TaskSettings(LoginRequiredMixin, View):
defaults={"enabled": False, "settings": {}},
)
row.enabled = bool(request.POST.get("enabled"))
row.save(update_fields=["enabled", "updated_at"])
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",
}
row.settings = settings_payload
row.save(update_fields=["enabled", "settings", "updated_at"])
return _settings_redirect(request)
if action == "external_chat_link_upsert":
provider = str(request.POST.get("provider") or "codex_cli").strip().lower() or "codex_cli"
external_chat_id = str(request.POST.get("external_chat_id") or "").strip()
person_identifier_id = str(request.POST.get("person_identifier_id") or "").strip()
if not external_chat_id:
messages.error(request, "External chat ID is required.")
return _settings_redirect(request)
identifier = None
if person_identifier_id:
identifier = get_object_or_404(
PersonIdentifier,
user=request.user,
id=person_identifier_id,
)
row, _ = ExternalChatLink.objects.update_or_create(
user=request.user,
provider=provider,
external_chat_id=external_chat_id,
defaults={
"person": getattr(identifier, "person", None),
"person_identifier": identifier,
"enabled": bool(request.POST.get("enabled")),
"metadata": {
"chat_link_mode": "task-sync",
"notes": str(request.POST.get("metadata_notes") or "").strip(),
},
},
)
if identifier and row.person_id != identifier.person_id:
row.person = identifier.person
row.save(update_fields=["person", "updated_at"])
return _settings_redirect(request)
if action == "external_chat_link_delete":
row = get_object_or_404(
ExternalChatLink,
id=request.POST.get("external_link_id"),
user=request.user,
)
row.delete()
return _settings_redirect(request)
if action == "sync_retry":
event = get_object_or_404(ExternalSyncEvent, id=request.POST.get("event_id"), user=request.user)
provider = get_provider(event.provider)
payload = dict(event.payload or {})
result = provider.append_update({}, payload)
event.status = "ok" if result.ok else "failed"
event.error = str(result.error or "")
event.payload = dict(payload, retried=True)
event.save(update_fields=["status", "error", "payload", "updated_at"])
if bool(getattr(provider, "run_in_worker", False)):
event.status = "pending"
event.error = ""
event.payload = dict(event.payload or {}, retried=True)
event.save(update_fields=["status", "error", "payload", "updated_at"])
else:
payload = dict(event.payload or {})
result = provider.append_update({}, payload)
event.status = "ok" if result.ok else "failed"
event.error = str(result.error or "")
event.payload = dict(payload, retried=True)
event.save(update_fields=["status", "error", "payload", "updated_at"])
return _settings_redirect(request)
return _settings_redirect(request)

View File

@@ -8,6 +8,8 @@ from mixins.views import ObjectList, ObjectRead
from core.clients import transport
from core.models import PersonIdentifier, PlatformChatLink
from core.presence import get_settings as get_availability_settings
from core.presence import latest_state_for_people
from core.util import logs
from core.views.compose import _compose_urls, _service_icon_class
from core.views.manage.permissions import SuperUserRequiredMixin
@@ -265,6 +267,10 @@ class WhatsAppChatsList(WhatsAppContactsList):
def get_queryset(self, *args, **kwargs):
rows = []
seen = set()
availability_settings = get_availability_settings(self.request.user)
show_availability = bool(
availability_settings.enabled and availability_settings.show_in_groups
)
state = transport.get_runtime_state("whatsapp")
runtime_contacts = state.get("contacts") or []
@@ -414,7 +420,36 @@ class WhatsAppChatsList(WhatsAppContactsList):
if not row.get("is_group") and row.get("identifier") in db_group_ids:
row["is_group"] = True
return [row for row in rows if row.get("is_group")]
group_rows = [row for row in rows if row.get("is_group")]
if show_availability and group_rows:
whatsapp_person_ids = list(
PersonIdentifier.objects.filter(
user=self.request.user,
service="whatsapp",
)
.exclude(person_id__isnull=True)
.values_list("person_id", flat=True)
.distinct()
)
state_map = latest_state_for_people(
user=self.request.user,
person_ids=[str(pid) for pid in whatsapp_person_ids if str(pid)],
service="whatsapp",
)
counts = {"available": 0, "fading": 0}
for value in state_map.values():
state_text = str((value or {}).get("state") or "").strip().lower()
if state_text in counts:
counts[state_text] += 1
aggregate = (
f"{counts['available']} available · {counts['fading']} fading"
if (counts["available"] or counts["fading"])
else ""
)
if aggregate:
for row in group_rows:
row["availability_label"] = aggregate
return group_rows
class WhatsAppAccountAdd(SuperUserRequiredMixin, ObjectRead):