Reimplement compose and add tiling windows
This commit is contained in:
@@ -3,7 +3,6 @@ from __future__ import annotations
|
||||
import re
|
||||
|
||||
from core.models import ChatTaskSource, TaskProject
|
||||
from core.tasks.codex_support import channel_variants
|
||||
|
||||
SAFE_TASK_FLAGS_DEFAULTS = {
|
||||
"derive_enabled": True,
|
||||
@@ -28,6 +27,33 @@ SIGNAL_PHONE_RE = re.compile(r"^\+\d+$")
|
||||
SIGNAL_INTERNAL_ID_RE = re.compile(r"^[A-Za-z0-9+/=]+$")
|
||||
|
||||
|
||||
def channel_variants(service: str, channel: str) -> list[str]:
|
||||
value = str(channel or "").strip()
|
||||
if not value:
|
||||
return []
|
||||
variants = [value]
|
||||
service_key = str(service or "").strip().lower()
|
||||
if service_key == "whatsapp":
|
||||
bare = value.split("@", 1)[0].strip()
|
||||
if bare and bare not in variants:
|
||||
variants.append(bare)
|
||||
direct = f"{bare}@s.whatsapp.net" if bare else ""
|
||||
if direct and direct not in variants:
|
||||
variants.append(direct)
|
||||
group = f"{bare}@g.us" if bare else ""
|
||||
if group and group not in variants:
|
||||
variants.append(group)
|
||||
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)
|
||||
return variants
|
||||
|
||||
|
||||
def _normalize_whatsapp_identifier(identifier: str) -> str:
|
||||
value = str(identifier or "").strip()
|
||||
if not value:
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
|
||||
from core.clients.transport import send_message_raw
|
||||
from core.models import CodexPermissionRequest, ExternalSyncEvent, TaskProviderConfig
|
||||
|
||||
|
||||
def _deterministic_approval_key(idempotency_key: str) -> str:
|
||||
digest = hashlib.sha1(str(idempotency_key or "").encode("utf-8")).hexdigest()[:12]
|
||||
return f"pre-{digest}"
|
||||
|
||||
|
||||
def queue_codex_event_with_pre_approval(
|
||||
*,
|
||||
user,
|
||||
run,
|
||||
task,
|
||||
task_event,
|
||||
action: str,
|
||||
provider_payload: dict,
|
||||
idempotency_key: str,
|
||||
provider: str = "codex_cli",
|
||||
) -> tuple[ExternalSyncEvent, CodexPermissionRequest]:
|
||||
provider = str(provider or "codex_cli").strip() or "codex_cli"
|
||||
approval_key = _deterministic_approval_key(idempotency_key)
|
||||
waiting_event, _ = ExternalSyncEvent.objects.update_or_create(
|
||||
idempotency_key=f"codex_waiting:{idempotency_key}",
|
||||
defaults={
|
||||
"user": user,
|
||||
"task": task,
|
||||
"task_event": task_event,
|
||||
"provider": provider,
|
||||
"status": "waiting_approval",
|
||||
"payload": {
|
||||
"action": str(action or "append_update"),
|
||||
"provider_payload": dict(provider_payload or {}),
|
||||
},
|
||||
"error": "",
|
||||
},
|
||||
)
|
||||
run.status = "waiting_approval"
|
||||
run.error = ""
|
||||
run.save(update_fields=["status", "error", "updated_at"])
|
||||
|
||||
provider_label = "Claude" if provider == "claude_cli" else "Codex"
|
||||
xmpp_cmd = ".claude" if provider == "claude_cli" else ".codex"
|
||||
request, _ = CodexPermissionRequest.objects.update_or_create(
|
||||
approval_key=approval_key,
|
||||
defaults={
|
||||
"user": user,
|
||||
"codex_run": run,
|
||||
"external_sync_event": waiting_event,
|
||||
"summary": f"Pre-submit approval required before sending to {provider_label}",
|
||||
"requested_permissions": {
|
||||
"type": "pre_submit",
|
||||
"provider": provider,
|
||||
"action": str(action or "append_update"),
|
||||
},
|
||||
"resume_payload": {
|
||||
"gate_type": "pre_submit",
|
||||
"action": str(action or "append_update"),
|
||||
"provider_payload": dict(provider_payload or {}),
|
||||
"idempotency_key": str(idempotency_key or ""),
|
||||
},
|
||||
"status": "pending",
|
||||
"resolved_at": None,
|
||||
"resolved_by_identifier": "",
|
||||
"resolution_note": "",
|
||||
},
|
||||
)
|
||||
|
||||
cfg = TaskProviderConfig.objects.filter(
|
||||
user=user, provider=provider, enabled=True
|
||||
).first()
|
||||
settings_payload = dict(getattr(cfg, "settings", {}) or {})
|
||||
approver_service = (
|
||||
str(settings_payload.get("approver_service") or "").strip().lower()
|
||||
)
|
||||
approver_identifier = str(settings_payload.get("approver_identifier") or "").strip()
|
||||
if approver_service and approver_identifier:
|
||||
try:
|
||||
async_to_sync(send_message_raw)(
|
||||
approver_service,
|
||||
approver_identifier,
|
||||
text=(
|
||||
f"[{provider} approval] key={approval_key}\n"
|
||||
f"summary=Pre-submit approval required before sending to {provider_label}\n"
|
||||
"requested=pre_submit\n"
|
||||
f"use: {xmpp_cmd} approve {approval_key} or {xmpp_cmd} deny {approval_key}"
|
||||
),
|
||||
attachments=[],
|
||||
metadata={"origin_tag": f"codex-pre-approval:{approval_key}"},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return waiting_event, request
|
||||
@@ -1,73 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from django.db.models import Q
|
||||
|
||||
from core.models import ExternalChatLink, PersonIdentifier
|
||||
|
||||
|
||||
def channel_variants(service: str, channel: str) -> list[str]:
|
||||
value = str(channel or "").strip()
|
||||
if not value:
|
||||
return []
|
||||
variants = [value]
|
||||
service_key = str(service or "").strip().lower()
|
||||
if service_key == "whatsapp":
|
||||
bare = value.split("@", 1)[0].strip()
|
||||
if bare and bare not in variants:
|
||||
variants.append(bare)
|
||||
direct = f"{bare}@s.whatsapp.net" if bare else ""
|
||||
if direct and direct not in variants:
|
||||
variants.append(direct)
|
||||
group = f"{bare}@g.us" if bare else ""
|
||||
if group and group not in variants:
|
||||
variants.append(group)
|
||||
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)
|
||||
return variants
|
||||
|
||||
|
||||
def resolve_external_chat_id(*, user, provider: str, service: str, channel: str) -> str:
|
||||
variants = channel_variants(service, channel)
|
||||
if not variants:
|
||||
return ""
|
||||
person_identifier = (
|
||||
PersonIdentifier.objects.filter(
|
||||
user=user,
|
||||
service=service,
|
||||
identifier__in=variants,
|
||||
)
|
||||
.select_related("person")
|
||||
.order_by("-id")
|
||||
.first()
|
||||
)
|
||||
if person_identifier is None:
|
||||
return ""
|
||||
link = (
|
||||
ExternalChatLink.objects.filter(
|
||||
user=user,
|
||||
provider=provider,
|
||||
enabled=True,
|
||||
)
|
||||
.filter(
|
||||
Q(person_identifier=person_identifier) | Q(person=person_identifier.person)
|
||||
)
|
||||
.order_by("-updated_at", "-id")
|
||||
.first()
|
||||
)
|
||||
return str(getattr(link, "external_chat_id", "") or "").strip()
|
||||
|
||||
|
||||
def compact_json_snippet(payload: Any, limit: int = 800) -> str:
|
||||
text = str(payload or "").strip()
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[:limit].rstrip() + "..."
|
||||
@@ -13,7 +13,6 @@ from core.models import (
|
||||
AI,
|
||||
Chat,
|
||||
ChatTaskSource,
|
||||
CodexRun,
|
||||
DerivedTask,
|
||||
DerivedTaskEvent,
|
||||
ExternalSyncEvent,
|
||||
@@ -27,8 +26,6 @@ from core.tasks.chat_defaults import (
|
||||
ensure_default_source_for_chat,
|
||||
resolve_message_scope,
|
||||
)
|
||||
from core.tasks.codex_approval import queue_codex_event_with_pre_approval
|
||||
from core.tasks.codex_support import resolve_external_chat_id
|
||||
from core.tasks.providers import get_provider
|
||||
|
||||
_TASK_HINT_RE = re.compile(r"\b(todo|task|action|need to|please)\b", re.IGNORECASE)
|
||||
@@ -506,21 +503,30 @@ async def _derive_title_with_flags(message: Message, flags: dict) -> str:
|
||||
async def _emit_sync_event(
|
||||
task: DerivedTask, event: DerivedTaskEvent, action: str
|
||||
) -> None:
|
||||
cfg = await sync_to_async(
|
||||
lambda: TaskProviderConfig.objects.filter(user=task.user, enabled=True)
|
||||
.order_by("provider")
|
||||
.first()
|
||||
)()
|
||||
provider_name = str(getattr(cfg, "provider", "mock") or "mock")
|
||||
provider_settings = dict(getattr(cfg, "settings", {}) or {})
|
||||
def _select_provider_config():
|
||||
enabled_cfg = (
|
||||
TaskProviderConfig.objects.filter(user=task.user, enabled=True)
|
||||
.order_by("provider")
|
||||
.first()
|
||||
)
|
||||
if enabled_cfg is not None:
|
||||
return enabled_cfg
|
||||
any_cfg_exists = TaskProviderConfig.objects.filter(user=task.user).exists()
|
||||
if any_cfg_exists:
|
||||
return None
|
||||
return False
|
||||
|
||||
cfg = await sync_to_async(_select_provider_config)()
|
||||
if cfg is None:
|
||||
return
|
||||
if cfg is False:
|
||||
provider_name = "mock"
|
||||
provider_settings = {}
|
||||
else:
|
||||
provider_name = str(getattr(cfg, "provider", "mock") or "mock")
|
||||
provider_settings = dict(getattr(cfg, "settings", {}) or {})
|
||||
provider = get_provider(provider_name)
|
||||
idempotency_key = f"{provider_name}:{task.id}:{event.id}"
|
||||
external_chat_id = await sync_to_async(resolve_external_chat_id)(
|
||||
user=task.user,
|
||||
provider=provider_name,
|
||||
service=str(task.source_service or ""),
|
||||
channel=str(task.source_channel or ""),
|
||||
)
|
||||
cached_project = task._state.fields_cache.get("project")
|
||||
cached_epic = task._state.fields_cache.get("epic")
|
||||
project_name = str(getattr(cached_project, "name", "") or "")
|
||||
@@ -545,7 +551,6 @@ async def _emit_sync_event(
|
||||
"epic_name": epic_name,
|
||||
"source_service": str(task.source_service or ""),
|
||||
"source_channel": str(task.source_channel or ""),
|
||||
"external_chat_id": external_chat_id,
|
||||
"origin_message_id": str(getattr(task, "origin_message_id", "") or ""),
|
||||
"trigger_message_id": str(
|
||||
getattr(event, "source_message_id", "")
|
||||
@@ -556,56 +561,6 @@ async def _emit_sync_event(
|
||||
"payload": event.payload,
|
||||
"memory_context": memory_context,
|
||||
}
|
||||
codex_run = await sync_to_async(CodexRun.objects.create)(
|
||||
user=task.user,
|
||||
task_id=task.id,
|
||||
derived_task_event_id=event.id,
|
||||
source_message_id=(event.source_message_id or task.origin_message_id),
|
||||
project_id=task.project_id,
|
||||
epic_id=task.epic_id,
|
||||
source_service=str(task.source_service or ""),
|
||||
source_channel=str(task.source_channel or ""),
|
||||
external_chat_id=external_chat_id,
|
||||
status="queued",
|
||||
request_payload={
|
||||
"action": action,
|
||||
"provider_payload": dict(request_payload),
|
||||
"idempotency_key": idempotency_key,
|
||||
},
|
||||
result_payload={},
|
||||
error="",
|
||||
)
|
||||
request_payload["codex_run_id"] = str(codex_run.id)
|
||||
|
||||
# Worker-backed providers are queued and executed by `manage.py codex_worker`.
|
||||
if bool(getattr(provider, "run_in_worker", False)):
|
||||
if provider_name == "codex_cli":
|
||||
await sync_to_async(queue_codex_event_with_pre_approval)(
|
||||
user=task.user,
|
||||
run=codex_run,
|
||||
task=task,
|
||||
task_event=event,
|
||||
action=action,
|
||||
provider_payload=dict(request_payload),
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
return
|
||||
await sync_to_async(ExternalSyncEvent.objects.update_or_create)(
|
||||
idempotency_key=idempotency_key,
|
||||
defaults={
|
||||
"user": task.user,
|
||||
"task": task,
|
||||
"task_event": event,
|
||||
"provider": provider_name,
|
||||
"status": "pending",
|
||||
"payload": {
|
||||
"action": action,
|
||||
"provider_payload": dict(request_payload),
|
||||
},
|
||||
"error": "",
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
if action == "create":
|
||||
result = provider.create_task(provider_settings, dict(request_payload))
|
||||
@@ -623,16 +578,14 @@ async def _emit_sync_event(
|
||||
"task_event": event,
|
||||
"provider": provider_name,
|
||||
"status": status,
|
||||
"payload": dict(result.payload or {}),
|
||||
"payload": {
|
||||
"action": action,
|
||||
"provider_payload": dict(request_payload),
|
||||
"result_payload": dict(result.payload or {}),
|
||||
},
|
||||
"error": str(result.error or ""),
|
||||
},
|
||||
)
|
||||
codex_run.status = status
|
||||
codex_run.result_payload = dict(result.payload or {})
|
||||
codex_run.error = str(result.error or "")
|
||||
await sync_to_async(codex_run.save)(
|
||||
update_fields=["status", "result_payload", "error", "updated_at"]
|
||||
)
|
||||
if result.ok and result.external_key and not task.external_key:
|
||||
task.external_key = str(result.external_key)
|
||||
await sync_to_async(task.save)(update_fields=["external_key"])
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from django.db.models import Q
|
||||
|
||||
from core.models import ExternalChatLink, PersonIdentifier
|
||||
|
||||
|
||||
def channel_variants(service: str, channel: str) -> list[str]:
|
||||
value = str(channel or "").strip()
|
||||
if not value:
|
||||
return []
|
||||
variants = [value]
|
||||
service_key = str(service or "").strip().lower()
|
||||
if service_key == "whatsapp":
|
||||
bare = value.split("@", 1)[0].strip()
|
||||
if bare and bare not in variants:
|
||||
variants.append(bare)
|
||||
direct = f"{bare}@s.whatsapp.net" if bare else ""
|
||||
if direct and direct not in variants:
|
||||
variants.append(direct)
|
||||
group = f"{bare}@g.us" if bare else ""
|
||||
if group and group not in variants:
|
||||
variants.append(group)
|
||||
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)
|
||||
return variants
|
||||
|
||||
|
||||
def resolve_external_chat_id(*, user, provider: str, service: str, channel: str) -> str:
|
||||
variants = channel_variants(service, channel)
|
||||
if not variants:
|
||||
return ""
|
||||
person_identifier = (
|
||||
PersonIdentifier.objects.filter(
|
||||
user=user,
|
||||
service=service,
|
||||
identifier__in=variants,
|
||||
)
|
||||
.select_related("person")
|
||||
.order_by("-id")
|
||||
.first()
|
||||
)
|
||||
if person_identifier is None:
|
||||
return ""
|
||||
link = (
|
||||
ExternalChatLink.objects.filter(
|
||||
user=user,
|
||||
provider=provider,
|
||||
enabled=True,
|
||||
)
|
||||
.filter(
|
||||
Q(person_identifier=person_identifier) | Q(person=person_identifier.person)
|
||||
)
|
||||
.order_by("-updated_at", "-id")
|
||||
.first()
|
||||
)
|
||||
return str(getattr(link, "external_chat_id", "") or "").strip()
|
||||
|
||||
|
||||
def compact_json_snippet(payload: Any, limit: int = 800) -> str:
|
||||
text = str(payload or "").strip()
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[:limit].rstrip() + "..."
|
||||
@@ -1,14 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import TaskProvider
|
||||
from .claude_cli import ClaudeCLITaskProvider
|
||||
from .codex_cli import CodexCLITaskProvider
|
||||
from .mock import MockTaskProvider
|
||||
|
||||
PROVIDERS = {
|
||||
"mock": MockTaskProvider(),
|
||||
"codex_cli": CodexCLITaskProvider(),
|
||||
"claude_cli": ClaudeCLITaskProvider(),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from hashlib import sha1
|
||||
|
||||
from .base import ProviderResult, TaskProvider
|
||||
|
||||
|
||||
class ClaudeCLITaskProvider(TaskProvider):
|
||||
name = "claude_cli"
|
||||
run_in_worker = True
|
||||
|
||||
def _timeout(self, config: dict) -> int:
|
||||
try:
|
||||
return max(1, int(config.get("timeout_seconds") or 60))
|
||||
except Exception:
|
||||
return 60
|
||||
|
||||
def _command(self, config: dict) -> str:
|
||||
return str(config.get("command") or "claude").strip() or "claude"
|
||||
|
||||
def _workspace(self, config: dict) -> str:
|
||||
return str(config.get("workspace_root") or "").strip()
|
||||
|
||||
def _profile(self, config: dict) -> str:
|
||||
return str(config.get("default_profile") or "").strip()
|
||||
|
||||
def _is_task_sync_contract_mismatch(self, stderr: str) -> bool:
|
||||
text = str(stderr or "").lower()
|
||||
if "unexpected argument '--op'" in text:
|
||||
return True
|
||||
if "unexpected argument 'create'" in text and "usage: claude" in text:
|
||||
return True
|
||||
if "unexpected argument 'append_update'" in text and "usage: claude" in text:
|
||||
return True
|
||||
if "unexpected argument 'mark_complete'" in text and "usage: claude" in text:
|
||||
return True
|
||||
if "unexpected argument 'link_task'" in text and "usage: claude" in text:
|
||||
return True
|
||||
if "unrecognized subcommand 'create'" in text and "usage: claude" in text:
|
||||
return True
|
||||
if (
|
||||
"unrecognized subcommand 'append_update'" in text
|
||||
and "usage: claude" in text
|
||||
):
|
||||
return True
|
||||
if (
|
||||
"unrecognized subcommand 'mark_complete'" in text
|
||||
and "usage: claude" in text
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _builtin_stub_result(
|
||||
self, op: str, payload: dict, stderr: str
|
||||
) -> ProviderResult:
|
||||
mode = str(payload.get("mode") or "default").strip().lower()
|
||||
external_key = (
|
||||
str(payload.get("external_key") or "").strip()
|
||||
or str(payload.get("task_id") or "").strip()
|
||||
)
|
||||
if mode == "approval_response":
|
||||
return ProviderResult(
|
||||
ok=True,
|
||||
external_key=external_key,
|
||||
payload={
|
||||
"op": op,
|
||||
"status": "ok",
|
||||
"summary": "approval acknowledged; resumed by builtin claude stub",
|
||||
"requires_approval": False,
|
||||
"output": "",
|
||||
"fallback_mode": "builtin_task_sync_stub",
|
||||
"fallback_reason": str(stderr or "")[:4000],
|
||||
},
|
||||
)
|
||||
task_id = str(payload.get("task_id") or "").strip()
|
||||
key_basis = f"{op}:{task_id}:{payload.get('trigger_message_id') or payload.get('origin_message_id') or ''}"
|
||||
approval_key = sha1(key_basis.encode("utf-8")).hexdigest()[:12]
|
||||
summary = "Claude approval required (builtin stub fallback)"
|
||||
return ProviderResult(
|
||||
ok=True,
|
||||
external_key=external_key,
|
||||
payload={
|
||||
"op": op,
|
||||
"status": "requires_approval",
|
||||
"requires_approval": True,
|
||||
"summary": summary,
|
||||
"approval_key": approval_key,
|
||||
"permission_request": {
|
||||
"summary": summary,
|
||||
"requested_permissions": ["workspace_write"],
|
||||
},
|
||||
"resume_payload": {
|
||||
"task_id": task_id,
|
||||
"op": op,
|
||||
},
|
||||
"fallback_mode": "builtin_task_sync_stub",
|
||||
"fallback_reason": str(stderr or "")[:4000],
|
||||
},
|
||||
)
|
||||
|
||||
def _run(self, config: dict, op: str, payload: dict) -> ProviderResult:
|
||||
base_cmd = [self._command(config), "task-sync"]
|
||||
workspace = self._workspace(config)
|
||||
profile = self._profile(config)
|
||||
command_timeout = self._timeout(config)
|
||||
data = json.dumps(dict(payload or {}), separators=(",", ":"))
|
||||
common_args: list[str] = []
|
||||
if workspace:
|
||||
common_args.extend(["--workspace", workspace])
|
||||
if profile:
|
||||
common_args.extend(["--profile", profile])
|
||||
|
||||
primary_cmd = [*base_cmd, "--op", str(op), *common_args, "--payload-json", data]
|
||||
fallback_cmd = [*base_cmd, str(op), *common_args, "--payload-json", data]
|
||||
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
primary_cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=command_timeout,
|
||||
check=False,
|
||||
cwd=workspace if workspace else None,
|
||||
)
|
||||
stderr_probe = str(completed.stderr or "").lower()
|
||||
if (
|
||||
completed.returncode != 0
|
||||
and "unexpected argument '--op'" in stderr_probe
|
||||
):
|
||||
completed = subprocess.run(
|
||||
fallback_cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=command_timeout,
|
||||
check=False,
|
||||
cwd=workspace if workspace else None,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return ProviderResult(
|
||||
ok=False,
|
||||
error=f"claude_cli_timeout_{command_timeout}s",
|
||||
payload={"op": op, "timeout_seconds": command_timeout},
|
||||
)
|
||||
except Exception as exc:
|
||||
return ProviderResult(
|
||||
ok=False, error=f"claude_cli_exec_error:{exc}", payload={"op": op}
|
||||
)
|
||||
|
||||
stdout = str(completed.stdout or "").strip()
|
||||
stderr = str(completed.stderr or "").strip()
|
||||
parsed = {}
|
||||
if stdout:
|
||||
try:
|
||||
parsed = json.loads(stdout)
|
||||
if not isinstance(parsed, dict):
|
||||
parsed = {"raw_stdout": stdout}
|
||||
except Exception:
|
||||
parsed = {"raw_stdout": stdout}
|
||||
|
||||
parsed_status = str(parsed.get("status") or "").strip().lower()
|
||||
permission_request = parsed.get("permission_request")
|
||||
requires_approval = bool(
|
||||
parsed.get("requires_approval")
|
||||
or parsed_status in {"requires_approval", "waiting_approval"}
|
||||
or permission_request
|
||||
)
|
||||
|
||||
ext = (
|
||||
str(parsed.get("external_key") or "").strip()
|
||||
or str(parsed.get("task_id") or "").strip()
|
||||
or str(payload.get("external_key") or "").strip()
|
||||
)
|
||||
|
||||
ok = completed.returncode == 0
|
||||
out_payload = {
|
||||
"op": op,
|
||||
"returncode": int(completed.returncode),
|
||||
"stdout": stdout[:4000],
|
||||
"stderr": stderr[:4000],
|
||||
"parsed_status": parsed_status,
|
||||
"requires_approval": requires_approval,
|
||||
}
|
||||
out_payload.update(parsed)
|
||||
if (not ok) and self._is_task_sync_contract_mismatch(stderr):
|
||||
return self._builtin_stub_result(op, dict(payload or {}), stderr)
|
||||
return ProviderResult(
|
||||
ok=ok,
|
||||
external_key=ext,
|
||||
error=("" if ok else stderr[:4000]),
|
||||
payload=out_payload,
|
||||
)
|
||||
|
||||
def healthcheck(self, config: dict) -> ProviderResult:
|
||||
command = self._command(config)
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
[command, "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=max(1, min(20, self._timeout(config))),
|
||||
check=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
return ProviderResult(ok=False, error=f"claude_cli_unavailable:{exc}")
|
||||
return ProviderResult(
|
||||
ok=(completed.returncode == 0),
|
||||
payload={
|
||||
"returncode": int(completed.returncode),
|
||||
"stdout": str(completed.stdout or "").strip()[:1000],
|
||||
"stderr": str(completed.stderr or "").strip()[:1000],
|
||||
},
|
||||
error=(
|
||||
""
|
||||
if completed.returncode == 0
|
||||
else str(completed.stderr or "").strip()[:1000]
|
||||
),
|
||||
)
|
||||
|
||||
def create_task(self, config: dict, payload: dict) -> ProviderResult:
|
||||
return self._run(config, "create", payload)
|
||||
|
||||
def append_update(self, config: dict, payload: dict) -> ProviderResult:
|
||||
return self._run(config, "append_update", payload)
|
||||
|
||||
def mark_complete(self, config: dict, payload: dict) -> ProviderResult:
|
||||
return self._run(config, "mark_complete", payload)
|
||||
|
||||
def link_task(self, config: dict, payload: dict) -> ProviderResult:
|
||||
return self._run(config, "link_task", payload)
|
||||
@@ -1,225 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from hashlib import sha1
|
||||
|
||||
from .base import ProviderResult, TaskProvider
|
||||
|
||||
|
||||
class CodexCLITaskProvider(TaskProvider):
|
||||
name = "codex_cli"
|
||||
run_in_worker = True
|
||||
|
||||
def _timeout(self, config: dict) -> int:
|
||||
try:
|
||||
return max(1, int(config.get("timeout_seconds") or 60))
|
||||
except Exception:
|
||||
return 60
|
||||
|
||||
def _command(self, config: dict) -> str:
|
||||
return str(config.get("command") or "codex").strip() or "codex"
|
||||
|
||||
def _workspace(self, config: dict) -> str:
|
||||
return str(config.get("workspace_root") or "").strip()
|
||||
|
||||
def _profile(self, config: dict) -> str:
|
||||
return str(config.get("default_profile") or "").strip()
|
||||
|
||||
def _is_task_sync_contract_mismatch(self, stderr: str) -> bool:
|
||||
text = str(stderr or "").lower()
|
||||
if "unexpected argument '--op'" in text:
|
||||
return True
|
||||
if "unexpected argument 'create'" in text and "usage: codex" in text:
|
||||
return True
|
||||
if "unexpected argument 'append_update'" in text and "usage: codex" in text:
|
||||
return True
|
||||
if "unexpected argument 'mark_complete'" in text and "usage: codex" in text:
|
||||
return True
|
||||
if "unexpected argument 'link_task'" in text and "usage: codex" in text:
|
||||
return True
|
||||
if "unrecognized subcommand 'create'" in text and "usage: codex" in text:
|
||||
return True
|
||||
if "unrecognized subcommand 'append_update'" in text and "usage: codex" in text:
|
||||
return True
|
||||
if "unrecognized subcommand 'mark_complete'" in text and "usage: codex" in text:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _builtin_stub_result(
|
||||
self, op: str, payload: dict, stderr: str
|
||||
) -> ProviderResult:
|
||||
mode = str(payload.get("mode") or "default").strip().lower()
|
||||
external_key = (
|
||||
str(payload.get("external_key") or "").strip()
|
||||
or str(payload.get("task_id") or "").strip()
|
||||
)
|
||||
if mode == "approval_response":
|
||||
return ProviderResult(
|
||||
ok=True,
|
||||
external_key=external_key,
|
||||
payload={
|
||||
"op": op,
|
||||
"status": "ok",
|
||||
"summary": "approval acknowledged; resumed by builtin codex stub",
|
||||
"requires_approval": False,
|
||||
"output": "",
|
||||
"fallback_mode": "builtin_task_sync_stub",
|
||||
"fallback_reason": str(stderr or "")[:4000],
|
||||
},
|
||||
)
|
||||
task_id = str(payload.get("task_id") or "").strip()
|
||||
key_basis = f"{op}:{task_id}:{payload.get('trigger_message_id') or payload.get('origin_message_id') or ''}"
|
||||
approval_key = sha1(key_basis.encode("utf-8")).hexdigest()[:12]
|
||||
summary = "Codex approval required (builtin stub fallback)"
|
||||
return ProviderResult(
|
||||
ok=True,
|
||||
external_key=external_key,
|
||||
payload={
|
||||
"op": op,
|
||||
"status": "requires_approval",
|
||||
"requires_approval": True,
|
||||
"summary": summary,
|
||||
"approval_key": approval_key,
|
||||
"permission_request": {
|
||||
"summary": summary,
|
||||
"requested_permissions": ["workspace_write"],
|
||||
},
|
||||
"resume_payload": {
|
||||
"task_id": task_id,
|
||||
"op": op,
|
||||
},
|
||||
"fallback_mode": "builtin_task_sync_stub",
|
||||
"fallback_reason": str(stderr or "")[:4000],
|
||||
},
|
||||
)
|
||||
|
||||
def _run(self, config: dict, op: str, payload: dict) -> ProviderResult:
|
||||
base_cmd = [self._command(config), "task-sync"]
|
||||
workspace = self._workspace(config)
|
||||
profile = self._profile(config)
|
||||
command_timeout = self._timeout(config)
|
||||
data = json.dumps(dict(payload or {}), separators=(",", ":"))
|
||||
common_args: list[str] = []
|
||||
if workspace:
|
||||
common_args.extend(["--workspace", workspace])
|
||||
if profile:
|
||||
common_args.extend(["--profile", profile])
|
||||
|
||||
primary_cmd = [*base_cmd, "--op", str(op), *common_args, "--payload-json", data]
|
||||
fallback_cmd = [*base_cmd, str(op), *common_args, "--payload-json", data]
|
||||
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
primary_cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=command_timeout,
|
||||
check=False,
|
||||
cwd=workspace if workspace else None,
|
||||
)
|
||||
stderr_probe = str(completed.stderr or "").lower()
|
||||
if (
|
||||
completed.returncode != 0
|
||||
and "unexpected argument '--op'" in stderr_probe
|
||||
):
|
||||
completed = subprocess.run(
|
||||
fallback_cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=command_timeout,
|
||||
check=False,
|
||||
cwd=workspace if workspace else None,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return ProviderResult(
|
||||
ok=False,
|
||||
error=f"codex_cli_timeout_{command_timeout}s",
|
||||
payload={"op": op, "timeout_seconds": command_timeout},
|
||||
)
|
||||
except Exception as exc:
|
||||
return ProviderResult(
|
||||
ok=False, error=f"codex_cli_exec_error:{exc}", payload={"op": op}
|
||||
)
|
||||
|
||||
stdout = str(completed.stdout or "").strip()
|
||||
stderr = str(completed.stderr or "").strip()
|
||||
parsed = {}
|
||||
if stdout:
|
||||
try:
|
||||
parsed = json.loads(stdout)
|
||||
if not isinstance(parsed, dict):
|
||||
parsed = {"raw_stdout": stdout}
|
||||
except Exception:
|
||||
parsed = {"raw_stdout": stdout}
|
||||
|
||||
parsed_status = str(parsed.get("status") or "").strip().lower()
|
||||
permission_request = parsed.get("permission_request")
|
||||
requires_approval = bool(
|
||||
parsed.get("requires_approval")
|
||||
or parsed_status in {"requires_approval", "waiting_approval"}
|
||||
or permission_request
|
||||
)
|
||||
|
||||
ext = (
|
||||
str(parsed.get("external_key") or "").strip()
|
||||
or str(parsed.get("task_id") or "").strip()
|
||||
or str(payload.get("external_key") or "").strip()
|
||||
)
|
||||
|
||||
ok = completed.returncode == 0
|
||||
out_payload = {
|
||||
"op": op,
|
||||
"returncode": int(completed.returncode),
|
||||
"stdout": stdout[:4000],
|
||||
"stderr": stderr[:4000],
|
||||
"parsed_status": parsed_status,
|
||||
"requires_approval": requires_approval,
|
||||
}
|
||||
out_payload.update(parsed)
|
||||
if (not ok) and self._is_task_sync_contract_mismatch(stderr):
|
||||
return self._builtin_stub_result(op, dict(payload or {}), stderr)
|
||||
return ProviderResult(
|
||||
ok=ok,
|
||||
external_key=ext,
|
||||
error=("" if ok else stderr[:4000]),
|
||||
payload=out_payload,
|
||||
)
|
||||
|
||||
def healthcheck(self, config: dict) -> ProviderResult:
|
||||
command = self._command(config)
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
[command, "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=max(1, min(20, self._timeout(config))),
|
||||
check=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
return ProviderResult(ok=False, error=f"codex_cli_unavailable:{exc}")
|
||||
return ProviderResult(
|
||||
ok=(completed.returncode == 0),
|
||||
payload={
|
||||
"returncode": int(completed.returncode),
|
||||
"stdout": str(completed.stdout or "").strip()[:1000],
|
||||
"stderr": str(completed.stderr or "").strip()[:1000],
|
||||
},
|
||||
error=(
|
||||
""
|
||||
if completed.returncode == 0
|
||||
else str(completed.stderr or "").strip()[:1000]
|
||||
),
|
||||
)
|
||||
|
||||
def create_task(self, config: dict, payload: dict) -> ProviderResult:
|
||||
return self._run(config, "create", payload)
|
||||
|
||||
def append_update(self, config: dict, payload: dict) -> ProviderResult:
|
||||
return self._run(config, "append_update", payload)
|
||||
|
||||
def mark_complete(self, config: dict, payload: dict) -> ProviderResult:
|
||||
return self._run(config, "mark_complete", payload)
|
||||
|
||||
def link_task(self, config: dict, payload: dict) -> ProviderResult:
|
||||
return self._run(config, "link_task", payload)
|
||||
Reference in New Issue
Block a user