FEAT - TASKER: internal adapter для Codex Agent Gateway

This commit is contained in:
DCCONSTRUCTIONS 2026-05-14 19:48:03 +03:00
parent 533f8c6356
commit 2ae353c8d5
2 changed files with 691 additions and 0 deletions

View File

@ -0,0 +1,641 @@
from html import escape
from django.db import transaction
from django.http import JsonResponse
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.csrf import csrf_exempt
from plane.app.realtime.issue_events import publish_issue_event_on_commit
from plane.authentication.views.nodedc_logout import is_internal_logout_request_authorized
from plane.authentication.views.nodedc_workspace_adapter import parse_json_body
from plane.db.models import (
Issue,
IssueAssignee,
IssueComment,
IssueLabel,
Label,
Project,
ProjectMember,
State,
User,
WorkspaceMember,
)
NODEDC_STRUCTURED_BLOCKS_KEY = "nodedc_structured_blocks"
AGENT_EMAIL_DOMAIN = "agents.nodedc.local"
AGENT_BOT_TYPE = "nodedc_codex_agent"
ALLOWED_PRIORITIES = ["none", "low", "medium", "high", "urgent"]
def unauthorized_response():
return JsonResponse({"ok": False, "error": "internal_access_unauthorized"}, status=401)
def invalid_json_response():
return JsonResponse({"ok": False, "error": "invalid_json"}, status=400)
def validation_error(error, status=400):
return JsonResponse({"ok": False, "error": error}, status=status)
def serialize_project(project):
return {
"id": str(project.id),
"workspace_slug": project.workspace.slug,
"name": project.name,
"identifier": project.identifier,
}
def serialize_state(state):
return {
"id": str(state.id),
"name": state.name,
"color": state.color,
"group": state.group,
"sequence": state.sequence,
"default": state.default,
}
def serialize_label(label):
return {
"id": str(label.id),
"name": label.name,
"color": label.color,
}
def serialize_member(project_member):
return {
"id": str(project_member.member_id),
"project_member_id": str(project_member.id),
"display_name": project_member.member.display_name,
"email": project_member.member.email,
"role": project_member.role,
}
def serialize_issue(issue):
return {
"id": str(issue.id),
"project_id": str(issue.project_id),
"workspace_slug": issue.workspace.slug,
"title": issue.name,
"description": issue.description_html,
"priority": issue.priority,
"state_id": str(issue.state_id) if issue.state_id else None,
"sequence_id": issue.sequence_id,
"detail_layout": issue.detail_layout or {},
"label_ids": [
str(label_id)
for label_id in IssueLabel.objects.filter(
issue=issue,
deleted_at__isnull=True,
).values_list("label_id", flat=True)
],
"assignee_ids": [
str(assignee_id)
for assignee_id in IssueAssignee.objects.filter(
issue=issue,
deleted_at__isnull=True,
).values_list("assignee_id", flat=True)
],
"created_by": str(issue.created_by_id) if issue.created_by_id else None,
"updated_by": str(issue.updated_by_id) if issue.updated_by_id else None,
"created_at": issue.created_at.isoformat(),
"updated_at": issue.updated_at.isoformat(),
}
def serialize_comment(comment):
return {
"id": str(comment.id),
"issue_id": str(comment.issue_id),
"body": comment.comment_html,
"actor_id": str(comment.actor_id) if comment.actor_id else None,
"created_at": comment.created_at.isoformat(),
}
def resolve_project(project_id, workspace_slug=None):
queryset = Project.objects.filter(
id=project_id,
deleted_at__isnull=True,
archived_at__isnull=True,
).select_related("workspace")
if workspace_slug:
queryset = queryset.filter(workspace__slug=workspace_slug)
return queryset.first()
def resolve_issue(project_id, issue_id, workspace_slug=None):
project = resolve_project(project_id, workspace_slug)
if project is None:
return None, None
issue = (
Issue.issue_objects.filter(project=project, id=issue_id)
.select_related("workspace", "project", "state")
.first()
)
return project, issue
def get_agent_header(request, key):
return request.headers.get(key, "").strip()
def get_agent_identity(request):
agent_id = get_agent_header(request, "X-NODEDC-Agent-Id")
owner_user_id = get_agent_header(request, "X-NODEDC-Agent-Owner-User-Id")
token_id = get_agent_header(request, "X-NODEDC-Agent-Token-Id")
if not agent_id or not owner_user_id or not token_id:
return None
return {
"agent_id": agent_id,
"owner_user_id": owner_user_id,
"token_id": token_id,
}
def ensure_agent_actor(request, workspace, project=None):
identity = get_agent_identity(request)
if identity is None:
return None
agent_id = identity["agent_id"]
short_id = agent_id.replace("-", "")[:12]
email = f"agent+{agent_id}@{AGENT_EMAIL_DOMAIN}"
username = f"nodedc_agent_{short_id}"
display_name = f"Codex Agent {short_id}"
user, _ = User.objects.get_or_create(
email=email,
defaults={
"username": username,
"display_name": display_name,
"first_name": "Codex",
"last_name": f"Agent {short_id}",
"is_bot": True,
"bot_type": AGENT_BOT_TYPE,
"is_active": True,
},
)
update_fields = []
if not user.is_bot:
user.is_bot = True
update_fields.append("is_bot")
if user.bot_type != AGENT_BOT_TYPE:
user.bot_type = AGENT_BOT_TYPE
update_fields.append("bot_type")
if not user.is_active:
user.is_active = True
update_fields.append("is_active")
if user.display_name != display_name:
user.display_name = display_name
update_fields.append("display_name")
if update_fields:
update_fields.append("updated_at")
user.save(update_fields=update_fields)
WorkspaceMember.objects.get_or_create(
workspace=workspace,
member=user,
defaults={
"role": 15,
"company_role": "codex-agent",
"is_active": True,
"is_banned": False,
},
)
if project is not None:
ProjectMember.objects.get_or_create(
workspace=workspace,
project=project,
member=user,
defaults={
"role": 15,
"is_active": True,
},
)
return user
def validate_internal_request(request):
if not is_internal_logout_request_authorized(request):
return unauthorized_response()
if get_agent_identity(request) is None:
return validation_error("missing_agent_headers", status=400)
return None
def html_from_text(value):
text = value.strip() if isinstance(value, str) else ""
return f"<p>{escape(text)}</p>" if text else "<p></p>"
def merge_structured_blocks(detail_layout, structured_blocks):
if structured_blocks is None:
return detail_layout or {}
if not isinstance(structured_blocks, list):
return None
updated_layout = dict(detail_layout or {})
updated_layout[NODEDC_STRUCTURED_BLOCKS_KEY] = structured_blocks
return updated_layout
def update_issue_labels(issue, label_ids):
labels = list(Label.objects.filter(id__in=label_ids, project=issue.project, deleted_at__isnull=True))
if len(labels) != len(set(label_ids)):
return False
IssueLabel.objects.filter(issue=issue, deleted_at__isnull=True).delete()
for label in labels:
IssueLabel.objects.create(issue=issue, label=label, project=issue.project, workspace=issue.workspace)
return True
def update_issue_assignees(issue, member_ids):
project_members = list(
ProjectMember.objects.filter(
project=issue.project,
member_id__in=member_ids,
is_active=True,
deleted_at__isnull=True,
).select_related("member")
)
if len(project_members) != len(set(member_ids)):
return False
IssueAssignee.objects.filter(issue=issue, deleted_at__isnull=True).delete()
for project_member in project_members:
IssueAssignee.objects.create(
issue=issue,
assignee=project_member.member,
project=issue.project,
workspace=issue.workspace,
)
return True
@method_decorator(csrf_exempt, name="dispatch")
class NodeDCAgentProjectResolveEndpoint(View):
def post(self, request):
error_response = validate_internal_request(request)
if error_response is not None:
return error_response
payload = parse_json_body(request)
if payload is None:
return invalid_json_response()
grants = payload.get("grants")
if not isinstance(grants, list):
return validation_error("grants_required")
project_filters = []
workspace_slugs = []
for grant in grants:
if not isinstance(grant, dict):
continue
workspace_slug = grant.get("workspace_slug")
project_id = grant.get("project_id")
if not isinstance(workspace_slug, str) or not workspace_slug:
continue
if isinstance(project_id, str) and project_id:
project_filters.append((workspace_slug, project_id))
else:
workspace_slugs.append(workspace_slug)
projects = []
if workspace_slugs:
projects.extend(
Project.objects.filter(
workspace__slug__in=workspace_slugs,
deleted_at__isnull=True,
archived_at__isnull=True,
).select_related("workspace")
)
for workspace_slug, project_id in project_filters:
project = resolve_project(project_id, workspace_slug)
if project is not None:
projects.append(project)
unique_projects = {str(project.id): project for project in projects}
return JsonResponse(
{
"ok": True,
"projects": [serialize_project(project) for project in unique_projects.values()],
}
)
@method_decorator(csrf_exempt, name="dispatch")
class NodeDCAgentProjectContextEndpoint(View):
def get(self, request, project_id):
error_response = validate_internal_request(request)
if error_response is not None:
return error_response
project = resolve_project(project_id, request.GET.get("workspace_slug"))
if project is None:
return validation_error("project_not_found", status=404)
states = State.objects.filter(project=project, deleted_at__isnull=True).order_by("sequence")
labels = Label.objects.filter(project=project, deleted_at__isnull=True).order_by("sort_order")
members = (
ProjectMember.objects.filter(project=project, is_active=True, deleted_at__isnull=True, member__is_bot=False)
.select_related("member")
.order_by("member__display_name")
)
return JsonResponse(
{
"ok": True,
"project": serialize_project(project),
"states": [serialize_state(state) for state in states],
"labels": [serialize_label(label) for label in labels],
"members": [serialize_member(member) for member in members],
}
)
@method_decorator(csrf_exempt, name="dispatch")
class NodeDCAgentIssueListEndpoint(View):
def get(self, request):
error_response = validate_internal_request(request)
if error_response is not None:
return error_response
project_id = request.GET.get("project_id")
if not project_id:
return validation_error("project_id_required")
project = resolve_project(project_id, request.GET.get("workspace_slug"))
if project is None:
return validation_error("project_not_found", status=404)
queryset = (
Issue.issue_objects.filter(project=project)
.select_related("workspace", "project", "state")
.order_by("-updated_at")
)
query = request.GET.get("query")
if query:
queryset = queryset.filter(name__icontains=query)
return JsonResponse({"ok": True, "issues": [serialize_issue(issue) for issue in queryset[:100]]})
def post(self, request):
error_response = validate_internal_request(request)
if error_response is not None:
return error_response
payload = parse_json_body(request)
if payload is None:
return invalid_json_response()
project = resolve_project(payload.get("project_id"), payload.get("workspace_slug"))
if project is None:
return validation_error("project_not_found", status=404)
title = payload.get("title")
if not isinstance(title, str) or not title.strip():
return validation_error("title_required")
priority = payload.get("priority") or "none"
if priority not in ALLOWED_PRIORITIES:
return validation_error("invalid_priority")
detail_layout = merge_structured_blocks({}, payload.get("structured_blocks"))
if detail_layout is None:
return validation_error("invalid_structured_blocks")
with transaction.atomic():
actor = ensure_agent_actor(request, project.workspace, project)
if actor is None:
return validation_error("missing_agent_headers")
issue = Issue(
project=project,
workspace=project.workspace,
name=title.strip(),
description_html=html_from_text(payload.get("description")),
priority=priority,
detail_layout=detail_layout,
external_source=AGENT_BOT_TYPE,
external_id=get_agent_header(request, "X-NODEDC-Agent-Id"),
)
issue.save(created_by_id=actor.id)
publish_issue_event_on_commit("issue.created", issue, actor_id=actor.id, changed_fields=payload.keys())
return JsonResponse({"ok": True, "issue": serialize_issue(issue)}, status=201)
@method_decorator(csrf_exempt, name="dispatch")
class NodeDCAgentIssueUpdateEndpoint(View):
def patch(self, request, issue_id):
error_response = validate_internal_request(request)
if error_response is not None:
return error_response
payload = parse_json_body(request)
if payload is None:
return invalid_json_response()
project, issue = resolve_issue(payload.get("project_id"), issue_id, payload.get("workspace_slug"))
if project is None:
return validation_error("project_not_found", status=404)
if issue is None:
return validation_error("issue_not_found", status=404)
detail_layout = (
merge_structured_blocks(issue.detail_layout, payload.get("structured_blocks"))
if "structured_blocks" in payload
else issue.detail_layout
)
if detail_layout is None:
return validation_error("invalid_structured_blocks")
update_fields = []
if isinstance(payload.get("title"), str) and payload["title"].strip():
issue.name = payload["title"].strip()
update_fields.append("name")
if "description" in payload:
issue.description_html = html_from_text(payload.get("description"))
update_fields.append("description_html")
if payload.get("priority") in ALLOWED_PRIORITIES:
issue.priority = payload["priority"]
update_fields.append("priority")
elif "priority" in payload:
return validation_error("invalid_priority")
if "structured_blocks" in payload:
issue.detail_layout = detail_layout
update_fields.append("detail_layout")
if update_fields:
actor = ensure_agent_actor(request, project.workspace, project)
if actor is None:
return validation_error("missing_agent_headers")
issue.updated_by = actor
update_fields.extend(["updated_by", "updated_at"])
issue.save(update_fields=update_fields, disable_auto_set_user=True)
publish_issue_event_on_commit("issue.updated", issue, actor_id=actor.id, changed_fields=update_fields)
return JsonResponse({"ok": True, "issue": serialize_issue(issue)})
@method_decorator(csrf_exempt, name="dispatch")
class NodeDCAgentIssueMoveEndpoint(View):
def post(self, request, issue_id):
error_response = validate_internal_request(request)
if error_response is not None:
return error_response
payload = parse_json_body(request)
if payload is None:
return invalid_json_response()
project, issue = resolve_issue(payload.get("project_id"), issue_id, payload.get("workspace_slug"))
if project is None:
return validation_error("project_not_found", status=404)
if issue is None:
return validation_error("issue_not_found", status=404)
state = State.objects.filter(project=project, id=payload.get("state_id"), deleted_at__isnull=True).first()
if state is None:
return validation_error("state_not_found", status=404)
actor = ensure_agent_actor(request, project.workspace, project)
if actor is None:
return validation_error("missing_agent_headers")
issue.state = state
issue.updated_by = actor
if state.group == "completed":
issue.completed_at = timezone.now()
else:
issue.completed_at = None
issue.save(update_fields=["state", "updated_by", "completed_at", "updated_at"], disable_auto_set_user=True)
publish_issue_event_on_commit("issue.updated", issue, actor_id=actor.id, changed_fields=["state"])
return JsonResponse({"ok": True, "issue": serialize_issue(issue)})
@method_decorator(csrf_exempt, name="dispatch")
class NodeDCAgentIssueCommentEndpoint(View):
def post(self, request, issue_id):
error_response = validate_internal_request(request)
if error_response is not None:
return error_response
payload = parse_json_body(request)
if payload is None:
return invalid_json_response()
project, issue = resolve_issue(payload.get("project_id"), issue_id, payload.get("workspace_slug"))
if project is None:
return validation_error("project_not_found", status=404)
if issue is None:
return validation_error("issue_not_found", status=404)
body = payload.get("body")
if not isinstance(body, str) or not body.strip():
return validation_error("body_required")
actor = ensure_agent_actor(request, project.workspace, project)
if actor is None:
return validation_error("missing_agent_headers")
comment = IssueComment(
project=project,
workspace=project.workspace,
issue=issue,
actor=actor,
comment_html=html_from_text(body),
comment_json={},
)
comment.save(created_by_id=actor.id)
return JsonResponse({"ok": True, "comment": serialize_comment(comment)}, status=201)
@method_decorator(csrf_exempt, name="dispatch")
class NodeDCAgentIssueLabelsEndpoint(View):
def put(self, request, issue_id):
error_response = validate_internal_request(request)
if error_response is not None:
return error_response
payload = parse_json_body(request)
if payload is None:
return invalid_json_response()
project, issue = resolve_issue(payload.get("project_id"), issue_id, payload.get("workspace_slug"))
if project is None:
return validation_error("project_not_found", status=404)
if issue is None:
return validation_error("issue_not_found", status=404)
label_ids = payload.get("label_ids")
if not isinstance(label_ids, list):
return validation_error("label_ids_required")
if not update_issue_labels(issue, label_ids):
return validation_error("label_not_found", status=404)
actor = ensure_agent_actor(request, project.workspace, project)
if actor is not None:
issue.updated_by = actor
issue.save(update_fields=["updated_by", "updated_at"], disable_auto_set_user=True)
publish_issue_event_on_commit("issue.updated", issue, actor_id=actor.id, changed_fields=["labels"])
return JsonResponse({"ok": True, "issue": serialize_issue(issue)})
@method_decorator(csrf_exempt, name="dispatch")
class NodeDCAgentIssueAssigneesEndpoint(View):
def put(self, request, issue_id):
error_response = validate_internal_request(request)
if error_response is not None:
return error_response
payload = parse_json_body(request)
if payload is None:
return invalid_json_response()
project, issue = resolve_issue(payload.get("project_id"), issue_id, payload.get("workspace_slug"))
if project is None:
return validation_error("project_not_found", status=404)
if issue is None:
return validation_error("issue_not_found", status=404)
member_ids = payload.get("member_ids")
if not isinstance(member_ids, list):
return validation_error("member_ids_required")
if not update_issue_assignees(issue, member_ids):
return validation_error("member_not_found", status=404)
actor = ensure_agent_actor(request, project.workspace, project)
if actor is not None:
issue.updated_by = actor
issue.save(update_fields=["updated_by", "updated_at"], disable_auto_set_user=True)
publish_issue_event_on_commit("issue.updated", issue, actor_id=actor.id, changed_fields=["assignees"])
return JsonResponse({"ok": True, "issue": serialize_issue(issue)})

