agent-Specialization/server/workflow_runtime_api.py
JOJO c49e4873e0 feat(runtime): 抽出 RuntimeService 公共任务入口,任务线程拆除 Flask 隐式上下文
Gateway 化阶段一/二实施(公共任务入口),计划与验收记录见 cache_research/gateway/phase12_implementation_plan.md;契约文档 docs/runtime_contract.md 为本地文档按仓库惯例不入库

- 新增 server/runtime/:RuntimeContext 三层模型(TrustedPrincipal/TaskParams/InternalDirectives)+ RuntimeService(create_task/cancel/guidance/queue/get_task_events)
- create_chat_task 强制显式 session_data(缺失 ValueError,i18n key tasks.missing_session_data);6 处调用点迁移至 runtime_service.create_task()(tasks/api、api_v1、workflow_runtime_api x2、chat_flow_task_main x2),门闸 token 移交与 notice 互斥豁免语义保留
- _run_chat_task 拆除 test_request_context 桥,任务线程全程 RuntimeIdentity 驱动
- 审批超时经 terminal._approval_timeout_seconds 透传至工具循环 4 个 _wait_* 调用点(默认 3600s 语义不变)
- 附带:config/_load_dotenv 容忍沙箱禁读 .env;AGENTS.md 同步结构

测试:改造相关 24/24 全绿(冒烟 6 + runtime_service 10 + model_persistence 4 + identity 路由 4);真实环境验证已由用户人工完成
2026-09-07 16:16:12 +08:00

