@josephyan/qingflow-cli 0.2.0-beta.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +30 -0
  2. package/docs/local-agent-install.md +235 -0
  3. package/entry_point.py +13 -0
  4. package/npm/bin/qingflow.mjs +5 -0
  5. package/npm/lib/runtime.mjs +204 -0
  6. package/npm/scripts/postinstall.mjs +16 -0
  7. package/package.json +34 -0
  8. package/pyproject.toml +67 -0
  9. package/qingflow +15 -0
  10. package/src/qingflow_mcp/__init__.py +5 -0
  11. package/src/qingflow_mcp/__main__.py +5 -0
  12. package/src/qingflow_mcp/backend_client.py +547 -0
  13. package/src/qingflow_mcp/builder_facade/__init__.py +3 -0
  14. package/src/qingflow_mcp/builder_facade/models.py +985 -0
  15. package/src/qingflow_mcp/builder_facade/service.py +8243 -0
  16. package/src/qingflow_mcp/cli/__init__.py +1 -0
  17. package/src/qingflow_mcp/cli/commands/__init__.py +15 -0
  18. package/src/qingflow_mcp/cli/commands/app.py +40 -0
  19. package/src/qingflow_mcp/cli/commands/auth.py +78 -0
  20. package/src/qingflow_mcp/cli/commands/builder.py +184 -0
  21. package/src/qingflow_mcp/cli/commands/common.py +47 -0
  22. package/src/qingflow_mcp/cli/commands/imports.py +86 -0
  23. package/src/qingflow_mcp/cli/commands/record.py +202 -0
  24. package/src/qingflow_mcp/cli/commands/task.py +87 -0
  25. package/src/qingflow_mcp/cli/commands/workspace.py +33 -0
  26. package/src/qingflow_mcp/cli/context.py +48 -0
  27. package/src/qingflow_mcp/cli/formatters.py +269 -0
  28. package/src/qingflow_mcp/cli/json_io.py +50 -0
  29. package/src/qingflow_mcp/cli/main.py +147 -0
  30. package/src/qingflow_mcp/config.py +221 -0
  31. package/src/qingflow_mcp/errors.py +66 -0
  32. package/src/qingflow_mcp/import_store.py +121 -0
  33. package/src/qingflow_mcp/json_types.py +18 -0
  34. package/src/qingflow_mcp/list_type_labels.py +76 -0
  35. package/src/qingflow_mcp/server.py +211 -0
  36. package/src/qingflow_mcp/server_app_builder.py +387 -0
  37. package/src/qingflow_mcp/server_app_user.py +317 -0
  38. package/src/qingflow_mcp/session_store.py +289 -0
  39. package/src/qingflow_mcp/solution/__init__.py +6 -0
  40. package/src/qingflow_mcp/solution/build_assembly_store.py +181 -0
  41. package/src/qingflow_mcp/solution/compiler/__init__.py +282 -0
  42. package/src/qingflow_mcp/solution/compiler/chart_compiler.py +96 -0
  43. package/src/qingflow_mcp/solution/compiler/form_compiler.py +466 -0
  44. package/src/qingflow_mcp/solution/compiler/icon_utils.py +113 -0
  45. package/src/qingflow_mcp/solution/compiler/navigation_compiler.py +57 -0
  46. package/src/qingflow_mcp/solution/compiler/package_compiler.py +19 -0
  47. package/src/qingflow_mcp/solution/compiler/portal_compiler.py +60 -0
  48. package/src/qingflow_mcp/solution/compiler/view_compiler.py +51 -0
  49. package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +173 -0
  50. package/src/qingflow_mcp/solution/design_session.py +222 -0
  51. package/src/qingflow_mcp/solution/design_store.py +100 -0
  52. package/src/qingflow_mcp/solution/executor.py +2339 -0
  53. package/src/qingflow_mcp/solution/normalizer.py +23 -0
  54. package/src/qingflow_mcp/solution/requirements_builder.py +536 -0
  55. package/src/qingflow_mcp/solution/run_store.py +244 -0
  56. package/src/qingflow_mcp/solution/spec_models.py +853 -0
  57. package/src/qingflow_mcp/tools/__init__.py +1 -0
  58. package/src/qingflow_mcp/tools/ai_builder_tools.py +2063 -0
  59. package/src/qingflow_mcp/tools/app_tools.py +850 -0
  60. package/src/qingflow_mcp/tools/approval_tools.py +833 -0
  61. package/src/qingflow_mcp/tools/auth_tools.py +697 -0
  62. package/src/qingflow_mcp/tools/base.py +81 -0
  63. package/src/qingflow_mcp/tools/code_block_tools.py +679 -0
  64. package/src/qingflow_mcp/tools/directory_tools.py +648 -0
  65. package/src/qingflow_mcp/tools/feedback_tools.py +230 -0
  66. package/src/qingflow_mcp/tools/file_tools.py +385 -0
  67. package/src/qingflow_mcp/tools/import_tools.py +1971 -0
  68. package/src/qingflow_mcp/tools/navigation_tools.py +177 -0
  69. package/src/qingflow_mcp/tools/package_tools.py +240 -0
  70. package/src/qingflow_mcp/tools/portal_tools.py +131 -0
  71. package/src/qingflow_mcp/tools/qingbi_report_tools.py +269 -0
  72. package/src/qingflow_mcp/tools/record_tools.py +12739 -0
  73. package/src/qingflow_mcp/tools/role_tools.py +94 -0
  74. package/src/qingflow_mcp/tools/solution_tools.py +3887 -0
  75. package/src/qingflow_mcp/tools/task_context_tools.py +1423 -0
  76. package/src/qingflow_mcp/tools/task_tools.py +843 -0
  77. package/src/qingflow_mcp/tools/view_tools.py +280 -0
  78. package/src/qingflow_mcp/tools/workflow_tools.py +312 -0
  79. package/src/qingflow_mcp/tools/workspace_tools.py +219 -0