View File

@ -15,6 +15,16 @@ from plane.authentication.views.nodedc_logout import (
NodeDCFrontChannelLogoutEndpoint,
NodeDCInternalSessionLogoutEndpoint,
)
from plane.authentication.views.nodedc_agent_adapter import (
NodeDCAgentIssueAssigneesEndpoint,
NodeDCAgentIssueCommentEndpoint,
NodeDCAgentIssueLabelsEndpoint,
NodeDCAgentIssueListEndpoint,
NodeDCAgentIssueMoveEndpoint,
NodeDCAgentIssueUpdateEndpoint,
NodeDCAgentProjectContextEndpoint,
NodeDCAgentProjectResolveEndpoint,
)
from plane.authentication.views.nodedc_workspace_adapter import (
NodeDCInternalProjectMembershipEnsureEndpoint,
NodeDCInternalProjectMembershipRemoveEndpoint,
@ -74,6 +84,46 @@ urlpatterns = [
NodeDCInternalProjectMembershipRemoveEndpoint.as_view(),
name="nodedc-internal-project-membership-remove",
),
path(
"api/internal/nodedc/agent/projects/resolve",
NodeDCAgentProjectResolveEndpoint.as_view(),
name="nodedc-agent-project-resolve",
),
path(
"api/internal/nodedc/agent/projects/<uuid:project_id>/context",
NodeDCAgentProjectContextEndpoint.as_view(),
name="nodedc-agent-project-context",
),
path(
"api/internal/nodedc/agent/issues",
NodeDCAgentIssueListEndpoint.as_view(),
name="nodedc-agent-issue-list",
),
path(
"api/internal/nodedc/agent/issues/<uuid:issue_id>",
NodeDCAgentIssueUpdateEndpoint.as_view(),
name="nodedc-agent-issue-update",
),
path(
"api/internal/nodedc/agent/issues/<uuid:issue_id>/move",
NodeDCAgentIssueMoveEndpoint.as_view(),
name="nodedc-agent-issue-move",
),
path(
"api/internal/nodedc/agent/issues/<uuid:issue_id>/comments",
NodeDCAgentIssueCommentEndpoint.as_view(),
name="nodedc-agent-issue-comment",
),
path(
"api/internal/nodedc/agent/issues/<uuid:issue_id>/labels",
NodeDCAgentIssueLabelsEndpoint.as_view(),
name="nodedc-agent-issue-labels",
),
path(
"api/internal/nodedc/agent/issues/<uuid:issue_id>/assignees",
NodeDCAgentIssueAssigneesEndpoint.as_view(),
name="nodedc-agent-issue-assignees",
),
path("api/", include("plane.app.urls")),
path("api/public/", include("plane.space.urls")),
path("api/instances/", include("plane.license.urls")),