@josephyan/qingflow-app-builder-mcp 0.2.0-beta.7 → 0.2.0-beta.71

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 (70) hide show
  1. package/README.md +5 -3
  2. package/docs/local-agent-install.md +21 -5
  3. package/npm/bin/qingflow-app-builder-mcp.mjs +1 -1
  4. package/npm/lib/runtime.mjs +168 -12
  5. package/package.json +1 -1
  6. package/pyproject.toml +4 -1
  7. package/skills/qingflow-app-builder/SKILL.md +155 -22
  8. package/skills/qingflow-app-builder/references/create-app.md +51 -21
  9. package/skills/qingflow-app-builder/references/environments.md +1 -1
  10. package/skills/qingflow-app-builder/references/flow-actors-and-permissions.md +123 -0
  11. package/skills/qingflow-app-builder/references/gotchas.md +28 -1
  12. package/skills/qingflow-app-builder/references/solution-playbooks.md +14 -12
  13. package/skills/qingflow-app-builder/references/tool-selection.md +47 -19
  14. package/skills/qingflow-app-builder/references/update-flow.md +112 -25
  15. package/skills/qingflow-app-builder/references/update-layout.md +11 -24
  16. package/skills/qingflow-app-builder/references/update-schema.md +1 -23
  17. package/skills/qingflow-app-builder/references/update-views.md +87 -21
  18. package/skills/qingflow-app-builder-code-integrations/SKILL.md +137 -0
  19. package/skills/qingflow-app-builder-code-integrations/agents/openai.yaml +4 -0
  20. package/skills/qingflow-app-builder-code-integrations/references/code-block.md +66 -0
  21. package/skills/qingflow-app-builder-code-integrations/references/q-linker.md +77 -0
  22. package/src/qingflow_mcp/__init__.py +1 -1
  23. package/src/qingflow_mcp/backend_client.py +210 -0
  24. package/src/qingflow_mcp/builder_facade/models.py +1252 -3
  25. package/src/qingflow_mcp/builder_facade/service.py +11367 -2389
  26. package/src/qingflow_mcp/cli/__init__.py +1 -0
  27. package/src/qingflow_mcp/cli/commands/__init__.py +15 -0
  28. package/src/qingflow_mcp/cli/commands/app.py +40 -0
  29. package/src/qingflow_mcp/cli/commands/auth.py +78 -0
  30. package/src/qingflow_mcp/cli/commands/builder.py +515 -0
  31. package/src/qingflow_mcp/cli/commands/common.py +62 -0
  32. package/src/qingflow_mcp/cli/commands/imports.py +96 -0
  33. package/src/qingflow_mcp/cli/commands/record.py +304 -0
  34. package/src/qingflow_mcp/cli/commands/task.py +89 -0
  35. package/src/qingflow_mcp/cli/commands/workspace.py +33 -0
  36. package/src/qingflow_mcp/cli/context.py +48 -0
  37. package/src/qingflow_mcp/cli/formatters.py +355 -0
  38. package/src/qingflow_mcp/cli/json_io.py +50 -0
  39. package/src/qingflow_mcp/cli/main.py +149 -0
  40. package/src/qingflow_mcp/config.py +39 -0
  41. package/src/qingflow_mcp/import_store.py +121 -0
  42. package/src/qingflow_mcp/list_type_labels.py +24 -0
  43. package/src/qingflow_mcp/response_trim.py +668 -0
  44. package/src/qingflow_mcp/server.py +160 -18
  45. package/src/qingflow_mcp/server_app_builder.py +275 -68
  46. package/src/qingflow_mcp/server_app_user.py +219 -191
  47. package/src/qingflow_mcp/session_store.py +41 -1
  48. package/src/qingflow_mcp/solution/compiler/form_compiler.py +43 -4
  49. package/src/qingflow_mcp/solution/compiler/icon_utils.py +119 -45
  50. package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +41 -2
  51. package/src/qingflow_mcp/solution/executor.py +107 -11
  52. package/src/qingflow_mcp/solution/spec_models.py +2 -0
  53. package/src/qingflow_mcp/tools/ai_builder_tools.py +2032 -127
  54. package/src/qingflow_mcp/tools/app_tools.py +419 -12
  55. package/src/qingflow_mcp/tools/approval_tools.py +571 -72
  56. package/src/qingflow_mcp/tools/auth_tools.py +398 -2
  57. package/src/qingflow_mcp/tools/code_block_tools.py +756 -0
  58. package/src/qingflow_mcp/tools/custom_button_tools.py +179 -0
  59. package/src/qingflow_mcp/tools/directory_tools.py +203 -31
  60. package/src/qingflow_mcp/tools/feedback_tools.py +230 -0
  61. package/src/qingflow_mcp/tools/file_tools.py +1 -0
  62. package/src/qingflow_mcp/tools/import_tools.py +2150 -0
  63. package/src/qingflow_mcp/tools/package_tools.py +18 -4
  64. package/src/qingflow_mcp/tools/portal_tools.py +31 -0
  65. package/src/qingflow_mcp/tools/qingbi_report_tools.py +109 -7
  66. package/src/qingflow_mcp/tools/record_tools.py +9894 -1104
  67. package/src/qingflow_mcp/tools/solution_tools.py +115 -3
  68. package/src/qingflow_mcp/tools/task_context_tools.py +2040 -0
  69. package/src/qingflow_mcp/tools/task_tools.py +376 -225
  70. package/src/qingflow_mcp/tools/workspace_tools.py +163 -19