@@ -0,0 +1,1423 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any
5
+ from uuid import uuid4
6
+
7
+ from mcp.server.fastmcp import FastMCP
8
+
9
+ from ..backend_client import BackendRequestContext
10
+ from ..config import DEFAULT_PROFILE
11
+ from ..errors import QingflowApiError, raise_tool_error
12
+ from ..json_types import JSONObject
13
+ from .approval_tools import ApprovalTools, _approval_page_amount, _approval_page_items, _approval_page_total
14
+ from .base import ToolBase
15
+ from .qingbi_report_tools import _qingbi_base_url
16
+ from .task_tools import TaskTools, _task_page_amount, _task_page_items, _task_page_total
17
+
18
+
19
+ class TaskContextTools(ToolBase):
20
+ def __init__(self, sessions, backend) -> None: # type: ignore[no-untyped-def]
21
+ super().__init__(sessions, backend)
22
+ self._task_tools = TaskTools(sessions, backend)
23
+ self._approval_tools = ApprovalTools(sessions, backend)
24
+
25
+ def register(self, mcp: FastMCP) -> None:
26
+ @mcp.tool()
27
+ def task_list(
28
+ profile: str = DEFAULT_PROFILE,
29
+ task_box: str = "todo",
30
+ flow_status: str = "all",
31
+ app_key: str | None = None,
32
+ workflow_node_id: int | None = None,
33
+ query: str | None = None,
34
+ page: int = 1,
35
+ page_size: int = 20,
36
+ ) -> dict[str, Any]:
37
+ return self.task_list(
38
+ profile=profile,
39
+ task_box=task_box,
40
+ flow_status=flow_status,
41
+ app_key=app_key,
42
+ workflow_node_id=workflow_node_id,
43
+ query=query,
44
+ page=page,
45
+ page_size=page_size,
46
+ )
47
+
48
+ @mcp.tool()
49
+ def task_get(
50
+ profile: str = DEFAULT_PROFILE,
51
+ app_key: str = "",
52
+ record_id: int = 0,
53
+ workflow_node_id: int = 0,
54
+ include_candidates: bool = True,
55
+ include_associated_reports: bool = True,
56
+ ) -> dict[str, Any]:
57
+ return self.task_get(
58
+ profile=profile,
59
+ app_key=app_key,
60
+ record_id=record_id,
61
+ workflow_node_id=workflow_node_id,
62
+ include_candidates=include_candidates,
63
+ include_associated_reports=include_associated_reports,
64
+ )
65
+
66
+ @mcp.tool(description=self._high_risk_tool_description(operation="execute", target="workflow task action"))
67
+ def task_action_execute(
68
+ profile: str = DEFAULT_PROFILE,
69
+ app_key: str = "",
70
+ record_id: int = 0,
71
+ workflow_node_id: int = 0,
72
+ action: str = "",
73
+ payload: dict[str, Any] | None = None,
74
+ ) -> dict[str, Any]:
75
+ return self.task_action_execute(
76
+ profile=profile,
77
+ app_key=app_key,
78
+ record_id=record_id,
79
+ workflow_node_id=workflow_node_id,
80
+ action=action,
81
+ payload=payload or {},
82
+ )
83
+
84
+ @mcp.tool()
85
+ def task_associated_report_detail_get(
86
+ profile: str = DEFAULT_PROFILE,
87
+ app_key: str = "",
88
+ record_id: int = 0,
89
+ workflow_node_id: int = 0,
90
+ report_id: int = 0,
91
+ page: int = 1,
92
+ page_size: int = 20,
93
+ ) -> dict[str, Any]:
94
+ return self.task_associated_report_detail_get(
95
+ profile=profile,
96
+ app_key=app_key,
97
+ record_id=record_id,
98
+ workflow_node_id=workflow_node_id,
99
+ report_id=report_id,
100
+ page=page,
101
+ page_size=page_size,
102
+ )
103
+
104
+ @mcp.tool()
105
+ def task_workflow_log_get(
106
+ profile: str = DEFAULT_PROFILE,
107
+ app_key: str = "",
108
+ record_id: int = 0,
109
+ workflow_node_id: int = 0,
110
+ ) -> dict[str, Any]:
111
+ return self.task_workflow_log_get(
112
+ profile=profile,
113
+ app_key=app_key,
114
+ record_id=record_id,
115
+ workflow_node_id=workflow_node_id,
116
+ )
117
+
118
+ def task_list(
119
+ self,
120
+ *,
121
+ profile: str,
122
+ task_box: str,
123
+ flow_status: str,
124
+ app_key: str | None,
125
+ workflow_node_id: int | None,
126
+ query: str | None,
127
+ page: int,
128
+ page_size: int,
129
+ ) -> dict[str, Any]:
130
+ normalized_type = self._task_tools._task_box_to_type(task_box)
131
+ normalized_status = self._task_tools._flow_status_to_process_status(flow_status)
132
+ raw = self._task_tools.task_list(
133
+ profile=profile,
134
+ type=normalized_type,
135
+ process_status=normalized_status,
136
+ app_key=app_key,
137
+ node_id=workflow_node_id,
138
+ search_key=query,
139
+ page_num=page,
140
+ page_size=page_size,
141
+ create_time_asc=None,
142
+ )
143
+ task_page = raw.get("page", {})
144
+ items = [
145
+ self._normalize_task_item(item, task_box=task_box, flow_status=flow_status)
146
+ for item in _task_page_items(task_page)
147
+ if isinstance(item, dict)
148
+ ]
149
+ return {
150
+ "profile": profile,
151
+ "ws_id": raw.get("ws_id"),
152
+ "ok": True,
153
+ "request_route": raw.get("request_route"),
154
+ "warnings": [],
155
+ "output_profile": "normal",
156
+ "data": {
157
+ "items": items,
158
+ "pagination": {
159
+ "page": page,
160
+ "page_size": page_size,
161
+ "returned_items": len(items),
162
+ "page_amount": _task_page_amount(task_page),
163
+ "reported_total": _task_page_total(task_page),
164
+ },
165
+ "selection": {
166
+ "task_box": task_box,
167
+ "flow_status": flow_status,
168
+ "app_key": app_key,
169
+ "workflow_node_id": workflow_node_id,
170
+ "query": query,
171
+ },
172
+ },
173
+ }
174
+
175
+ def task_get(
176
+ self,
177
+ *,
178
+ profile: str,
179
+ app_key: str,
180
+ record_id: int,
181
+ workflow_node_id: int,
182
+ include_candidates: bool,
183
+ include_associated_reports: bool,
184
+ ) -> dict[str, Any]:
185
+ self._require_app_record_and_node(app_key, record_id, workflow_node_id)
186
+
187
+ def runner(session_profile, context):
188
+ data = self._build_task_context(
189
+ context,
190
+ app_key=app_key,
191
+ record_id=record_id,
192
+ workflow_node_id=workflow_node_id,
193
+ include_candidates=include_candidates,
194
+ include_associated_reports=include_associated_reports,
195
+ )
196
+ return {
197
+ "profile": profile,
198
+ "ws_id": session_profile.selected_ws_id,
199
+ "ok": True,
200
+ "request_route": self._request_route_payload(context),
201
+ "warnings": [],
202
+ "output_profile": "normal",
203
+ "data": data,
204
+ }
205
+
206
+ return self._run(profile, runner)
207
+
208
+ def task_action_execute(
209
+ self,
210
+ *,
211
+ profile: str,
212
+ app_key: str,
213
+ record_id: int,
214
+ workflow_node_id: int,
215
+ action: str,
216
+ payload: dict[str, Any],
217
+ ) -> dict[str, Any]:
218
+ self._require_app_record_and_node(app_key, record_id, workflow_node_id)
219
+ normalized_action = (action or "").strip().lower()
220
+ if normalized_action not in {"approve", "reject", "rollback", "transfer", "urge"}:
221
+ raise_tool_error(
222
+ QingflowApiError.not_supported(
223
+ "TASK_ACTION_UNSUPPORTED: action must be one of approve, reject, rollback, transfer, or urge"
224
+ )
225
+ )
226
+ body = dict(payload or {})
227
+
228
+ def runner(session_profile, context):
229
+ try:
230
+ task_context = self._build_task_context(
231
+ context,
232
+ app_key=app_key,
233
+ record_id=record_id,
234
+ workflow_node_id=workflow_node_id,
235
+ include_candidates=False,
236
+ include_associated_reports=False,
237
+ )
238
+ except QingflowApiError as error:
239
+ if error.backend_code == 46001:
240
+ return self._task_action_visibility_unverified_response(
241
+ profile=profile,
242
+ session_profile=session_profile,
243
+ context=context,
244
+ app_key=app_key,
245
+ record_id=record_id,
246
+ workflow_node_id=workflow_node_id,
247
+ action=normalized_action,
248
+ source_error=error,
249
+ before_apply_status=None,
250
+ )
251
+ raise
252
+ capabilities = task_context.get("capabilities") or {}
253
+ available_actions = capabilities.get("available_actions") or []
254
+ if normalized_action not in available_actions:
255
+ raise_tool_error(
256
+ QingflowApiError.config_error(
257
+ f"task action '{normalized_action}' is not currently available for app_key='{app_key}' record_id={record_id} workflow_node_id={workflow_node_id}"
258
+ )
259
+ )
260
+ feedback_required_for = capabilities.get("action_constraints", {}).get("feedback_required_for") or []
261
+ if normalized_action in feedback_required_for and not self._extract_audit_feedback(body):
262
+ raise_tool_error(
263
+ QingflowApiError.config_error(
264
+ f"payload.audit_feedback is required for action '{normalized_action}' on the current node"
265
+ )
266
+ )
267
+ before_apply_status = ((task_context.get("record") or {}).get("apply_status"))
268
+ runtime_baseline = self._capture_task_runtime_baseline(
269
+ profile=profile,
270
+ context=context,
271
+ app_key=app_key,
272
+ record_id=record_id,
273
+ workflow_node_id=workflow_node_id,
274
+ )
275
+ try:
276
+ raw = self._execute_task_action(
277
+ profile=profile,
278
+ app_key=app_key,
279
+ record_id=record_id,
280
+ workflow_node_id=workflow_node_id,
281
+ normalized_action=normalized_action,
282
+ payload=body,
283
+ )
284
+ except QingflowApiError as error:
285
+ if error.backend_code == 46001:
286
+ return self._task_action_visibility_unverified_response(
287
+ profile=profile,
288
+ session_profile=session_profile,
289
+ context=context,
290
+ app_key=app_key,
291
+ record_id=record_id,
292
+ workflow_node_id=workflow_node_id,
293
+ action=normalized_action,
294
+ source_error=error,
295
+ before_apply_status=before_apply_status,
296
+ )
297
+ raise
298
+
299
+ verification, warnings = self._verify_task_action_runtime(
300
+ profile=profile,
301
+ context=context,
302
+ app_key=app_key,
303
+ record_id=record_id,
304
+ workflow_node_id=workflow_node_id,
305
+ action=normalized_action,
306
+ before_apply_status=before_apply_status,
307
+ runtime_baseline=runtime_baseline,
308
+ )
309
+ runtime_verified = bool(verification.get("runtime_continuation_verified"))
310
+ status = "success" if runtime_verified else "partial_success"
311
+ return {
312
+ "profile": raw.get("profile", profile),
313
+ "ws_id": raw.get("ws_id", session_profile.selected_ws_id),
314
+ "ok": bool(raw.get("ok", True)),
315
+ "status": status,
316
+ "error_code": None if runtime_verified else "WORKFLOW_CONTINUATION_UNVERIFIED",
317
+ "request_route": raw.get("request_route") or self._request_route_payload(context),
318
+ "warnings": warnings,
319
+ "verification": verification,
320
+ "output_profile": "normal",
321
+ "data": {
322
+ "action": normalized_action,
323
+ "resource": {
324
+ "app_key": app_key,
325
+ "record_id": record_id,
326
+ "workflow_node_id": workflow_node_id,
327
+ },
328
+ "selection": {"action": normalized_action},
329
+ "result": raw.get("result"),
330
+ "human_review": True,
331
+ },
332
+ }
333
+
334
+ return self._run(profile, runner)
335
+
336
+ def _execute_task_action(
337
+ self,
338
+ *,
339
+ profile: str,
340
+ app_key: str,
341
+ record_id: int,
342
+ workflow_node_id: int,
343
+ normalized_action: str,
344
+ payload: dict[str, Any],
345
+ ) -> dict[str, Any]:
346
+ if normalized_action == "approve":
347
+ action_payload = dict(payload)
348
+ action_payload["nodeId"] = workflow_node_id
349
+ return self._approval_tools.record_approve(
350
+ profile=profile,
351
+ app_key=app_key,
352
+ apply_id=record_id,
353
+ payload=action_payload,
354
+ )
355
+ if normalized_action == "reject":
356
+ action_payload = dict(payload)
357
+ action_payload["nodeId"] = workflow_node_id
358
+ if not self._extract_audit_feedback(action_payload):
359
+ raise_tool_error(QingflowApiError.config_error("payload.audit_feedback is required for reject"))
360
+ return self._approval_tools.record_reject(
361
+ profile=profile,
362
+ app_key=app_key,
363
+ apply_id=record_id,
364
+ payload=action_payload,
365
+ )
366
+ if normalized_action == "rollback":
367
+ target_node_id = self._extract_positive_int(payload, "target_workflow_node_id", aliases=("targetAuditNodeId", "targetWorkflowNodeId"))
368
+ action_payload: JSONObject = {
369
+ "auditNodeId": workflow_node_id,
370
+ "targetAuditNodeId": target_node_id,
371
+ }
372
+ audit_feedback = self._extract_audit_feedback(payload)
373
+ if audit_feedback:
374
+ action_payload["auditFeedback"] = audit_feedback
375
+ return self._approval_tools.record_rollback(
376
+ profile=profile,
377
+ app_key=app_key,
378
+ apply_id=record_id,
379
+ payload=action_payload,
380
+ )
381
+ if normalized_action == "transfer":
382
+ target_member_id = self._extract_positive_int(payload, "target_member_id", aliases=("uid", "targetMemberId"))
383
+ action_payload = {
384
+ "auditNodeId": workflow_node_id,
385
+ "uid": target_member_id,
386
+ }
387
+ audit_feedback = self._extract_audit_feedback(payload)
388
+ if audit_feedback:
389
+ action_payload["auditFeedback"] = audit_feedback
390
+ return self._approval_tools.record_transfer(
391
+ profile=profile,
392
+ app_key=app_key,
393
+ apply_id=record_id,
394
+ payload=action_payload,
395
+ )
396
+ return self._task_tools.task_urge(
397
+ profile=profile,
398
+ app_key=app_key,
399
+ row_record_id=record_id,
400
+ )
401
+
402
+ def _verify_task_action_runtime(
403
+ self,
404
+ *,
405
+ profile: str,
406
+ context: BackendRequestContext,
407
+ app_key: str,
408
+ record_id: int,
409
+ workflow_node_id: int,
410
+ action: str,
411
+ before_apply_status: Any,
412
+ runtime_baseline: dict[str, Any] | None = None,
413
+ ) -> tuple[dict[str, Any], list[dict[str, Any]]]:
414
+ verification: dict[str, Any] = {
415
+ "action_executed": True,
416
+ "runtime_continuation_verified": action == "urge",
417
+ "scope": "workflow_runtime",
418
+ "task_context_visibility_verified": True,
419
+ }
420
+ warnings: list[dict[str, Any]] = []
421
+ if action == "urge":
422
+ return verification, warnings
423
+
424
+ state_after: dict[str, Any] | None = None
425
+ try:
426
+ state_after = self.backend.request(
427
+ "GET",
428
+ context,
429
+ f"/app/{app_key}/apply/{record_id}",
430
+ params={"role": 3, "listType": 1, "auditNodeId": workflow_node_id},
431
+ )
432
+ verification["record_state_readable"] = True
433
+ verification["before_apply_status"] = before_apply_status
434
+ verification["after_apply_status"] = state_after.get("applyStatus") if isinstance(state_after, dict) else None
435
+ verification["record_state_changed"] = verification["after_apply_status"] != before_apply_status
436
+ except QingflowApiError as error:
437
+ verification["record_state_readable"] = False
438
+ verification["record_state_changed"] = False
439
+ verification["record_state_error"] = {
440
+ "http_status": error.http_status,
441
+ "backend_code": error.backend_code,
442
+ "category": error.category,
443
+ }
444
+
445
+ log_items: list[dict[str, Any]] = []
446
+ try:
447
+ log_page = self.backend.request(
448
+ "POST",
449
+ context,
450
+ "/application/workflow/node/record",
451
+ json_body={
452
+ "key": app_key,
453
+ "rowRecordId": record_id,
454
+ "nodeId": workflow_node_id,
455
+ "role": 3,
456
+ "pageNum": 1,
457
+ "pageSize": 50,
458
+ },
459
+ )
460
+ log_items = self._normalize_workflow_logs(log_page)
461
+ verification["workflow_log_visible"] = True
462
+ verification["workflow_log_count"] = len(log_items)
463
+ except QingflowApiError as error:
464
+ verification["workflow_log_visible"] = False
465
+ verification["workflow_log_count"] = None
466
+ verification["workflow_log_error"] = {
467
+ "http_status": error.http_status,
468
+ "backend_code": error.backend_code,
469
+ "category": error.category,
470
+ }
471
+
472
+ todo_items = self._safe_task_list_items(profile=profile, task_box="todo", app_key=app_key)
473
+ initiated_items = self._safe_task_list_items(profile=profile, task_box="initiated", app_key=app_key)
474
+ downstream_todo_detected = any(
475
+ int(item.get("record_id") or 0) == record_id and int(item.get("workflow_node_id") or 0) != workflow_node_id
476
+ for item in todo_items
477
+ if isinstance(item, dict)
478
+ )
479
+ initiated_visible = any(
480
+ int(item.get("record_id") or 0) == record_id
481
+ for item in initiated_items
482
+ if isinstance(item, dict)
483
+ )
484
+ verification["downstream_todo_detected"] = downstream_todo_detected
485
+ verification["initiated_task_visible"] = initiated_visible
486
+ baseline_downstream_nodes = set()
487
+ baseline_log_count = None
488
+ baseline_log_digest = None
489
+ if isinstance(runtime_baseline, dict):
490
+ baseline_downstream_nodes = set(runtime_baseline.get("downstream_todo_nodes") or [])
491
+ baseline_log_count = runtime_baseline.get("workflow_log_count")
492
+ baseline_log_digest = runtime_baseline.get("workflow_log_digest")
493
+ current_downstream_nodes = {
494
+ int(item.get("workflow_node_id") or 0)
495
+ for item in todo_items
496
+ if isinstance(item, dict)
497
+ and int(item.get("record_id") or 0) == record_id
498
+ and int(item.get("workflow_node_id") or 0) != workflow_node_id
499
+ }
500
+ workflow_log_digest = self._workflow_log_digest(log_items)
501
+ verification["downstream_todo_nodes"] = sorted(node_id for node_id in current_downstream_nodes if node_id > 0)
502
+ verification["downstream_todo_changed"] = current_downstream_nodes != baseline_downstream_nodes
503
+ verification["workflow_log_advanced"] = bool(
504
+ verification.get("workflow_log_visible")
505
+ and (
506
+ (isinstance(baseline_log_count, int) and len(log_items) > baseline_log_count)
507
+ or (baseline_log_digest is not None and workflow_log_digest is not None and workflow_log_digest != baseline_log_digest)
508
+ )
509
+ )
510
+ runtime_verified = bool(
511
+ verification.get("record_state_changed")
512
+ or verification.get("downstream_todo_changed")
513
+ or verification.get("workflow_log_advanced")
514
+ )
515
+ verification["runtime_continuation_verified"] = runtime_verified
516
+ if not runtime_verified:
517
+ warnings.append(
518
+ {
519
+ "code": "WORKFLOW_CONTINUATION_UNVERIFIED",
520
+ "message": "task action executed, but MCP could not verify downstream workflow continuation from record state, workflow logs, or downstream todo tasks.",
521
+ }
522
+ )
523
+ return verification, warnings
524
+
525
+ def _capture_task_runtime_baseline(
526
+ self,
527
+ *,
528
+ profile: str,
529
+ context: BackendRequestContext,
530
+ app_key: str,
531
+ record_id: int,
532
+ workflow_node_id: int,
533
+ ) -> dict[str, Any]:
534
+ baseline: dict[str, Any] = {
535
+ "workflow_log_visible": False,
536
+ "workflow_log_count": None,
537
+ "workflow_log_digest": None,
538
+ "downstream_todo_nodes": [],
539
+ }
540
+ try:
541
+ log_page = self.backend.request(
542
+ "POST",
543
+ context,
544
+ "/application/workflow/node/record",
545
+ json_body={
546
+ "key": app_key,
547
+ "rowRecordId": record_id,
548
+ "nodeId": workflow_node_id,
549
+ "role": 3,
550
+ "pageNum": 1,
551
+ "pageSize": 50,
552
+ },
553
+ )
554
+ log_items = self._normalize_workflow_logs(log_page)
555
+ baseline["workflow_log_visible"] = True
556
+ baseline["workflow_log_count"] = len(log_items)
557
+ baseline["workflow_log_digest"] = self._workflow_log_digest(log_items)
558
+ except QingflowApiError:
559
+ pass
560
+ todo_items = self._safe_task_list_items(profile=profile, task_box="todo", app_key=app_key)
561
+ baseline["downstream_todo_nodes"] = sorted(
562
+ {
563
+ int(item.get("workflow_node_id") or 0)
564
+ for item in todo_items
565
+ if isinstance(item, dict)
566
+ and int(item.get("record_id") or 0) == record_id
567
+ and int(item.get("workflow_node_id") or 0) != workflow_node_id
568
+ }
569
+ )
570
+ return baseline
571
+
572
+ def _task_action_visibility_unverified_response(
573
+ self,
574
+ *,
575
+ profile: str,
576
+ session_profile,
577
+ context: BackendRequestContext,
578
+ app_key: str,
579
+ record_id: int,
580
+ workflow_node_id: int,
581
+ action: str,
582
+ source_error: QingflowApiError,
583
+ before_apply_status: Any,
584
+ ) -> dict[str, Any]:
585
+ verification, warnings = self._verify_task_action_runtime(
586
+ profile=profile,
587
+ context=context,
588
+ app_key=app_key,
589
+ record_id=record_id,
590
+ workflow_node_id=workflow_node_id,
591
+ action=action,
592
+ before_apply_status=before_apply_status,
593
+ )
594
+ verification["action_executed"] = False
595
+ verification["task_context_visibility_verified"] = bool(verification.get("runtime_continuation_verified"))
596
+ if verification["task_context_visibility_verified"]:
597
+ warnings.append(
598
+ {
599
+ "code": "TASK_ALREADY_PROCESSED_UNCONFIRMED_ACTOR",
600
+ "message": "the task is no longer actionable in the current context; MCP found downstream workflow evidence and treats it as already processed by another actor.",
601
+ }
602
+ )
603
+ return {
604
+ "profile": profile,
605
+ "ws_id": session_profile.selected_ws_id,
606
+ "ok": True,
607
+ "status": "partial_success",
608
+ "error_code": "TASK_ALREADY_PROCESSED",
609
+ "request_route": self._request_route_payload(context),
610
+ "warnings": warnings,
611
+ "verification": verification,
612
+ "output_profile": "normal",
613
+ "data": {
614
+ "action": action,
615
+ "resource": {
616
+ "app_key": app_key,
617
+ "record_id": record_id,
618
+ "workflow_node_id": workflow_node_id,
619
+ },
620
+ "selection": {"action": action},
621
+ "result": None,
622
+ "human_review": True,
623
+ },
624
+ }
625
+ warnings.append(
626
+ {
627
+ "code": "TASK_CONTEXT_VISIBILITY_UNVERIFIED",
628
+ "message": "the task is no longer actionable, and MCP could not verify from state or workflow logs whether it was already processed.",
629
+ }
630
+ )
631
+ return {
632
+ "profile": profile,
633
+ "ws_id": session_profile.selected_ws_id,
634
+ "ok": False,
635
+ "status": "failed",
636
+ "error_code": "TASK_CONTEXT_VISIBILITY_UNVERIFIED",
637
+ "request_route": self._request_route_payload(context),
638
+ "warnings": warnings,
639
+ "verification": verification,
640
+ "output_profile": "normal",
641
+ "data": {
642
+ "action": action,
643
+ "resource": {
644
+ "app_key": app_key,
645
+ "record_id": record_id,
646
+ "workflow_node_id": workflow_node_id,
647
+ },
648
+ "selection": {"action": action},
649
+ "result": None,
650
+ "human_review": True,
651
+ "transport_error": {
652
+ "http_status": source_error.http_status,
653
+ "backend_code": source_error.backend_code,
654
+ "category": source_error.category,
655
+ },
656
+ },
657
+ }
658
+
659
+ def _safe_task_list_items(self, *, profile: str, task_box: str, app_key: str) -> list[dict[str, Any]]:
660
+ try:
661
+ response = self.task_list(
662
+ profile=profile,
663
+ task_box=task_box,
664
+ flow_status="all",
665
+ app_key=app_key,
666
+ workflow_node_id=None,
667
+ query=None,
668
+ page=1,
669
+ page_size=50,
670
+ )
671
+ except QingflowApiError:
672
+ return []
673
+ data = response.get("data") if isinstance(response, dict) else None
674
+ items = data.get("items") if isinstance(data, dict) else None
675
+ if not isinstance(items, list):
676
+ return []
677
+ return [item for item in items if isinstance(item, dict)]
678
+
679
+ def task_associated_report_detail_get(
680
+ self,
681
+ *,
682
+ profile: str,
683
+ app_key: str,
684
+ record_id: int,
685
+ workflow_node_id: int,
686
+ report_id: int,
687
+ page: int,
688
+ page_size: int,
689
+ ) -> dict[str, Any]:
690
+ self._require_app_record_and_node(app_key, record_id, workflow_node_id)
691
+ if report_id <= 0:
692
+ raise_tool_error(QingflowApiError.config_error("report_id must be positive"))
693
+ if page <= 0 or page_size <= 0:
694
+ raise_tool_error(QingflowApiError.config_error("page and page_size must be positive"))
695
+
696
+ def runner(session_profile, context):
697
+ task_context = self._build_task_context(
698
+ context,
699
+ app_key=app_key,
700
+ record_id=record_id,
701
+ workflow_node_id=workflow_node_id,
702
+ include_candidates=False,
703
+ include_associated_reports=True,
704
+ )
705
+ report_item = self._find_associated_report(task_context, report_id)
706
+ if report_item is None:
707
+ raise_tool_error(
708
+ QingflowApiError.config_error(
709
+ f"report_id={report_id} is not visible for app_key='{app_key}' record_id={record_id} workflow_node_id={workflow_node_id}"
710
+ )
711
+ )
712
+ association_query = self._build_association_query(
713
+ report_item["raw"],
714
+ task_context.get("record", {}).get("answers") or [],
715
+ )
716
+ selection = {
717
+ "app_key": app_key,
718
+ "record_id": record_id,
719
+ "workflow_node_id": workflow_node_id,
720
+ "report_id": report_id,
721
+ "target_app_key": report_item.get("target_app_key"),
722
+ "target_app_name": report_item.get("target_app_name"),
723
+ "chart_key": report_item.get("chart_key"),
724
+ "chart_name": report_item.get("chart_name"),
725
+ }
726
+ context_payload = {
727
+ "match_rules": report_item.get("match_rules") or [],
728
+ "resolved_filters": association_query.get("keyQueValues") or [],
729
+ }
730
+
731
+ if report_item.get("graph_type") == "view":
732
+ viewgraph_key = str(report_item.get("chart_key") or "")
733
+ body = {
734
+ "filter": {},
735
+ "viewgraphKey": viewgraph_key,
736
+ "equipmentType": 0,
737
+ "associationQuery": association_query,
738
+ }
739
+ result = self.backend.request(
740
+ "POST",
741
+ context,
742
+ f"/view/{viewgraph_key}/apply/filter",
743
+ json_body=body,
744
+ )
745
+ items = _task_page_items(result)
746
+ return {
747
+ "profile": profile,
748
+ "ws_id": session_profile.selected_ws_id,
749
+ "ok": True,
750
+ "request_route": self._request_route_payload(context),
751
+ "warnings": [],
752
+ "output_profile": "normal",
753
+ "data": {
754
+ "result_type": "view_list",
755
+ "result": {
756
+ "items": items,
757
+ "pagination": {
758
+ "page": page,
759
+ "page_size": page_size,
760
+ "returned_items": len(items),
761
+ "page_amount": _task_page_amount(result),
762
+ "reported_total": _task_page_total(result),
763
+ },
764
+ },
765
+ "selection": selection,
766
+ "context": context_payload,
767
+ },
768
+ }
769
+
770
+ chart_key = str(report_item.get("chart_key") or "")
771
+ source_type = report_item.get("source_type")
772
+ if source_type == "qingbi":
773
+ qingbi_context = BackendRequestContext(
774
+ base_url=_qingbi_base_url(context.base_url),
775
+ token=context.token,
776
+ ws_id=context.ws_id,
777
+ qf_request_id=context.qf_request_id,
778
+ qf_version=context.qf_version,
779
+ qf_version_source=context.qf_version_source,
780
+ )
781
+ chart_result = self.backend.request(
782
+ "POST",
783
+ qingbi_context,
784
+ f"/qingbi/charts/data/{chart_key}",
785
+ params={
786
+ "qfUUID": uuid4().hex,
787
+ "pageNum": page,
788
+ "pageSize": page_size,
789
+ },
790
+ json_body={
791
+ "asosChartId": report_id,
792
+ "keyQueValues": association_query.get("keyQueValues") or [],
793
+ },
794
+ )
795
+ request_route = {
796
+ "base_url": qingbi_context.base_url,
797
+ "qf_version": qingbi_context.qf_version,
798
+ "qf_version_source": qingbi_context.qf_version_source or "context",
799
+ }
800
+ elif self._qingflow_chart_uses_apply_filter(context, chart_key):
801
+ chart_result = self.backend.request(
802
+ "POST",
803
+ context,
804
+ f"/chart/{chart_key}/apply/filter",
805
+ json_body={
806
+ "filter": {
807
+ "pageNum": page,
808
+ "pageSize": page_size,
809
+ },
810
+ "asosChartId": report_id,
811
+ "keyQueValues": association_query.get("keyQueValues") or [],
812
+ },
813
+ )
814
+ items = _task_page_items(chart_result)
815
+ return {
816
+ "profile": profile,
817
+ "ws_id": session_profile.selected_ws_id,
818
+ "ok": True,
819
+ "request_route": self._request_route_payload(context),
820
+ "warnings": [],
821
+ "output_profile": "normal",
822
+ "data": {
823
+ "result_type": "view_list",
824
+ "result": {
825
+ "items": items,
826
+ "pagination": {
827
+ "page": page,
828
+ "page_size": page_size,
829
+ "returned_items": len(items),
830
+ "page_amount": _task_page_amount(chart_result),
831
+ "reported_total": _task_page_total(chart_result),
832
+ },
833
+ },
834
+ "selection": selection,
835
+ "context": context_payload,
836
+ },
837
+ }
838
+ else:
839
+ chart_result = self.backend.request(
840
+ "POST",
841
+ context,
842
+ f"/chart/{chart_key}/chartData",
843
+ json_body={
844
+ "asosChartId": report_id,
845
+ "keyQueValues": association_query.get("keyQueValues") or [],
846
+ },
847
+ )
848
+ request_route = self._request_route_payload(context)
849
+
850
+ return {
851
+ "profile": profile,
852
+ "ws_id": session_profile.selected_ws_id,
853
+ "ok": True,
854
+ "request_route": request_route,
855
+ "warnings": [],
856
+ "output_profile": "normal",
857
+ "data": {
858
+ "result_type": "chart_data",
859
+ "result": self._normalize_chart_result(chart_result),
860
+ "selection": selection,
861
+ "context": context_payload,
862
+ },
863
+ }
864
+
865
+ return self._run(profile, runner)
866
+
867
+ def task_workflow_log_get(
868
+ self,
869
+ *,
870
+ profile: str,
871
+ app_key: str,
872
+ record_id: int,
873
+ workflow_node_id: int,
874
+ ) -> dict[str, Any]:
875
+ self._require_app_record_and_node(app_key, record_id, workflow_node_id)
876
+
877
+ def runner(session_profile, context):
878
+ task_context = self._build_task_context(
879
+ context,
880
+ app_key=app_key,
881
+ record_id=record_id,
882
+ workflow_node_id=workflow_node_id,
883
+ include_candidates=False,
884
+ include_associated_reports=False,
885
+ )
886
+ visibility = task_context.get("visibility") or {}
887
+ if not visibility.get("audit_record_visible"):
888
+ raise_tool_error(
889
+ QingflowApiError.config_error(
890
+ f"workflow logs are not visible for app_key='{app_key}' record_id={record_id} workflow_node_id={workflow_node_id}"
891
+ )
892
+ )
893
+ page = self.backend.request(
894
+ "POST",
895
+ context,
896
+ "/application/workflow/node/record",
897
+ json_body={
898
+ "key": app_key,
899
+ "rowRecordId": record_id,
900
+ "nodeId": workflow_node_id,
901
+ "role": 3,
902
+ "pageNum": 1,
903
+ "pageSize": 200,
904
+ },
905
+ )
906
+ items = self._normalize_workflow_logs(page)
907
+ return {
908
+ "profile": profile,
909
+ "ws_id": session_profile.selected_ws_id,
910
+ "ok": True,
911
+ "request_route": self._request_route_payload(context),
912
+ "warnings": [],
913
+ "output_profile": "normal",
914
+ "data": {
915
+ "selection": {
916
+ "app_key": app_key,
917
+ "record_id": record_id,
918
+ "workflow_node_id": workflow_node_id,
919
+ },
920
+ "visibility": {
921
+ "audit_record_visible": visibility.get("audit_record_visible"),
922
+ "qrobot_record_visible": visibility.get("qrobot_record_visible"),
923
+ },
924
+ "items": items,
925
+ },
926
+ }
927
+
928
+ return self._run(profile, runner)
929
+
930
+ def _build_task_context(
931
+ self,
932
+ context: BackendRequestContext,
933
+ *,
934
+ app_key: str,
935
+ record_id: int,
936
+ workflow_node_id: int,
937
+ include_candidates: bool,
938
+ include_associated_reports: bool,
939
+ ) -> dict[str, Any]:
940
+ audit_infos = self.backend.request(
941
+ "GET",
942
+ context,
943
+ f"/app/{app_key}/apply/{record_id}/auditInfo",
944
+ params={"type": 1},
945
+ )
946
+ node_info = self._select_task_node(audit_infos, workflow_node_id, app_key=app_key, record_id=record_id)
947
+ detail = self.backend.request(
948
+ "GET",
949
+ context,
950
+ f"/app/{app_key}/apply/{record_id}",
951
+ params={"role": 3, "listType": 1, "auditNodeId": workflow_node_id},
952
+ )
953
+ associated_report_visible = self._resolve_associated_report_visible(node_info, detail)
954
+ associated_reports = {"visible": associated_report_visible, "count": 0, "items": []}
955
+ if include_associated_reports and associated_report_visible:
956
+ asos_chart_list = self.backend.request(
957
+ "GET",
958
+ context,
959
+ f"/app/{app_key}/asosChart",
960
+ params={"role": 3, "auditNodeId": workflow_node_id, "beingDraft": False},
961
+ )
962
+ associated_items = [
963
+ self._normalize_associated_report(item)
964
+ for item in (asos_chart_list.get("asosCharts") or [])
965
+ if isinstance(item, dict)
966
+ ]
967
+ associated_reports = {
968
+ "visible": True,
969
+ "count": len(associated_items),
970
+ "items": associated_items,
971
+ }
972
+ rollback_items: list[dict[str, Any]] = []
973
+ transfer_items: list[dict[str, Any]] = []
974
+ if include_candidates:
975
+ rollback_result = self.backend.request(
976
+ "GET",
977
+ context,
978
+ f"/app/{app_key}/apply/{record_id}/revertNode",
979
+ params={"auditNodeId": workflow_node_id},
980
+ )
981
+ rollback_items = self._rollback_candidate_items(rollback_result)
982
+ transfer_result = self.backend.request(
983
+ "GET",
984
+ context,
985
+ f"/app/{app_key}/apply/{record_id}/transfer/member",
986
+ params={"pageNum": 1, "pageSize": 20, "auditNodeId": workflow_node_id},
987
+ )
988
+ transfer_items = _approval_page_items(transfer_result)
989
+
990
+ capabilities = self._build_capabilities(node_info)
991
+ visibility = self._build_visibility(node_info, detail)
992
+ return {
993
+ "task": {
994
+ "app_key": app_key,
995
+ "record_id": record_id,
996
+ "workflow_node_id": workflow_node_id,
997
+ "workflow_node_name": node_info.get("auditNodeName") or node_info.get("nodeName"),
998
+ "actionable": True,
999
+ },
1000
+ "record": {
1001
+ "apply_id": detail.get("applyId", record_id),
1002
+ "apply_status": detail.get("applyStatus"),
1003
+ "apply_num": detail.get("applyNum"),
1004
+ "custom_apply_num": detail.get("customApplyNum"),
1005
+ "apply_user": detail.get("applyUser"),
1006
+ "apply_time": detail.get("applyTime"),
1007
+ "last_update_time": detail.get("lastUpdateTime"),
1008
+ "answers": detail.get("answers") or [],
1009
+ },
1010
+ "capabilities": capabilities,
1011
+ "field_permissions": {
1012
+ "que_auth_setting": node_info.get("queAuthSetting") or [],
1013
+ },
1014
+ "visibility": visibility,
1015
+ "associated_reports": associated_reports,
1016
+ "candidates": {
1017
+ "rollback_nodes": rollback_items,
1018
+ "transfer_members": transfer_items,
1019
+ },
1020
+ "workflow_log_summary": {
1021
+ "visible": visibility["audit_record_visible"],
1022
+ "available": visibility["audit_record_visible"],
1023
+ "history_count": None,
1024
+ "qrobot_log_visible": visibility["qrobot_record_visible"],
1025
+ },
1026
+ }
1027
+
1028
+ def _normalize_task_item(self, raw: dict[str, Any], *, task_box: str, flow_status: str) -> dict[str, Any]:
1029
+ app_key = raw.get("appKey") or raw.get("app_key")
1030
+ record_id = raw.get("rowRecordId") or raw.get("recordId") or raw.get("applyId")
1031
+ workflow_node_id = raw.get("nodeId") or raw.get("auditNodeId")
1032
+ apply_user = raw.get("applyUser")
1033
+ if apply_user is None:
1034
+ user_uid = raw.get("applyUserUid")
1035
+ user_name = raw.get("applyUserName")
1036
+ if user_uid is not None or user_name is not None:
1037
+ apply_user = {"uid": user_uid, "name": user_name}
1038
+ return {
1039
+ "task_id": raw.get("id") or raw.get("taskId") or record_id,
1040
+ "app_key": app_key,
1041
+ "app_name": raw.get("formTitle") or raw.get("worksheetName") or raw.get("appName"),
1042
+ "record_id": record_id,
1043
+ "workflow_node_id": workflow_node_id,
1044
+ "workflow_node_name": raw.get("nodeName") or raw.get("auditNodeName"),
1045
+ "title": raw.get("title") or raw.get("applyTitle") or raw.get("name") or raw.get("formTitle"),
1046
+ "apply_user": apply_user,
1047
+ "apply_time": raw.get("applyTime") or raw.get("receiveTime"),
1048
+ "task_box": task_box,
1049
+ "flow_status": flow_status,
1050
+ "actionable": task_box == "todo" and bool(record_id) and bool(workflow_node_id),
1051
+ }
1052
+
1053
+ def _select_task_node(self, infos: Any, workflow_node_id: int, *, app_key: str, record_id: int) -> dict[str, Any]:
1054
+ if not isinstance(infos, list) or not infos:
1055
+ raise_tool_error(
1056
+ QingflowApiError.config_error(
1057
+ f"record_id={record_id} is not currently actionable for the logged-in user in app_key='{app_key}'"
1058
+ )
1059
+ )
1060
+ for item in infos:
1061
+ if not isinstance(item, dict):
1062
+ continue
1063
+ candidate = item.get("auditNodeId")
1064
+ if not isinstance(candidate, int):
1065
+ candidate = item.get("nodeId")
1066
+ if candidate == workflow_node_id:
1067
+ return item
1068
+ raise_tool_error(
1069
+ QingflowApiError.config_error(
1070
+ f"workflow_node_id={workflow_node_id} is not an actionable todo node for app_key='{app_key}' record_id={record_id}"
1071
+ )
1072
+ )
1073
+
1074
+ def _build_capabilities(self, node_info: dict[str, Any]) -> dict[str, Any]:
1075
+ available_actions = ["approve"]
1076
+ if self._coerce_bool(node_info.get("rejectBtnStatus")):
1077
+ available_actions.append("reject")
1078
+ if self._coerce_bool(node_info.get("canRevert")):
1079
+ available_actions.append("rollback")
1080
+ if self._coerce_bool(node_info.get("canTransfer")):
1081
+ available_actions.append("transfer")
1082
+ if self._coerce_bool(node_info.get("canUrge")):
1083
+ available_actions.append("urge")
1084
+
1085
+ visible_but_unimplemented_actions: list[str] = []
1086
+ if self._coerce_bool(node_info.get("canRevoke")):
1087
+ visible_but_unimplemented_actions.append("revoke")
1088
+ if self._coerce_bool(node_info.get("beingEndWorkflow")):
1089
+ visible_but_unimplemented_actions.append("end_workflow")
1090
+ if self._coerce_bool(node_info.get("beingCanApplyAgain")):
1091
+ visible_but_unimplemented_actions.append("apply_again")
1092
+
1093
+ feedback_required_for = []
1094
+ raw_feedback_required = node_info.get("feedbackRequiredOperationType")
1095
+ if isinstance(raw_feedback_required, list):
1096
+ feedback_required_for = [str(item).strip().lower() for item in raw_feedback_required if str(item).strip()]
1097
+
1098
+ return {
1099
+ "available_actions": available_actions,
1100
+ "visible_but_unimplemented_actions": visible_but_unimplemented_actions,
1101
+ "action_constraints": {
1102
+ "feedback_required_for": feedback_required_for,
1103
+ "submit_check_enabled": self._coerce_bool(node_info.get("beingSubmitCheck")),
1104
+ "submit_preview_enabled": self._coerce_bool(node_info.get("beingSubmitPreview")),
1105
+ "can_end_workflow": self._coerce_bool(node_info.get("beingEndWorkflow")),
1106
+ "can_apply_again": self._coerce_bool(node_info.get("beingCanApplyAgain")),
1107
+ },
1108
+ }
1109
+
1110
+ def _build_visibility(self, node_info: dict[str, Any], detail: dict[str, Any]) -> dict[str, bool]:
1111
+ return {
1112
+ "comment_visible": self._coerce_bool(node_info.get("commentStatus")),
1113
+ "audit_record_visible": self._coerce_bool(node_info.get("auditRecordVisible")),
1114
+ "workflow_future_visible": self._coerce_bool(node_info.get("beingWorkflowNodeFutureListVisible")),
1115
+ "qrobot_record_visible": self._coerce_bool(node_info.get("qrobotRecordBeingVisible")),
1116
+ "associated_report_visible": self._resolve_associated_report_visible(node_info, detail),
1117
+ }
1118
+
1119
+ def _resolve_associated_report_visible(self, node_info: dict[str, Any], detail: dict[str, Any]) -> bool:
1120
+ node_visible = node_info.get("asosChartVisible")
1121
+ if node_visible is not None:
1122
+ return self._coerce_bool(node_visible)
1123
+ return self._coerce_bool(detail.get("viewAsosChartVisible"))
1124
+
1125
+ def _normalize_associated_report(self, raw: dict[str, Any]) -> dict[str, Any]:
1126
+ graph_type = str(raw.get("graphType") or "").strip().lower()
1127
+ source_type = str(raw.get("sourceType") or "").strip().lower()
1128
+ return {
1129
+ "report_id": raw.get("id"),
1130
+ "chart_key": raw.get("chartKey"),
1131
+ "chart_name": raw.get("chartName"),
1132
+ "graph_type": "view" if graph_type.endswith("view") or graph_type == "view" else "chart",
1133
+ "source_type": source_type or "qingflow",
1134
+ "target_app_key": raw.get("appKey"),
1135
+ "target_app_name": raw.get("formTitle"),
1136
+ "match_rules": raw.get("matchRules") or [],
1137
+ "raw": dict(raw),
1138
+ }
1139
+
1140
+ def _rollback_candidate_items(self, payload: Any) -> list[dict[str, Any]]:
1141
+ if isinstance(payload, dict):
1142
+ revert_nodes = payload.get("revertNodes")
1143
+ if isinstance(revert_nodes, list):
1144
+ return [item for item in revert_nodes if isinstance(item, dict)]
1145
+ return [item for item in _approval_page_items(payload) if isinstance(item, dict)]
1146
+
1147
+ def _find_associated_report(self, task_context: dict[str, Any], report_id: int) -> dict[str, Any] | None:
1148
+ associated_reports = ((task_context.get("associated_reports") or {}).get("items") or [])
1149
+ for item in associated_reports:
1150
+ if isinstance(item, dict) and item.get("report_id") == report_id:
1151
+ return item
1152
+ return None
1153
+
1154
+ def _build_association_query(self, asos_chart: dict[str, Any], answers: list[dict[str, Any]]) -> dict[str, Any]:
1155
+ key_que_ids = self._collect_match_rule_question_ids(asos_chart.get("matchRules") or [])
1156
+ key_values: list[dict[str, Any]] = []
1157
+ for answer in answers:
1158
+ if not isinstance(answer, dict):
1159
+ continue
1160
+ answer_que_id = answer.get("queId")
1161
+ if isinstance(answer_que_id, int) and answer_que_id in key_que_ids:
1162
+ extracted_values = self._extract_answer_values(answer)
1163
+ key_values.append({"keyQueId": answer_que_id, "values": extracted_values or None})
1164
+ table_values = answer.get("tableValues")
1165
+ if not isinstance(table_values, list):
1166
+ continue
1167
+ for idx, row in enumerate(table_values, start=1):
1168
+ if not isinstance(row, list):
1169
+ continue
1170
+ for sub_answer in row:
1171
+ if not isinstance(sub_answer, dict):
1172
+ continue
1173
+ sub_que_id = sub_answer.get("queId")
1174
+ if isinstance(sub_que_id, int) and sub_que_id in key_que_ids:
1175
+ extracted_values = self._extract_answer_values(sub_answer)
1176
+ key_values.append(
1177
+ {
1178
+ "keyQueId": sub_que_id,
1179
+ "ordinal": idx,
1180
+ "values": extracted_values or None,
1181
+ }
1182
+ )
1183
+ return {
1184
+ "asosChart": self._sanitize_associated_chart(asos_chart),
1185
+ "keyQueValues": key_values,
1186
+ }
1187
+
1188
+ def _collect_match_rule_question_ids(self, match_rules: Any) -> set[int]:
1189
+ question_ids: set[int] = set()
1190
+
1191
+ def visit(node: Any) -> None:
1192
+ if isinstance(node, list):
1193
+ for item in node:
1194
+ visit(item)
1195
+ return
1196
+ if not isinstance(node, dict):
1197
+ return
1198
+ for key in ("queId", "judgeQueId"):
1199
+ value = node.get(key)
1200
+ if isinstance(value, int):
1201
+ question_ids.add(value)
1202
+ for value in node.values():
1203
+ if isinstance(value, (list, dict)):
1204
+ visit(value)
1205
+
1206
+ visit(match_rules)
1207
+ return question_ids
1208
+
1209
+ def _extract_answer_values(self, answer: dict[str, Any]) -> list[str]:
1210
+ values = answer.get("values")
1211
+ if not isinstance(values, list):
1212
+ return []
1213
+ normalized: list[str] = []
1214
+ for item in values:
1215
+ if item is None:
1216
+ continue
1217
+ if isinstance(item, (str, int, float, bool)):
1218
+ normalized.append(str(item))
1219
+ continue
1220
+ if not isinstance(item, dict):
1221
+ continue
1222
+ for key in ("value", "id", "uid", "userId", "applyId", "phone", "email", "name", "title", "label"):
1223
+ value = item.get(key)
1224
+ if value not in (None, ""):
1225
+ normalized.append(str(value))
1226
+ break
1227
+ deduped: list[str] = []
1228
+ seen: set[str] = set()
1229
+ for item in normalized:
1230
+ if item in seen:
1231
+ continue
1232
+ deduped.append(item)
1233
+ seen.add(item)
1234
+ return deduped
1235
+
1236
+ def _sanitize_associated_chart(self, asos_chart: dict[str, Any]) -> dict[str, Any]:
1237
+ return {
1238
+ "id": asos_chart.get("id"),
1239
+ "appKey": asos_chart.get("appKey"),
1240
+ "formTitle": asos_chart.get("formTitle"),
1241
+ "chartKey": asos_chart.get("chartKey"),
1242
+ "chartName": asos_chart.get("chartName"),
1243
+ "chartType": asos_chart.get("chartType"),
1244
+ "matchRules": asos_chart.get("matchRules") or [],
1245
+ "sourceType": asos_chart.get("sourceType"),
1246
+ "graphType": asos_chart.get("graphType"),
1247
+ "viewType": asos_chart.get("viewType"),
1248
+ }
1249
+
1250
+ def _qingflow_chart_uses_apply_filter(self, context: BackendRequestContext, chart_key: str) -> bool:
1251
+ if not chart_key:
1252
+ return False
1253
+ try:
1254
+ auth = self.backend.request(
1255
+ "GET",
1256
+ context,
1257
+ f"/chart/{chart_key}/auth",
1258
+ )
1259
+ except QingflowApiError:
1260
+ return False
1261
+ if not isinstance(auth, dict):
1262
+ return False
1263
+ return self._coerce_bool(auth.get("detailedViewStatus")) and auth.get("lastViewType") == 1
1264
+
1265
+ def _normalize_chart_result(self, payload: Any) -> dict[str, Any]:
1266
+ if isinstance(payload, dict):
1267
+ rows = payload.get("rows")
1268
+ if not isinstance(rows, list):
1269
+ rows = payload.get("list") if isinstance(payload.get("list"), list) else []
1270
+ series = payload.get("series")
1271
+ if not isinstance(series, list):
1272
+ series = payload.get("xAxis") if isinstance(payload.get("xAxis"), list) else []
1273
+ metrics = payload.get("metrics")
1274
+ if not isinstance(metrics, list):
1275
+ metrics = payload.get("yAxis") if isinstance(payload.get("yAxis"), list) else []
1276
+ summary = payload.get("summary") if isinstance(payload.get("summary"), dict) else {}
1277
+ return {
1278
+ "summary": summary,
1279
+ "rows": rows or [],
1280
+ "series": series or [],
1281
+ "metrics": metrics or [],
1282
+ }
1283
+ if isinstance(payload, list):
1284
+ return {"summary": {}, "rows": payload, "series": [], "metrics": []}
1285
+ return {"summary": {}, "rows": [], "series": [], "metrics": []}
1286
+
1287
+ def _normalize_workflow_logs(self, payload: Any) -> list[dict[str, Any]]:
1288
+ if isinstance(payload, dict):
1289
+ page = payload.get("list") if isinstance(payload.get("list"), list) else payload.get("rows")
1290
+ if not isinstance(page, list):
1291
+ nested = payload.get("data")
1292
+ if isinstance(nested, dict):
1293
+ page = nested.get("list") if isinstance(nested.get("list"), list) else nested.get("rows")
1294
+ if not isinstance(page, list):
1295
+ page = []
1296
+ elif isinstance(payload, list):
1297
+ page = payload
1298
+ else:
1299
+ page = []
1300
+
1301
+ items: list[dict[str, Any]] = []
1302
+ for node_record in page:
1303
+ if not isinstance(node_record, dict):
1304
+ continue
1305
+ node_id = node_record.get("nodeId")
1306
+ node_name = node_record.get("nodeName")
1307
+ operation_record_list = node_record.get("operationRecordList")
1308
+ if not isinstance(operation_record_list, list):
1309
+ operation_record_list = []
1310
+ for operation in operation_record_list:
1311
+ if not isinstance(operation, dict):
1312
+ continue
1313
+ detail = self._first_nested_operation_detail(operation)
1314
+ items.append(
1315
+ {
1316
+ "log_id": operation.get("workflowNodeOperationRecordId")
1317
+ or node_record.get("workflowNodeProcessRecordId"),
1318
+ "node_id": node_id,
1319
+ "node_name": node_name,
1320
+ "operator": operation.get("operator"),
1321
+ "operation": operation.get("operationType"),
1322
+ "operation_result": detail,
1323
+ "operation_time": operation.get("operationTime"),
1324
+ "remark": self._extract_remark(detail),
1325
+ "signature_url": self._extract_signature_url(detail),
1326
+ "attachments": self._extract_attachments(detail),
1327
+ "qrobot_related": any(
1328
+ operation.get(key)
1329
+ for key in ("qRobotAdd", "qRobotUpdate", "qRobotSMS", "qRobotMail", "webhook")
1330
+ ),
1331
+ }
1332
+ )
1333
+ return items
1334
+
1335
+ def _workflow_log_digest(self, items: list[dict[str, Any]]) -> str | None:
1336
+ if not items:
1337
+ return None
1338
+ try:
1339
+ return json.dumps(items, ensure_ascii=False, sort_keys=True, default=str)
1340
+ except TypeError:
1341
+ return str(items)
1342
+
1343
+ def _first_nested_operation_detail(self, operation: dict[str, Any]) -> Any:
1344
+ for key in ("approval", "filling", "cc", "applicant", "qRobotAdd", "qRobotUpdate", "webhook", "qRobotSMS", "qRobotMail"):
1345
+ value = operation.get(key)
1346
+ if value is not None:
1347
+ return value
1348
+ return None
1349
+
1350
+ def _extract_remark(self, detail: Any) -> Any:
1351
+ if not isinstance(detail, dict):
1352
+ return None
1353
+ for key in ("remark", "feedback", "comment", "content"):
1354
+ value = detail.get(key)
1355
+ if value not in (None, ""):
1356
+ return value
1357
+ return None
1358
+
1359
+ def _extract_signature_url(self, detail: Any) -> Any:
1360
+ if not isinstance(detail, dict):
1361
+ return None
1362
+ for key in ("signatureUrl", "handSignImageUrl"):
1363
+ value = detail.get(key)
1364
+ if value not in (None, ""):
1365
+ return value
1366
+ return None
1367
+
1368
+ def _extract_attachments(self, detail: Any) -> Any:
1369
+ if not isinstance(detail, dict):
1370
+ return []
1371
+ for key in ("attachments", "files", "uploadFiles"):
1372
+ value = detail.get(key)
1373
+ if isinstance(value, list):
1374
+ return value
1375
+ return []
1376
+
1377
+ def _extract_audit_feedback(self, payload: dict[str, Any]) -> str | None:
1378
+ for key in ("audit_feedback", "auditFeedback"):
1379
+ value = payload.get(key)
1380
+ if isinstance(value, str) and value.strip():
1381
+ return value.strip()
1382
+ return None
1383
+
1384
+ def _extract_positive_int(self, payload: dict[str, Any], key: str, *, aliases: tuple[str, ...] = ()) -> int:
1385
+ candidates = (key, *aliases)
1386
+ value: Any = None
1387
+ for candidate in candidates:
1388
+ if candidate in payload:
1389
+ value = payload.get(candidate)
1390
+ break
1391
+ if not isinstance(value, int) or value <= 0:
1392
+ names = ", ".join(candidates)
1393
+ raise_tool_error(QingflowApiError.config_error(f"one of [{names}] must be a positive integer"))
1394
+ return value
1395
+
1396
+ def _require_app_record_and_node(self, app_key: str, record_id: int, workflow_node_id: int) -> None:
1397
+ if not app_key:
1398
+ raise_tool_error(QingflowApiError.config_error("app_key is required"))
1399
+ if record_id <= 0:
1400
+ raise_tool_error(QingflowApiError.config_error("record_id must be positive"))
1401
+ if workflow_node_id <= 0:
1402
+ raise_tool_error(QingflowApiError.config_error("workflow_node_id must be positive"))
1403
+
1404
+ def _coerce_bool(self, value: Any) -> bool:
1405
+ if isinstance(value, bool):
1406
+ return value
1407
+ if isinstance(value, int):
1408
+ return value != 0
1409
+ if isinstance(value, str):
1410
+ return value.strip().lower() in {"1", "true", "yes", "y", "show", "visible", "enabled"}
1411
+ return bool(value)
1412
+
1413
+ def _request_route_payload(self, context: BackendRequestContext) -> dict[str, Any]:
1414
+ describe_route = getattr(self.backend, "describe_route", None)
1415
+ if callable(describe_route):
1416
+ payload = describe_route(context)
1417
+ if isinstance(payload, dict):
1418
+ return payload
1419
+ return {
1420
+ "base_url": context.base_url,
1421
+ "qf_version": context.qf_version,
1422
+ "qf_version_source": context.qf_version_source or ("context" if context.qf_version else "unknown"),
1423
+ }