2419 lines
94 KiB
Python
2419 lines
94 KiB
Python
"""OpenClaw 指挥层与操作执行服务。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
from datetime import datetime, timezone
|
||
import hashlib
|
||
import json
|
||
import re
|
||
import time
|
||
from types import SimpleNamespace
|
||
from typing import Any, Optional
|
||
from uuid import uuid4
|
||
|
||
from sqlalchemy import and_, func, or_, select
|
||
from sqlalchemy.exc import IntegrityError
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from ..config.ai_prompts import PROMPT_OPENCLAW_COMMAND_DISPATCH
|
||
from ..models import (
|
||
OperationExecutionLedger,
|
||
Project,
|
||
SkillTag,
|
||
Task,
|
||
TruthNode,
|
||
User,
|
||
UserProject,
|
||
UserSkillBinding,
|
||
UserWorkload,
|
||
)
|
||
from . import workload_service
|
||
from .adaptive_rag_service import AdaptiveRAGService
|
||
from .ai_orchestration_run_service import AIOrchestrationRunService
|
||
from .context_cache import context_cache_service
|
||
from .llm import llm_service
|
||
from .operation_catalog_service import operation_catalog_service
|
||
from .operation_log_service import log_operation
|
||
from .notification_service import NotificationService
|
||
from .skill_score_service import process_task_completion
|
||
from .sstc_service import ALIGNED_STATUSES
|
||
from .task_persistence import build_task_create_draft, persist_task
|
||
from ..utils.project_members import ensure_project_default_members
|
||
from ..utils.sequence import ensure_project_id_sequence
|
||
from ..utils.title import compact_title
|
||
|
||
from ..utils.timezone import now_cn_naive
|
||
SUPPORTED_COMMANDER_OPERATION_CODES = (
|
||
"project.create",
|
||
"project.update",
|
||
"project.archive",
|
||
"project.reopen",
|
||
"task.create",
|
||
"task.assign",
|
||
"task.prioritize",
|
||
"task.status_transition",
|
||
"notification.create",
|
||
"personnel.workload_update",
|
||
)
|
||
PERSISTENT_IDEMPOTENCY_OPERATION_CODES = {"notification.create", "project.create"}
|
||
SUPPORTED_PROJECT_STATUSES = {"active", "archived", "done"}
|
||
SUPPORTED_TASK_STATUSES = {"todo", "in_progress", "review", "done", "blocked", "closed"}
|
||
DONE_TASK_STATUSES = {"done", "completed", "closed"}
|
||
SOURCE_ONBOARDING_FALLBACK_RE = re.compile(
|
||
r"(新增|增加|添加|接入|配置|注册|建立).{0,10}(信号源|来源|监控源|github|飞书|webhook|rss|url|source|channel)",
|
||
re.IGNORECASE,
|
||
)
|
||
|
||
|
||
def _utc_now_naive() -> datetime:
|
||
return now_cn_naive()
|
||
|
||
|
||
class OpenClawCommanderService:
|
||
"""将自然语言指令转成可执行 operation plan。"""
|
||
|
||
def __init__(self, db: AsyncSession) -> None:
|
||
self.db = db
|
||
|
||
async def dispatch_command(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
operator_user: User,
|
||
project_id: int,
|
||
instruction: str,
|
||
dry_run: bool,
|
||
max_operations: int,
|
||
response_mode: str = "fast",
|
||
trace_id: Optional[str] = None,
|
||
persist_run: bool = True,
|
||
) -> dict[str, Any]:
|
||
project_row = await self._load_project(tenant_id=tenant_id, project_id=project_id)
|
||
if project_row is None:
|
||
raise ValueError("project not found")
|
||
project = self._build_project_snapshot(project_row)
|
||
|
||
members = await self._load_project_team_context(
|
||
tenant_id=tenant_id,
|
||
project_id=project_id,
|
||
owner_id=int(project.owner_id) if project.owner_id else None,
|
||
)
|
||
tasks, task_summary = await self._load_project_tasks(tenant_id=tenant_id, project_id=project_id, members=members)
|
||
planner_role = await llm_service._resolve_active_prompt_profile(self.db, tenant_id=tenant_id)
|
||
trace_id = str(trace_id or f"openclaw-{tenant_id}-{project_id}-{int(time.time() * 1000)}-{uuid4().hex[:8]}")
|
||
|
||
plan, planner_status, planner_diagnostics = await self._plan_operations(
|
||
tenant_id=tenant_id,
|
||
operator_user=operator_user,
|
||
project=project,
|
||
tasks=tasks,
|
||
task_summary=task_summary,
|
||
members=members,
|
||
instruction=instruction,
|
||
dry_run=dry_run,
|
||
max_operations=max_operations,
|
||
response_mode=response_mode,
|
||
project_directory=[self._serialize_project_directory_item(project_row)],
|
||
)
|
||
|
||
executor = OperationExecutorService(self.db)
|
||
execution = await executor.execute_operations(
|
||
tenant_id=tenant_id,
|
||
operator_user=operator_user,
|
||
project=project,
|
||
operations=plan.get("operations") or [],
|
||
dry_run=dry_run,
|
||
trace_id=trace_id,
|
||
)
|
||
openclaw_status = self._resolve_openclaw_status(execution=execution, dry_run=dry_run)
|
||
run_status = openclaw_status if openclaw_status in {"completed", "partial", "failed"} else "completed"
|
||
|
||
result = {
|
||
"trace_id": trace_id,
|
||
"response_mode": response_mode,
|
||
"run_status": run_status,
|
||
"planner_status": planner_status,
|
||
"planner_diagnostics": planner_diagnostics,
|
||
"openclaw_status": openclaw_status,
|
||
"project": {
|
||
"id": str(project.id),
|
||
"title": project.title or project.name,
|
||
"status": project.status,
|
||
},
|
||
"prompt_role": {
|
||
"role_key": str(planner_role.get("role_key") or ""),
|
||
"role_name": str(planner_role.get("role_name") or ""),
|
||
},
|
||
"instruction": instruction,
|
||
"dry_run": bool(dry_run),
|
||
"summary": str(plan.get("summary") or "").strip(),
|
||
"confidence": self._to_float(plan.get("confidence"), default=0.0),
|
||
"task_summary": task_summary,
|
||
"member_count": len(members),
|
||
"planned_operations": len(plan.get("operations") or []),
|
||
"execution": execution,
|
||
"pending_workers": [],
|
||
}
|
||
if persist_run:
|
||
run_service = AIOrchestrationRunService(self.db)
|
||
await run_service.create_run(
|
||
tenant_id=tenant_id,
|
||
trace_id=trace_id,
|
||
run_type="openclaw_dispatch",
|
||
project_id=int(project.id),
|
||
signal_id=None,
|
||
response_mode=response_mode,
|
||
status=run_status,
|
||
planner_status=planner_status,
|
||
openclaw_status=openclaw_status,
|
||
worker_total=0,
|
||
worker_completed=0,
|
||
worker_failed=0,
|
||
summary=str(result.get("summary") or "").strip(),
|
||
planner_diagnostics=planner_diagnostics,
|
||
result_payload=dict(result),
|
||
created_by_user_id=int(operator_user.id) if getattr(operator_user, "id", None) else None,
|
||
completed_at=_utc_now_naive() if run_status in {"completed", "partial", "failed"} else None,
|
||
)
|
||
await self.db.commit()
|
||
return result
|
||
|
||
async def dispatch_global_command(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
operator_user: User,
|
||
instruction: str,
|
||
dry_run: bool,
|
||
max_operations: int,
|
||
response_mode: str = "fast",
|
||
trace_id: Optional[str] = None,
|
||
persist_run: bool = False,
|
||
) -> dict[str, Any]:
|
||
project = self._build_global_project_snapshot()
|
||
project_directory = await self._load_project_directory(tenant_id=tenant_id)
|
||
planner_role = await llm_service._resolve_active_prompt_profile(self.db, tenant_id=tenant_id)
|
||
trace_id = str(trace_id or f"openclaw-{tenant_id}-global-{int(time.time() * 1000)}-{uuid4().hex[:8]}")
|
||
|
||
plan, planner_status, planner_diagnostics = await self._plan_operations(
|
||
tenant_id=tenant_id,
|
||
operator_user=operator_user,
|
||
project=project,
|
||
tasks=[],
|
||
task_summary={"total": 0, "active": 0, "done": 0, "blocked": 0, "sampled": 0},
|
||
members=[],
|
||
instruction=instruction,
|
||
dry_run=dry_run,
|
||
max_operations=max_operations,
|
||
response_mode=response_mode,
|
||
project_directory=project_directory,
|
||
)
|
||
|
||
executor = OperationExecutorService(self.db)
|
||
execution = await executor.execute_operations(
|
||
tenant_id=tenant_id,
|
||
operator_user=operator_user,
|
||
project=None,
|
||
operations=plan.get("operations") or [],
|
||
dry_run=dry_run,
|
||
trace_id=trace_id,
|
||
)
|
||
openclaw_status = self._resolve_openclaw_status(execution=execution, dry_run=dry_run)
|
||
run_status = openclaw_status if openclaw_status in {"completed", "partial", "failed"} else "completed"
|
||
project_ref = self._extract_project_reference_from_execution(execution) or {
|
||
"id": None,
|
||
"title": "",
|
||
"status": "",
|
||
}
|
||
result = {
|
||
"trace_id": trace_id,
|
||
"response_mode": response_mode,
|
||
"run_status": run_status,
|
||
"planner_status": planner_status,
|
||
"planner_diagnostics": planner_diagnostics,
|
||
"openclaw_status": openclaw_status,
|
||
"project": project_ref,
|
||
"prompt_role": {
|
||
"role_key": str(planner_role.get("role_key") or ""),
|
||
"role_name": str(planner_role.get("role_name") or ""),
|
||
},
|
||
"instruction": instruction,
|
||
"dry_run": bool(dry_run),
|
||
"summary": str(plan.get("summary") or "").strip(),
|
||
"confidence": self._to_float(plan.get("confidence"), default=0.0),
|
||
"task_summary": {"total": 0, "active": 0, "done": 0, "blocked": 0, "sampled": 0},
|
||
"member_count": 0,
|
||
"planned_operations": len(plan.get("operations") or []),
|
||
"execution": execution,
|
||
"pending_workers": [],
|
||
}
|
||
resolved_project_id = self._to_int(project_ref.get("id")) if isinstance(project_ref, dict) else None
|
||
if persist_run and resolved_project_id is not None and resolved_project_id > 0:
|
||
run_service = AIOrchestrationRunService(self.db)
|
||
await run_service.create_run(
|
||
tenant_id=tenant_id,
|
||
trace_id=trace_id,
|
||
run_type="openclaw_dispatch",
|
||
project_id=int(resolved_project_id),
|
||
signal_id=None,
|
||
response_mode=response_mode,
|
||
status=run_status,
|
||
planner_status=planner_status,
|
||
openclaw_status=openclaw_status,
|
||
worker_total=0,
|
||
worker_completed=0,
|
||
worker_failed=0,
|
||
summary=str(result.get("summary") or "").strip(),
|
||
planner_diagnostics=planner_diagnostics,
|
||
result_payload=dict(result),
|
||
created_by_user_id=int(operator_user.id) if getattr(operator_user, "id", None) else None,
|
||
completed_at=_utc_now_naive() if run_status in {"completed", "partial", "failed"} else None,
|
||
)
|
||
await self.db.commit()
|
||
return result
|
||
|
||
@staticmethod
|
||
def _build_project_snapshot(project: Project) -> SimpleNamespace:
|
||
return SimpleNamespace(
|
||
id=int(project.id),
|
||
owner_id=int(project.owner_id) if getattr(project, "owner_id", None) else None,
|
||
title=str(project.title or "").strip(),
|
||
name=str(project.name or "").strip(),
|
||
status=str(project.status or "").strip(),
|
||
assigned_role=str(project.assigned_role or "").strip(),
|
||
description=str(project.description or "").strip(),
|
||
)
|
||
|
||
@staticmethod
|
||
def _build_global_project_snapshot() -> SimpleNamespace:
|
||
return SimpleNamespace(
|
||
id=0,
|
||
owner_id=None,
|
||
title="",
|
||
name="",
|
||
status="",
|
||
assigned_role="",
|
||
description="",
|
||
)
|
||
|
||
@staticmethod
|
||
def _serialize_project_directory_item(project: Project) -> dict[str, Any]:
|
||
return {
|
||
"project_id": int(project.id),
|
||
"title": str(project.title or project.name or "").strip(),
|
||
"status": str(project.status or "").strip(),
|
||
}
|
||
|
||
async def _plan_operations(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
operator_user: User,
|
||
project: Project,
|
||
tasks: list[dict[str, Any]],
|
||
task_summary: dict[str, Any],
|
||
members: list[dict[str, Any]],
|
||
instruction: str,
|
||
dry_run: bool,
|
||
max_operations: int,
|
||
response_mode: str,
|
||
project_directory: Optional[list[dict[str, Any]]] = None,
|
||
) -> tuple[dict[str, Any], str, dict[str, Any]]:
|
||
command_payload = {
|
||
"instruction": instruction,
|
||
"operator_user_id": int(operator_user.id),
|
||
"operator_name": str(operator_user.name or operator_user.username or operator_user.id),
|
||
"operator_role": str(operator_user.role or ""),
|
||
"dry_run": bool(dry_run),
|
||
"max_operations": int(max_operations),
|
||
}
|
||
project_payload = {
|
||
"scope": "project" if self._to_int(project.id) and int(project.id) > 0 else "global",
|
||
"project_id": int(project.id) if self._to_int(project.id) and int(project.id) > 0 else None,
|
||
"title": str(project.title or project.name or "").strip(),
|
||
"description": str(project.description or "").strip()[:400],
|
||
"status": str(project.status or "").strip(),
|
||
"assigned_role": str(project.assigned_role or "").strip(),
|
||
"task_summary": task_summary,
|
||
"candidate_projects": project_directory or [],
|
||
}
|
||
operation_catalog_payload = [
|
||
{
|
||
"operation_code": item["operation_code"],
|
||
"description": item["description"],
|
||
"allowed_fields": sorted(
|
||
list(
|
||
(
|
||
(item.get("input_schema") or {}).get("properties") or {}
|
||
).keys()
|
||
)
|
||
),
|
||
}
|
||
for item in self._commander_operation_catalog()
|
||
]
|
||
|
||
prompt = PROMPT_OPENCLAW_COMMAND_DISPATCH.format(
|
||
command_json=json.dumps(command_payload, ensure_ascii=False),
|
||
project_json=json.dumps(project_payload, ensure_ascii=False),
|
||
tasks_json=json.dumps(tasks, ensure_ascii=False),
|
||
members_json=json.dumps(members, ensure_ascii=False),
|
||
operation_catalog_json=json.dumps(operation_catalog_payload, ensure_ascii=False),
|
||
max_operations=int(max_operations),
|
||
)
|
||
|
||
try:
|
||
extraction = await llm_service.generate_structured_object(
|
||
prompt,
|
||
db=self.db,
|
||
tenant_id=tenant_id,
|
||
function_name="openclaw_command_dispatch",
|
||
use_prompt_profile=False,
|
||
temperature=0.0,
|
||
max_tokens=520,
|
||
response_mode=response_mode,
|
||
)
|
||
diagnostics = self._build_planner_diagnostics(extraction)
|
||
if extraction.payload is None:
|
||
fallback = await self._build_fallback_plan(
|
||
tenant_id=tenant_id,
|
||
project_id=self._to_int(project.id),
|
||
instruction=instruction,
|
||
max_operations=max_operations,
|
||
error=str(extraction.error_code or "structured_unknown"),
|
||
project_directory=project_directory,
|
||
)
|
||
return fallback, "fallback", diagnostics
|
||
plan = extraction.payload
|
||
normalized_ops = self._normalize_operations(plan.get("operations"), max_operations=max_operations)
|
||
return (
|
||
{
|
||
"summary": str(plan.get("summary") or "").strip() or "OpenClaw 已生成操作计划。",
|
||
"confidence": self._to_float(plan.get("confidence"), default=0.0),
|
||
"operations": normalized_ops,
|
||
},
|
||
"planned",
|
||
diagnostics,
|
||
)
|
||
except Exception as exc:
|
||
fallback = await self._build_fallback_plan(
|
||
tenant_id=tenant_id,
|
||
project_id=self._to_int(project.id),
|
||
instruction=instruction,
|
||
max_operations=max_operations,
|
||
error=str(exc),
|
||
project_directory=project_directory,
|
||
)
|
||
return (
|
||
fallback,
|
||
"fallback",
|
||
{
|
||
"used_fallback": True,
|
||
"error_code": "structured_empty",
|
||
"source_field": None,
|
||
"provider": "",
|
||
"model": "",
|
||
},
|
||
)
|
||
|
||
async def _build_fallback_plan(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
project_id: Optional[int],
|
||
instruction: str,
|
||
max_operations: int,
|
||
error: str,
|
||
project_directory: Optional[list[dict[str, Any]]] = None,
|
||
) -> dict[str, Any]:
|
||
instruction_text = str(instruction or "").strip()
|
||
text = instruction_text.lower()
|
||
project_ops = await self._build_project_fallback_operations(
|
||
tenant_id=tenant_id,
|
||
project_id=project_id,
|
||
instruction=instruction_text,
|
||
max_operations=max_operations,
|
||
project_directory=project_directory,
|
||
)
|
||
if project_ops:
|
||
return {
|
||
"summary": "OpenClaw 规划失败,已回退到项目命令最小执行闭环。",
|
||
"confidence": 0.58,
|
||
"operations": project_ops,
|
||
}
|
||
if self._looks_like_signal_source_instruction(instruction_text):
|
||
if project_id is None:
|
||
return {
|
||
"summary": "当前信号源指令缺少项目上下文,系统未自动执行。",
|
||
"confidence": 0.0,
|
||
"operations": [],
|
||
}
|
||
operations = await self._build_signal_source_fallback_operations(
|
||
tenant_id=tenant_id,
|
||
project_id=int(project_id),
|
||
instruction=instruction_text,
|
||
max_operations=max_operations,
|
||
)
|
||
if operations:
|
||
return {
|
||
"summary": "OpenClaw 规划失败,已回退为信号源接入最小闭环。",
|
||
"confidence": 0.28,
|
||
"operations": operations,
|
||
}
|
||
return {
|
||
"summary": "OpenClaw 未生成可安全执行的信号源接入动作,等待人工补充上下文。",
|
||
"confidence": 0.0,
|
||
"operations": [],
|
||
}
|
||
|
||
unassigned_only = any(token in text for token in ("未分配", "没人负责", "unassigned"))
|
||
rebalance = any(token in text for token in ("重平衡", "优化负载", "重新分配", "调度"))
|
||
assign_like = rebalance or any(token in text for token in ("分配", "负责人", "指派", "接管"))
|
||
|
||
if not assign_like:
|
||
return {
|
||
"summary": "当前指令缺少足够上下文,系统未执行任何动作。",
|
||
"confidence": 0.0,
|
||
"operations": [],
|
||
}
|
||
if project_id is None:
|
||
return {
|
||
"summary": "当前指令缺少项目上下文,系统未自动执行分配动作。",
|
||
"confidence": 0.0,
|
||
"operations": [],
|
||
}
|
||
|
||
proposals = await workload_service.build_ai_optimize_preview(
|
||
self.db,
|
||
tenant_id=tenant_id,
|
||
project_id=int(project_id),
|
||
max_moves=max_operations,
|
||
unassigned_only=unassigned_only and not rebalance,
|
||
)
|
||
operations = [
|
||
{
|
||
"operation_code": "task.assign",
|
||
"payload": {
|
||
"task_id": int(item.get("task_id")),
|
||
"assignee_id": int(item.get("new_assignee_id")),
|
||
},
|
||
"reason": str(item.get("reason") or "fallback_workload_optimizer"),
|
||
}
|
||
for item in proposals[:max_operations]
|
||
if self._to_int(item.get("task_id")) and self._to_int(item.get("new_assignee_id"))
|
||
]
|
||
|
||
if operations:
|
||
return {
|
||
"summary": "OpenClaw 规划失败,已回退到内置负载优化器生成分配计划。",
|
||
"confidence": 0.35,
|
||
"operations": operations,
|
||
}
|
||
return {
|
||
"summary": "系统未生成可安全执行的分配动作,等待补充更明确的负责人或任务上下文。",
|
||
"confidence": 0.0,
|
||
"operations": [],
|
||
}
|
||
|
||
@staticmethod
|
||
def _build_planner_diagnostics(extraction: Any) -> dict[str, Any]:
|
||
return {
|
||
"used_fallback": extraction.error_code is not None,
|
||
"error_code": extraction.error_code,
|
||
"source_field": extraction.source_field or None,
|
||
"provider": str(extraction.provider or ""),
|
||
"model": str(extraction.model or ""),
|
||
}
|
||
|
||
@staticmethod
|
||
def _resolve_openclaw_status(*, execution: dict[str, Any], dry_run: bool) -> str:
|
||
if dry_run:
|
||
return "planned"
|
||
failed_count = int(execution.get("failed_count") or 0)
|
||
applied_count = int(execution.get("applied_count") or 0)
|
||
skipped_count = int(execution.get("skipped_count") or 0)
|
||
if failed_count > 0 and applied_count == 0 and skipped_count == 0:
|
||
return "failed"
|
||
if failed_count > 0:
|
||
return "partial"
|
||
return "completed"
|
||
|
||
@staticmethod
|
||
def _looks_like_signal_source_instruction(text: str) -> bool:
|
||
normalized = re.sub(r"\s+", "", str(text or ""))
|
||
if len(normalized) < 6:
|
||
return False
|
||
if "信号源" in normalized and any(token in normalized for token in ("新增", "增加", "添加", "接入", "配置", "注册", "建立")):
|
||
return True
|
||
return bool(SOURCE_ONBOARDING_FALLBACK_RE.search(normalized))
|
||
|
||
async def _build_signal_source_fallback_operations(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
project_id: int,
|
||
instruction: str,
|
||
max_operations: int,
|
||
) -> list[dict[str, Any]]:
|
||
if max_operations <= 0:
|
||
return []
|
||
project = await self._load_project(tenant_id=tenant_id, project_id=project_id)
|
||
if project is None:
|
||
return []
|
||
|
||
source_name = self._extract_signal_source_name(instruction)
|
||
charter = await self._load_latest_aligned_charter(tenant_id=tenant_id, project_id=project_id)
|
||
recipient_id = await self._resolve_signal_source_notification_recipient(
|
||
tenant_id=tenant_id,
|
||
owner_id=int(project.owner_id) if project.owner_id else None,
|
||
)
|
||
operations: list[dict[str, Any]] = []
|
||
|
||
if charter is not None and len(operations) < max_operations:
|
||
operations.append(
|
||
{
|
||
"operation_code": "task.create",
|
||
"payload": {
|
||
"project_id": int(project_id),
|
||
"title": f"接入{source_name}信号源"[:120],
|
||
"description": self._build_signal_source_task_description(
|
||
source_name=source_name,
|
||
instruction=instruction,
|
||
),
|
||
"priority": "high",
|
||
},
|
||
"reason": "fallback_signal_source_onboarding",
|
||
}
|
||
)
|
||
|
||
if recipient_id is not None and len(operations) < max_operations:
|
||
operations.append(
|
||
{
|
||
"operation_code": "notification.create",
|
||
"payload": {
|
||
"user_id": int(recipient_id),
|
||
"title": f"{source_name}信号源接入待确认"[:120],
|
||
"content": self._build_signal_source_notification_content(
|
||
source_name=source_name,
|
||
instruction=instruction,
|
||
charter_ready=charter is not None,
|
||
),
|
||
},
|
||
"reason": "fallback_signal_source_onboarding",
|
||
}
|
||
)
|
||
|
||
return operations[:max_operations]
|
||
|
||
@staticmethod
|
||
def _extract_signal_source_name(text: str) -> str:
|
||
cleaned = re.sub(r"\s+", " ", str(text or "")).strip()
|
||
if not cleaned:
|
||
return "未命名"
|
||
repo_match = re.search(r"\b([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)\b", cleaned)
|
||
if repo_match:
|
||
return repo_match.group(1)[:80]
|
||
quoted_match = re.search(r"[\"'“](.+?)[\"'”]", cleaned)
|
||
if quoted_match:
|
||
candidate = quoted_match.group(1).strip(" []【】()()::\"'“”")
|
||
if candidate:
|
||
return candidate[:80]
|
||
for pattern in (
|
||
r"(?:新增|增加|添加|接入|配置|注册|建立)(?:一个|1个|一条|一组|新的|一个新的)?\s*(.+?)(?:信号源|来源|监控源)",
|
||
r"(?:信号源|来源|监控源)(?:为|是|叫|命名为)?\s*(.+)",
|
||
):
|
||
match = re.search(pattern, cleaned, re.IGNORECASE)
|
||
if not match:
|
||
continue
|
||
candidate = re.sub(r"[,。,.;;::].*$", "", match.group(1)).strip(" []【】()()::\"'“”")
|
||
candidate = re.sub(
|
||
r"^(?:一个|1个|一条|一组|一个新的|新的|新增的)\s+",
|
||
"",
|
||
candidate,
|
||
flags=re.IGNORECASE,
|
||
)
|
||
candidate = re.sub(r"(?:信号源|来源|监控源|source|channel)$", "", candidate, flags=re.IGNORECASE).strip()
|
||
if candidate:
|
||
return candidate[:80]
|
||
lower = cleaned.lower()
|
||
if "github" in lower:
|
||
return "GitHub"
|
||
if "飞书" in cleaned or "lark" in lower:
|
||
return "Feishu"
|
||
if "webhook" in lower:
|
||
return "Webhook"
|
||
return cleaned[:80]
|
||
|
||
@staticmethod
|
||
def _build_signal_source_task_description(*, source_name: str, instruction: str) -> str:
|
||
return (
|
||
f"为信号源“{source_name}”完成最小接入任务。"
|
||
"包含接入配置、权限校验、数据字段确认和回流验证。"
|
||
f"原始指令:{instruction[:400]}"
|
||
)[:5000]
|
||
|
||
@staticmethod
|
||
def _build_signal_source_notification_content(*, source_name: str, instruction: str, charter_ready: bool) -> str:
|
||
suffix = (
|
||
"已自动生成接入任务,请确认负责人和接入边界。"
|
||
if charter_ready
|
||
else "当前项目尚未有 aligned charter,系统只先发出确认通知,避免无宪章自动建任务。"
|
||
)
|
||
return f"OpenClaw 已识别到新增信号源请求:{source_name}。{suffix} 原始指令:{instruction[:300]}".strip()
|
||
|
||
async def _resolve_signal_source_notification_recipient(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
owner_id: Optional[int],
|
||
) -> Optional[int]:
|
||
if owner_id is not None:
|
||
owner = (
|
||
await self.db.execute(
|
||
select(User.id).where(
|
||
and_(
|
||
User.tenant_id == tenant_id,
|
||
User.id == int(owner_id),
|
||
User.is_active.is_(True),
|
||
User.status == "active",
|
||
)
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
if owner is not None:
|
||
return int(owner)
|
||
|
||
manager = (
|
||
await self.db.execute(
|
||
select(User.id).where(
|
||
and_(
|
||
User.tenant_id == tenant_id,
|
||
User.is_active.is_(True),
|
||
User.status == "active",
|
||
User.role.in_(["BOSS", "ADMIN"]),
|
||
)
|
||
).order_by(User.id.asc()).limit(1)
|
||
)
|
||
).scalar_one_or_none()
|
||
return int(manager) if manager is not None else None
|
||
|
||
def _commander_operation_catalog(self) -> list[dict[str, Any]]:
|
||
items: list[dict[str, Any]] = []
|
||
for code in SUPPORTED_COMMANDER_OPERATION_CODES:
|
||
row = operation_catalog_service.get_operation(code)
|
||
if row is not None:
|
||
items.append(row)
|
||
return items
|
||
|
||
async def _load_project(self, *, tenant_id: int, project_id: int) -> Optional[Project]:
|
||
return (
|
||
await self.db.execute(
|
||
select(Project).where(
|
||
and_(
|
||
Project.tenant_id == tenant_id,
|
||
Project.id == project_id,
|
||
)
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
|
||
async def _load_project_directory(self, *, tenant_id: int, limit: int = 20) -> list[dict[str, Any]]:
|
||
rows = (
|
||
await self.db.execute(
|
||
select(Project).where(
|
||
and_(
|
||
Project.tenant_id == tenant_id,
|
||
Project.status != "inactive",
|
||
)
|
||
).order_by(Project.updated_at.desc(), Project.id.desc()).limit(limit)
|
||
)
|
||
).scalars().all()
|
||
return [self._serialize_project_directory_item(row) for row in rows]
|
||
|
||
async def _build_project_fallback_operations(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
project_id: Optional[int],
|
||
instruction: str,
|
||
max_operations: int,
|
||
project_directory: Optional[list[dict[str, Any]]],
|
||
) -> list[dict[str, Any]]:
|
||
if max_operations <= 0:
|
||
return []
|
||
title = self._extract_project_create_title(instruction)
|
||
if title:
|
||
payload: dict[str, Any] = {"title": title}
|
||
description = self._extract_project_create_description(instruction=instruction, title=title)
|
||
if description:
|
||
payload["description"] = description
|
||
return [
|
||
{
|
||
"operation_code": "project.create",
|
||
"payload": payload,
|
||
"reason": "explicit_project_create",
|
||
}
|
||
]
|
||
|
||
resolved_project_id = project_id or await self._resolve_project_id_from_instruction(
|
||
tenant_id=tenant_id,
|
||
instruction=instruction,
|
||
project_directory=project_directory,
|
||
)
|
||
if resolved_project_id is None:
|
||
return []
|
||
|
||
status_operation = self._build_project_status_fallback_operation(
|
||
project_id=int(resolved_project_id),
|
||
instruction=instruction,
|
||
)
|
||
return [status_operation] if status_operation else []
|
||
|
||
async def _resolve_project_id_from_instruction(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
instruction: str,
|
||
project_directory: Optional[list[dict[str, Any]]],
|
||
) -> Optional[int]:
|
||
directory = project_directory or await self._load_project_directory(tenant_id=tenant_id, limit=50)
|
||
normalized = re.sub(r"\s+", "", str(instruction or "")).lower()
|
||
for item in directory:
|
||
title = re.sub(r"\s+", "", str(item.get("title") or "")).lower()
|
||
project_id = self._to_int(item.get("project_id"))
|
||
if project_id is None or not title or len(title) < 2:
|
||
continue
|
||
if title in normalized:
|
||
return int(project_id)
|
||
return None
|
||
|
||
@staticmethod
|
||
def _normalize_project_title_candidate(candidate: str) -> str:
|
||
cleaned = re.sub(r"\s+", " ", str(candidate or "")).strip(" []【】()()::\"'“”")
|
||
if not cleaned:
|
||
return ""
|
||
normalized = re.sub(r"\s+", "", cleaned).lower()
|
||
generic_tokens = {
|
||
"项目",
|
||
"一个项目",
|
||
"新项目",
|
||
"项目列表",
|
||
"项目列表里面",
|
||
"项目库",
|
||
"项目台账",
|
||
"项目主数据",
|
||
"pmflow",
|
||
"pmflow项目列表",
|
||
}
|
||
if normalized in generic_tokens:
|
||
return ""
|
||
if normalized.startswith(("在项目列表", "到项目列表", "进项目列表", "在pmflow", "到pmflow", "进pmflow")):
|
||
return ""
|
||
return compact_title(cleaned, max_len=24)
|
||
|
||
@staticmethod
|
||
def _extract_project_create_title(text: str) -> str:
|
||
cleaned = re.sub(r"\s+", " ", str(text or "")).strip()
|
||
if not cleaned:
|
||
return ""
|
||
quoted_match = re.search(r"(?:项目|新项目)[^\"“”']{0,10}[\"“”']([^\"“”']{2,40})[\"“”']", cleaned)
|
||
if quoted_match:
|
||
return OpenClawCommanderService._normalize_project_title_candidate(quoted_match.group(1))
|
||
for pattern in (
|
||
r"(?:帮我|请|麻烦|给我)?(?:新增|增加|添加|创建|新建|建立|开个|开一个|建个|建一个|做个|做一个|加个|加一个)(?:一个|个)?项目(?:叫|名为|名称是|名称为|是|为)?[::,,\s]*(.+)",
|
||
r"(?:新增|增加|添加|创建|新建|建立)项目[::,,\s]*(.+)",
|
||
r"项目(?:叫|名为|名称是|名称为)[::,,\s]*(.+)",
|
||
):
|
||
match = re.search(pattern, cleaned, re.IGNORECASE)
|
||
if not match:
|
||
continue
|
||
candidate = re.sub(r"[,。,.;;!??].*$", "", match.group(1)).strip(" []【】()()::\"'“”")
|
||
candidate = re.sub(r"^(?:一个|个|新项目)\s*", "", candidate, flags=re.IGNORECASE)
|
||
if candidate:
|
||
return OpenClawCommanderService._normalize_project_title_candidate(candidate)
|
||
return ""
|
||
|
||
@staticmethod
|
||
def _extract_project_create_description(*, instruction: str, title: str) -> str:
|
||
cleaned = re.sub(r"\s+", " ", str(instruction or "")).strip()
|
||
if not cleaned:
|
||
return ""
|
||
if title:
|
||
cleaned = cleaned.replace(title, "", 1).strip(" ,,::")
|
||
cleaned = re.sub(
|
||
r"^(?:帮我|请|麻烦|给我)?(?:新增|增加|添加|创建|新建|建立|开个|开一个|建个|建一个|做个|做一个|加个|加一个)(?:一个|个)?项目(?:叫|名为|名称是|名称为|是|为)?",
|
||
"",
|
||
cleaned,
|
||
flags=re.IGNORECASE,
|
||
).strip(" ,,::")
|
||
if not cleaned:
|
||
return ""
|
||
return cleaned[:4000]
|
||
|
||
@staticmethod
|
||
def _build_project_status_fallback_operation(*, project_id: int, instruction: str) -> Optional[dict[str, Any]]:
|
||
normalized = re.sub(r"\s+", "", str(instruction or "")).lower()
|
||
if any(token in normalized for token in ("归档", "archive", "停用项目", "项目归档", "状态改成archived", "状态改为archived")):
|
||
return {
|
||
"operation_code": "project.archive",
|
||
"payload": {"project_id": int(project_id)},
|
||
"reason": "explicit_project_archive",
|
||
}
|
||
if any(
|
||
token in normalized
|
||
for token in (
|
||
"重新打开",
|
||
"重开",
|
||
"恢复active",
|
||
"恢复为active",
|
||
"恢复项目",
|
||
"激活项目",
|
||
"设为active",
|
||
"设成active",
|
||
"改成active",
|
||
"改为active",
|
||
"状态改成active",
|
||
"状态改为active",
|
||
"状态设为active",
|
||
)
|
||
):
|
||
return {
|
||
"operation_code": "project.reopen",
|
||
"payload": {"project_id": int(project_id)},
|
||
"reason": "explicit_project_reopen",
|
||
}
|
||
if any(
|
||
token in normalized
|
||
for token in (
|
||
"设为done",
|
||
"设成done",
|
||
"改成done",
|
||
"改为done",
|
||
"状态改成done",
|
||
"状态改为done",
|
||
"状态设为done",
|
||
"标记完成",
|
||
)
|
||
):
|
||
return {
|
||
"operation_code": "project.update",
|
||
"payload": {"project_id": int(project_id), "status": "done"},
|
||
"reason": "explicit_project_done",
|
||
}
|
||
return None
|
||
|
||
@staticmethod
|
||
def _extract_project_reference_from_execution(execution: dict[str, Any]) -> Optional[dict[str, Any]]:
|
||
results = execution.get("results") if isinstance(execution, dict) else None
|
||
if not isinstance(results, list):
|
||
return None
|
||
for row in results:
|
||
if not isinstance(row, dict):
|
||
continue
|
||
if str(row.get("entity_type") or "").strip() != "project":
|
||
continue
|
||
payload = row.get("result") if isinstance(row.get("result"), dict) else {}
|
||
project_id = payload.get("project_id") or row.get("entity_id")
|
||
title = payload.get("title") or payload.get("project_title") or ""
|
||
status = payload.get("status") or ""
|
||
if project_id is None:
|
||
continue
|
||
return {
|
||
"id": str(project_id),
|
||
"title": str(title or "").strip(),
|
||
"status": str(status or "").strip(),
|
||
}
|
||
return None
|
||
|
||
async def _load_latest_aligned_charter(self, *, tenant_id: int, project_id: int) -> Optional[TruthNode]:
|
||
return (
|
||
await self.db.execute(
|
||
select(TruthNode).where(
|
||
and_(
|
||
TruthNode.tenant_id == tenant_id,
|
||
TruthNode.project_id == int(project_id),
|
||
TruthNode.node_type == "charter",
|
||
TruthNode.status.in_(tuple(ALIGNED_STATUSES)),
|
||
)
|
||
).order_by(TruthNode.updated_at.desc(), TruthNode.id.desc())
|
||
)
|
||
).scalars().first()
|
||
|
||
async def _load_project_tasks(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
project_id: int,
|
||
members: list[dict[str, Any]],
|
||
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
||
task_rows = (
|
||
await self.db.execute(
|
||
select(Task).where(
|
||
and_(
|
||
Task.tenant_id == tenant_id,
|
||
Task.project_id == project_id,
|
||
)
|
||
).order_by(Task.id.asc())
|
||
)
|
||
).scalars().all()
|
||
|
||
member_name_map = {int(item["user_id"]): str(item.get("name") or "") for item in members if self._to_int(item.get("user_id"))}
|
||
active_tasks = [task for task in task_rows if str(task.status or "").strip().lower() not in DONE_TASK_STATUSES]
|
||
selected_tasks = active_tasks[:40] if active_tasks else task_rows[:20]
|
||
serialized = [
|
||
{
|
||
"task_id": int(task.id),
|
||
"title": str(task.title or "").strip(),
|
||
"status": str(task.status or "").strip().lower(),
|
||
"priority": str(task.priority or "").strip().lower(),
|
||
"assignee_id": int(task.assignee_id) if task.assignee_id else None,
|
||
"assignee_name": member_name_map.get(int(task.assignee_id), "") if task.assignee_id else "",
|
||
"required_skill_tag_ids": [int(item) for item in (task.required_skill_tag_ids or []) if self._to_int(item)],
|
||
"estimated_hours": float(task.estimated_hours or 0.0),
|
||
}
|
||
for task in selected_tasks
|
||
]
|
||
summary = {
|
||
"total": len(task_rows),
|
||
"active": len(active_tasks),
|
||
"done": len([task for task in task_rows if str(task.status or "").strip().lower() in DONE_TASK_STATUSES]),
|
||
"blocked": len([task for task in task_rows if str(task.status or "").strip().lower() == "blocked"]),
|
||
"sampled": len(serialized),
|
||
}
|
||
return serialized, summary
|
||
|
||
async def _load_project_team_context(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
project_id: int,
|
||
owner_id: Optional[int],
|
||
) -> list[dict[str, Any]]:
|
||
member_ids_result = await self.db.execute(
|
||
select(UserProject.user_id).where(
|
||
and_(
|
||
UserProject.tenant_id == tenant_id,
|
||
UserProject.project_id == project_id,
|
||
)
|
||
)
|
||
)
|
||
member_ids = {int(uid) for uid in member_ids_result.scalars().all()}
|
||
if owner_id:
|
||
member_ids.add(int(owner_id))
|
||
if not member_ids:
|
||
manager_result = await self.db.execute(
|
||
select(User.id).where(
|
||
and_(
|
||
User.tenant_id == tenant_id,
|
||
User.status == "active",
|
||
User.is_active.is_(True),
|
||
User.role.in_(["BOSS", "ADMIN"]),
|
||
)
|
||
).limit(5)
|
||
)
|
||
member_ids = {int(uid) for uid in manager_result.scalars().all()}
|
||
if not member_ids:
|
||
return []
|
||
|
||
users = (
|
||
await self.db.execute(
|
||
select(User).where(
|
||
and_(
|
||
User.tenant_id == tenant_id,
|
||
User.id.in_(sorted(member_ids)),
|
||
User.status == "active",
|
||
User.is_active.is_(True),
|
||
)
|
||
)
|
||
)
|
||
).scalars().all()
|
||
if not users:
|
||
return []
|
||
|
||
user_ids = [int(user.id) for user in users]
|
||
workload_rows = (
|
||
await self.db.execute(
|
||
select(UserWorkload).where(
|
||
and_(
|
||
UserWorkload.tenant_id == tenant_id,
|
||
UserWorkload.user_id.in_(user_ids),
|
||
)
|
||
)
|
||
)
|
||
).scalars().all()
|
||
workload_map = {int(item.user_id): item for item in workload_rows}
|
||
|
||
skill_rows = (
|
||
await self.db.execute(
|
||
select(
|
||
UserSkillBinding.user_id,
|
||
SkillTag.id,
|
||
SkillTag.name,
|
||
SkillTag.slug,
|
||
UserSkillBinding.proficiency,
|
||
)
|
||
.join(SkillTag, SkillTag.id == UserSkillBinding.skill_tag_id)
|
||
.where(
|
||
and_(
|
||
UserSkillBinding.tenant_id == tenant_id,
|
||
UserSkillBinding.user_id.in_(user_ids),
|
||
SkillTag.tenant_id == tenant_id,
|
||
)
|
||
)
|
||
)
|
||
).all()
|
||
|
||
skill_map: dict[int, list[dict[str, Any]]] = {}
|
||
for uid, sid, name, slug, proficiency in skill_rows:
|
||
skill_map.setdefault(int(uid), []).append(
|
||
{
|
||
"skill_id": int(sid),
|
||
"name": str(name or "").strip(),
|
||
"slug": str(slug or "").strip(),
|
||
"proficiency": int(proficiency or 0),
|
||
}
|
||
)
|
||
|
||
members: list[dict[str, Any]] = []
|
||
for user in users:
|
||
uid = int(user.id)
|
||
workload_row = workload_map.get(uid)
|
||
member_skills = skill_map.get(uid, [])
|
||
members.append(
|
||
{
|
||
"user_id": uid,
|
||
"name": str(user.name or user.username or uid),
|
||
"role": str(user.role or ""),
|
||
"active": bool(user.is_active and str(user.status or "").strip().lower() == "active"),
|
||
"workload_ratio": round(
|
||
float(workload_row.workload_ratio if workload_row is not None else float(user.workload or 0.0)),
|
||
2,
|
||
),
|
||
"current_assigned_hours": round(
|
||
float(workload_row.current_assigned_hours or 0.0) if workload_row else 0.0,
|
||
2,
|
||
),
|
||
"max_available_hours_per_week": round(
|
||
float(workload_row.max_available_hours_per_week or 40.0) if workload_row else 40.0,
|
||
2,
|
||
),
|
||
"skill_tag_ids": [int(item["skill_id"]) for item in member_skills],
|
||
"skill_names": [str(item["name"]) for item in member_skills],
|
||
}
|
||
)
|
||
members.sort(key=lambda item: (float(item.get("workload_ratio") or 0.0), int(item.get("user_id") or 0)))
|
||
return members
|
||
|
||
@classmethod
|
||
def _parse_plan_response(cls, raw_response: str) -> dict[str, Any]:
|
||
content = (raw_response or "").strip()
|
||
if not content:
|
||
raise ValueError("empty llm response")
|
||
if content.startswith("```"):
|
||
parts = content.split("```")
|
||
if len(parts) >= 2:
|
||
content = parts[1].strip()
|
||
if content.startswith("json"):
|
||
content = content[4:].strip()
|
||
try:
|
||
parsed = json.loads(content)
|
||
if isinstance(parsed, dict):
|
||
return parsed
|
||
except Exception:
|
||
pass
|
||
object_text = cls._extract_first_json_object(content)
|
||
if not object_text:
|
||
raise ValueError("llm json not found")
|
||
parsed = json.loads(object_text)
|
||
if not isinstance(parsed, dict):
|
||
raise ValueError("llm json is not object")
|
||
return parsed
|
||
|
||
@classmethod
|
||
def _extract_first_json_object(cls, text: str) -> Optional[str]:
|
||
raw = str(text or "")
|
||
start = raw.find("{")
|
||
if start < 0:
|
||
return None
|
||
depth = 0
|
||
in_str = False
|
||
escaped = False
|
||
for idx in range(start, len(raw)):
|
||
ch = raw[idx]
|
||
if in_str:
|
||
if escaped:
|
||
escaped = False
|
||
elif ch == "\\":
|
||
escaped = True
|
||
elif ch == '"':
|
||
in_str = False
|
||
continue
|
||
if ch == '"':
|
||
in_str = True
|
||
continue
|
||
if ch == "{":
|
||
depth += 1
|
||
elif ch == "}":
|
||
depth -= 1
|
||
if depth == 0:
|
||
return raw[start : idx + 1]
|
||
return None
|
||
|
||
def _normalize_operations(self, raw_operations: Any, *, max_operations: int) -> list[dict[str, Any]]:
|
||
if not isinstance(raw_operations, list):
|
||
return []
|
||
normalized: list[dict[str, Any]] = []
|
||
for item in raw_operations:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
operation_code = str(item.get("operation_code") or "").strip().lower()
|
||
payload = item.get("payload")
|
||
if not operation_code or not isinstance(payload, dict):
|
||
continue
|
||
normalized.append(
|
||
{
|
||
"operation_code": operation_code,
|
||
"payload": dict(payload),
|
||
"reason": str(item.get("reason") or "").strip()[:300],
|
||
}
|
||
)
|
||
if len(normalized) >= max_operations:
|
||
break
|
||
return normalized
|
||
|
||
@staticmethod
|
||
def _to_int(value: Any) -> Optional[int]:
|
||
if isinstance(value, bool):
|
||
return None
|
||
try:
|
||
return int(str(value).strip())
|
||
except Exception:
|
||
return None
|
||
|
||
@staticmethod
|
||
def _to_float(value: Any, *, default: float = 0.0) -> float:
|
||
try:
|
||
return float(value)
|
||
except Exception:
|
||
return float(default)
|
||
|
||
|
||
class OperationExecutorService:
|
||
"""对白名单操作做 schema 校验并执行。"""
|
||
|
||
def __init__(self, db: AsyncSession) -> None:
|
||
self.db = db
|
||
self._project_member_cache: dict[int, set[int]] = {}
|
||
|
||
async def execute_operations(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
operator_user: User,
|
||
project: Optional[Project | SimpleNamespace],
|
||
operations: list[dict[str, Any]],
|
||
dry_run: bool,
|
||
trace_id: str,
|
||
) -> dict[str, Any]:
|
||
seen_idempotency_keys: set[str] = set()
|
||
results: list[dict[str, Any]] = []
|
||
applied_count = 0
|
||
skipped_count = 0
|
||
failed_count = 0
|
||
context_project_id = self._project_context_id(project)
|
||
|
||
for index, item in enumerate(operations, start=1):
|
||
operation_code = str(item.get("operation_code") or "").strip().lower()
|
||
raw_payload = item.get("payload")
|
||
reason = str(item.get("reason") or "").strip()
|
||
persistent_claim: OperationExecutionLedger | None = None
|
||
|
||
try:
|
||
payload = self._validate_and_normalize_payload(operation_code=operation_code, payload=raw_payload)
|
||
idempotency_key = self._build_idempotency_key(
|
||
operation_code=operation_code,
|
||
payload=payload,
|
||
tenant_id=tenant_id,
|
||
project_id=context_project_id,
|
||
)
|
||
if idempotency_key in seen_idempotency_keys:
|
||
skipped_count += 1
|
||
results.append(
|
||
{
|
||
"index": index,
|
||
"operation_code": operation_code,
|
||
"payload": payload,
|
||
"idempotency_key": idempotency_key,
|
||
"status": "skipped",
|
||
"message": "duplicate_in_request",
|
||
}
|
||
)
|
||
continue
|
||
seen_idempotency_keys.add(idempotency_key)
|
||
|
||
if dry_run:
|
||
results.append(
|
||
{
|
||
"index": index,
|
||
"operation_code": operation_code,
|
||
"payload": payload,
|
||
"idempotency_key": idempotency_key,
|
||
"status": "planned",
|
||
"message": reason or "dry_run",
|
||
}
|
||
)
|
||
continue
|
||
|
||
persistent_claim, duplicate_result = await self._claim_persistent_idempotency(
|
||
tenant_id=tenant_id,
|
||
project_id=context_project_id if context_project_id > 0 else None,
|
||
operation_code=operation_code,
|
||
payload=payload,
|
||
idempotency_key=idempotency_key,
|
||
trace_id=trace_id,
|
||
)
|
||
if duplicate_result is not None:
|
||
skipped_count += 1
|
||
results.append(
|
||
{
|
||
"index": index,
|
||
"operation_code": operation_code,
|
||
"payload": payload,
|
||
"idempotency_key": idempotency_key,
|
||
**duplicate_result,
|
||
}
|
||
)
|
||
continue
|
||
|
||
result = await self._execute_single_operation(
|
||
tenant_id=tenant_id,
|
||
operator_user=operator_user,
|
||
project=project,
|
||
operation_code=operation_code,
|
||
payload=payload,
|
||
reason=reason,
|
||
trace_id=trace_id,
|
||
idempotency_key=idempotency_key,
|
||
persistent_claim=persistent_claim,
|
||
)
|
||
status = str(result.get("status") or "applied")
|
||
if status == "applied":
|
||
applied_count += 1
|
||
elif status == "skipped":
|
||
skipped_count += 1
|
||
results.append(
|
||
{
|
||
"index": index,
|
||
"operation_code": operation_code,
|
||
"payload": payload,
|
||
"idempotency_key": idempotency_key,
|
||
**result,
|
||
}
|
||
)
|
||
except Exception as exc:
|
||
await self._safe_rollback()
|
||
failed_count += 1
|
||
results.append(
|
||
{
|
||
"index": index,
|
||
"operation_code": operation_code,
|
||
"payload": raw_payload if isinstance(raw_payload, dict) else {},
|
||
"status": "failed",
|
||
"message": str(exc),
|
||
}
|
||
)
|
||
|
||
return {
|
||
"dry_run": bool(dry_run),
|
||
"requested_count": len(operations),
|
||
"applied_count": applied_count,
|
||
"skipped_count": skipped_count,
|
||
"failed_count": failed_count,
|
||
"results": results,
|
||
}
|
||
|
||
async def _claim_persistent_idempotency(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
project_id: Optional[int],
|
||
operation_code: str,
|
||
payload: dict[str, Any],
|
||
idempotency_key: str,
|
||
trace_id: str,
|
||
) -> tuple[Optional[OperationExecutionLedger], Optional[dict[str, Any]]]:
|
||
if operation_code not in PERSISTENT_IDEMPOTENCY_OPERATION_CODES:
|
||
return None, None
|
||
|
||
claim = OperationExecutionLedger(
|
||
tenant_id=tenant_id,
|
||
operation_code=operation_code,
|
||
project_id=int(project_id) if project_id is not None and int(project_id) > 0 else None,
|
||
idempotency_key=idempotency_key,
|
||
trace_id=trace_id,
|
||
status="processing",
|
||
payload=dict(payload),
|
||
)
|
||
self.db.add(claim)
|
||
try:
|
||
await self.db.flush()
|
||
return claim, None
|
||
except IntegrityError:
|
||
await self._safe_rollback()
|
||
existing = await self._load_execution_claim(
|
||
tenant_id=tenant_id,
|
||
operation_code=operation_code,
|
||
idempotency_key=idempotency_key,
|
||
)
|
||
if existing is None:
|
||
raise
|
||
if str(existing.status or "").strip().lower() == "failed":
|
||
await self.db.delete(existing)
|
||
await self.db.flush()
|
||
return await self._claim_persistent_idempotency(
|
||
tenant_id=tenant_id,
|
||
project_id=project_id,
|
||
operation_code=operation_code,
|
||
payload=payload,
|
||
idempotency_key=idempotency_key,
|
||
trace_id=trace_id,
|
||
)
|
||
return (
|
||
None,
|
||
{
|
||
"status": "skipped",
|
||
"message": "duplicate_persistent"
|
||
if str(existing.status or "").strip().lower() == "applied"
|
||
else "duplicate_in_progress",
|
||
"entity_type": str(existing.entity_type or "").strip() or None,
|
||
"entity_id": str(existing.entity_id or "").strip() or None,
|
||
"trace_id": str(existing.trace_id or "").strip() or None,
|
||
},
|
||
)
|
||
|
||
async def _load_execution_claim(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
operation_code: str,
|
||
idempotency_key: str,
|
||
) -> Optional[OperationExecutionLedger]:
|
||
return (
|
||
await self.db.execute(
|
||
select(OperationExecutionLedger).where(
|
||
and_(
|
||
OperationExecutionLedger.tenant_id == tenant_id,
|
||
OperationExecutionLedger.operation_code == operation_code,
|
||
OperationExecutionLedger.idempotency_key == idempotency_key,
|
||
)
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
|
||
async def _safe_rollback(self) -> None:
|
||
try:
|
||
await self.db.rollback()
|
||
except Exception:
|
||
return
|
||
|
||
def _validate_and_normalize_payload(self, *, operation_code: str, payload: Any) -> dict[str, Any]:
|
||
row = operation_catalog_service.get_operation(operation_code)
|
||
if row is None or operation_code not in SUPPORTED_COMMANDER_OPERATION_CODES:
|
||
raise ValueError(f"unsupported operation_code: {operation_code}")
|
||
if not isinstance(payload, dict):
|
||
raise ValueError(f"{operation_code} payload must be object")
|
||
|
||
input_schema = row.get("input_schema") if isinstance(row.get("input_schema"), dict) else {}
|
||
properties = input_schema.get("properties") if isinstance(input_schema.get("properties"), dict) else {}
|
||
required = [str(item) for item in (input_schema.get("required") or [])]
|
||
normalized: dict[str, Any] = {}
|
||
|
||
if input_schema.get("additionalProperties") is False:
|
||
extra_keys = sorted(set(payload.keys()) - set(properties.keys()))
|
||
if extra_keys:
|
||
raise ValueError(f"{operation_code} payload has unsupported fields: {', '.join(extra_keys)}")
|
||
|
||
for field, spec in properties.items():
|
||
if field not in payload:
|
||
continue
|
||
normalized[field] = self._coerce_value(field=field, value=payload[field], spec=spec if isinstance(spec, dict) else {})
|
||
|
||
missing = [field for field in required if field not in normalized]
|
||
if missing:
|
||
raise ValueError(f"{operation_code} missing required fields: {', '.join(missing)}")
|
||
return normalized
|
||
|
||
@classmethod
|
||
def _coerce_value(cls, *, field: str, value: Any, spec: dict[str, Any]) -> Any:
|
||
value_type = str(spec.get("type") or "").strip().lower()
|
||
if value_type == "integer":
|
||
parsed = cls._to_int(value)
|
||
if parsed is None:
|
||
raise ValueError(f"{field} must be integer")
|
||
return parsed
|
||
if value_type == "number":
|
||
if isinstance(value, bool):
|
||
raise ValueError(f"{field} must be number")
|
||
try:
|
||
parsed_float = float(value)
|
||
except Exception as exc:
|
||
raise ValueError(f"{field} must be number") from exc
|
||
minimum = spec.get("minimum")
|
||
maximum = spec.get("maximum")
|
||
if minimum is not None and parsed_float < float(minimum):
|
||
raise ValueError(f"{field} below minimum")
|
||
if maximum is not None and parsed_float > float(maximum):
|
||
raise ValueError(f"{field} above maximum")
|
||
return parsed_float
|
||
if value_type == "string":
|
||
text = str(value or "").strip()
|
||
if not text:
|
||
raise ValueError(f"{field} must be non-empty string")
|
||
enum_values = spec.get("enum")
|
||
if isinstance(enum_values, list) and enum_values:
|
||
enum_map = {str(item).strip().lower(): str(item) for item in enum_values}
|
||
matched = enum_map.get(text.lower())
|
||
if matched is None:
|
||
raise ValueError(f"{field} must be one of {', '.join(str(item) for item in enum_values)}")
|
||
text = matched
|
||
min_length = spec.get("minLength")
|
||
max_length = spec.get("maxLength")
|
||
if min_length is not None and len(text) < int(min_length):
|
||
raise ValueError(f"{field} shorter than minLength")
|
||
if max_length is not None and len(text) > int(max_length):
|
||
raise ValueError(f"{field} longer than maxLength")
|
||
return text
|
||
if value_type == "array":
|
||
if not isinstance(value, list):
|
||
raise ValueError(f"{field} must be array")
|
||
max_items = spec.get("maxItems")
|
||
if max_items is not None and len(value) > int(max_items):
|
||
raise ValueError(f"{field} too many items")
|
||
item_spec = spec.get("items") if isinstance(spec.get("items"), dict) else {}
|
||
return [
|
||
cls._coerce_value(field=field, value=item, spec=item_spec)
|
||
for item in value
|
||
]
|
||
return value
|
||
|
||
async def _execute_single_operation(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
operator_user: User,
|
||
project: Optional[Project | SimpleNamespace],
|
||
operation_code: str,
|
||
payload: dict[str, Any],
|
||
reason: str,
|
||
trace_id: str,
|
||
idempotency_key: str,
|
||
persistent_claim: Optional[OperationExecutionLedger],
|
||
) -> dict[str, Any]:
|
||
if operation_code == "project.create":
|
||
return await self._execute_project_create(
|
||
tenant_id=tenant_id,
|
||
operator_user=operator_user,
|
||
payload=payload,
|
||
reason=reason,
|
||
trace_id=trace_id,
|
||
)
|
||
if operation_code == "project.update":
|
||
return await self._execute_project_update(
|
||
tenant_id=tenant_id,
|
||
operator_user=operator_user,
|
||
project=project,
|
||
payload=payload,
|
||
reason=reason,
|
||
trace_id=trace_id,
|
||
)
|
||
if operation_code == "project.archive":
|
||
return await self._execute_project_archive(
|
||
tenant_id=tenant_id,
|
||
operator_user=operator_user,
|
||
project=project,
|
||
payload=payload,
|
||
reason=reason,
|
||
trace_id=trace_id,
|
||
)
|
||
if operation_code == "project.reopen":
|
||
return await self._execute_project_reopen(
|
||
tenant_id=tenant_id,
|
||
operator_user=operator_user,
|
||
project=project,
|
||
payload=payload,
|
||
reason=reason,
|
||
trace_id=trace_id,
|
||
)
|
||
if operation_code == "task.assign":
|
||
return await self._execute_task_assign(
|
||
tenant_id=tenant_id,
|
||
operator_user=operator_user,
|
||
project=project,
|
||
payload=payload,
|
||
reason=reason,
|
||
trace_id=trace_id,
|
||
)
|
||
if operation_code == "task.create":
|
||
return await self._execute_task_create(
|
||
tenant_id=tenant_id,
|
||
operator_user=operator_user,
|
||
project=project,
|
||
payload=payload,
|
||
reason=reason,
|
||
trace_id=trace_id,
|
||
)
|
||
if operation_code == "task.prioritize":
|
||
return await self._execute_task_prioritize(
|
||
tenant_id=tenant_id,
|
||
operator_user=operator_user,
|
||
project=project,
|
||
payload=payload,
|
||
reason=reason,
|
||
trace_id=trace_id,
|
||
)
|
||
if operation_code == "task.status_transition":
|
||
return await self._execute_task_status_transition(
|
||
tenant_id=tenant_id,
|
||
operator_user=operator_user,
|
||
project=project,
|
||
payload=payload,
|
||
reason=reason,
|
||
trace_id=trace_id,
|
||
)
|
||
if operation_code == "notification.create":
|
||
return await self._execute_notification_create(
|
||
tenant_id=tenant_id,
|
||
operator_user=operator_user,
|
||
project=project,
|
||
payload=payload,
|
||
reason=reason,
|
||
trace_id=trace_id,
|
||
idempotency_key=idempotency_key,
|
||
persistent_claim=persistent_claim,
|
||
)
|
||
if operation_code == "personnel.workload_update":
|
||
return await self._execute_personnel_workload_update(
|
||
tenant_id=tenant_id,
|
||
operator_user=operator_user,
|
||
project=project,
|
||
payload=payload,
|
||
reason=reason,
|
||
trace_id=trace_id,
|
||
)
|
||
raise ValueError(f"unsupported operation_code: {operation_code}")
|
||
|
||
async def _execute_project_create(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
operator_user: User,
|
||
payload: dict[str, Any],
|
||
reason: str,
|
||
trace_id: str,
|
||
) -> dict[str, Any]:
|
||
title = compact_title(str(payload["title"]), max_len=24)
|
||
if not title:
|
||
raise ValueError("project title required")
|
||
existing = await self._load_project_by_title(tenant_id=tenant_id, title=title)
|
||
if existing is not None:
|
||
return {
|
||
"status": "skipped",
|
||
"message": "project_already_exists",
|
||
"entity_type": "project",
|
||
"entity_id": str(existing.id),
|
||
"result": {
|
||
"project_id": int(existing.id),
|
||
"title": str(existing.title or existing.name or "").strip(),
|
||
"status": str(existing.status or "").strip(),
|
||
},
|
||
}
|
||
|
||
owner_id = self._to_int(payload.get("owner_id")) or int(operator_user.id)
|
||
if owner_id is not None:
|
||
owner = await self._load_active_user(tenant_id=tenant_id, user_id=int(owner_id))
|
||
if owner is None:
|
||
owner_id = int(operator_user.id)
|
||
|
||
await ensure_project_id_sequence(self.db)
|
||
project = Project(
|
||
tenant_id=tenant_id,
|
||
name=title,
|
||
title=title,
|
||
description=str(payload.get("description") or "").strip()[:4000] or None,
|
||
owner_id=int(owner_id) if owner_id is not None else None,
|
||
status="active",
|
||
domain_id=None,
|
||
assigned_role="ai_engineer",
|
||
signal_count=0,
|
||
)
|
||
self.db.add(project)
|
||
await self.db.flush()
|
||
await ensure_project_default_members(
|
||
self.db,
|
||
tenant_id=tenant_id,
|
||
project_id=int(project.id),
|
||
owner_id=int(project.owner_id) if project.owner_id is not None else None,
|
||
assigned_role=project.assigned_role,
|
||
)
|
||
await log_operation(
|
||
self.db,
|
||
tenant_id=tenant_id,
|
||
user_id=int(operator_user.id),
|
||
project_id=int(project.id),
|
||
entity_type="project",
|
||
entity_id=str(project.id),
|
||
action="project.create",
|
||
before_data=None,
|
||
after_data={
|
||
"title": project.title,
|
||
"status": project.status,
|
||
"owner_id": int(project.owner_id) if project.owner_id is not None else None,
|
||
},
|
||
remark=f"{trace_id}:{reason}"[:1000] if reason else trace_id,
|
||
)
|
||
await self.db.commit()
|
||
await context_cache_service.invalidate_system_context(tenant_id=tenant_id)
|
||
return {
|
||
"status": "applied",
|
||
"message": f"project created #{int(project.id)}",
|
||
"entity_type": "project",
|
||
"entity_id": str(project.id),
|
||
"result": {
|
||
"project_id": int(project.id),
|
||
"title": str(project.title or project.name or "").strip(),
|
||
"status": str(project.status or "").strip(),
|
||
},
|
||
}
|
||
|
||
async def _execute_project_update(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
operator_user: User,
|
||
project: Optional[Project | SimpleNamespace],
|
||
payload: dict[str, Any],
|
||
reason: str,
|
||
trace_id: str,
|
||
) -> dict[str, Any]:
|
||
target_project = await self._load_project_for_operation(
|
||
tenant_id=tenant_id,
|
||
project=project,
|
||
payload_project_id=int(payload["project_id"]),
|
||
operation_code="project.update",
|
||
)
|
||
before = {
|
||
"title": str(target_project.title or target_project.name or "").strip(),
|
||
"description": str(target_project.description or "").strip(),
|
||
"status": str(target_project.status or "").strip(),
|
||
}
|
||
changed = False
|
||
if "title" in payload:
|
||
next_title = compact_title(str(payload.get("title") or ""), max_len=24)
|
||
if not next_title:
|
||
raise ValueError("project title required")
|
||
if next_title != str(target_project.title or target_project.name or "").strip():
|
||
target_project.title = next_title
|
||
target_project.name = next_title
|
||
changed = True
|
||
if "description" in payload:
|
||
next_description = str(payload.get("description") or "").strip()[:4000] or None
|
||
if next_description != (target_project.description or None):
|
||
target_project.description = next_description
|
||
changed = True
|
||
if "status" in payload:
|
||
next_status = self._normalize_project_status(payload.get("status"))
|
||
if next_status != str(target_project.status or "").strip().lower():
|
||
target_project.status = next_status
|
||
changed = True
|
||
if not changed:
|
||
return {
|
||
"status": "skipped",
|
||
"message": "already_in_target_state",
|
||
"entity_type": "project",
|
||
"entity_id": str(target_project.id),
|
||
"result": {
|
||
"project_id": int(target_project.id),
|
||
"title": str(target_project.title or target_project.name or "").strip(),
|
||
"status": str(target_project.status or "").strip(),
|
||
},
|
||
}
|
||
|
||
await log_operation(
|
||
self.db,
|
||
tenant_id=tenant_id,
|
||
user_id=int(operator_user.id),
|
||
project_id=int(target_project.id),
|
||
entity_type="project",
|
||
entity_id=str(target_project.id),
|
||
action="project.update",
|
||
before_data=before,
|
||
after_data={
|
||
"title": str(target_project.title or target_project.name or "").strip(),
|
||
"description": str(target_project.description or "").strip(),
|
||
"status": str(target_project.status or "").strip(),
|
||
},
|
||
remark=f"{trace_id}:{reason}"[:1000] if reason else trace_id,
|
||
)
|
||
await self.db.commit()
|
||
await context_cache_service.invalidate_system_context(tenant_id=tenant_id)
|
||
return {
|
||
"status": "applied",
|
||
"message": f"project updated #{int(target_project.id)}",
|
||
"entity_type": "project",
|
||
"entity_id": str(target_project.id),
|
||
"result": {
|
||
"project_id": int(target_project.id),
|
||
"title": str(target_project.title or target_project.name or "").strip(),
|
||
"status": str(target_project.status or "").strip(),
|
||
},
|
||
}
|
||
|
||
async def _execute_project_archive(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
operator_user: User,
|
||
project: Optional[Project | SimpleNamespace],
|
||
payload: dict[str, Any],
|
||
reason: str,
|
||
trace_id: str,
|
||
) -> dict[str, Any]:
|
||
return await self._execute_project_update(
|
||
tenant_id=tenant_id,
|
||
operator_user=operator_user,
|
||
project=project,
|
||
payload={"project_id": int(payload["project_id"]), "status": "archived"},
|
||
reason=reason,
|
||
trace_id=trace_id,
|
||
)
|
||
|
||
async def _execute_project_reopen(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
operator_user: User,
|
||
project: Optional[Project | SimpleNamespace],
|
||
payload: dict[str, Any],
|
||
reason: str,
|
||
trace_id: str,
|
||
) -> dict[str, Any]:
|
||
return await self._execute_project_update(
|
||
tenant_id=tenant_id,
|
||
operator_user=operator_user,
|
||
project=project,
|
||
payload={"project_id": int(payload["project_id"]), "status": "active"},
|
||
reason=reason,
|
||
trace_id=trace_id,
|
||
)
|
||
|
||
async def _execute_task_create(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
operator_user: User,
|
||
project: Optional[Project | SimpleNamespace],
|
||
payload: dict[str, Any],
|
||
reason: str,
|
||
trace_id: str,
|
||
) -> dict[str, Any]:
|
||
project = self._require_project_context(project=project, operation_code="task.create")
|
||
project_id = int(payload["project_id"])
|
||
if project_id != int(project.id):
|
||
raise ValueError("task.create project_id mismatch")
|
||
|
||
charter = await self._load_latest_aligned_charter(tenant_id=tenant_id, project_id=project_id)
|
||
if charter is None:
|
||
raise ValueError("aligned charter required before task.create")
|
||
|
||
assignee_id = self._to_int(payload.get("assignee_id"))
|
||
touched_user_ids: list[int] = []
|
||
if assignee_id is not None:
|
||
assignee = await self._load_active_user(tenant_id=tenant_id, user_id=assignee_id)
|
||
if assignee is None:
|
||
raise ValueError("assignee not found or inactive")
|
||
member_ids = await self._get_project_member_ids(
|
||
tenant_id=tenant_id,
|
||
project_id=project_id,
|
||
owner_id=int(project.owner_id) if project.owner_id else None,
|
||
)
|
||
if int(assignee.id) not in member_ids:
|
||
raise ValueError("assignee is not project member")
|
||
touched_user_ids.append(int(assignee.id))
|
||
|
||
due_date = self._parse_due_date_value(payload.get("due_date"))
|
||
required_skill_tag_ids = self._safe_int_list(payload.get("required_skill_tag_ids"))
|
||
task = await persist_task(
|
||
self.db,
|
||
tenant_id=tenant_id,
|
||
truth_node_id=int(charter.id),
|
||
project_id=project_id,
|
||
draft=build_task_create_draft(
|
||
title=str(payload["title"]),
|
||
description=str(payload.get("description") or "").strip() or None,
|
||
assignee_id=assignee_id,
|
||
required_skill_tag_ids=required_skill_tag_ids,
|
||
priority=payload.get("priority"),
|
||
status="todo",
|
||
estimated_hours=self._to_float(payload.get("estimated_hours"), default=0.0) if payload.get("estimated_hours") is not None else None,
|
||
due_date=due_date,
|
||
),
|
||
actor_user_id=int(operator_user.id),
|
||
action="task.create",
|
||
remark=f"{trace_id}:{reason}"[:1000] if reason else trace_id,
|
||
commit=not touched_user_ids,
|
||
refresh_workload=bool(touched_user_ids),
|
||
)
|
||
|
||
await context_cache_service.invalidate_system_context(tenant_id=tenant_id)
|
||
for user_id in touched_user_ids:
|
||
await context_cache_service.invalidate_user_context(tenant_id=tenant_id, user_id=user_id)
|
||
self._sync_task_rag(task=task, tenant_id=tenant_id)
|
||
|
||
return {
|
||
"status": "applied",
|
||
"message": f"task created #{int(task.id)}",
|
||
"entity_type": "task",
|
||
"entity_id": str(task.id),
|
||
}
|
||
|
||
async def _execute_task_assign(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
operator_user: User,
|
||
project: Optional[Project | SimpleNamespace],
|
||
payload: dict[str, Any],
|
||
reason: str,
|
||
trace_id: str,
|
||
) -> dict[str, Any]:
|
||
project = self._require_project_context(project=project, operation_code="task.assign")
|
||
task = await self._load_task(tenant_id=tenant_id, task_id=int(payload["task_id"]))
|
||
if task is None or int(task.project_id) != int(project.id):
|
||
raise ValueError("task not found in target project")
|
||
|
||
assignee = await self._load_active_user(tenant_id=tenant_id, user_id=int(payload["assignee_id"]))
|
||
if assignee is None:
|
||
raise ValueError("assignee not found or inactive")
|
||
|
||
member_ids = await self._get_project_member_ids(tenant_id=tenant_id, project_id=int(project.id), owner_id=int(project.owner_id) if project.owner_id else None)
|
||
if int(assignee.id) not in member_ids:
|
||
raise ValueError("assignee is not project member")
|
||
|
||
current_assignee_id = int(task.assignee_id) if task.assignee_id else None
|
||
if current_assignee_id == int(assignee.id):
|
||
return {
|
||
"status": "skipped",
|
||
"message": "already_assigned",
|
||
"entity_type": "task",
|
||
"entity_id": str(task.id),
|
||
}
|
||
|
||
before = {
|
||
"assignee_id": current_assignee_id,
|
||
"status": task.status,
|
||
}
|
||
task.assignee_id = int(assignee.id)
|
||
await log_operation(
|
||
self.db,
|
||
tenant_id=tenant_id,
|
||
user_id=int(operator_user.id),
|
||
project_id=int(project.id),
|
||
entity_type="task",
|
||
entity_id=str(task.id),
|
||
action="task.assign",
|
||
before_data=before,
|
||
after_data={"assignee_id": int(assignee.id), "status": task.status},
|
||
remark=f"{trace_id}:{reason}"[:1000] if reason else trace_id,
|
||
)
|
||
|
||
touched_user_ids: list[int] = []
|
||
if current_assignee_id:
|
||
touched_user_ids.append(current_assignee_id)
|
||
touched_user_ids.append(int(assignee.id))
|
||
for uid in touched_user_ids:
|
||
await workload_service.refresh_user_assigned_hours_from_tasks(self.db, tenant_id=tenant_id, user_id=uid)
|
||
|
||
await context_cache_service.invalidate_system_context(tenant_id=tenant_id)
|
||
for uid in sorted(set(touched_user_ids)):
|
||
await context_cache_service.invalidate_user_context(tenant_id=tenant_id, user_id=uid)
|
||
self._sync_task_rag(task=task, tenant_id=tenant_id)
|
||
|
||
return {
|
||
"status": "applied",
|
||
"message": f"task assigned to user#{int(assignee.id)}",
|
||
"entity_type": "task",
|
||
"entity_id": str(task.id),
|
||
}
|
||
|
||
async def _execute_task_prioritize(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
operator_user: User,
|
||
project: Optional[Project | SimpleNamespace],
|
||
payload: dict[str, Any],
|
||
reason: str,
|
||
trace_id: str,
|
||
) -> dict[str, Any]:
|
||
project = self._require_project_context(project=project, operation_code="task.prioritize")
|
||
task = await self._load_task(tenant_id=tenant_id, task_id=int(payload["task_id"]))
|
||
if task is None or int(task.project_id) != int(project.id):
|
||
raise ValueError("task not found in target project")
|
||
|
||
before_priority = self._normalize_task_priority(task.priority)
|
||
next_priority = self._normalize_task_priority(payload.get("priority"))
|
||
if before_priority == next_priority:
|
||
return {
|
||
"status": "skipped",
|
||
"message": "already_in_target_priority",
|
||
"entity_type": "task",
|
||
"entity_id": str(task.id),
|
||
}
|
||
|
||
task.priority = next_priority
|
||
await log_operation(
|
||
self.db,
|
||
tenant_id=tenant_id,
|
||
user_id=int(operator_user.id),
|
||
project_id=int(project.id),
|
||
entity_type="task",
|
||
entity_id=str(task.id),
|
||
action="task.prioritize",
|
||
before_data={"priority": before_priority},
|
||
after_data={"priority": next_priority},
|
||
remark=f"{trace_id}:{reason}"[:1000] if reason else trace_id,
|
||
)
|
||
await self.db.commit()
|
||
await context_cache_service.invalidate_system_context(tenant_id=tenant_id)
|
||
if task.assignee_id:
|
||
await context_cache_service.invalidate_user_context(tenant_id=tenant_id, user_id=int(task.assignee_id))
|
||
self._sync_task_rag(task=task, tenant_id=tenant_id)
|
||
|
||
return {
|
||
"status": "applied",
|
||
"message": f"task priority updated to {next_priority}",
|
||
"entity_type": "task",
|
||
"entity_id": str(task.id),
|
||
}
|
||
|
||
async def _execute_task_status_transition(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
operator_user: User,
|
||
project: Optional[Project | SimpleNamespace],
|
||
payload: dict[str, Any],
|
||
reason: str,
|
||
trace_id: str,
|
||
) -> dict[str, Any]:
|
||
project = self._require_project_context(project=project, operation_code="task.status_transition")
|
||
task = await self._load_task(tenant_id=tenant_id, task_id=int(payload["task_id"]))
|
||
if task is None or int(task.project_id) != int(project.id):
|
||
raise ValueError("task not found in target project")
|
||
|
||
status = str(payload["status"]).strip().lower()
|
||
if status not in SUPPORTED_TASK_STATUSES:
|
||
raise ValueError("unsupported task status")
|
||
before_status = str(task.status or "").strip().lower()
|
||
if before_status == status:
|
||
return {
|
||
"status": "skipped",
|
||
"message": "already_in_target_status",
|
||
"entity_type": "task",
|
||
"entity_id": str(task.id),
|
||
}
|
||
|
||
task.status = status
|
||
if status in DONE_TASK_STATUSES:
|
||
task.completed_at = now_cn_naive()
|
||
|
||
await log_operation(
|
||
self.db,
|
||
tenant_id=tenant_id,
|
||
user_id=int(operator_user.id),
|
||
project_id=int(project.id),
|
||
entity_type="task",
|
||
entity_id=str(task.id),
|
||
action="task.status_transition",
|
||
before_data={"status": before_status},
|
||
after_data={"status": status},
|
||
remark=f"{trace_id}:{reason}"[:1000] if reason else trace_id,
|
||
)
|
||
|
||
if task.assignee_id:
|
||
await workload_service.refresh_user_assigned_hours_from_tasks(
|
||
self.db,
|
||
tenant_id=tenant_id,
|
||
user_id=int(task.assignee_id),
|
||
)
|
||
else:
|
||
await self.db.commit()
|
||
|
||
await context_cache_service.invalidate_system_context(tenant_id=tenant_id)
|
||
if task.assignee_id:
|
||
await context_cache_service.invalidate_user_context(tenant_id=tenant_id, user_id=int(task.assignee_id))
|
||
self._sync_task_rag(task=task, tenant_id=tenant_id)
|
||
|
||
if status in DONE_TASK_STATUSES and before_status not in DONE_TASK_STATUSES and task.assignee_id:
|
||
asyncio.create_task(
|
||
process_task_completion(
|
||
task_id=int(task.id),
|
||
tenant_id=tenant_id,
|
||
operator_user_id=int(operator_user.id),
|
||
)
|
||
)
|
||
|
||
return {
|
||
"status": "applied",
|
||
"message": f"task status updated to {status}",
|
||
"entity_type": "task",
|
||
"entity_id": str(task.id),
|
||
}
|
||
|
||
async def _execute_notification_create(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
operator_user: User,
|
||
project: Optional[Project | SimpleNamespace],
|
||
payload: dict[str, Any],
|
||
reason: str,
|
||
trace_id: str,
|
||
idempotency_key: str,
|
||
persistent_claim: Optional[OperationExecutionLedger],
|
||
) -> dict[str, Any]:
|
||
project = self._require_project_context(project=project, operation_code="notification.create")
|
||
user = await self._load_active_user(tenant_id=tenant_id, user_id=int(payload["user_id"]))
|
||
if user is None:
|
||
raise ValueError("notification target user not found or inactive")
|
||
|
||
notification = await NotificationService(self.db).create_notification(
|
||
tenant_id=tenant_id,
|
||
user_id=int(user.id),
|
||
category="system",
|
||
title=str(payload["title"]).strip(),
|
||
content=str(payload["content"]).strip(),
|
||
source="openclaw_commander",
|
||
entity_type="project",
|
||
entity_id=int(project.id),
|
||
target_url=f"/projects/{int(project.id)}/board",
|
||
commit=False,
|
||
)
|
||
if persistent_claim is not None:
|
||
persistent_claim.status = "applied"
|
||
persistent_claim.entity_type = "notification"
|
||
persistent_claim.entity_id = str(notification.id)
|
||
persistent_claim.result_payload = {
|
||
"status": "applied",
|
||
"notification_id": int(notification.id),
|
||
"idempotency_key": idempotency_key,
|
||
}
|
||
persistent_claim.error_message = None
|
||
await log_operation(
|
||
self.db,
|
||
tenant_id=tenant_id,
|
||
user_id=int(operator_user.id),
|
||
project_id=int(project.id),
|
||
entity_type="notification",
|
||
entity_id=str(notification.id),
|
||
action="notification.create",
|
||
before_data=None,
|
||
after_data={
|
||
"user_id": int(user.id),
|
||
"title": notification.title,
|
||
"source": notification.source,
|
||
},
|
||
remark=f"{trace_id}:{reason}"[:1000] if reason else trace_id,
|
||
)
|
||
await self.db.commit()
|
||
|
||
return {
|
||
"status": "applied",
|
||
"message": f"notification created for user#{int(user.id)}",
|
||
"entity_type": "notification",
|
||
"entity_id": str(notification.id),
|
||
}
|
||
|
||
async def _execute_personnel_workload_update(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
operator_user: User,
|
||
project: Optional[Project | SimpleNamespace],
|
||
payload: dict[str, Any],
|
||
reason: str,
|
||
trace_id: str,
|
||
) -> dict[str, Any]:
|
||
project = self._require_project_context(project=project, operation_code="personnel.workload_update")
|
||
user = await self._load_active_user(tenant_id=tenant_id, user_id=int(payload["user_id"]))
|
||
if user is None:
|
||
raise ValueError("target user not found or inactive")
|
||
|
||
member_ids = await self._get_project_member_ids(
|
||
tenant_id=tenant_id,
|
||
project_id=int(project.id),
|
||
owner_id=int(project.owner_id) if project.owner_id else None,
|
||
)
|
||
if int(user.id) not in member_ids:
|
||
raise ValueError("target user is not project member")
|
||
|
||
before_workload = await self._load_user_workload(tenant_id=tenant_id, user_id=int(user.id))
|
||
result = await workload_service.update_user_max_weekly_hours(
|
||
self.db,
|
||
tenant_id=tenant_id,
|
||
user_id=int(user.id),
|
||
max_weekly_hours=float(payload["max_weekly_hours"]),
|
||
)
|
||
if result is None:
|
||
raise ValueError("target user workload not found")
|
||
|
||
await log_operation(
|
||
self.db,
|
||
tenant_id=tenant_id,
|
||
user_id=int(operator_user.id),
|
||
project_id=int(project.id),
|
||
entity_type="user_workload",
|
||
entity_id=str(user.id),
|
||
action="personnel.workload_update",
|
||
before_data=before_workload,
|
||
after_data=result,
|
||
remark=f"{trace_id}:{reason}"[:1000] if reason else trace_id,
|
||
)
|
||
await self.db.commit()
|
||
await context_cache_service.invalidate_system_context(tenant_id=tenant_id)
|
||
await context_cache_service.invalidate_user_context(tenant_id=tenant_id, user_id=int(user.id))
|
||
|
||
return {
|
||
"status": "applied",
|
||
"message": f"user#{int(user.id)} max weekly hours updated",
|
||
"entity_type": "user_workload",
|
||
"entity_id": str(user.id),
|
||
}
|
||
|
||
async def _load_task(self, *, tenant_id: int, task_id: int) -> Optional[Task]:
|
||
return (
|
||
await self.db.execute(
|
||
select(Task).where(
|
||
and_(
|
||
Task.tenant_id == tenant_id,
|
||
Task.id == int(task_id),
|
||
)
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
|
||
async def _load_project_by_title(self, *, tenant_id: int, title: str) -> Optional[Project]:
|
||
normalized = compact_title(str(title or ""), max_len=24)
|
||
if not normalized:
|
||
return None
|
||
return (
|
||
await self.db.execute(
|
||
select(Project).where(
|
||
and_(
|
||
Project.tenant_id == tenant_id,
|
||
or_(
|
||
func.lower(Project.title) == normalized.lower(),
|
||
func.lower(Project.name) == normalized.lower(),
|
||
),
|
||
Project.status != "inactive",
|
||
)
|
||
).limit(1)
|
||
)
|
||
).scalar_one_or_none()
|
||
|
||
async def _load_project_for_operation(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
project: Optional[Project | SimpleNamespace],
|
||
payload_project_id: int,
|
||
operation_code: str,
|
||
) -> Project:
|
||
context_project_id = self._project_context_id(project)
|
||
if context_project_id > 0 and context_project_id != int(payload_project_id):
|
||
raise ValueError(f"{operation_code} project_id mismatch")
|
||
row = await self._load_project(tenant_id=tenant_id, project_id=int(payload_project_id))
|
||
if row is None:
|
||
raise ValueError("project not found")
|
||
return row
|
||
|
||
async def _load_active_user(self, *, tenant_id: int, user_id: int) -> Optional[User]:
|
||
return (
|
||
await self.db.execute(
|
||
select(User).where(
|
||
and_(
|
||
User.tenant_id == tenant_id,
|
||
User.id == int(user_id),
|
||
User.is_active.is_(True),
|
||
User.status == "active",
|
||
)
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
|
||
async def _load_user_workload(self, *, tenant_id: int, user_id: int) -> Optional[dict[str, Any]]:
|
||
row = (
|
||
await self.db.execute(
|
||
select(UserWorkload).where(
|
||
and_(
|
||
UserWorkload.tenant_id == tenant_id,
|
||
UserWorkload.user_id == int(user_id),
|
||
)
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
if row is None:
|
||
return None
|
||
return {
|
||
"user_id": str(user_id),
|
||
"current_assigned_hours": float(row.current_assigned_hours or 0.0),
|
||
"max_weekly_hours": float(row.max_available_hours_per_week or 40.0),
|
||
"workload_ratio": float(row.workload_ratio or 0.0),
|
||
}
|
||
|
||
async def _get_project_member_ids(self, *, tenant_id: int, project_id: int, owner_id: Optional[int]) -> set[int]:
|
||
cached = self._project_member_cache.get(int(project_id))
|
||
if cached is not None:
|
||
return set(cached)
|
||
rows = await self.db.execute(
|
||
select(UserProject.user_id).where(
|
||
and_(
|
||
UserProject.tenant_id == tenant_id,
|
||
UserProject.project_id == int(project_id),
|
||
)
|
||
)
|
||
)
|
||
member_ids = {int(uid) for uid in rows.scalars().all()}
|
||
if owner_id:
|
||
member_ids.add(int(owner_id))
|
||
self._project_member_cache[int(project_id)] = set(member_ids)
|
||
return member_ids
|
||
|
||
async def _load_latest_aligned_charter(self, *, tenant_id: int, project_id: int) -> Optional[TruthNode]:
|
||
return (
|
||
await self.db.execute(
|
||
select(TruthNode).where(
|
||
and_(
|
||
TruthNode.tenant_id == tenant_id,
|
||
TruthNode.project_id == int(project_id),
|
||
TruthNode.node_type == "charter",
|
||
TruthNode.status.in_(tuple(ALIGNED_STATUSES)),
|
||
)
|
||
).order_by(TruthNode.updated_at.desc(), TruthNode.id.desc())
|
||
)
|
||
).scalars().first()
|
||
|
||
@staticmethod
|
||
def _project_context_id(project: Optional[Project | SimpleNamespace]) -> int:
|
||
if project is None:
|
||
return 0
|
||
try:
|
||
return max(0, int(getattr(project, "id", 0) or 0))
|
||
except Exception:
|
||
return 0
|
||
|
||
def _require_project_context(
|
||
self,
|
||
*,
|
||
project: Optional[Project | SimpleNamespace],
|
||
operation_code: str,
|
||
) -> Project | SimpleNamespace:
|
||
if self._project_context_id(project) <= 0:
|
||
raise ValueError(f"{operation_code} requires project context")
|
||
if project is None:
|
||
raise ValueError(f"{operation_code} requires project context")
|
||
return project
|
||
|
||
def _build_idempotency_key(
|
||
self,
|
||
*,
|
||
operation_code: str,
|
||
payload: dict[str, Any],
|
||
tenant_id: int,
|
||
project_id: int,
|
||
) -> str:
|
||
row = operation_catalog_service.get_operation(operation_code)
|
||
fields = []
|
||
if isinstance(row, dict):
|
||
fields = [str(item) for item in ((row.get("idempotency_key") or {}).get("fields") or [])]
|
||
|
||
parts: dict[str, Any] = {}
|
||
for field in fields:
|
||
if field == "tenant_id":
|
||
parts[field] = int(tenant_id)
|
||
elif field == "project_id":
|
||
parts[field] = int(project_id)
|
||
elif field == "content_hash":
|
||
parts[field] = hashlib.sha256(str(payload.get("content") or "").strip().encode("utf-8")).hexdigest()[:16]
|
||
else:
|
||
parts[field] = payload.get(field)
|
||
encoded = json.dumps(parts, ensure_ascii=False, sort_keys=True, default=str)
|
||
return hashlib.sha256(f"{operation_code}|{encoded}".encode("utf-8")).hexdigest()[:24]
|
||
|
||
def _sync_task_rag(self, *, task: Task, tenant_id: int) -> None:
|
||
try:
|
||
content = (
|
||
(task.description or "")
|
||
+ f"\n状态: {task.status}"
|
||
+ f"\n负责人: {int(task.assignee_id) if task.assignee_id else '未分配'}"
|
||
)
|
||
asyncio.create_task(
|
||
AdaptiveRAGService.upsert_pmflow_record_async(
|
||
tenant_id=tenant_id,
|
||
record_type="task",
|
||
record_id=int(task.id),
|
||
project_id=int(task.project_id),
|
||
title=str(task.title or "任务"),
|
||
content=content,
|
||
)
|
||
)
|
||
except Exception:
|
||
return
|
||
|
||
@staticmethod
|
||
def _to_int(value: Any) -> Optional[int]:
|
||
if isinstance(value, bool):
|
||
return None
|
||
try:
|
||
return int(str(value).strip())
|
||
except Exception:
|
||
return None
|
||
|
||
@staticmethod
|
||
def _safe_int_list(value: Any) -> list[int]:
|
||
if not isinstance(value, list):
|
||
return []
|
||
items: list[int] = []
|
||
for raw in value:
|
||
try:
|
||
items.append(int(raw))
|
||
except Exception:
|
||
continue
|
||
return items
|
||
|
||
@classmethod
|
||
def _normalize_task_priority(cls, value: Any) -> str:
|
||
text = str(value or "medium").strip().lower()
|
||
if text in {"p0", "critical", "high"}:
|
||
return "high"
|
||
if text in {"p2", "p3", "low"}:
|
||
return "low"
|
||
return "medium"
|
||
|
||
@staticmethod
|
||
def _normalize_project_status(value: Any) -> str:
|
||
text = str(value or "active").strip().lower()
|
||
if text not in SUPPORTED_PROJECT_STATUSES:
|
||
raise ValueError("unsupported project status")
|
||
return text
|
||
|
||
@staticmethod
|
||
def _parse_due_date_value(value: Any) -> Optional[datetime]:
|
||
text = str(value or "").strip()
|
||
if not text:
|
||
return None
|
||
try:
|
||
return datetime.fromisoformat(text.replace("Z", "+00:00")).replace(tzinfo=None)
|
||
except ValueError as exc:
|
||
raise ValueError("invalid due_date") from exc
|