@@ -0,0 +1,2040 @@
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 .record_tools import (
17
+ LAYOUT_ONLY_QUE_TYPES,
18
+ SUBTABLE_QUE_TYPES,
19
+ RecordTools,
20
+ _build_applicant_hidden_linked_top_level_field_index,
21
+ _build_applicant_top_level_field_index,
22
+ _build_static_schema_linkage_payloads,
23
+ _canonical_value_is_empty,
24
+ _canonicalize_answer_value_for_compare,
25
+ _clone_form_field,
26
+ _coerce_count,
27
+ _collect_linked_required_field_ids,
28
+ _collect_option_linked_field_ids,
29
+ _collect_question_relations,
30
+ _field_ref_payload,
31
+ _merge_field_indexes,
32
+ _subtable_descendant_ids,
33
+ )
34
+ from .task_tools import TaskTools, _task_page_amount, _task_page_items, _task_page_total
35
+
36
+
37
+ class TaskContextTools(ToolBase):
38
+ def __init__(self, sessions, backend) -> None: # type: ignore[no-untyped-def]
39
+ super().__init__(sessions, backend)
40
+ self._task_tools = TaskTools(sessions, backend)
41
+ self._approval_tools = ApprovalTools(sessions, backend)
42
+ self._record_tools = RecordTools(sessions, backend)
43
+
44
+ def register(self, mcp: FastMCP) -> None:
45
+ @mcp.tool()
46
+ def task_list(
47
+ profile: str = DEFAULT_PROFILE,
48
+ task_box: str = "todo",
49
+ flow_status: str = "all",
50
+ app_key: str | None = None,
51
+ workflow_node_id: int | None = None,
52
+ query: str | None = None,
53
+ page: int = 1,
54
+ page_size: int = 20,
55
+ ) -> dict[str, Any]:
56
+ return self.task_list(
57
+ profile=profile,
58
+ task_box=task_box,
59
+ flow_status=flow_status,
60
+ app_key=app_key,
61
+ workflow_node_id=workflow_node_id,
62
+ query=query,
63
+ page=page,
64
+ page_size=page_size,
65
+ )
66
+
67
+ @mcp.tool()
68
+ def task_get(
69
+ profile: str = DEFAULT_PROFILE,
70
+ app_key: str = "",
71
+ record_id: int = 0,
72
+ workflow_node_id: int = 0,
73
+ include_candidates: bool = True,
74
+ include_associated_reports: bool = True,
75
+ ) -> dict[str, Any]:
76
+ return self.task_get(
77
+ profile=profile,
78
+ app_key=app_key,
79
+ record_id=record_id,
80
+ workflow_node_id=workflow_node_id,
81
+ include_candidates=include_candidates,
82
+ include_associated_reports=include_associated_reports,
83
+ )
84
+
85
+ @mcp.tool(description=self._high_risk_tool_description(operation="execute", target="workflow task action"))
86
+ def task_action_execute(
87
+ profile: str = DEFAULT_PROFILE,
88
+ app_key: str = "",
89
+ record_id: int = 0,
90
+ workflow_node_id: int = 0,
91
+ action: str = "",
92
+ payload: dict[str, Any] | None = None,
93
+ fields: dict[str, Any] | None = None,
94
+ ) -> dict[str, Any]:
95
+ return self.task_action_execute(
96
+ profile=profile,
97
+ app_key=app_key,
98
+ record_id=record_id,
99
+ workflow_node_id=workflow_node_id,
100
+ action=action,
101
+ payload=payload or {},
102
+ fields=fields or {},
103
+ )
104
+
105
+ @mcp.tool()
106
+ def task_associated_report_detail_get(
107
+ profile: str = DEFAULT_PROFILE,
108
+ app_key: str = "",
109
+ record_id: int = 0,
110
+ workflow_node_id: int = 0,
111
+ report_id: int = 0,
112
+ page: int = 1,
113
+ page_size: int = 20,
114
+ ) -> dict[str, Any]:
115
+ return self.task_associated_report_detail_get(
116
+ profile=profile,
117
+ app_key=app_key,
118
+ record_id=record_id,
119
+ workflow_node_id=workflow_node_id,
120
+ report_id=report_id,
121
+ page=page,
122
+ page_size=page_size,
123
+ )
124
+
125
+ @mcp.tool()
126
+ def task_workflow_log_get(
127
+ profile: str = DEFAULT_PROFILE,
128
+ app_key: str = "",
129
+ record_id: int = 0,
130
+ workflow_node_id: int = 0,
131
+ ) -> dict[str, Any]:
132
+ return self.task_workflow_log_get(
133
+ profile=profile,
134
+ app_key=app_key,
135
+ record_id=record_id,
136
+ workflow_node_id=workflow_node_id,
137
+ )
138
+
139
+ def task_list(
140
+ self,
141
+ *,
142
+ profile: str,
143
+ task_box: str,
144
+ flow_status: str,
145
+ app_key: str | None,
146
+ workflow_node_id: int | None,
147
+ query: str | None,
148
+ page: int,
149
+ page_size: int,
150
+ ) -> dict[str, Any]:
151
+ normalized_type = self._task_tools._task_box_to_type(task_box)
152
+ normalized_status = self._task_tools._flow_status_to_process_status(flow_status)
153
+ raw = self._task_tools.task_list(
154
+ profile=profile,
155
+ type=normalized_type,
156
+ process_status=normalized_status,
157
+ app_key=app_key,
158
+ node_id=workflow_node_id,
159
+ search_key=query,
160
+ page_num=page,
161
+ page_size=page_size,
162
+ create_time_asc=None,
163
+ )
164
+ task_page = raw.get("page", {})
165
+ items = [
166
+ self._normalize_task_item(item, task_box=task_box, flow_status=flow_status)
167
+ for item in _task_page_items(task_page)
168
+ if isinstance(item, dict)
169
+ ]
170
+ return {
171
+ "profile": profile,
172
+ "ws_id": raw.get("ws_id"),
173
+ "ok": True,
174
+ "request_route": raw.get("request_route"),
175
+ "warnings": [],
176
+ "output_profile": "normal",
177
+ "data": {
178
+ "items": items,
179
+ "pagination": {
180
+ "page": page,
181
+ "page_size": page_size,
182
+ "returned_items": len(items),
183
+ "page_amount": _task_page_amount(task_page),
184
+ "reported_total": _task_page_total(task_page),
185
+ },
186
+ "selection": {
187
+ "task_box": task_box,
188
+ "flow_status": flow_status,
189
+ "app_key": app_key,
190
+ "workflow_node_id": workflow_node_id,
191
+ "query": query,
192
+ },
193
+ },
194
+ }
195
+
196
+ def task_get(
197
+ self,
198
+ *,
199
+ profile: str,
200
+ app_key: str,
201
+ record_id: int,
202
+ workflow_node_id: int,
203
+ include_candidates: bool,
204
+ include_associated_reports: bool,
205
+ ) -> dict[str, Any]:
206
+ self._require_app_record_and_node(app_key, record_id, workflow_node_id)
207
+
208
+ def runner(session_profile, context):
209
+ data = self._build_task_context(
210
+ profile=profile,
211
+ context=context,
212
+ app_key=app_key,
213
+ record_id=record_id,
214
+ workflow_node_id=workflow_node_id,
215
+ include_candidates=include_candidates,
216
+ include_associated_reports=include_associated_reports,
217
+ current_uid=session_profile.uid,
218
+ )
219
+ return {
220
+ "profile": profile,
221
+ "ws_id": session_profile.selected_ws_id,
222
+ "ok": True,
223
+ "request_route": self._request_route_payload(context),
224
+ "warnings": [],
225
+ "output_profile": "normal",
226
+ "data": data,
227
+ }
228
+
229
+ return self._run(profile, runner)
230
+
231
+ def task_action_execute(
232
+ self,
233
+ *,
234
+ profile: str,
235
+ app_key: str,
236
+ record_id: int,
237
+ workflow_node_id: int,
238
+ action: str,
239
+ payload: dict[str, Any],
240
+ fields: dict[str, Any] | None = None,
241
+ ) -> dict[str, Any]:
242
+ self._require_app_record_and_node(app_key, record_id, workflow_node_id)
243
+ normalized_action = (action or "").strip().lower()
244
+ if normalized_action not in {"approve", "reject", "rollback", "transfer", "urge", "save_only"}:
245
+ raise_tool_error(
246
+ QingflowApiError.not_supported(
247
+ "TASK_ACTION_UNSUPPORTED: action must be one of approve, reject, rollback, transfer, urge, or save_only"
248
+ )
249
+ )
250
+ body = dict(payload or {})
251
+ field_updates = dict(fields or {})
252
+ if field_updates and body.get("answers") is not None:
253
+ raise_tool_error(
254
+ QingflowApiError.config_error(
255
+ "task actions must not provide payload.answers and fields at the same time; pass field changes through fields only"
256
+ )
257
+ )
258
+
259
+ def runner(session_profile, context):
260
+ try:
261
+ task_context = self._build_task_context(
262
+ profile=profile,
263
+ context=context,
264
+ app_key=app_key,
265
+ record_id=record_id,
266
+ workflow_node_id=workflow_node_id,
267
+ include_candidates=False,
268
+ include_associated_reports=False,
269
+ current_uid=session_profile.uid,
270
+ )
271
+ except QingflowApiError as error:
272
+ if error.backend_code == 46001:
273
+ return self._task_action_visibility_unverified_response(
274
+ profile=profile,
275
+ session_profile=session_profile,
276
+ context=context,
277
+ app_key=app_key,
278
+ record_id=record_id,
279
+ workflow_node_id=workflow_node_id,
280
+ action=normalized_action,
281
+ source_error=error,
282
+ before_apply_status=None,
283
+ )
284
+ raise
285
+ if normalized_action == "save_only" and not field_updates:
286
+ raise_tool_error(
287
+ QingflowApiError.config_error("fields is required and must be non-empty for action 'save_only'")
288
+ )
289
+ if normalized_action == "transfer":
290
+ target_member_id = self._extract_positive_int(body, "target_member_id", aliases=("uid", "targetMemberId"))
291
+ if target_member_id == session_profile.uid:
292
+ raise_tool_error(
293
+ QingflowApiError.config_error(
294
+ "task transfer does not support transferring to the current user; choose another transfer member"
295
+ )
296
+ )
297
+ capabilities = task_context.get("capabilities") or {}
298
+ available_actions = capabilities.get("available_actions") or []
299
+ if normalized_action not in available_actions:
300
+ raise_tool_error(
301
+ QingflowApiError.config_error(
302
+ f"task action '{normalized_action}' is not currently available for app_key='{app_key}' record_id={record_id} workflow_node_id={workflow_node_id}"
303
+ )
304
+ )
305
+ feedback_required_for = capabilities.get("action_constraints", {}).get("feedback_required_for") or []
306
+ if normalized_action in feedback_required_for and not self._extract_audit_feedback(body):
307
+ raise_tool_error(
308
+ QingflowApiError.config_error(
309
+ f"payload.audit_feedback is required for action '{normalized_action}' on the current node"
310
+ )
311
+ )
312
+ if normalized_action == "urge" and field_updates:
313
+ raise_tool_error(
314
+ QingflowApiError.not_supported(
315
+ "TASK_ACTION_FIELDS_NOT_SUPPORTED: action 'urge' does not support fields because the downstream route does not accept task answers"
316
+ )
317
+ )
318
+ prepared_fields = None
319
+ if field_updates:
320
+ prepared_fields = self._prepare_task_field_update(
321
+ profile=profile,
322
+ context=context,
323
+ app_key=app_key,
324
+ record_id=record_id,
325
+ workflow_node_id=workflow_node_id,
326
+ task_context=task_context,
327
+ fields=field_updates,
328
+ )
329
+ before_apply_status = ((task_context.get("record") or {}).get("apply_status"))
330
+ runtime_baseline = None
331
+ if normalized_action != "save_only":
332
+ runtime_baseline = self._capture_task_runtime_baseline(
333
+ profile=profile,
334
+ context=context,
335
+ app_key=app_key,
336
+ record_id=record_id,
337
+ workflow_node_id=workflow_node_id,
338
+ )
339
+ try:
340
+ raw = self._execute_task_action(
341
+ profile=profile,
342
+ app_key=app_key,
343
+ record_id=record_id,
344
+ workflow_node_id=workflow_node_id,
345
+ normalized_action=normalized_action,
346
+ payload=body,
347
+ prepared_fields=prepared_fields,
348
+ )
349
+ except QingflowApiError as error:
350
+ if error.backend_code == 46001:
351
+ return self._task_action_visibility_unverified_response(
352
+ profile=profile,
353
+ session_profile=session_profile,
354
+ context=context,
355
+ app_key=app_key,
356
+ record_id=record_id,
357
+ workflow_node_id=workflow_node_id,
358
+ action=normalized_action,
359
+ source_error=error,
360
+ before_apply_status=before_apply_status,
361
+ )
362
+ raise
363
+
364
+ if normalized_action == "save_only":
365
+ verification, warnings = self._verify_task_save_only(
366
+ context=context,
367
+ app_key=app_key,
368
+ record_id=record_id,
369
+ workflow_node_id=workflow_node_id,
370
+ before_apply_status=before_apply_status,
371
+ expected_answers=((prepared_fields or {}).get("normalized_answers") or []),
372
+ task_context=task_context,
373
+ )
374
+ save_verified = bool(verification.get("fields_saved_verified")) and bool(verification.get("task_still_actionable"))
375
+ status = "success" if save_verified else "failed"
376
+ error_code = None if save_verified else "TASK_SAVE_ONLY_VERIFICATION_FAILED"
377
+ else:
378
+ verification, warnings = self._verify_task_action_runtime(
379
+ profile=profile,
380
+ context=context,
381
+ app_key=app_key,
382
+ record_id=record_id,
383
+ workflow_node_id=workflow_node_id,
384
+ action=normalized_action,
385
+ before_apply_status=before_apply_status,
386
+ runtime_baseline=runtime_baseline,
387
+ )
388
+ runtime_verified = bool(verification.get("runtime_continuation_verified"))
389
+ status = "success" if runtime_verified else "partial_success"
390
+ error_code = None if runtime_verified else "WORKFLOW_CONTINUATION_UNVERIFIED"
391
+ return {
392
+ "profile": raw.get("profile", profile),
393
+ "ws_id": raw.get("ws_id", session_profile.selected_ws_id),
394
+ "ok": bool(raw.get("ok", True)) and status != "failed",
395
+ "status": status,
396
+ "error_code": error_code,
397
+ "request_route": raw.get("request_route") or self._request_route_payload(context),
398
+ "warnings": warnings,
399
+ "verification": verification,
400
+ "output_profile": "normal",
401
+ "data": {
402
+ "action": normalized_action,
403
+ "resource": {
404
+ "app_key": app_key,
405
+ "record_id": record_id,
406
+ "workflow_node_id": workflow_node_id,
407
+ },
408
+ "selection": {"action": normalized_action},
409
+ "result": raw.get("result"),
410
+ "human_review": True,
411
+ "field_update_applied": bool(field_updates),
412
+ },
413
+ }
414
+
415
+ return self._run(profile, runner)
416
+
417
+ def _execute_task_action(
418
+ self,
419
+ *,
420
+ profile: str,
421
+ app_key: str,
422
+ record_id: int,
423
+ workflow_node_id: int,
424
+ normalized_action: str,
425
+ payload: dict[str, Any],
426
+ prepared_fields: dict[str, Any] | None,
427
+ ) -> dict[str, Any]:
428
+ merged_answers = None
429
+ if isinstance(prepared_fields, dict):
430
+ candidate_answers = prepared_fields.get("merged_answers")
431
+ if isinstance(candidate_answers, list):
432
+ merged_answers = candidate_answers
433
+ if normalized_action == "approve":
434
+ action_payload = dict(payload)
435
+ action_payload["nodeId"] = workflow_node_id
436
+ if merged_answers is not None:
437
+ action_payload["answers"] = merged_answers
438
+ return self._approval_tools.record_approve(
439
+ profile=profile,
440
+ app_key=app_key,
441
+ apply_id=record_id,
442
+ payload=action_payload,
443
+ )
444
+ if normalized_action == "reject":
445
+ action_payload = dict(payload)
446
+ action_payload["nodeId"] = workflow_node_id
447
+ if merged_answers is not None:
448
+ action_payload["answers"] = merged_answers
449
+ if not self._extract_audit_feedback(action_payload):
450
+ raise_tool_error(QingflowApiError.config_error("payload.audit_feedback is required for reject"))
451
+ return self._approval_tools.record_reject(
452
+ profile=profile,
453
+ app_key=app_key,
454
+ apply_id=record_id,
455
+ payload=action_payload,
456
+ )
457
+ if normalized_action == "rollback":
458
+ target_node_id = self._extract_positive_int(payload, "target_workflow_node_id", aliases=("targetAuditNodeId", "targetWorkflowNodeId"))
459
+ action_payload: JSONObject = {
460
+ "auditNodeId": workflow_node_id,
461
+ "targetAuditNodeId": target_node_id,
462
+ }
463
+ audit_feedback = self._extract_audit_feedback(payload)
464
+ if audit_feedback:
465
+ action_payload["auditFeedback"] = audit_feedback
466
+ if merged_answers is not None:
467
+ action_payload["answers"] = merged_answers
468
+ return self._approval_tools.record_rollback(
469
+ profile=profile,
470
+ app_key=app_key,
471
+ apply_id=record_id,
472
+ payload=action_payload,
473
+ )
474
+ if normalized_action == "transfer":
475
+ target_member_id = self._extract_positive_int(payload, "target_member_id", aliases=("uid", "targetMemberId"))
476
+ action_payload = {
477
+ "auditNodeId": workflow_node_id,
478
+ "uid": target_member_id,
479
+ }
480
+ audit_feedback = self._extract_audit_feedback(payload)
481
+ if audit_feedback:
482
+ action_payload["auditFeedback"] = audit_feedback
483
+ if merged_answers is not None:
484
+ action_payload["answers"] = merged_answers
485
+ return self._approval_tools.record_transfer(
486
+ profile=profile,
487
+ app_key=app_key,
488
+ apply_id=record_id,
489
+ payload=action_payload,
490
+ )
491
+ if normalized_action == "save_only":
492
+ if merged_answers is None:
493
+ raise_tool_error(QingflowApiError.config_error("fields is required for action 'save_only'"))
494
+ return self._task_save_only(
495
+ profile=profile,
496
+ app_key=app_key,
497
+ record_id=record_id,
498
+ workflow_node_id=workflow_node_id,
499
+ merged_answers=merged_answers,
500
+ )
501
+ return self._task_tools.task_urge(
502
+ profile=profile,
503
+ app_key=app_key,
504
+ row_record_id=record_id,
505
+ )
506
+
507
+ def _verify_task_action_runtime(
508
+ self,
509
+ *,
510
+ profile: str,
511
+ context: BackendRequestContext,
512
+ app_key: str,
513
+ record_id: int,
514
+ workflow_node_id: int,
515
+ action: str,
516
+ before_apply_status: Any,
517
+ runtime_baseline: dict[str, Any] | None = None,
518
+ ) -> tuple[dict[str, Any], list[dict[str, Any]]]:
519
+ verification: dict[str, Any] = {
520
+ "action_executed": True,
521
+ "runtime_continuation_verified": action == "urge",
522
+ "scope": "workflow_runtime",
523
+ "task_context_visibility_verified": True,
524
+ }
525
+ warnings: list[dict[str, Any]] = []
526
+ if action == "urge":
527
+ return verification, warnings
528
+
529
+ state_after: dict[str, Any] | None = None
530
+ try:
531
+ state_after = self.backend.request(
532
+ "GET",
533
+ context,
534
+ f"/app/{app_key}/apply/{record_id}",
535
+ params={"role": 3, "listType": 1, "auditNodeId": workflow_node_id},
536
+ )
537
+ verification["record_state_readable"] = True
538
+ verification["before_apply_status"] = before_apply_status
539
+ verification["after_apply_status"] = state_after.get("applyStatus") if isinstance(state_after, dict) else None
540
+ verification["record_state_changed"] = verification["after_apply_status"] != before_apply_status
541
+ except QingflowApiError as error:
542
+ verification["record_state_readable"] = False
543
+ verification["record_state_changed"] = False
544
+ verification["record_state_error"] = {
545
+ "http_status": error.http_status,
546
+ "backend_code": error.backend_code,
547
+ "category": error.category,
548
+ }
549
+
550
+ log_items: list[dict[str, Any]] = []
551
+ try:
552
+ log_page = self.backend.request(
553
+ "POST",
554
+ context,
555
+ "/application/workflow/node/record",
556
+ json_body={
557
+ "key": app_key,
558
+ "rowRecordId": record_id,
559
+ "nodeId": workflow_node_id,
560
+ "role": 3,
561
+ "pageNum": 1,
562
+ "pageSize": 50,
563
+ },
564
+ )
565
+ log_items = self._normalize_workflow_logs(log_page)
566
+ verification["workflow_log_visible"] = True
567
+ verification["workflow_log_count"] = len(log_items)
568
+ except QingflowApiError as error:
569
+ verification["workflow_log_visible"] = False
570
+ verification["workflow_log_count"] = None
571
+ verification["workflow_log_error"] = {
572
+ "http_status": error.http_status,
573
+ "backend_code": error.backend_code,
574
+ "category": error.category,
575
+ }
576
+
577
+ todo_items = self._safe_task_list_items(profile=profile, task_box="todo", app_key=app_key)
578
+ initiated_items = self._safe_task_list_items(profile=profile, task_box="initiated", app_key=app_key)
579
+ downstream_todo_detected = any(
580
+ int(item.get("record_id") or 0) == record_id and int(item.get("workflow_node_id") or 0) != workflow_node_id
581
+ for item in todo_items
582
+ if isinstance(item, dict)
583
+ )
584
+ initiated_visible = any(
585
+ int(item.get("record_id") or 0) == record_id
586
+ for item in initiated_items
587
+ if isinstance(item, dict)
588
+ )
589
+ verification["downstream_todo_detected"] = downstream_todo_detected
590
+ verification["initiated_task_visible"] = initiated_visible
591
+ baseline_downstream_nodes = set()
592
+ baseline_log_count = None
593
+ baseline_log_digest = None
594
+ if isinstance(runtime_baseline, dict):
595
+ baseline_downstream_nodes = set(runtime_baseline.get("downstream_todo_nodes") or [])
596
+ baseline_log_count = runtime_baseline.get("workflow_log_count")
597
+ baseline_log_digest = runtime_baseline.get("workflow_log_digest")
598
+ current_downstream_nodes = {
599
+ int(item.get("workflow_node_id") or 0)
600
+ for item in todo_items
601
+ if isinstance(item, dict)
602
+ and int(item.get("record_id") or 0) == record_id
603
+ and int(item.get("workflow_node_id") or 0) != workflow_node_id
604
+ }
605
+ workflow_log_digest = self._workflow_log_digest(log_items)
606
+ verification["downstream_todo_nodes"] = sorted(node_id for node_id in current_downstream_nodes if node_id > 0)
607
+ verification["downstream_todo_changed"] = current_downstream_nodes != baseline_downstream_nodes
608
+ verification["workflow_log_advanced"] = bool(
609
+ verification.get("workflow_log_visible")
610
+ and (
611
+ (isinstance(baseline_log_count, int) and len(log_items) > baseline_log_count)
612
+ or (baseline_log_digest is not None and workflow_log_digest is not None and workflow_log_digest != baseline_log_digest)
613
+ )
614
+ )
615
+ runtime_verified = bool(
616
+ verification.get("record_state_changed")
617
+ or verification.get("downstream_todo_changed")
618
+ or verification.get("workflow_log_advanced")
619
+ )
620
+ verification["runtime_continuation_verified"] = runtime_verified
621
+ if not runtime_verified:
622
+ warnings.append(
623
+ {
624
+ "code": "WORKFLOW_CONTINUATION_UNVERIFIED",
625
+ "message": "task action executed, but MCP could not verify downstream workflow continuation from record state, workflow logs, or downstream todo tasks.",
626
+ }
627
+ )
628
+ return verification, warnings
629
+
630
+ def _verify_task_save_only(
631
+ self,
632
+ *,
633
+ context: BackendRequestContext,
634
+ app_key: str,
635
+ record_id: int,
636
+ workflow_node_id: int,
637
+ before_apply_status: Any,
638
+ expected_answers: list[dict[str, Any]],
639
+ task_context: dict[str, Any],
640
+ ) -> tuple[dict[str, Any], list[dict[str, Any]]]:
641
+ verification: dict[str, Any] = {
642
+ "action_executed": True,
643
+ "scope": "task_field_save",
644
+ "runtime_continuation_verified": False,
645
+ "task_context_visibility_verified": True,
646
+ "fields_saved_verified": False,
647
+ "task_still_actionable": False,
648
+ "workflow_not_advanced": False,
649
+ "before_apply_status": before_apply_status,
650
+ }
651
+ warnings: list[dict[str, Any]] = []
652
+ try:
653
+ detail = self.backend.request(
654
+ "GET",
655
+ context,
656
+ f"/app/{app_key}/apply/{record_id}",
657
+ params={"role": 3, "listType": 1, "auditNodeId": workflow_node_id},
658
+ )
659
+ except QingflowApiError as error:
660
+ verification["record_state_readable"] = False
661
+ verification["task_context_visibility_verified"] = False
662
+ verification["transport_error"] = {
663
+ "http_status": error.http_status,
664
+ "backend_code": error.backend_code,
665
+ "category": error.category,
666
+ }
667
+ warnings.append(
668
+ {
669
+ "code": "TASK_SAVE_ONLY_UNVERIFIED",
670
+ "message": "save_only write was sent, but MCP could not re-read the current task context to verify that the node remains actionable and fields were saved.",
671
+ }
672
+ )
673
+ return verification, warnings
674
+
675
+ verification["record_state_readable"] = True
676
+ verification["task_still_actionable"] = True
677
+ after_apply_status = detail.get("applyStatus") if isinstance(detail, dict) else None
678
+ verification["after_apply_status"] = after_apply_status
679
+ verification["workflow_not_advanced"] = after_apply_status == before_apply_status
680
+ current_record = task_context.get("record") if isinstance(task_context.get("record"), dict) else {}
681
+ actual_answers = detail.get("answers") if isinstance(detail, dict) and isinstance(detail.get("answers"), list) else []
682
+ expected_by_id = {
683
+ que_id: answer
684
+ for answer in expected_answers
685
+ if isinstance(answer, dict) and (que_id := _coerce_count(answer.get("queId"))) is not None and que_id > 0
686
+ }
687
+ actual_by_id = {
688
+ que_id: answer
689
+ for answer in actual_answers
690
+ if isinstance(answer, dict) and (que_id := _coerce_count(answer.get("queId"))) is not None and que_id > 0
691
+ }
692
+ update_schema = task_context.get("update_schema") if isinstance(task_context.get("update_schema"), dict) else {}
693
+ writable_titles = {
694
+ item.get("title"): item
695
+ for item in (update_schema.get("writable_fields") or [])
696
+ if isinstance(item, dict) and item.get("title")
697
+ }
698
+ missing_fields: list[dict[str, Any]] = []
699
+ mismatched_fields: list[dict[str, Any]] = []
700
+ for que_id, expected in expected_by_id.items():
701
+ actual = actual_by_id.get(que_id)
702
+ title = None
703
+ if isinstance(current_record, dict):
704
+ for answer in (current_record.get("answers") or []):
705
+ if isinstance(answer, dict) and _coerce_count(answer.get("queId")) == que_id:
706
+ title = answer.get("queTitle")
707
+ break
708
+ if title is None:
709
+ title = next((key for key, item in writable_titles.items() if isinstance(item, dict) and item.get("field_id") == que_id), None)
710
+ field_payload = {"que_id": que_id, "que_title": title}
711
+ if actual is None:
712
+ missing_fields.append(field_payload)
713
+ continue
714
+ expected_value = _canonicalize_answer_value_for_compare(expected, None)
715
+ actual_value = _canonicalize_answer_value_for_compare(actual, None)
716
+ if _canonical_value_is_empty(expected_value):
717
+ continue
718
+ if actual_value != expected_value:
719
+ mismatched_fields.append(
720
+ {
721
+ **field_payload,
722
+ "expected": expected_value,
723
+ "actual": actual_value,
724
+ }
725
+ )
726
+ verification["missing_fields"] = missing_fields
727
+ verification["mismatched_fields"] = mismatched_fields
728
+ verification["fields_saved_verified"] = not missing_fields and not mismatched_fields
729
+ if not verification["workflow_not_advanced"]:
730
+ warnings.append(
731
+ {
732
+ "code": "TASK_SAVE_ONLY_ADVANCED_WORKFLOW",
733
+ "message": "save_only unexpectedly changed the workflow runtime state; the task should have remained on the current node.",
734
+ }
735
+ )
736
+ if not verification["fields_saved_verified"]:
737
+ warnings.append(
738
+ {
739
+ "code": "TASK_SAVE_ONLY_FIELD_VERIFICATION_FAILED",
740
+ "message": "save_only completed, but MCP could not verify that all requested field changes were persisted on the current task node.",
741
+ }
742
+ )
743
+ return verification, warnings
744
+
745
+ def _capture_task_runtime_baseline(
746
+ self,
747
+ *,
748
+ profile: str,
749
+ context: BackendRequestContext,
750
+ app_key: str,
751
+ record_id: int,
752
+ workflow_node_id: int,
753
+ ) -> dict[str, Any]:
754
+ baseline: dict[str, Any] = {
755
+ "workflow_log_visible": False,
756
+ "workflow_log_count": None,
757
+ "workflow_log_digest": None,
758
+ "downstream_todo_nodes": [],
759
+ }
760
+ try:
761
+ log_page = self.backend.request(
762
+ "POST",
763
+ context,
764
+ "/application/workflow/node/record",
765
+ json_body={
766
+ "key": app_key,
767
+ "rowRecordId": record_id,
768
+ "nodeId": workflow_node_id,
769
+ "role": 3,
770
+ "pageNum": 1,
771
+ "pageSize": 50,
772
+ },
773
+ )
774
+ log_items = self._normalize_workflow_logs(log_page)
775
+ baseline["workflow_log_visible"] = True
776
+ baseline["workflow_log_count"] = len(log_items)
777
+ baseline["workflow_log_digest"] = self._workflow_log_digest(log_items)
778
+ except QingflowApiError:
779
+ pass
780
+ todo_items = self._safe_task_list_items(profile=profile, task_box="todo", app_key=app_key)
781
+ baseline["downstream_todo_nodes"] = sorted(
782
+ {
783
+ int(item.get("workflow_node_id") or 0)
784
+ for item in todo_items
785
+ if isinstance(item, dict)
786
+ and int(item.get("record_id") or 0) == record_id
787
+ and int(item.get("workflow_node_id") or 0) != workflow_node_id
788
+ }
789
+ )
790
+ return baseline
791
+
792
+ def _task_action_visibility_unverified_response(
793
+ self,
794
+ *,
795
+ profile: str,
796
+ session_profile,
797
+ context: BackendRequestContext,
798
+ app_key: str,
799
+ record_id: int,
800
+ workflow_node_id: int,
801
+ action: str,
802
+ source_error: QingflowApiError,
803
+ before_apply_status: Any,
804
+ ) -> dict[str, Any]:
805
+ verification, warnings = self._verify_task_action_runtime(
806
+ profile=profile,
807
+ context=context,
808
+ app_key=app_key,
809
+ record_id=record_id,
810
+ workflow_node_id=workflow_node_id,
811
+ action=action,
812
+ before_apply_status=before_apply_status,
813
+ )
814
+ verification["action_executed"] = False
815
+ verification["task_context_visibility_verified"] = bool(verification.get("runtime_continuation_verified"))
816
+ if verification["task_context_visibility_verified"]:
817
+ warnings.append(
818
+ {
819
+ "code": "TASK_ALREADY_PROCESSED_UNCONFIRMED_ACTOR",
820
+ "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.",
821
+ }
822
+ )
823
+ return {
824
+ "profile": profile,
825
+ "ws_id": session_profile.selected_ws_id,
826
+ "ok": True,
827
+ "status": "partial_success",
828
+ "error_code": "TASK_ALREADY_PROCESSED",
829
+ "request_route": self._request_route_payload(context),
830
+ "warnings": warnings,
831
+ "verification": verification,
832
+ "output_profile": "normal",
833
+ "data": {
834
+ "action": action,
835
+ "resource": {
836
+ "app_key": app_key,
837
+ "record_id": record_id,
838
+ "workflow_node_id": workflow_node_id,
839
+ },
840
+ "selection": {"action": action},
841
+ "result": None,
842
+ "human_review": True,
843
+ },
844
+ }
845
+ warnings.append(
846
+ {
847
+ "code": "TASK_CONTEXT_VISIBILITY_UNVERIFIED",
848
+ "message": "the task is no longer actionable, and MCP could not verify from state or workflow logs whether it was already processed.",
849
+ }
850
+ )
851
+ return {
852
+ "profile": profile,
853
+ "ws_id": session_profile.selected_ws_id,
854
+ "ok": False,
855
+ "status": "failed",
856
+ "error_code": "TASK_CONTEXT_VISIBILITY_UNVERIFIED",
857
+ "request_route": self._request_route_payload(context),
858
+ "warnings": warnings,
859
+ "verification": verification,
860
+ "output_profile": "normal",
861
+ "data": {
862
+ "action": action,
863
+ "resource": {
864
+ "app_key": app_key,
865
+ "record_id": record_id,
866
+ "workflow_node_id": workflow_node_id,
867
+ },
868
+ "selection": {"action": action},
869
+ "result": None,
870
+ "human_review": True,
871
+ "transport_error": {
872
+ "http_status": source_error.http_status,
873
+ "backend_code": source_error.backend_code,
874
+ "category": source_error.category,
875
+ },
876
+ },
877
+ }
878
+
879
+ def _safe_task_list_items(self, *, profile: str, task_box: str, app_key: str) -> list[dict[str, Any]]:
880
+ try:
881
+ response = self.task_list(
882
+ profile=profile,
883
+ task_box=task_box,
884
+ flow_status="all",
885
+ app_key=app_key,
886
+ workflow_node_id=None,
887
+ query=None,
888
+ page=1,
889
+ page_size=50,
890
+ )
891
+ except QingflowApiError:
892
+ return []
893
+ data = response.get("data") if isinstance(response, dict) else None
894
+ items = data.get("items") if isinstance(data, dict) else None
895
+ if not isinstance(items, list):
896
+ return []
897
+ return [item for item in items if isinstance(item, dict)]
898
+
899
+ def task_associated_report_detail_get(
900
+ self,
901
+ *,
902
+ profile: str,
903
+ app_key: str,
904
+ record_id: int,
905
+ workflow_node_id: int,
906
+ report_id: int,
907
+ page: int,
908
+ page_size: int,
909
+ ) -> dict[str, Any]:
910
+ self._require_app_record_and_node(app_key, record_id, workflow_node_id)
911
+ if report_id <= 0:
912
+ raise_tool_error(QingflowApiError.config_error("report_id must be positive"))
913
+ if page <= 0 or page_size <= 0:
914
+ raise_tool_error(QingflowApiError.config_error("page and page_size must be positive"))
915
+
916
+ def runner(session_profile, context):
917
+ task_context = self._build_task_context(
918
+ profile=profile,
919
+ context=context,
920
+ app_key=app_key,
921
+ record_id=record_id,
922
+ workflow_node_id=workflow_node_id,
923
+ include_candidates=False,
924
+ include_associated_reports=True,
925
+ current_uid=session_profile.uid,
926
+ )
927
+ report_item = self._find_associated_report(task_context, report_id)
928
+ if report_item is None:
929
+ raise_tool_error(
930
+ QingflowApiError.config_error(
931
+ f"report_id={report_id} is not visible for app_key='{app_key}' record_id={record_id} workflow_node_id={workflow_node_id}"
932
+ )
933
+ )
934
+ association_query = self._build_association_query(
935
+ report_item["raw"],
936
+ task_context.get("record", {}).get("answers") or [],
937
+ )
938
+ selection = {
939
+ "app_key": app_key,
940
+ "record_id": record_id,
941
+ "workflow_node_id": workflow_node_id,
942
+ "report_id": report_id,
943
+ "target_app_key": report_item.get("target_app_key"),
944
+ "target_app_name": report_item.get("target_app_name"),
945
+ "chart_key": report_item.get("chart_key"),
946
+ "chart_name": report_item.get("chart_name"),
947
+ }
948
+ context_payload = {
949
+ "match_rules": report_item.get("match_rules") or [],
950
+ "resolved_filters": association_query.get("keyQueValues") or [],
951
+ }
952
+
953
+ if report_item.get("graph_type") == "view":
954
+ viewgraph_key = str(report_item.get("chart_key") or "")
955
+ body = {
956
+ "filter": {},
957
+ "viewgraphKey": viewgraph_key,
958
+ "equipmentType": 0,
959
+ "associationQuery": association_query,
960
+ }
961
+ result = self.backend.request(
962
+ "POST",
963
+ context,
964
+ f"/view/{viewgraph_key}/apply/filter",
965
+ json_body=body,
966
+ )
967
+ items = _task_page_items(result)
968
+ return {
969
+ "profile": profile,
970
+ "ws_id": session_profile.selected_ws_id,
971
+ "ok": True,
972
+ "request_route": self._request_route_payload(context),
973
+ "warnings": [],
974
+ "output_profile": "normal",
975
+ "data": {
976
+ "result_type": "view_list",
977
+ "result": {
978
+ "items": items,
979
+ "pagination": {
980
+ "page": page,
981
+ "page_size": page_size,
982
+ "returned_items": len(items),
983
+ "page_amount": _task_page_amount(result),
984
+ "reported_total": _task_page_total(result),
985
+ },
986
+ },
987
+ "selection": selection,
988
+ "context": context_payload,
989
+ },
990
+ }
991
+
992
+ chart_key = str(report_item.get("chart_key") or "")
993
+ source_type = report_item.get("source_type")
994
+ if source_type == "qingbi":
995
+ qingbi_context = BackendRequestContext(
996
+ base_url=_qingbi_base_url(context.base_url),
997
+ token=context.token,
998
+ ws_id=context.ws_id,
999
+ qf_request_id=context.qf_request_id,
1000
+ qf_version=context.qf_version,
1001
+ qf_version_source=context.qf_version_source,
1002
+ )
1003
+ chart_result = self.backend.request(
1004
+ "POST",
1005
+ qingbi_context,
1006
+ f"/qingbi/charts/data/{chart_key}",
1007
+ params={
1008
+ "qfUUID": uuid4().hex,
1009
+ "pageNum": page,
1010
+ "pageSize": page_size,
1011
+ },
1012
+ json_body={
1013
+ "asosChartId": report_id,
1014
+ "keyQueValues": association_query.get("keyQueValues") or [],
1015
+ },
1016
+ )
1017
+ request_route = {
1018
+ "base_url": qingbi_context.base_url,
1019
+ "qf_version": qingbi_context.qf_version,
1020
+ "qf_version_source": qingbi_context.qf_version_source or "context",
1021
+ }
1022
+ elif self._qingflow_chart_uses_apply_filter(context, chart_key):
1023
+ chart_result = self.backend.request(
1024
+ "POST",
1025
+ context,
1026
+ f"/chart/{chart_key}/apply/filter",
1027
+ json_body={
1028
+ "filter": {
1029
+ "pageNum": page,
1030
+ "pageSize": page_size,
1031
+ },
1032
+ "asosChartId": report_id,
1033
+ "keyQueValues": association_query.get("keyQueValues") or [],
1034
+ },
1035
+ )
1036
+ items = _task_page_items(chart_result)
1037
+ return {
1038
+ "profile": profile,
1039
+ "ws_id": session_profile.selected_ws_id,
1040
+ "ok": True,
1041
+ "request_route": self._request_route_payload(context),
1042
+ "warnings": [],
1043
+ "output_profile": "normal",
1044
+ "data": {
1045
+ "result_type": "view_list",
1046
+ "result": {
1047
+ "items": items,
1048
+ "pagination": {
1049
+ "page": page,
1050
+ "page_size": page_size,
1051
+ "returned_items": len(items),
1052
+ "page_amount": _task_page_amount(chart_result),
1053
+ "reported_total": _task_page_total(chart_result),
1054
+ },
1055
+ },
1056
+ "selection": selection,
1057
+ "context": context_payload,
1058
+ },
1059
+ }
1060
+ else:
1061
+ chart_result = self.backend.request(
1062
+ "POST",
1063
+ context,
1064
+ f"/chart/{chart_key}/chartData",
1065
+ json_body={
1066
+ "asosChartId": report_id,
1067
+ "keyQueValues": association_query.get("keyQueValues") or [],
1068
+ },
1069
+ )
1070
+ request_route = self._request_route_payload(context)
1071
+
1072
+ return {
1073
+ "profile": profile,
1074
+ "ws_id": session_profile.selected_ws_id,
1075
+ "ok": True,
1076
+ "request_route": request_route,
1077
+ "warnings": [],
1078
+ "output_profile": "normal",
1079
+ "data": {
1080
+ "result_type": "chart_data",
1081
+ "result": self._normalize_chart_result(chart_result),
1082
+ "selection": selection,
1083
+ "context": context_payload,
1084
+ },
1085
+ }
1086
+
1087
+ return self._run(profile, runner)
1088
+
1089
+ def task_workflow_log_get(
1090
+ self,
1091
+ *,
1092
+ profile: str,
1093
+ app_key: str,
1094
+ record_id: int,
1095
+ workflow_node_id: int,
1096
+ ) -> dict[str, Any]:
1097
+ self._require_app_record_and_node(app_key, record_id, workflow_node_id)
1098
+
1099
+ def runner(session_profile, context):
1100
+ task_context = self._build_task_context(
1101
+ profile=profile,
1102
+ context=context,
1103
+ app_key=app_key,
1104
+ record_id=record_id,
1105
+ workflow_node_id=workflow_node_id,
1106
+ include_candidates=False,
1107
+ include_associated_reports=False,
1108
+ current_uid=session_profile.uid,
1109
+ )
1110
+ visibility = task_context.get("visibility") or {}
1111
+ if not visibility.get("audit_record_visible"):
1112
+ raise_tool_error(
1113
+ QingflowApiError.config_error(
1114
+ f"workflow logs are not visible for app_key='{app_key}' record_id={record_id} workflow_node_id={workflow_node_id}"
1115
+ )
1116
+ )
1117
+ page = self.backend.request(
1118
+ "POST",
1119
+ context,
1120
+ "/application/workflow/node/record",
1121
+ json_body={
1122
+ "key": app_key,
1123
+ "rowRecordId": record_id,
1124
+ "nodeId": workflow_node_id,
1125
+ "role": 3,
1126
+ "pageNum": 1,
1127
+ "pageSize": 200,
1128
+ },
1129
+ )
1130
+ items = self._normalize_workflow_logs(page)
1131
+ return {
1132
+ "profile": profile,
1133
+ "ws_id": session_profile.selected_ws_id,
1134
+ "ok": True,
1135
+ "request_route": self._request_route_payload(context),
1136
+ "warnings": [],
1137
+ "output_profile": "normal",
1138
+ "data": {
1139
+ "selection": {
1140
+ "app_key": app_key,
1141
+ "record_id": record_id,
1142
+ "workflow_node_id": workflow_node_id,
1143
+ },
1144
+ "visibility": {
1145
+ "audit_record_visible": visibility.get("audit_record_visible"),
1146
+ "qrobot_record_visible": visibility.get("qrobot_record_visible"),
1147
+ },
1148
+ "items": items,
1149
+ },
1150
+ }
1151
+
1152
+ return self._run(profile, runner)
1153
+
1154
+ def _build_task_context(
1155
+ self,
1156
+ profile: str,
1157
+ context: BackendRequestContext,
1158
+ *,
1159
+ app_key: str,
1160
+ record_id: int,
1161
+ workflow_node_id: int,
1162
+ include_candidates: bool,
1163
+ include_associated_reports: bool,
1164
+ current_uid: int | None = None,
1165
+ ) -> dict[str, Any]:
1166
+ audit_infos = self.backend.request(
1167
+ "GET",
1168
+ context,
1169
+ f"/app/{app_key}/apply/{record_id}/auditInfo",
1170
+ params={"type": 1},
1171
+ )
1172
+ node_info = self._select_task_node(audit_infos, workflow_node_id, app_key=app_key, record_id=record_id)
1173
+ detail = self.backend.request(
1174
+ "GET",
1175
+ context,
1176
+ f"/app/{app_key}/apply/{record_id}",
1177
+ params={"role": 3, "listType": 1, "auditNodeId": workflow_node_id},
1178
+ )
1179
+ associated_report_visible = self._resolve_associated_report_visible(node_info, detail)
1180
+ associated_reports = {"visible": associated_report_visible, "count": 0, "items": []}
1181
+ if include_associated_reports and associated_report_visible:
1182
+ asos_chart_list = self.backend.request(
1183
+ "GET",
1184
+ context,
1185
+ f"/app/{app_key}/asosChart",
1186
+ params={"role": 3, "auditNodeId": workflow_node_id, "beingDraft": False},
1187
+ )
1188
+ associated_items = [
1189
+ self._normalize_associated_report(item)
1190
+ for item in (asos_chart_list.get("asosCharts") or [])
1191
+ if isinstance(item, dict)
1192
+ ]
1193
+ associated_reports = {
1194
+ "visible": True,
1195
+ "count": len(associated_items),
1196
+ "items": associated_items,
1197
+ }
1198
+ rollback_items: list[dict[str, Any]] = []
1199
+ transfer_items: list[dict[str, Any]] = []
1200
+ if include_candidates:
1201
+ rollback_result = self.backend.request(
1202
+ "GET",
1203
+ context,
1204
+ f"/app/{app_key}/apply/{record_id}/revertNode",
1205
+ params={"auditNodeId": workflow_node_id},
1206
+ )
1207
+ rollback_items = self._rollback_candidate_items(rollback_result)
1208
+ transfer_result = self.backend.request(
1209
+ "GET",
1210
+ context,
1211
+ f"/app/{app_key}/apply/{record_id}/transfer/member",
1212
+ params={"pageNum": 1, "pageSize": 20, "auditNodeId": workflow_node_id},
1213
+ )
1214
+ transfer_items = self._filter_transfer_members(_approval_page_items(transfer_result), current_uid=current_uid)
1215
+
1216
+ update_schema_state = self._build_task_update_schema(
1217
+ profile=profile,
1218
+ context=context,
1219
+ app_key=app_key,
1220
+ record_id=record_id,
1221
+ workflow_node_id=workflow_node_id,
1222
+ node_info=node_info,
1223
+ current_answers=detail.get("answers") or [],
1224
+ )
1225
+ update_schema = update_schema_state["public_schema"]
1226
+ capabilities = self._build_capabilities(
1227
+ node_info,
1228
+ allow_save_only=bool(update_schema.get("writable_fields")),
1229
+ )
1230
+ visibility = self._build_visibility(node_info, detail)
1231
+ return {
1232
+ "task": {
1233
+ "app_key": app_key,
1234
+ "record_id": record_id,
1235
+ "workflow_node_id": workflow_node_id,
1236
+ "workflow_node_name": node_info.get("auditNodeName") or node_info.get("nodeName"),
1237
+ "actionable": True,
1238
+ },
1239
+ "node": {
1240
+ "workflow_node_id": workflow_node_id,
1241
+ "workflow_node_name": node_info.get("auditNodeName") or node_info.get("nodeName"),
1242
+ "raw": dict(node_info),
1243
+ },
1244
+ "record": {
1245
+ "apply_id": detail.get("applyId", record_id),
1246
+ "apply_status": detail.get("applyStatus"),
1247
+ "apply_num": detail.get("applyNum"),
1248
+ "custom_apply_num": detail.get("customApplyNum"),
1249
+ "apply_user": detail.get("applyUser"),
1250
+ "apply_time": detail.get("applyTime"),
1251
+ "last_update_time": detail.get("lastUpdateTime"),
1252
+ "answers": detail.get("answers") or [],
1253
+ },
1254
+ "capabilities": capabilities,
1255
+ "field_permissions": {
1256
+ "que_auth_setting": node_info.get("queAuthSetting") or [],
1257
+ "editable_question_ids": update_schema_state["editable_question_ids"],
1258
+ "editable_question_ids_source": update_schema_state["editable_question_ids_source"],
1259
+ },
1260
+ "visibility": visibility,
1261
+ "associated_reports": associated_reports,
1262
+ "candidates": {
1263
+ "rollback_nodes": rollback_items,
1264
+ "transfer_members": transfer_items,
1265
+ },
1266
+ "workflow_log_summary": {
1267
+ "visible": visibility["audit_record_visible"],
1268
+ "available": visibility["audit_record_visible"],
1269
+ "history_count": None,
1270
+ "qrobot_log_visible": visibility["qrobot_record_visible"],
1271
+ },
1272
+ "update_schema": update_schema,
1273
+ }
1274
+
1275
+ def _normalize_task_item(self, raw: dict[str, Any], *, task_box: str, flow_status: str) -> dict[str, Any]:
1276
+ app_key = raw.get("appKey") or raw.get("app_key")
1277
+ record_id = raw.get("rowRecordId") or raw.get("recordId") or raw.get("applyId")
1278
+ workflow_node_id = raw.get("nodeId") or raw.get("auditNodeId")
1279
+ apply_user = raw.get("applyUser")
1280
+ if apply_user is None:
1281
+ user_uid = raw.get("applyUserUid")
1282
+ user_name = raw.get("applyUserName")
1283
+ if user_uid is not None or user_name is not None:
1284
+ apply_user = {"uid": user_uid, "name": user_name}
1285
+ return {
1286
+ "task_id": raw.get("id") or raw.get("taskId") or record_id,
1287
+ "app_key": app_key,
1288
+ "app_name": raw.get("formTitle") or raw.get("worksheetName") or raw.get("appName"),
1289
+ "record_id": record_id,
1290
+ "workflow_node_id": workflow_node_id,
1291
+ "workflow_node_name": raw.get("nodeName") or raw.get("auditNodeName"),
1292
+ "title": raw.get("title") or raw.get("applyTitle") or raw.get("name") or raw.get("formTitle"),
1293
+ "apply_user": apply_user,
1294
+ "apply_time": raw.get("applyTime") or raw.get("receiveTime"),
1295
+ "task_box": task_box,
1296
+ "flow_status": flow_status,
1297
+ "actionable": task_box == "todo" and bool(record_id) and bool(workflow_node_id),
1298
+ }
1299
+
1300
+ def _select_task_node(self, infos: Any, workflow_node_id: int, *, app_key: str, record_id: int) -> dict[str, Any]:
1301
+ if not isinstance(infos, list) or not infos:
1302
+ raise_tool_error(
1303
+ QingflowApiError.config_error(
1304
+ f"record_id={record_id} is not currently actionable for the logged-in user in app_key='{app_key}'"
1305
+ )
1306
+ )
1307
+ for item in infos:
1308
+ if not isinstance(item, dict):
1309
+ continue
1310
+ candidate = item.get("auditNodeId")
1311
+ if not isinstance(candidate, int):
1312
+ candidate = item.get("nodeId")
1313
+ if candidate == workflow_node_id:
1314
+ return item
1315
+ raise_tool_error(
1316
+ QingflowApiError.config_error(
1317
+ f"workflow_node_id={workflow_node_id} is not an actionable todo node for app_key='{app_key}' record_id={record_id}"
1318
+ )
1319
+ )
1320
+
1321
+ def _build_capabilities(self, node_info: dict[str, Any], *, allow_save_only: bool) -> dict[str, Any]:
1322
+ available_actions = ["approve"]
1323
+ if self._coerce_bool(node_info.get("rejectBtnStatus")):
1324
+ available_actions.append("reject")
1325
+ if self._coerce_bool(node_info.get("canRevert")):
1326
+ available_actions.append("rollback")
1327
+ if self._coerce_bool(node_info.get("canTransfer")):
1328
+ available_actions.append("transfer")
1329
+ if self._coerce_bool(node_info.get("canUrge")):
1330
+ available_actions.append("urge")
1331
+ if allow_save_only:
1332
+ available_actions.append("save_only")
1333
+
1334
+ visible_but_unimplemented_actions: list[str] = []
1335
+ if self._coerce_bool(node_info.get("canRevoke")):
1336
+ visible_but_unimplemented_actions.append("revoke")
1337
+ if self._coerce_bool(node_info.get("beingEndWorkflow")):
1338
+ visible_but_unimplemented_actions.append("end_workflow")
1339
+ if self._coerce_bool(node_info.get("beingCanApplyAgain")):
1340
+ visible_but_unimplemented_actions.append("apply_again")
1341
+
1342
+ feedback_required_for = []
1343
+ raw_feedback_required = node_info.get("feedbackRequiredOperationType")
1344
+ if isinstance(raw_feedback_required, list):
1345
+ feedback_required_for = [str(item).strip().lower() for item in raw_feedback_required if str(item).strip()]
1346
+
1347
+ return {
1348
+ "available_actions": available_actions,
1349
+ "visible_but_unimplemented_actions": visible_but_unimplemented_actions,
1350
+ "action_constraints": {
1351
+ "feedback_required_for": feedback_required_for,
1352
+ "submit_check_enabled": self._coerce_bool(node_info.get("beingSubmitCheck")),
1353
+ "submit_preview_enabled": self._coerce_bool(node_info.get("beingSubmitPreview")),
1354
+ "can_end_workflow": self._coerce_bool(node_info.get("beingEndWorkflow")),
1355
+ "can_apply_again": self._coerce_bool(node_info.get("beingCanApplyAgain")),
1356
+ },
1357
+ }
1358
+
1359
+ def _build_task_update_schema(
1360
+ self,
1361
+ profile: str,
1362
+ context: BackendRequestContext,
1363
+ *,
1364
+ app_key: str,
1365
+ record_id: int,
1366
+ workflow_node_id: int,
1367
+ node_info: dict[str, Any],
1368
+ current_answers: Any,
1369
+ ) -> dict[str, Any]:
1370
+ try:
1371
+ app_schema = self._record_tools._get_form_schema(profile, context, app_key, force_refresh=False)
1372
+ except QingflowApiError as error:
1373
+ public_schema: JSONObject = {
1374
+ "schema_scope": "task_update_ready",
1375
+ "writable_fields": [],
1376
+ "payload_template": {},
1377
+ "blockers": ["TASK_UPDATE_SCHEMA_UNAVAILABLE"],
1378
+ "warnings": [
1379
+ {
1380
+ "code": "TASK_UPDATE_SCHEMA_UNAVAILABLE",
1381
+ "message": "task detail could not load the form schema for the current app, so node-scoped update schema is unavailable.",
1382
+ }
1383
+ ],
1384
+ "selection": {
1385
+ "app_key": app_key,
1386
+ "record_id": record_id,
1387
+ "workflow_node_id": workflow_node_id,
1388
+ },
1389
+ "transport_error": {
1390
+ "http_status": error.http_status,
1391
+ "backend_code": error.backend_code,
1392
+ "category": error.category,
1393
+ },
1394
+ }
1395
+ return {
1396
+ "public_schema": public_schema,
1397
+ "index": None,
1398
+ "editable_question_ids": [],
1399
+ "effective_editable_question_ids": [],
1400
+ "editable_question_ids_source": "schema_unavailable",
1401
+ }
1402
+
1403
+ question_relations = _collect_question_relations(app_schema)
1404
+ linked_field_ids = _collect_linked_required_field_ids(question_relations)
1405
+ base_index = _build_applicant_top_level_field_index(app_schema)
1406
+ linked_field_ids.update(_collect_option_linked_field_ids(base_index))
1407
+ linked_hidden_index = _build_applicant_hidden_linked_top_level_field_index(
1408
+ app_schema,
1409
+ linked_field_ids=linked_field_ids,
1410
+ )
1411
+ index = _merge_field_indexes(base_index, linked_hidden_index)
1412
+ editable_question_ids, schema_warnings, source = self._resolve_task_editable_question_ids(
1413
+ context,
1414
+ app_key=app_key,
1415
+ workflow_node_id=workflow_node_id,
1416
+ node_info=node_info,
1417
+ )
1418
+ effective_editable_ids = set(editable_question_ids)
1419
+ for field in index.by_id.values():
1420
+ if field.que_type in SUBTABLE_QUE_TYPES and (_subtable_descendant_ids(field) & set(editable_question_ids)):
1421
+ effective_editable_ids.add(field.que_id)
1422
+ writable_fields: list[JSONObject] = []
1423
+ linkage_payloads_by_field_id = _build_static_schema_linkage_payloads(
1424
+ index=index,
1425
+ question_relations=question_relations,
1426
+ )
1427
+ for field in index.by_id.values():
1428
+ if field.que_type in LAYOUT_ONLY_QUE_TYPES or field.que_id not in effective_editable_ids:
1429
+ continue
1430
+ editable_field = _clone_form_field(field, readonly=False)
1431
+ write_hints = self._record_tools._schema_write_hints(editable_field)
1432
+ if not bool(write_hints.get("writable")):
1433
+ continue
1434
+ writable_fields.append(
1435
+ self._record_tools._ready_schema_field_payload(
1436
+ profile,
1437
+ context,
1438
+ editable_field,
1439
+ ws_id=context.ws_id,
1440
+ required_override=False,
1441
+ linkage_payloads_by_field_id=linkage_payloads_by_field_id,
1442
+ )
1443
+ )
1444
+ blockers: list[str] = []
1445
+ if not writable_fields:
1446
+ blockers.append("NO_TASK_EDITABLE_FIELDS")
1447
+ schema_warnings.append(
1448
+ {
1449
+ "code": "NO_TASK_EDITABLE_FIELDS",
1450
+ "message": "the current task node does not expose any writable fields for task-scoped edits.",
1451
+ }
1452
+ )
1453
+ public_schema: JSONObject = {
1454
+ "schema_scope": "task_update_ready",
1455
+ "writable_fields": writable_fields,
1456
+ "payload_template": {
1457
+ item["title"]: self._record_tools._ready_schema_template_value(item)
1458
+ for item in writable_fields
1459
+ if isinstance(item, dict) and item.get("title")
1460
+ },
1461
+ "blockers": blockers,
1462
+ "warnings": schema_warnings,
1463
+ "selection": {
1464
+ "app_key": app_key,
1465
+ "record_id": record_id,
1466
+ "workflow_node_id": workflow_node_id,
1467
+ },
1468
+ }
1469
+ return {
1470
+ "public_schema": public_schema,
1471
+ "index": index,
1472
+ "editable_question_ids": sorted(editable_question_ids),
1473
+ "effective_editable_question_ids": sorted(effective_editable_ids),
1474
+ "editable_question_ids_source": source,
1475
+ }
1476
+
1477
+ def _resolve_task_editable_question_ids(
1478
+ self,
1479
+ context: BackendRequestContext,
1480
+ *,
1481
+ app_key: str,
1482
+ workflow_node_id: int,
1483
+ node_info: dict[str, Any],
1484
+ ) -> tuple[set[int], list[JSONObject], str]:
1485
+ warnings: list[JSONObject] = []
1486
+ try:
1487
+ payload = self.backend.request(
1488
+ "GET",
1489
+ context,
1490
+ f"/app/{app_key}/auditNode/{workflow_node_id}/editableQueIds",
1491
+ )
1492
+ question_ids = self._extract_question_ids(payload)
1493
+ if question_ids:
1494
+ return question_ids, warnings, "workflow_editable_que_ids"
1495
+ except QingflowApiError as error:
1496
+ if error.backend_code not in {40002, 40027, 404} and error.http_status != 404:
1497
+ raise
1498
+ warnings.append(
1499
+ {
1500
+ "code": "TASK_EDITABLE_IDS_FALLBACK",
1501
+ "message": "editable question ids endpoint is unavailable in the current route; task update schema fell back to queAuthSetting and may be conservative.",
1502
+ }
1503
+ )
1504
+ fallback_ids = self._editable_ids_from_que_auth_setting(node_info.get("queAuthSetting"))
1505
+ return fallback_ids, warnings, "que_auth_setting"
1506
+
1507
+ def _extract_question_ids(self, payload: Any) -> set[int]:
1508
+ candidates: list[Any] = []
1509
+ if isinstance(payload, list):
1510
+ candidates = payload
1511
+ elif isinstance(payload, dict):
1512
+ for key in ("editableQueIds", "editableQuestionIds", "queIds", "questionIds", "ids", "list", "result"):
1513
+ value = payload.get(key)
1514
+ if isinstance(value, list):
1515
+ candidates = value
1516
+ break
1517
+ question_ids: set[int] = set()
1518
+ for item in candidates:
1519
+ if isinstance(item, int) and item > 0:
1520
+ question_ids.add(item)
1521
+ continue
1522
+ if isinstance(item, dict):
1523
+ for key in ("queId", "questionId", "id"):
1524
+ value = _coerce_count(item.get(key))
1525
+ if value is not None and value > 0:
1526
+ question_ids.add(value)
1527
+ break
1528
+ return question_ids
1529
+
1530
+ def _editable_ids_from_que_auth_setting(self, payload: Any) -> set[int]:
1531
+ if not isinstance(payload, list):
1532
+ return set()
1533
+ editable_ids: set[int] = set()
1534
+ for item in payload:
1535
+ if not isinstance(item, dict):
1536
+ continue
1537
+ que_id = _coerce_count(item.get("queId") or item.get("questionId"))
1538
+ if que_id is None or que_id <= 0:
1539
+ continue
1540
+ explicit_editable_keys = ("editable", "writable", "canEdit", "editStatus", "beingEditable")
1541
+ explicit_readonly_keys = ("readonly", "beingReadonly")
1542
+ if any(key in item for key in explicit_editable_keys):
1543
+ if any(bool(item.get(key)) for key in explicit_editable_keys):
1544
+ editable_ids.add(que_id)
1545
+ continue
1546
+ if any(bool(item.get(key)) for key in explicit_readonly_keys):
1547
+ continue
1548
+ if item.get("readable") is False:
1549
+ continue
1550
+ if item.get("readable") is True or item.get("visible") is True:
1551
+ editable_ids.add(que_id)
1552
+ return editable_ids
1553
+
1554
+ def _prepare_task_field_update(
1555
+ self,
1556
+ *,
1557
+ profile: str,
1558
+ context: BackendRequestContext,
1559
+ app_key: str,
1560
+ record_id: int,
1561
+ workflow_node_id: int,
1562
+ task_context: dict[str, Any],
1563
+ fields: dict[str, Any],
1564
+ ) -> dict[str, Any]:
1565
+ record = task_context.get("record") if isinstance(task_context.get("record"), dict) else {}
1566
+ current_answers = record.get("answers") if isinstance(record.get("answers"), list) else []
1567
+ node = task_context.get("node") if isinstance(task_context.get("node"), dict) else {}
1568
+ node_info = node.get("raw") if isinstance(node.get("raw"), dict) else {}
1569
+ schema_state = self._build_task_update_schema(
1570
+ profile=profile,
1571
+ context=context,
1572
+ app_key=app_key,
1573
+ record_id=record_id,
1574
+ workflow_node_id=workflow_node_id,
1575
+ node_info=node_info,
1576
+ current_answers=current_answers,
1577
+ )
1578
+ update_schema = schema_state["public_schema"]
1579
+ if update_schema.get("blockers"):
1580
+ raise_tool_error(
1581
+ QingflowApiError(
1582
+ category="config",
1583
+ message="task field update is blocked because the current node does not expose a usable update schema",
1584
+ details={
1585
+ "error_code": "TASK_UPDATE_SCHEMA_BLOCKED",
1586
+ "update_schema": update_schema,
1587
+ },
1588
+ )
1589
+ )
1590
+ preflight = self._record_tools._build_record_write_preflight(
1591
+ profile=profile,
1592
+ context=context,
1593
+ operation="update",
1594
+ app_key=app_key,
1595
+ apply_id=record_id,
1596
+ answers=[],
1597
+ fields=fields,
1598
+ force_refresh_form=False,
1599
+ view_id=None,
1600
+ list_type=None,
1601
+ view_key=None,
1602
+ view_name=None,
1603
+ existing_answers_override=current_answers,
1604
+ )
1605
+ index = schema_state["index"]
1606
+ effective_editable_ids = set(schema_state["effective_editable_question_ids"])
1607
+ scoped_field_errors = self._task_scope_field_errors(
1608
+ normalized_answers=preflight.get("normalized_answers") or [],
1609
+ index=index,
1610
+ effective_editable_ids=effective_editable_ids,
1611
+ )
1612
+ field_errors = list(preflight.get("field_errors") or [])
1613
+ field_errors.extend(scoped_field_errors)
1614
+ blockers = list(preflight.get("blockers") or [])
1615
+ if scoped_field_errors:
1616
+ blockers.append("payload writes fields that are not editable on the current task node")
1617
+ confirmation_requests = list(preflight.get("confirmation_requests") or [])
1618
+ if field_errors or confirmation_requests or blockers:
1619
+ raise_tool_error(
1620
+ QingflowApiError(
1621
+ category="config",
1622
+ message="task field update preflight was blocked",
1623
+ details={
1624
+ "error_code": "TASK_FIELD_PLAN_BLOCKED",
1625
+ "blockers": blockers,
1626
+ "field_errors": field_errors,
1627
+ "confirmation_requests": confirmation_requests,
1628
+ "update_schema": update_schema,
1629
+ "recommended_next_actions": preflight.get("recommended_next_actions") or [],
1630
+ },
1631
+ )
1632
+ )
1633
+ normalized_answers = [item for item in (preflight.get("normalized_answers") or []) if isinstance(item, dict)]
1634
+ merged_answers = self._record_tools._merge_record_answers(current_answers, normalized_answers)
1635
+ return {
1636
+ "update_schema": update_schema,
1637
+ "normalized_answers": normalized_answers,
1638
+ "merged_answers": merged_answers,
1639
+ }
1640
+
1641
+ def _task_scope_field_errors(
1642
+ self,
1643
+ *,
1644
+ normalized_answers: list[dict[str, Any]],
1645
+ index: Any,
1646
+ effective_editable_ids: set[int],
1647
+ ) -> list[dict[str, Any]]:
1648
+ if index is None:
1649
+ return []
1650
+ field_errors: list[dict[str, Any]] = []
1651
+ for answer in normalized_answers:
1652
+ que_id = _coerce_count(answer.get("queId"))
1653
+ if que_id is None or que_id <= 0:
1654
+ continue
1655
+ field = index.by_id.get(str(que_id))
1656
+ field_payload = _field_ref_payload(field) if field is not None else {"que_id": que_id}
1657
+ if que_id not in effective_editable_ids:
1658
+ field_errors.append(
1659
+ {
1660
+ "location": field.que_title if field is not None else str(que_id),
1661
+ "message": "field is not editable on the current task node",
1662
+ "error_code": "TASK_FIELD_NOT_EDITABLE",
1663
+ "field": field_payload,
1664
+ }
1665
+ )
1666
+ continue
1667
+ if field is None or field.que_type not in SUBTABLE_QUE_TYPES:
1668
+ continue
1669
+ table_values = answer.get("tableValues") if isinstance(answer.get("tableValues"), list) else []
1670
+ subtable_index = self._record_tools._subtable_field_index_optional(field)
1671
+ for row_ordinal, row in enumerate(table_values, start=1):
1672
+ row_cells = [item for item in row if isinstance(item, dict)] if isinstance(row, list) else []
1673
+ for cell in row_cells:
1674
+ cell_que_id = _coerce_count(cell.get("queId"))
1675
+ if cell_que_id is None or cell_que_id <= 0 or cell_que_id in effective_editable_ids:
1676
+ continue
1677
+ subfield = subtable_index.by_id.get(str(cell_que_id)) if subtable_index is not None else None
1678
+ field_errors.append(
1679
+ {
1680
+ "location": f"{field.que_title}[{row_ordinal}].{subfield.que_title if subfield is not None else cell_que_id}",
1681
+ "message": "subtable field is not editable on the current task node",
1682
+ "error_code": "TASK_FIELD_NOT_EDITABLE",
1683
+ "field": _field_ref_payload(subfield) if subfield is not None else {"que_id": cell_que_id},
1684
+ }
1685
+ )
1686
+ return field_errors
1687
+
1688
+ def _task_save_only(
1689
+ self,
1690
+ *,
1691
+ profile: str,
1692
+ app_key: str,
1693
+ record_id: int,
1694
+ workflow_node_id: int,
1695
+ merged_answers: list[dict[str, Any]],
1696
+ ) -> dict[str, Any]:
1697
+ def runner(session_profile, context):
1698
+ result = self.backend.request(
1699
+ "POST",
1700
+ context,
1701
+ f"/app/{app_key}/apply/{record_id}",
1702
+ json_body={"role": 3, "auditNodeId": workflow_node_id, "answers": merged_answers},
1703
+ )
1704
+ return {
1705
+ "profile": profile,
1706
+ "ws_id": session_profile.selected_ws_id,
1707
+ "app_key": app_key,
1708
+ "apply_id": record_id,
1709
+ "result": result,
1710
+ "request_route": self._request_route_payload(context),
1711
+ }
1712
+
1713
+ return self._run(profile, runner)
1714
+
1715
+ def _build_visibility(self, node_info: dict[str, Any], detail: dict[str, Any]) -> dict[str, bool]:
1716
+ return {
1717
+ "comment_visible": self._coerce_bool(node_info.get("commentStatus")),
1718
+ "audit_record_visible": self._coerce_bool(node_info.get("auditRecordVisible")),
1719
+ "workflow_future_visible": self._coerce_bool(node_info.get("beingWorkflowNodeFutureListVisible")),
1720
+ "qrobot_record_visible": self._coerce_bool(node_info.get("qrobotRecordBeingVisible")),
1721
+ "associated_report_visible": self._resolve_associated_report_visible(node_info, detail),
1722
+ }
1723
+
1724
+ def _resolve_associated_report_visible(self, node_info: dict[str, Any], detail: dict[str, Any]) -> bool:
1725
+ node_visible = node_info.get("asosChartVisible")
1726
+ if node_visible is not None:
1727
+ return self._coerce_bool(node_visible)
1728
+ return self._coerce_bool(detail.get("viewAsosChartVisible"))
1729
+
1730
+ def _normalize_associated_report(self, raw: dict[str, Any]) -> dict[str, Any]:
1731
+ graph_type = str(raw.get("graphType") or "").strip().lower()
1732
+ source_type = str(raw.get("sourceType") or "").strip().lower()
1733
+ return {
1734
+ "report_id": raw.get("id"),
1735
+ "chart_key": raw.get("chartKey"),
1736
+ "chart_name": raw.get("chartName"),
1737
+ "graph_type": "view" if graph_type.endswith("view") or graph_type == "view" else "chart",
1738
+ "source_type": source_type or "qingflow",
1739
+ "target_app_key": raw.get("appKey"),
1740
+ "target_app_name": raw.get("formTitle"),
1741
+ "match_rules": raw.get("matchRules") or [],
1742
+ "raw": dict(raw),
1743
+ }
1744
+
1745
+ def _rollback_candidate_items(self, payload: Any) -> list[dict[str, Any]]:
1746
+ if isinstance(payload, dict):
1747
+ revert_nodes = payload.get("revertNodes")
1748
+ if isinstance(revert_nodes, list):
1749
+ return [item for item in revert_nodes if isinstance(item, dict)]
1750
+ return [item for item in _approval_page_items(payload) if isinstance(item, dict)]
1751
+
1752
+ def _filter_transfer_members(self, items: Any, *, current_uid: int | None) -> list[dict[str, Any]]:
1753
+ if not isinstance(items, list):
1754
+ return []
1755
+ filtered: list[dict[str, Any]] = []
1756
+ for item in items:
1757
+ if not isinstance(item, dict):
1758
+ continue
1759
+ if current_uid is not None and item.get("uid") == current_uid:
1760
+ continue
1761
+ filtered.append(item)
1762
+ return filtered
1763
+
1764
+ def _find_associated_report(self, task_context: dict[str, Any], report_id: int) -> dict[str, Any] | None:
1765
+ associated_reports = ((task_context.get("associated_reports") or {}).get("items") or [])
1766
+ for item in associated_reports:
1767
+ if isinstance(item, dict) and item.get("report_id") == report_id:
1768
+ return item
1769
+ return None
1770
+
1771
+ def _build_association_query(self, asos_chart: dict[str, Any], answers: list[dict[str, Any]]) -> dict[str, Any]:
1772
+ key_que_ids = self._collect_match_rule_question_ids(asos_chart.get("matchRules") or [])
1773
+ key_values: list[dict[str, Any]] = []
1774
+ for answer in answers:
1775
+ if not isinstance(answer, dict):
1776
+ continue
1777
+ answer_que_id = answer.get("queId")
1778
+ if isinstance(answer_que_id, int) and answer_que_id in key_que_ids:
1779
+ extracted_values = self._extract_answer_values(answer)
1780
+ key_values.append({"keyQueId": answer_que_id, "values": extracted_values or None})
1781
+ table_values = answer.get("tableValues")
1782
+ if not isinstance(table_values, list):
1783
+ continue
1784
+ for idx, row in enumerate(table_values, start=1):
1785
+ if not isinstance(row, list):
1786
+ continue
1787
+ for sub_answer in row:
1788
+ if not isinstance(sub_answer, dict):
1789
+ continue
1790
+ sub_que_id = sub_answer.get("queId")
1791
+ if isinstance(sub_que_id, int) and sub_que_id in key_que_ids:
1792
+ extracted_values = self._extract_answer_values(sub_answer)
1793
+ key_values.append(
1794
+ {
1795
+ "keyQueId": sub_que_id,
1796
+ "ordinal": idx,
1797
+ "values": extracted_values or None,
1798
+ }
1799
+ )
1800
+ return {
1801
+ "asosChart": self._sanitize_associated_chart(asos_chart),
1802
+ "keyQueValues": key_values,
1803
+ }
1804
+
1805
+ def _collect_match_rule_question_ids(self, match_rules: Any) -> set[int]:
1806
+ question_ids: set[int] = set()
1807
+
1808
+ def visit(node: Any) -> None:
1809
+ if isinstance(node, list):
1810
+ for item in node:
1811
+ visit(item)
1812
+ return
1813
+ if not isinstance(node, dict):
1814
+ return
1815
+ for key in ("queId", "judgeQueId"):
1816
+ value = node.get(key)
1817
+ if isinstance(value, int):
1818
+ question_ids.add(value)
1819
+ for value in node.values():
1820
+ if isinstance(value, (list, dict)):
1821
+ visit(value)
1822
+
1823
+ visit(match_rules)
1824
+ return question_ids
1825
+
1826
+ def _extract_answer_values(self, answer: dict[str, Any]) -> list[str]:
1827
+ values = answer.get("values")
1828
+ if not isinstance(values, list):
1829
+ return []
1830
+ normalized: list[str] = []
1831
+ for item in values:
1832
+ if item is None:
1833
+ continue
1834
+ if isinstance(item, (str, int, float, bool)):
1835
+ normalized.append(str(item))
1836
+ continue
1837
+ if not isinstance(item, dict):
1838
+ continue
1839
+ for key in ("value", "id", "uid", "userId", "applyId", "phone", "email", "name", "title", "label"):
1840
+ value = item.get(key)
1841
+ if value not in (None, ""):
1842
+ normalized.append(str(value))
1843
+ break
1844
+ deduped: list[str] = []
1845
+ seen: set[str] = set()
1846
+ for item in normalized:
1847
+ if item in seen:
1848
+ continue
1849
+ deduped.append(item)
1850
+ seen.add(item)
1851
+ return deduped
1852
+
1853
+ def _sanitize_associated_chart(self, asos_chart: dict[str, Any]) -> dict[str, Any]:
1854
+ return {
1855
+ "id": asos_chart.get("id"),
1856
+ "appKey": asos_chart.get("appKey"),
1857
+ "formTitle": asos_chart.get("formTitle"),
1858
+ "chartKey": asos_chart.get("chartKey"),
1859
+ "chartName": asos_chart.get("chartName"),
1860
+ "chartType": asos_chart.get("chartType"),
1861
+ "matchRules": asos_chart.get("matchRules") or [],
1862
+ "sourceType": asos_chart.get("sourceType"),
1863
+ "graphType": asos_chart.get("graphType"),
1864
+ "viewType": asos_chart.get("viewType"),
1865
+ }
1866
+
1867
+ def _qingflow_chart_uses_apply_filter(self, context: BackendRequestContext, chart_key: str) -> bool:
1868
+ if not chart_key:
1869
+ return False
1870
+ try:
1871
+ auth = self.backend.request(
1872
+ "GET",
1873
+ context,
1874
+ f"/chart/{chart_key}/auth",
1875
+ )
1876
+ except QingflowApiError:
1877
+ return False
1878
+ if not isinstance(auth, dict):
1879
+ return False
1880
+ return self._coerce_bool(auth.get("detailedViewStatus")) and auth.get("lastViewType") == 1
1881
+
1882
+ def _normalize_chart_result(self, payload: Any) -> dict[str, Any]:
1883
+ if isinstance(payload, dict):
1884
+ rows = payload.get("rows")
1885
+ if not isinstance(rows, list):
1886
+ rows = payload.get("list") if isinstance(payload.get("list"), list) else []
1887
+ series = payload.get("series")
1888
+ if not isinstance(series, list):
1889
+ series = payload.get("xAxis") if isinstance(payload.get("xAxis"), list) else []
1890
+ metrics = payload.get("metrics")
1891
+ if not isinstance(metrics, list):
1892
+ metrics = payload.get("yAxis") if isinstance(payload.get("yAxis"), list) else []
1893
+ summary = payload.get("summary") if isinstance(payload.get("summary"), dict) else {}
1894
+ return {
1895
+ "summary": summary,
1896
+ "rows": rows or [],
1897
+ "series": series or [],
1898
+ "metrics": metrics or [],
1899
+ }
1900
+ if isinstance(payload, list):
1901
+ return {"summary": {}, "rows": payload, "series": [], "metrics": []}
1902
+ return {"summary": {}, "rows": [], "series": [], "metrics": []}
1903
+
1904
+ def _normalize_workflow_logs(self, payload: Any) -> list[dict[str, Any]]:
1905
+ if isinstance(payload, dict):
1906
+ page = payload.get("list") if isinstance(payload.get("list"), list) else payload.get("rows")
1907
+ if not isinstance(page, list):
1908
+ nested = payload.get("data")
1909
+ if isinstance(nested, dict):
1910
+ page = nested.get("list") if isinstance(nested.get("list"), list) else nested.get("rows")
1911
+ if not isinstance(page, list):
1912
+ page = []
1913
+ elif isinstance(payload, list):
1914
+ page = payload
1915
+ else:
1916
+ page = []
1917
+
1918
+ items: list[dict[str, Any]] = []
1919
+ for node_record in page:
1920
+ if not isinstance(node_record, dict):
1921
+ continue
1922
+ node_id = node_record.get("nodeId")
1923
+ node_name = node_record.get("nodeName")
1924
+ operation_record_list = node_record.get("operationRecordList")
1925
+ if not isinstance(operation_record_list, list):
1926
+ operation_record_list = []
1927
+ for operation in operation_record_list:
1928
+ if not isinstance(operation, dict):
1929
+ continue
1930
+ detail = self._first_nested_operation_detail(operation)
1931
+ items.append(
1932
+ {
1933
+ "log_id": operation.get("workflowNodeOperationRecordId")
1934
+ or node_record.get("workflowNodeProcessRecordId"),
1935
+ "node_id": node_id,
1936
+ "node_name": node_name,
1937
+ "operator": operation.get("operator"),
1938
+ "operation": operation.get("operationType"),
1939
+ "operation_result": detail,
1940
+ "operation_time": operation.get("operationTime"),
1941
+ "remark": self._extract_remark(detail),
1942
+ "signature_url": self._extract_signature_url(detail),
1943
+ "attachments": self._extract_attachments(detail),
1944
+ "qrobot_related": any(
1945
+ operation.get(key)
1946
+ for key in ("qRobotAdd", "qRobotUpdate", "qRobotSMS", "qRobotMail", "webhook")
1947
+ ),
1948
+ }
1949
+ )
1950
+ return items
1951
+
1952
+ def _workflow_log_digest(self, items: list[dict[str, Any]]) -> str | None:
1953
+ if not items:
1954
+ return None
1955
+ try:
1956
+ return json.dumps(items, ensure_ascii=False, sort_keys=True, default=str)
1957
+ except TypeError:
1958
+ return str(items)
1959
+
1960
+ def _first_nested_operation_detail(self, operation: dict[str, Any]) -> Any:
1961
+ for key in ("approval", "filling", "cc", "applicant", "qRobotAdd", "qRobotUpdate", "webhook", "qRobotSMS", "qRobotMail"):
1962
+ value = operation.get(key)
1963
+ if value is not None:
1964
+ return value
1965
+ return None
1966
+
1967
+ def _extract_remark(self, detail: Any) -> Any:
1968
+ if not isinstance(detail, dict):
1969
+ return None
1970
+ for key in ("remark", "feedback", "comment", "content"):
1971
+ value = detail.get(key)
1972
+ if value not in (None, ""):
1973
+ return value
1974
+ return None
1975
+
1976
+ def _extract_signature_url(self, detail: Any) -> Any:
1977
+ if not isinstance(detail, dict):
1978
+ return None
1979
+ for key in ("signatureUrl", "handSignImageUrl"):
1980
+ value = detail.get(key)
1981
+ if value not in (None, ""):
1982
+ return value
1983
+ return None
1984
+
1985
+ def _extract_attachments(self, detail: Any) -> Any:
1986
+ if not isinstance(detail, dict):
1987
+ return []
1988
+ for key in ("attachments", "files", "uploadFiles"):
1989
+ value = detail.get(key)
1990
+ if isinstance(value, list):
1991
+ return value
1992
+ return []
1993
+
1994
+ def _extract_audit_feedback(self, payload: dict[str, Any]) -> str | None:
1995
+ for key in ("audit_feedback", "auditFeedback"):
1996
+ value = payload.get(key)
1997
+ if isinstance(value, str) and value.strip():
1998
+ return value.strip()
1999
+ return None
2000
+
2001
+ def _extract_positive_int(self, payload: dict[str, Any], key: str, *, aliases: tuple[str, ...] = ()) -> int:
2002
+ candidates = (key, *aliases)
2003
+ value: Any = None
2004
+ for candidate in candidates:
2005
+ if candidate in payload:
2006
+ value = payload.get(candidate)
2007
+ break
2008
+ if not isinstance(value, int) or value <= 0:
2009
+ names = ", ".join(candidates)
2010
+ raise_tool_error(QingflowApiError.config_error(f"one of [{names}] must be a positive integer"))
2011
+ return value
2012
+
2013
+ def _require_app_record_and_node(self, app_key: str, record_id: int, workflow_node_id: int) -> None:
2014
+ if not app_key:
2015
+ raise_tool_error(QingflowApiError.config_error("app_key is required"))
2016
+ if record_id <= 0:
2017
+ raise_tool_error(QingflowApiError.config_error("record_id must be positive"))
2018
+ if workflow_node_id <= 0:
2019
+ raise_tool_error(QingflowApiError.config_error("workflow_node_id must be positive"))
2020
+
2021
+ def _coerce_bool(self, value: Any) -> bool:
2022
+ if isinstance(value, bool):
2023
+ return value
2024
+ if isinstance(value, int):
2025
+ return value != 0
2026
+ if isinstance(value, str):
2027
+ return value.strip().lower() in {"1", "true", "yes", "y", "show", "visible", "enabled"}
2028
+ return bool(value)
2029
+
2030
+ def _request_route_payload(self, context: BackendRequestContext) -> dict[str, Any]:
2031
+ describe_route = getattr(self.backend, "describe_route", None)
2032
+ if callable(describe_route):
2033
+ payload = describe_route(context)
2034
+ if isinstance(payload, dict):
2035
+ return payload
2036
+ return {
2037
+ "base_url": context.base_url,
2038
+ "qf_version": context.qf_version,
2039
+ "qf_version_source": context.qf_version_source or ("context" if context.qf_version else "unknown"),
2040
+ }