306 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""工作流运行时 REST API区别于 workflow_page.py 的编辑器 CRUD
- `POST /api/workflow/activate` slash 菜单激活。仅智能体空闲(主任务门闸未被持有)
可用;激活即快照定义,随后以一条 user 提示消息派发一轮工作(门闸 token 随任务移交)。
- `POST /api/workflow/deactivate` :用户主动退出。柔性原则——只摘牌 + 通知,绝不掐断
正在运行的智能体:忙时通知入池(由工具循环末尾 inline 消费),闲时直接派发一轮任务。
- `GET /api/workflow/status` :当前对话工作流进度快照(前端刷新/恢复用)。
"""
from __future__ import annotations
from datetime import datetime
from flask import Blueprint, jsonify, request
from server.auth_helpers import api_login_required
from server.context import make_terminal_callback, with_terminal
from server.runtime import InternalDirectives, RuntimeContext, TaskParams, runtime_service
from modules.i18n import tr
workflow_runtime_bp = Blueprint("workflow_runtime", __name__)
def _broadcast_progress(data_dir, conversation_id: str, username: str) -> None:
"""向该用户房间广播一次工作流进度快照(前端按 conversation_id 过滤)。"""
try:
from modules.workflow_state_manager import WorkflowStateManager
from server.workflow_flow import emit_workflow_progress
wsm = WorkflowStateManager(data_dir, conversation_id)
emit_workflow_progress(
wsm=wsm,
sender=make_terminal_callback(username),
conversation_id=conversation_id,
)
except Exception:
pass
@workflow_runtime_bp.route("/api/workflow/activate", methods=["POST"])
@api_login_required
@with_terminal
def api_activate_workflow(terminal, workspace, username):
data = request.get_json(silent=True) or {}
name = str(data.get("name") or "").strip()
conversation_id = str(data.get("conversation_id") or "").strip() or None
if not name:
return jsonify({"error": tr("workflow_api.missing_workflow_name")}), 400
from server.main_task_gate import release_main_task_gate, try_acquire_main_task_gate
from server.workflow_flow import activate_workflow
created_new = False
if not conversation_id:
# 空对话激活(定稿语义:激活条件是「智能体空闲」而非「对话非空」):
# 自动创建对话,对齐 tasks/api.py 未带 conversation_id 时的补建先例。
cm = getattr(getattr(terminal, "context_manager", None), "conversation_manager", None)
if cm is None:
return jsonify({"error": tr("workflow_api.conversation_manager_unavailable")}), 500
_run_mode = str(getattr(terminal, "run_mode", "") or "fast")
if _run_mode not in {"fast", "thinking", "deep"}:
_run_mode = "fast"
_thinking = getattr(terminal, "thinking_mode", None)
_thinking = bool(_thinking) if _thinking is not None else (_run_mode != "fast")
_svc_cid_before = getattr(cm, "current_conversation_id", None)
# 完整对齐 /api/conversations 正常创建路径的模式继承(否则任务 terminal 恢复时
# work_mode 回退个性化默认 plan、reasoning_effort 丢失,导致 plan 抑制执行 + 思考退化):
# work_mode/权限/执行环境沿用 terminal 当前值effort 优先级 terminal 当前档 > 个性化默认。
try:
from modules.personalization_manager import load_personalization_config as _load_prefs
_prefs = _load_prefs(workspace.data_dir) or {}
except Exception:
_prefs = {}
_work_mode = "plan"
try:
_work_mode = str(terminal.get_work_mode() or "plan") if hasattr(terminal, "get_work_mode") else "plan"
except Exception:
pass
if _work_mode not in ("plan", "ask", "execute"):
_work_mode = "plan"
_permission_mode = getattr(terminal, "get_permission_mode", lambda: "unrestricted")()
if _permission_mode not in ("readonly", "approval", "auto_approval", "unrestricted"):
_permission_mode = "unrestricted"
# plan 档不变量:权限必须只读,同时记录进入前权限供离开 plan 恢复
_pre_plan_permission = None
if _work_mode == "plan":
if _permission_mode != "readonly":
_pre_plan_permission = _permission_mode
_permission_mode = "readonly"
_reasoning_effort = getattr(terminal, "reasoning_effort", None)
if not (isinstance(_reasoning_effort, str) and _reasoning_effort.strip()):
_reasoning_effort = (_prefs.get("default_reasoning_effort") or None)
_meta_overrides = {
"work_mode": _work_mode,
"permission_mode": _permission_mode,
"execution_mode": getattr(terminal, "get_execution_mode", lambda: "sandbox")(),
"pre_plan_permission_mode": _pre_plan_permission,
"reasoning_effort": _reasoning_effort,
}
try:
conversation_id = cm.create_conversation(
project_path=str(getattr(workspace, "project_path", "") or "."),
run_mode=_run_mode,
thinking_mode=_thinking,
model_key=getattr(terminal, "model_key", None),
metadata_overrides=_meta_overrides,
)
except Exception as exc: # noqa: BLE001
return jsonify({"error": tr("workflow_api.create_conversation_failed", error=exc)}), 500
created_new = True
# 恢复服务 terminal 的对话指针,避免污染共享服务 terminal 的上下文状态
try:
cm.current_conversation_id = _svc_cid_before
except Exception:
pass
# 仅智能体空闲(主任务门闸未被持有)可激活;新建对话不可能有并发任务,跳过预占。
gate_token = None
if not created_new:
gate_token = try_acquire_main_task_gate(terminal)
if gate_token is None:
return jsonify({"error": tr("workflow_api.busy_cannot_activate")}), 409
try:
# 激活提示消息将追加到历史末尾,阶段消息游标取当前长度 + 1。
# 新建对话:激活消息是第 1 条,游标固定为 1。
# 服务实例(工作区级)不挂载历史:非新建时游标以磁盘消息数为准。
if created_new:
msg_index = 1
else:
try:
cm_router = getattr(terminal, "context_manager", None)
wf_manager = cm_router._get_conversation_manager_for_id(conversation_id) if cm_router else None
conv_data = wf_manager.load_conversation(conversation_id) if wf_manager else None
msg_index = len((conv_data or {}).get("messages") or []) + 1
except Exception:
msg_index = 0
result = activate_workflow(
data_dir=workspace.data_dir,
conversation_id=conversation_id,
name=name,
msg_index=msg_index,
)
if not result.get("success"):
release_main_task_gate(terminal, gate_token)
return jsonify({"error": result.get("error")}), 400
# 幂等:同工作流已激活 → 只广播进度,不重复派发
if result.get("already"):
if gate_token:
release_main_task_gate(terminal, gate_token)
_broadcast_progress(workspace.data_dir, conversation_id, username)
return jsonify({
"success": True,
"already": True,
"conversation_id": conversation_id,
"snapshot": result["manager"].progress_snapshot(),
})
activation_text = str(result.get("text") or "")
prompt = (
"工作流已激活,请立即开始按流程执行。"
"完成当前步骤后调用 report_workflow_stage(summary) 汇报。\n\n"
f"{activation_text}"
)
# 公共任务入口:内部调用方,从 terminal 构造可信上下文;
# 门闸 token 与 user 消息事件回放走 InternalDirectives客户端不可提交
ctx = RuntimeContext.from_terminal(
terminal,
workspace,
username,
params=TaskParams(
message=prompt,
conversation_id=conversation_id,
message_source="workflow",
),
directives=InternalDirectives(
# 门闸 token 随任务移交,由任务线程认领(见 process_message_task
main_task_gate_token=gate_token,
# 让任务事件流携带该 user 消息,保证轮询客户端/刷新后可见
auto_user_message_event=True,
auto_user_message_payload={
"message_source": "workflow",
"workflow_activate": True,
"visibility": "chat",
"starts_work": True,
"timestamp": datetime.now().isoformat(),
},
),
)
try:
rec = runtime_service.create_task(ctx)
except RuntimeError as exc:
release_main_task_gate(terminal, gate_token)
return jsonify({"error": str(exc)}), 409
_broadcast_progress(workspace.data_dir, conversation_id, username)
return jsonify({
"success": True,
"task_id": rec.task_id,
"conversation_id": conversation_id,
# 快照随响应返回:前端立即写入 store不等任务事件流的首个进度事件
"snapshot": result["manager"].progress_snapshot(),
})
except Exception as exc: # noqa: BLE001
release_main_task_gate(terminal, gate_token)
return jsonify({"error": tr("workflow_api.activate_failed", error=exc)}), 500
@workflow_runtime_bp.route("/api/workflow/deactivate", methods=["POST"])
@api_login_required
@with_terminal
def api_deactivate_workflow(terminal, workspace, username):
data = request.get_json(silent=True) or {}
conversation_id = str(data.get("conversation_id") or "").strip()
if not conversation_id:
return jsonify({"error": tr("workflow_api.missing_conversation_id")}), 400
from server.main_task_gate import release_main_task_gate, try_acquire_main_task_gate
from server.workflow_flow import deactivate_workflow_by_user
result = deactivate_workflow_by_user(
data_dir=workspace.data_dir,
conversation_id=conversation_id,
)
if not result.get("success"):
return jsonify({"error": result.get("error")}), 400
_broadcast_progress(workspace.data_dir, conversation_id, username)
# 柔性通知已入池:闲时直接取出并派发一轮任务;忙时留池,
# 由运行中的工具循环末尾 process_workflow_updates inline 消费。
dispatched = False
gate_token = try_acquire_main_task_gate(terminal)
if gate_token is not None:
wsm = None
notices = None
restored = False
try:
from modules.workflow_state_manager import WorkflowStateManager
wsm = WorkflowStateManager(workspace.data_dir, conversation_id)
notices = wsm.poll_notices()
notice_text = "\n\n".join(
str(n.get("message") or "").strip() for n in notices if str(n.get("message") or "").strip()
)
if notice_text:
ctx = RuntimeContext.from_terminal(
terminal,
workspace,
username,
params=TaskParams(
message=notice_text,
conversation_id=conversation_id,
message_source="workflow",
),
directives=InternalDirectives(
main_task_gate_token=gate_token,
auto_user_message_event=True,
auto_user_message_payload={
"message_source": "workflow",
"workflow_notice": True,
"visibility": "chat",
"starts_work": True,
"timestamp": datetime.now().isoformat(),
},
),
)
runtime_service.create_task(ctx)
dispatched = True
except Exception: # noqa: BLE001
# 任何失败:通知放回池(等轮询器/工具循环消费),不静默丢失
if wsm is not None and notices and not restored:
try:
wsm.restore_notices(notices)
restored = True
except Exception: # noqa: BLE001
pass
finally:
if not dispatched:
release_main_task_gate(terminal, gate_token)
return jsonify({
"success": True,
"workflow_name": result.get("workflow_name"),
"dispatched": dispatched,
# 摘牌后快照active=False前端据此关闭窗口
"snapshot": {"active": False},
})
@workflow_runtime_bp.route("/api/workflow/status", methods=["GET"])
@api_login_required
@with_terminal
def api_workflow_status(terminal, workspace, username):
conversation_id = str(request.args.get("conversation_id") or "").strip()
if not conversation_id:
return jsonify({"error": tr("workflow_api.missing_conversation_id")}), 400
try:
from modules.workflow_state_manager import WorkflowStateManager
wsm = WorkflowStateManager(workspace.data_dir, conversation_id)
return jsonify({"success": True, "snapshot": wsm.progress_snapshot()})
except Exception as exc: # noqa: BLE001
return jsonify({"error": tr("workflow_api.status_read_failed", error=exc)}), 500