@qingflow-tech/qingflow-app-builder-mcp 1.0.2 → 1.0.4

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 (53) hide show
  1. package/README.md +2 -2
  2. package/docs/local-agent-install.md +9 -3
  3. package/npm/lib/runtime.mjs +10 -3
  4. package/package.json +1 -1
  5. package/pyproject.toml +1 -1
  6. package/skills/qingflow-app-builder/SKILL.md +88 -184
  7. package/skills/qingflow-app-builder/references/create-app.md +15 -34
  8. package/skills/qingflow-app-builder/references/gotchas.md +3 -3
  9. package/skills/qingflow-app-builder/references/solution-playbooks.md +1 -2
  10. package/skills/qingflow-app-builder/references/tool-selection.md +9 -10
  11. package/src/qingflow_mcp/__init__.py +33 -1
  12. package/src/qingflow_mcp/backend_client.py +109 -0
  13. package/src/qingflow_mcp/builder_facade/button_style_catalog.py +282 -0
  14. package/src/qingflow_mcp/builder_facade/models.py +58 -9
  15. package/src/qingflow_mcp/builder_facade/service.py +1711 -240
  16. package/src/qingflow_mcp/cli/commands/__init__.py +2 -1
  17. package/src/qingflow_mcp/cli/commands/app.py +47 -1
  18. package/src/qingflow_mcp/cli/commands/auth.py +63 -0
  19. package/src/qingflow_mcp/cli/commands/builder.py +11 -3
  20. package/src/qingflow_mcp/cli/commands/exports.py +111 -0
  21. package/src/qingflow_mcp/cli/commands/record.py +5 -5
  22. package/src/qingflow_mcp/cli/commands/task.py +701 -27
  23. package/src/qingflow_mcp/cli/commands/workspace.py +84 -0
  24. package/src/qingflow_mcp/cli/context.py +3 -0
  25. package/src/qingflow_mcp/cli/formatters.py +424 -50
  26. package/src/qingflow_mcp/cli/interaction.py +72 -0
  27. package/src/qingflow_mcp/cli/main.py +11 -1
  28. package/src/qingflow_mcp/cli/qingflow_login.py +116 -0
  29. package/src/qingflow_mcp/cli/terminal_ui.py +218 -0
  30. package/src/qingflow_mcp/config.py +1 -1
  31. package/src/qingflow_mcp/errors.py +4 -4
  32. package/src/qingflow_mcp/export_store.py +14 -0
  33. package/src/qingflow_mcp/id_utils.py +49 -0
  34. package/src/qingflow_mcp/public_surface.py +16 -1
  35. package/src/qingflow_mcp/response_trim.py +394 -9
  36. package/src/qingflow_mcp/server.py +26 -0
  37. package/src/qingflow_mcp/server_app_builder.py +15 -1
  38. package/src/qingflow_mcp/server_app_user.py +113 -0
  39. package/src/qingflow_mcp/session_store.py +126 -21
  40. package/src/qingflow_mcp/solution/compiler/form_compiler.py +2 -2
  41. package/src/qingflow_mcp/solution/executor.py +2 -2
  42. package/src/qingflow_mcp/tools/ai_builder_tools.py +107 -34
  43. package/src/qingflow_mcp/tools/app_tools.py +1 -0
  44. package/src/qingflow_mcp/tools/auth_tools.py +243 -9
  45. package/src/qingflow_mcp/tools/base.py +6 -2
  46. package/src/qingflow_mcp/tools/code_block_tools.py +2 -2
  47. package/src/qingflow_mcp/tools/custom_button_tools.py +0 -2
  48. package/src/qingflow_mcp/tools/export_tools.py +1565 -0
  49. package/src/qingflow_mcp/tools/import_tools.py +78 -4
  50. package/src/qingflow_mcp/tools/record_tools.py +551 -165
  51. package/src/qingflow_mcp/tools/resource_read_tools.py +154 -33
  52. package/src/qingflow_mcp/tools/task_context_tools.py +917 -141
  53. package/src/qingflow_mcp/tools/workspace_tools.py +141 -0
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ import re
4
5
  from typing import Any
5
6
  from uuid import uuid4
6
7
 
@@ -9,6 +10,7 @@ from mcp.server.fastmcp import FastMCP
9
10
  from ..backend_client import BackendRequestContext
10
11
  from ..config import DEFAULT_PROFILE
11
12
  from ..errors import QingflowApiError, raise_tool_error
13
+ from ..id_utils import ids_equal, normalize_positive_id_int, normalize_positive_id_text, stringify_backend_id
12
14
  from ..json_types import JSONObject
13
15
  from .approval_tools import ApprovalTools, _approval_page_amount, _approval_page_items, _approval_page_total
14
16
  from .base import ToolBase, tool_cn_name
@@ -52,10 +54,16 @@ class TaskContextTools(ToolBase):
52
54
  self._task_tools = TaskTools(sessions, backend)
53
55
  self._approval_tools = ApprovalTools(sessions, backend)
54
56
  self._record_tools = RecordTools(sessions, backend)
57
+ self._app_name_cache: dict[str, str | None] = {}
55
58
 
56
59
  def register(self, mcp: FastMCP) -> None:
57
60
  """注册当前工具到 MCP 服务。"""
58
- @mcp.tool()
61
+ @mcp.tool(
62
+ description=(
63
+ "List workflow tasks. `query` first uses backend task search; if the backend returns zero rows, "
64
+ "public task_list falls back to local matching on app_name, workflow_node_name, app_key, and record_id."
65
+ )
66
+ )
59
67
  def task_list(
60
68
  profile: str = DEFAULT_PROFILE,
61
69
  task_box: str = "todo",
@@ -80,14 +88,16 @@ class TaskContextTools(ToolBase):
80
88
  @mcp.tool()
81
89
  def task_get(
82
90
  profile: str = DEFAULT_PROFILE,
91
+ task_id: str = "",
83
92
  app_key: str = "",
84
- record_id: int = 0,
93
+ record_id: str = "",
85
94
  workflow_node_id: int = 0,
86
95
  include_candidates: bool = True,
87
96
  include_associated_reports: bool = True,
88
97
  ) -> dict[str, Any]:
89
98
  return self.task_get(
90
99
  profile=profile,
100
+ task_id=task_id,
91
101
  app_key=app_key,
92
102
  record_id=record_id,
93
103
  workflow_node_id=workflow_node_id,
@@ -98,8 +108,9 @@ class TaskContextTools(ToolBase):
98
108
  @mcp.tool(description=self._high_risk_tool_description(operation="execute", target="workflow task action"))
99
109
  def task_action_execute(
100
110
  profile: str = DEFAULT_PROFILE,
111
+ task_id: str = "",
101
112
  app_key: str = "",
102
- record_id: int = 0,
113
+ record_id: str = "",
103
114
  workflow_node_id: int = 0,
104
115
  action: str = "",
105
116
  payload: dict[str, Any] | None = None,
@@ -107,6 +118,7 @@ class TaskContextTools(ToolBase):
107
118
  ) -> dict[str, Any]:
108
119
  return self.task_action_execute(
109
120
  profile=profile,
121
+ task_id=task_id,
110
122
  app_key=app_key,
111
123
  record_id=record_id,
112
124
  workflow_node_id=workflow_node_id,
@@ -118,8 +130,9 @@ class TaskContextTools(ToolBase):
118
130
  @mcp.tool()
119
131
  def task_associated_report_detail_get(
120
132
  profile: str = DEFAULT_PROFILE,
133
+ task_id: str = "",
121
134
  app_key: str = "",
122
- record_id: int = 0,
135
+ record_id: str = "",
123
136
  workflow_node_id: int = 0,
124
137
  report_id: int = 0,
125
138
  page: int = 1,
@@ -127,6 +140,7 @@ class TaskContextTools(ToolBase):
127
140
  ) -> dict[str, Any]:
128
141
  return self.task_associated_report_detail_get(
129
142
  profile=profile,
143
+ task_id=task_id,
130
144
  app_key=app_key,
131
145
  record_id=record_id,
132
146
  workflow_node_id=workflow_node_id,
@@ -138,12 +152,14 @@ class TaskContextTools(ToolBase):
138
152
  @mcp.tool()
139
153
  def task_workflow_log_get(
140
154
  profile: str = DEFAULT_PROFILE,
155
+ task_id: str = "",
141
156
  app_key: str = "",
142
- record_id: int = 0,
157
+ record_id: str = "",
143
158
  workflow_node_id: int = 0,
144
159
  ) -> dict[str, Any]:
145
160
  return self.task_workflow_log_get(
146
161
  profile=profile,
162
+ task_id=task_id,
147
163
  app_key=app_key,
148
164
  record_id=record_id,
149
165
  workflow_node_id=workflow_node_id,
@@ -163,44 +179,63 @@ class TaskContextTools(ToolBase):
163
179
  page_size: int,
164
180
  ) -> dict[str, Any]:
165
181
  """执行任务相关逻辑。"""
166
- normalized_type = self._task_tools._task_box_to_type(task_box)
167
- normalized_status = self._task_tools._flow_status_to_process_status(flow_status)
168
- raw = self._task_tools.task_list(
182
+ response = self._list_normalized_task_items(
169
183
  profile=profile,
170
- type=normalized_type,
171
- process_status=normalized_status,
184
+ task_box=task_box,
185
+ flow_status=flow_status,
172
186
  app_key=app_key,
173
- node_id=workflow_node_id,
174
- search_key=query,
175
- page_num=page,
187
+ workflow_node_id=workflow_node_id,
188
+ query=query,
189
+ page=page,
176
190
  page_size=page_size,
177
- create_time_asc=None,
178
191
  )
179
- task_page = raw.get("page", {})
180
- items = [
181
- self._normalize_task_item(item, task_box=task_box, flow_status=flow_status)
182
- for item in _task_page_items(task_page)
183
- if isinstance(item, dict)
184
- ]
192
+ warnings: list[dict[str, Any]] = []
193
+ items = response["items"] if isinstance(response.get("items"), list) else []
194
+ page_amount = response.get("page_amount")
195
+ reported_total = response.get("reported_total")
196
+ if query and not items:
197
+ fallback = self._task_list_local_query_fallback(
198
+ profile=profile,
199
+ task_box=task_box,
200
+ flow_status=flow_status,
201
+ app_key=app_key,
202
+ workflow_node_id=workflow_node_id,
203
+ query=query,
204
+ page=page,
205
+ page_size=page_size,
206
+ )
207
+ if fallback is not None:
208
+ items = fallback["items"]
209
+ returned_items = len(items)
210
+ page_amount = fallback["page_amount"]
211
+ reported_total = fallback["reported_total"]
212
+ warnings.append(
213
+ {
214
+ "code": "TASK_LIST_QUERY_FALLBACK_APPLIED",
215
+ "message": (
216
+ "backend searchKey returned zero tasks; task_list fell back to local matching on "
217
+ "app_name, workflow_node_name, app_key, and record_id."
218
+ ),
219
+ }
220
+ )
221
+ public_items = [self._public_task_item(item) for item in items]
185
222
  return {
186
223
  "profile": profile,
187
- "ws_id": raw.get("ws_id"),
224
+ "ws_id": response.get("raw", {}).get("ws_id") if isinstance(response.get("raw"), dict) else None,
188
225
  "ok": True,
189
- "request_route": raw.get("request_route"),
190
- "warnings": [],
226
+ "request_route": response.get("raw", {}).get("request_route") if isinstance(response.get("raw"), dict) else None,
227
+ "warnings": warnings,
191
228
  "output_profile": "normal",
192
229
  "data": {
193
- "items": items,
230
+ "items": public_items,
194
231
  "pagination": {
195
232
  "page": page,
196
233
  "page_size": page_size,
197
- "returned_items": len(items),
198
- "page_amount": _task_page_amount(task_page),
199
- "reported_total": _task_page_total(task_page),
234
+ "returned_items": len(public_items),
235
+ "page_amount": page_amount,
236
+ "reported_total": reported_total,
200
237
  },
201
238
  "selection": {
202
- "task_box": task_box,
203
- "flow_status": flow_status,
204
239
  "app_key": app_key,
205
240
  "workflow_node_id": workflow_node_id,
206
241
  "query": query,
@@ -213,26 +248,44 @@ class TaskContextTools(ToolBase):
213
248
  self,
214
249
  *,
215
250
  profile: str,
216
- app_key: str,
217
- record_id: int,
218
- workflow_node_id: int,
219
- include_candidates: bool,
220
- include_associated_reports: bool,
251
+ task_id: Any = None,
252
+ app_key: str = "",
253
+ record_id: Any = "",
254
+ workflow_node_id: int = 0,
255
+ include_candidates: bool = True,
256
+ include_associated_reports: bool = True,
221
257
  ) -> dict[str, Any]:
222
258
  """执行任务相关逻辑。"""
223
- self._require_app_record_and_node(app_key, record_id, workflow_node_id)
259
+ if task_id in (None, ""):
260
+ normalize_positive_id_int(record_id, field_name="record_id")
224
261
 
225
262
  def runner(session_profile, context):
226
- data = self._build_task_context(
263
+ locator = self._resolve_task_locator_input(
227
264
  profile=profile,
228
- context=context,
265
+ task_id=task_id,
229
266
  app_key=app_key,
230
267
  record_id=record_id,
231
268
  workflow_node_id=workflow_node_id,
269
+ )
270
+ task_id_text = locator["task_id"]
271
+ resolved_app_key = str(locator["app_key"])
272
+ resolved_record_id = int(locator["record_id"])
273
+ resolved_workflow_node_id = int(locator["workflow_node_id"])
274
+ self._require_app_record_and_node(resolved_app_key, resolved_record_id, resolved_workflow_node_id)
275
+ data = self._build_task_context(
276
+ profile=profile,
277
+ context=context,
278
+ app_key=resolved_app_key,
279
+ record_id=resolved_record_id,
280
+ workflow_node_id=resolved_workflow_node_id,
232
281
  include_candidates=include_candidates,
233
282
  include_associated_reports=include_associated_reports,
234
283
  current_uid=session_profile.uid,
235
284
  )
285
+ data = self._compact_task_get_context(data)
286
+ task_payload = data.get("task")
287
+ if isinstance(task_payload, dict) and task_id_text is not None:
288
+ task_payload["task_id"] = task_id_text
236
289
  return {
237
290
  "profile": profile,
238
291
  "ws_id": session_profile.selected_ws_id,
@@ -251,7 +304,7 @@ class TaskContextTools(ToolBase):
251
304
  *,
252
305
  profile: str,
253
306
  app_key: str,
254
- record_id: int,
307
+ record_id: Any,
255
308
  workflow_node_id: int,
256
309
  fields: dict[str, Any] | None = None,
257
310
  ) -> dict[str, Any]:
@@ -274,15 +327,18 @@ class TaskContextTools(ToolBase):
274
327
  self,
275
328
  *,
276
329
  profile: str,
277
- app_key: str,
278
- record_id: int,
279
- workflow_node_id: int,
330
+ task_id: Any = None,
331
+ app_key: str = "",
332
+ record_id: Any = "",
333
+ workflow_node_id: int = 0,
280
334
  action: str,
281
335
  payload: dict[str, Any],
282
336
  fields: dict[str, Any] | None = None,
283
337
  ) -> dict[str, Any]:
284
338
  """执行任务相关逻辑。"""
285
- self._require_app_record_and_node(app_key, record_id, workflow_node_id)
339
+ if task_id in (None, ""):
340
+ normalize_positive_id_int(record_id, field_name="record_id")
341
+
286
342
  normalized_action = (action or "").strip().lower()
287
343
  if normalized_action not in {"approve", "reject", "rollback", "transfer", "urge", "save_only"}:
288
344
  raise_tool_error(
@@ -300,13 +356,27 @@ class TaskContextTools(ToolBase):
300
356
  )
301
357
 
302
358
  def runner(session_profile, context):
359
+ locator = self._resolve_task_locator_input(
360
+ profile=profile,
361
+ task_id=task_id,
362
+ app_key=app_key,
363
+ record_id=record_id,
364
+ workflow_node_id=workflow_node_id,
365
+ )
366
+ task_id_text = locator["task_id"]
367
+ resolved_app_key = str(locator["app_key"])
368
+ resolved_record_id = int(locator["record_id"])
369
+ resolved_record_id_text = str(locator["record_id_text"] or "")
370
+ resolved_workflow_node_id = int(locator["workflow_node_id"])
371
+ record_id_text = resolved_record_id_text
372
+ self._require_app_record_and_node(resolved_app_key, resolved_record_id, resolved_workflow_node_id)
303
373
  try:
304
374
  task_context = self._build_task_context(
305
375
  profile=profile,
306
376
  context=context,
307
- app_key=app_key,
308
- record_id=record_id,
309
- workflow_node_id=workflow_node_id,
377
+ app_key=resolved_app_key,
378
+ record_id=resolved_record_id,
379
+ workflow_node_id=resolved_workflow_node_id,
310
380
  include_candidates=False,
311
381
  include_associated_reports=False,
312
382
  current_uid=session_profile.uid,
@@ -317,12 +387,13 @@ class TaskContextTools(ToolBase):
317
387
  profile=profile,
318
388
  session_profile=session_profile,
319
389
  context=context,
320
- app_key=app_key,
321
- record_id=record_id,
322
- workflow_node_id=workflow_node_id,
390
+ app_key=resolved_app_key,
391
+ record_id=resolved_record_id,
392
+ workflow_node_id=resolved_workflow_node_id,
323
393
  action=normalized_action,
324
394
  source_error=error,
325
395
  before_apply_status=None,
396
+ task_id=task_id_text,
326
397
  )
327
398
  raise
328
399
  if normalized_action == "save_only" and not field_updates:
@@ -351,7 +422,7 @@ class TaskContextTools(ToolBase):
351
422
  raise_tool_error(QingflowApiError.config_error(message))
352
423
  raise_tool_error(
353
424
  QingflowApiError.config_error(
354
- f"task action '{normalized_action}' is not currently available for app_key='{app_key}' record_id={record_id} workflow_node_id={workflow_node_id}"
425
+ f"task action '{normalized_action}' is not currently available for app_key='{resolved_app_key}' record_id={record_id_text} workflow_node_id={resolved_workflow_node_id}"
355
426
  )
356
427
  )
357
428
  feedback_required_for = capabilities.get("action_constraints", {}).get("feedback_required_for") or []
@@ -372,9 +443,9 @@ class TaskContextTools(ToolBase):
372
443
  prepared_fields = self._prepare_task_field_update(
373
444
  profile=profile,
374
445
  context=context,
375
- app_key=app_key,
376
- record_id=record_id,
377
- workflow_node_id=workflow_node_id,
446
+ app_key=resolved_app_key,
447
+ record_id=resolved_record_id,
448
+ workflow_node_id=resolved_workflow_node_id,
378
449
  task_context=task_context,
379
450
  fields=field_updates,
380
451
  )
@@ -384,16 +455,16 @@ class TaskContextTools(ToolBase):
384
455
  runtime_baseline = self._capture_task_runtime_baseline(
385
456
  profile=profile,
386
457
  context=context,
387
- app_key=app_key,
388
- record_id=record_id,
389
- workflow_node_id=workflow_node_id,
458
+ app_key=resolved_app_key,
459
+ record_id=resolved_record_id,
460
+ workflow_node_id=resolved_workflow_node_id,
390
461
  )
391
462
  try:
392
463
  raw = self._execute_task_action(
393
464
  profile=profile,
394
- app_key=app_key,
395
- record_id=record_id,
396
- workflow_node_id=workflow_node_id,
465
+ app_key=resolved_app_key,
466
+ record_id=resolved_record_id,
467
+ workflow_node_id=resolved_workflow_node_id,
397
468
  normalized_action=normalized_action,
398
469
  payload=body,
399
470
  prepared_fields=prepared_fields,
@@ -404,21 +475,22 @@ class TaskContextTools(ToolBase):
404
475
  profile=profile,
405
476
  session_profile=session_profile,
406
477
  context=context,
407
- app_key=app_key,
408
- record_id=record_id,
409
- workflow_node_id=workflow_node_id,
478
+ app_key=resolved_app_key,
479
+ record_id=resolved_record_id,
480
+ workflow_node_id=resolved_workflow_node_id,
410
481
  action=normalized_action,
411
482
  source_error=error,
412
483
  before_apply_status=before_apply_status,
484
+ task_id=task_id_text,
413
485
  )
414
486
  raise
415
487
 
416
488
  if normalized_action == "save_only":
417
489
  verification, warnings = self._verify_task_save_only(
418
490
  context=context,
419
- app_key=app_key,
420
- record_id=record_id,
421
- workflow_node_id=workflow_node_id,
491
+ app_key=resolved_app_key,
492
+ record_id=resolved_record_id,
493
+ workflow_node_id=resolved_workflow_node_id,
422
494
  before_apply_status=before_apply_status,
423
495
  expected_answers=((prepared_fields or {}).get("normalized_answers") or []),
424
496
  task_context=task_context,
@@ -430,9 +502,9 @@ class TaskContextTools(ToolBase):
430
502
  verification, warnings = self._verify_task_action_runtime(
431
503
  profile=profile,
432
504
  context=context,
433
- app_key=app_key,
434
- record_id=record_id,
435
- workflow_node_id=workflow_node_id,
505
+ app_key=resolved_app_key,
506
+ record_id=resolved_record_id,
507
+ workflow_node_id=resolved_workflow_node_id,
436
508
  action=normalized_action,
437
509
  before_apply_status=before_apply_status,
438
510
  runtime_baseline=runtime_baseline,
@@ -440,7 +512,7 @@ class TaskContextTools(ToolBase):
440
512
  runtime_verified = bool(verification.get("runtime_continuation_verified"))
441
513
  status = "success" if runtime_verified else "partial_success"
442
514
  error_code = None if runtime_verified else "WORKFLOW_CONTINUATION_UNVERIFIED"
443
- return {
515
+ result = {
444
516
  "profile": raw.get("profile", profile),
445
517
  "ws_id": raw.get("ws_id", session_profile.selected_ws_id),
446
518
  "ok": bool(raw.get("ok", True)) and status != "failed",
@@ -453,9 +525,9 @@ class TaskContextTools(ToolBase):
453
525
  "data": {
454
526
  "action": normalized_action,
455
527
  "resource": {
456
- "app_key": app_key,
457
- "record_id": record_id,
458
- "workflow_node_id": workflow_node_id,
528
+ "app_key": resolved_app_key,
529
+ "record_id": record_id_text,
530
+ "workflow_node_id": resolved_workflow_node_id,
459
531
  },
460
532
  "selection": {"action": normalized_action},
461
533
  "result": raw.get("result"),
@@ -463,6 +535,11 @@ class TaskContextTools(ToolBase):
463
535
  "field_update_applied": bool(field_updates),
464
536
  },
465
537
  }
538
+ if task_id_text is not None:
539
+ resource = result["data"].get("resource")
540
+ if isinstance(resource, dict):
541
+ resource["task_id"] = task_id_text
542
+ return result
466
543
 
467
544
  return self._run(profile, runner)
468
545
 
@@ -471,7 +548,7 @@ class TaskContextTools(ToolBase):
471
548
  *,
472
549
  profile: str,
473
550
  app_key: str,
474
- record_id: int,
551
+ record_id: Any,
475
552
  workflow_node_id: int,
476
553
  normalized_action: str,
477
554
  payload: dict[str, Any],
@@ -635,12 +712,12 @@ class TaskContextTools(ToolBase):
635
712
  todo_items = self._safe_task_list_items(profile=profile, task_box="todo", app_key=app_key)
636
713
  initiated_items = self._safe_task_list_items(profile=profile, task_box="initiated", app_key=app_key)
637
714
  downstream_todo_detected = any(
638
- int(item.get("record_id") or 0) == record_id and int(item.get("workflow_node_id") or 0) != workflow_node_id
715
+ ids_equal(item.get("record_id"), record_id) and int(item.get("workflow_node_id") or 0) != workflow_node_id
639
716
  for item in todo_items
640
717
  if isinstance(item, dict)
641
718
  )
642
719
  initiated_visible = any(
643
- int(item.get("record_id") or 0) == record_id
720
+ ids_equal(item.get("record_id"), record_id)
644
721
  for item in initiated_items
645
722
  if isinstance(item, dict)
646
723
  )
@@ -657,7 +734,7 @@ class TaskContextTools(ToolBase):
657
734
  int(item.get("workflow_node_id") or 0)
658
735
  for item in todo_items
659
736
  if isinstance(item, dict)
660
- and int(item.get("record_id") or 0) == record_id
737
+ and ids_equal(item.get("record_id"), record_id)
661
738
  and int(item.get("workflow_node_id") or 0) != workflow_node_id
662
739
  }
663
740
  workflow_log_digest = self._workflow_log_digest(log_items)
@@ -675,6 +752,25 @@ class TaskContextTools(ToolBase):
675
752
  or verification.get("downstream_todo_changed")
676
753
  or verification.get("workflow_log_advanced")
677
754
  )
755
+ record_state_error = verification.get("record_state_error")
756
+ runtime_consumed_after_action = bool(
757
+ runtime_verified
758
+ and isinstance(record_state_error, dict)
759
+ and record_state_error.get("backend_code") == 46001
760
+ )
761
+ if runtime_consumed_after_action:
762
+ verification["record_state_scope"] = "current_node_runtime"
763
+ verification["record_state_unavailable_reason"] = "runtime_consumed_after_action"
764
+ verification["record_state_unavailability_expected"] = True
765
+ warnings.append(
766
+ {
767
+ "code": "TASK_RUNTIME_CONSUMED_AFTER_ACTION",
768
+ "message": (
769
+ "the current workflow node runtime is no longer readable after the action (backend 46001), "
770
+ "which usually means the node has been consumed and the workflow has already continued."
771
+ ),
772
+ }
773
+ )
678
774
  verification["runtime_continuation_verified"] = runtime_verified
679
775
  if not runtime_verified:
680
776
  warnings.append(
@@ -843,7 +939,7 @@ class TaskContextTools(ToolBase):
843
939
  int(item.get("workflow_node_id") or 0)
844
940
  for item in todo_items
845
941
  if isinstance(item, dict)
846
- and int(item.get("record_id") or 0) == record_id
942
+ and ids_equal(item.get("record_id"), record_id)
847
943
  and int(item.get("workflow_node_id") or 0) != workflow_node_id
848
944
  }
849
945
  )
@@ -861,8 +957,10 @@ class TaskContextTools(ToolBase):
861
957
  action: str,
862
958
  source_error: QingflowApiError,
863
959
  before_apply_status: Any,
960
+ task_id: str | None = None,
864
961
  ) -> dict[str, Any]:
865
962
  """执行内部辅助逻辑。"""
963
+ record_id_text = stringify_backend_id(record_id)
866
964
  verification, warnings = self._verify_task_action_runtime(
867
965
  profile=profile,
868
966
  context=context,
@@ -881,7 +979,7 @@ class TaskContextTools(ToolBase):
881
979
  "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.",
882
980
  }
883
981
  )
884
- return {
982
+ result = {
885
983
  "profile": profile,
886
984
  "ws_id": session_profile.selected_ws_id,
887
985
  "ok": True,
@@ -895,7 +993,7 @@ class TaskContextTools(ToolBase):
895
993
  "action": action,
896
994
  "resource": {
897
995
  "app_key": app_key,
898
- "record_id": record_id,
996
+ "record_id": record_id_text,
899
997
  "workflow_node_id": workflow_node_id,
900
998
  },
901
999
  "selection": {"action": action},
@@ -903,13 +1001,18 @@ class TaskContextTools(ToolBase):
903
1001
  "human_review": True,
904
1002
  },
905
1003
  }
1004
+ if task_id is not None:
1005
+ resource = result["data"].get("resource")
1006
+ if isinstance(resource, dict):
1007
+ resource["task_id"] = task_id
1008
+ return result
906
1009
  warnings.append(
907
1010
  {
908
1011
  "code": "TASK_CONTEXT_VISIBILITY_UNVERIFIED",
909
1012
  "message": "the task is no longer actionable, and MCP could not verify from state or workflow logs whether it was already processed.",
910
1013
  }
911
1014
  )
912
- return {
1015
+ result = {
913
1016
  "profile": profile,
914
1017
  "ws_id": session_profile.selected_ws_id,
915
1018
  "ok": False,
@@ -923,7 +1026,7 @@ class TaskContextTools(ToolBase):
923
1026
  "action": action,
924
1027
  "resource": {
925
1028
  "app_key": app_key,
926
- "record_id": record_id,
1029
+ "record_id": record_id_text,
927
1030
  "workflow_node_id": workflow_node_id,
928
1031
  },
929
1032
  "selection": {"action": action},
@@ -936,11 +1039,16 @@ class TaskContextTools(ToolBase):
936
1039
  },
937
1040
  },
938
1041
  }
1042
+ if task_id is not None:
1043
+ resource = result["data"].get("resource")
1044
+ if isinstance(resource, dict):
1045
+ resource["task_id"] = task_id
1046
+ return result
939
1047
 
940
1048
  def _safe_task_list_items(self, *, profile: str, task_box: str, app_key: str) -> list[dict[str, Any]]:
941
1049
  """执行内部辅助逻辑。"""
942
1050
  try:
943
- response = self.task_list(
1051
+ response = self._list_normalized_task_items(
944
1052
  profile=profile,
945
1053
  task_box=task_box,
946
1054
  flow_status="all",
@@ -952,38 +1060,260 @@ class TaskContextTools(ToolBase):
952
1060
  )
953
1061
  except QingflowApiError:
954
1062
  return []
955
- data = response.get("data") if isinstance(response, dict) else None
956
- items = data.get("items") if isinstance(data, dict) else None
1063
+ items = response.get("items") if isinstance(response, dict) else None
957
1064
  if not isinstance(items, list):
958
1065
  return []
959
1066
  return [item for item in items if isinstance(item, dict)]
960
1067
 
1068
+ def _list_normalized_task_items(
1069
+ self,
1070
+ *,
1071
+ profile: str,
1072
+ task_box: str,
1073
+ flow_status: str,
1074
+ app_key: str | None,
1075
+ workflow_node_id: int | None,
1076
+ query: str | None,
1077
+ page: int,
1078
+ page_size: int,
1079
+ ) -> dict[str, Any]:
1080
+ normalized_type = self._task_tools._task_box_to_type(task_box)
1081
+ normalized_status = self._task_tools._flow_status_to_process_status(flow_status)
1082
+ raw = self._task_tools.task_list(
1083
+ profile=profile,
1084
+ type=normalized_type,
1085
+ process_status=normalized_status,
1086
+ app_key=app_key,
1087
+ node_id=workflow_node_id,
1088
+ search_key=query,
1089
+ page_num=page,
1090
+ page_size=page_size,
1091
+ create_time_asc=None,
1092
+ )
1093
+ task_page = raw.get("page", {})
1094
+ return {
1095
+ "raw": raw,
1096
+ "items": [self._normalize_task_item(item) for item in _task_page_items(task_page) if isinstance(item, dict)],
1097
+ "page_amount": _task_page_amount(task_page),
1098
+ "reported_total": _task_page_total(task_page),
1099
+ }
1100
+
1101
+ def _task_list_local_query_fallback(
1102
+ self,
1103
+ *,
1104
+ profile: str,
1105
+ task_box: str,
1106
+ flow_status: str,
1107
+ app_key: str | None,
1108
+ workflow_node_id: int | None,
1109
+ query: str,
1110
+ page: int,
1111
+ page_size: int,
1112
+ ) -> dict[str, Any] | None:
1113
+ scan_page_size = max(page_size, 100)
1114
+ scan_page = 1
1115
+ page_amount: int | None = None
1116
+ matched_items: list[dict[str, Any]] = []
1117
+ while True:
1118
+ response = self._list_normalized_task_items(
1119
+ profile=profile,
1120
+ task_box=task_box,
1121
+ flow_status=flow_status,
1122
+ app_key=app_key,
1123
+ workflow_node_id=workflow_node_id,
1124
+ query=None,
1125
+ page=scan_page,
1126
+ page_size=scan_page_size,
1127
+ )
1128
+ normalized_items = response.get("items") if isinstance(response.get("items"), list) else []
1129
+ matched_items.extend(item for item in normalized_items if self._task_item_matches_query(item, query))
1130
+ if page_amount is None:
1131
+ coerced_page_amount = _coerce_count(response.get("page_amount"))
1132
+ if coerced_page_amount is not None and coerced_page_amount > 0:
1133
+ page_amount = coerced_page_amount
1134
+ if page_amount is not None and scan_page >= page_amount:
1135
+ break
1136
+ if not normalized_items or len(normalized_items) < scan_page_size:
1137
+ break
1138
+ scan_page += 1
1139
+ if not matched_items:
1140
+ return None
1141
+ start = max(page - 1, 0) * page_size
1142
+ end = start + page_size
1143
+ matched_total = len(matched_items)
1144
+ matched_page_amount = (matched_total + page_size - 1) // page_size if page_size > 0 else 0
1145
+ return {
1146
+ "items": matched_items[start:end],
1147
+ "page_amount": matched_page_amount,
1148
+ "reported_total": matched_total,
1149
+ }
1150
+
1151
+ def _resolve_task_locator_by_task_id(self, *, profile: str, task_id: Any) -> dict[str, Any]:
1152
+ task_id_text = normalize_positive_id_text(task_id, field_name="task_id")
1153
+ searched_task_boxes = ("todo", "initiated", "cc", "done")
1154
+ incomplete_task_boxes: list[str] = []
1155
+ page_size = 100
1156
+ for task_box in searched_task_boxes:
1157
+ page = 1
1158
+ page_amount: int | None = None
1159
+ while True:
1160
+ response = self._list_normalized_task_items(
1161
+ profile=profile,
1162
+ task_box=task_box,
1163
+ flow_status="all",
1164
+ app_key=None,
1165
+ workflow_node_id=None,
1166
+ query=None,
1167
+ page=page,
1168
+ page_size=page_size,
1169
+ )
1170
+ items = response.get("items") if isinstance(response.get("items"), list) else []
1171
+ for item in items:
1172
+ if not isinstance(item, dict) or not ids_equal(item.get("task_id"), task_id_text):
1173
+ continue
1174
+ app_key = str(item.get("app_key") or "").strip()
1175
+ record_id = stringify_backend_id(item.get("record_id"))
1176
+ workflow_node_id = int(item.get("workflow_node_id") or 0)
1177
+ if not app_key or record_id is None or workflow_node_id <= 0:
1178
+ incomplete_task_boxes.append(task_box)
1179
+ continue
1180
+ return {
1181
+ "task_id": task_id_text,
1182
+ "task_box": task_box,
1183
+ "app_key": app_key,
1184
+ "record_id": record_id,
1185
+ "workflow_node_id": workflow_node_id,
1186
+ }
1187
+ if page_amount is None:
1188
+ coerced_page_amount = _coerce_count(response.get("page_amount"))
1189
+ if coerced_page_amount is not None and coerced_page_amount > 0:
1190
+ page_amount = coerced_page_amount
1191
+ if page_amount is not None and page >= page_amount:
1192
+ break
1193
+ if not items or len(items) < page_size:
1194
+ break
1195
+ page += 1
1196
+ if incomplete_task_boxes:
1197
+ searched = ", ".join(incomplete_task_boxes)
1198
+ raise_tool_error(
1199
+ QingflowApiError.config_error(
1200
+ f"task_id={task_id_text} resolved to an incomplete task locator in task_box={searched}; please refresh the task list and retry"
1201
+ )
1202
+ )
1203
+ raise_tool_error(
1204
+ QingflowApiError.config_error(
1205
+ f"task_id={task_id_text} was not found in the current visible task boxes (todo, initiated, cc, done)"
1206
+ )
1207
+ )
1208
+
1209
+ def _resolve_task_locator_input(
1210
+ self,
1211
+ *,
1212
+ profile: str,
1213
+ task_id: Any = None,
1214
+ app_key: str = "",
1215
+ record_id: Any = "",
1216
+ workflow_node_id: int = 0,
1217
+ ) -> dict[str, Any]:
1218
+ task_id_text = normalize_positive_id_text(task_id, field_name="task_id") if task_id not in (None, "") else None
1219
+ resolved_app_key = (app_key or "").strip()
1220
+ resolved_record_id: int
1221
+ resolved_workflow_node_id: int
1222
+ if task_id_text is not None:
1223
+ locator = self._resolve_task_locator_by_task_id(profile=profile, task_id=task_id_text)
1224
+ resolved_app_key = str(locator["app_key"])
1225
+ resolved_record_id = normalize_positive_id_int(locator["record_id"], field_name="record_id")
1226
+ resolved_workflow_node_id = int(locator["workflow_node_id"])
1227
+ explicit_app_key = (app_key or "").strip()
1228
+ if explicit_app_key and explicit_app_key != resolved_app_key:
1229
+ raise_tool_error(
1230
+ QingflowApiError.config_error(
1231
+ f"task_id={task_id_text} resolved to app_key='{resolved_app_key}', which does not match app_key='{explicit_app_key}'"
1232
+ )
1233
+ )
1234
+ if record_id not in (None, ""):
1235
+ explicit_record_id = normalize_positive_id_text(record_id, field_name="record_id")
1236
+ if explicit_record_id != stringify_backend_id(resolved_record_id):
1237
+ raise_tool_error(
1238
+ QingflowApiError.config_error(
1239
+ f"task_id={task_id_text} resolved to record_id={resolved_record_id}, which does not match record_id={explicit_record_id}"
1240
+ )
1241
+ )
1242
+ if workflow_node_id not in (None, 0) and int(workflow_node_id) != resolved_workflow_node_id:
1243
+ raise_tool_error(
1244
+ QingflowApiError.config_error(
1245
+ f"task_id={task_id_text} resolved to workflow_node_id={resolved_workflow_node_id}, which does not match workflow_node_id={workflow_node_id}"
1246
+ )
1247
+ )
1248
+ else:
1249
+ resolved_record_id = normalize_positive_id_int(record_id, field_name="record_id")
1250
+ resolved_workflow_node_id = int(workflow_node_id)
1251
+ return {
1252
+ "task_id": task_id_text,
1253
+ "app_key": resolved_app_key,
1254
+ "record_id": resolved_record_id,
1255
+ "record_id_text": stringify_backend_id(resolved_record_id),
1256
+ "workflow_node_id": resolved_workflow_node_id,
1257
+ }
1258
+
1259
+ def _task_item_matches_query(self, item: dict[str, Any], query: str) -> bool:
1260
+ needle = str(query or "").strip().casefold()
1261
+ if not needle:
1262
+ return False
1263
+ for candidate in (
1264
+ item.get("app_name"),
1265
+ item.get("workflow_node_name"),
1266
+ item.get("app_key"),
1267
+ item.get("record_id"),
1268
+ ):
1269
+ if candidate in (None, ""):
1270
+ continue
1271
+ if needle in str(candidate).casefold():
1272
+ return True
1273
+ return False
1274
+
961
1275
  @tool_cn_name("任务关联报表详情")
962
1276
  def task_associated_report_detail_get(
963
1277
  self,
964
1278
  *,
965
1279
  profile: str,
966
- app_key: str,
967
- record_id: int,
968
- workflow_node_id: int,
1280
+ task_id: Any = None,
1281
+ app_key: str = "",
1282
+ record_id: Any = "",
1283
+ workflow_node_id: int = 0,
969
1284
  report_id: int,
970
1285
  page: int,
971
1286
  page_size: int,
972
1287
  ) -> dict[str, Any]:
973
1288
  """执行任务相关逻辑。"""
974
- self._require_app_record_and_node(app_key, record_id, workflow_node_id)
1289
+ if task_id in (None, ""):
1290
+ normalize_positive_id_int(record_id, field_name="record_id")
1291
+
975
1292
  if report_id <= 0:
976
1293
  raise_tool_error(QingflowApiError.config_error("report_id must be positive"))
977
1294
  if page <= 0 or page_size <= 0:
978
1295
  raise_tool_error(QingflowApiError.config_error("page and page_size must be positive"))
979
1296
 
980
1297
  def runner(session_profile, context):
981
- task_context = self._build_task_context(
1298
+ locator = self._resolve_task_locator_input(
982
1299
  profile=profile,
983
- context=context,
1300
+ task_id=task_id,
984
1301
  app_key=app_key,
985
1302
  record_id=record_id,
986
1303
  workflow_node_id=workflow_node_id,
1304
+ )
1305
+ task_id_text = locator["task_id"]
1306
+ resolved_app_key = str(locator["app_key"])
1307
+ resolved_record_id = int(locator["record_id"])
1308
+ record_id_text = str(locator["record_id_text"] or "")
1309
+ resolved_workflow_node_id = int(locator["workflow_node_id"])
1310
+ self._require_app_record_and_node(resolved_app_key, resolved_record_id, resolved_workflow_node_id)
1311
+ task_context = self._build_task_context(
1312
+ profile=profile,
1313
+ context=context,
1314
+ app_key=resolved_app_key,
1315
+ record_id=resolved_record_id,
1316
+ workflow_node_id=resolved_workflow_node_id,
987
1317
  include_candidates=False,
988
1318
  include_associated_reports=True,
989
1319
  current_uid=session_profile.uid,
@@ -992,7 +1322,7 @@ class TaskContextTools(ToolBase):
992
1322
  if report_item is None:
993
1323
  raise_tool_error(
994
1324
  QingflowApiError.config_error(
995
- f"report_id={report_id} is not visible for app_key='{app_key}' record_id={record_id} workflow_node_id={workflow_node_id}"
1325
+ f"report_id={report_id} is not visible for app_key='{resolved_app_key}' record_id={record_id_text} workflow_node_id={resolved_workflow_node_id}"
996
1326
  )
997
1327
  )
998
1328
  association_query = self._build_association_query(
@@ -1000,15 +1330,17 @@ class TaskContextTools(ToolBase):
1000
1330
  task_context.get("record", {}).get("answers") or [],
1001
1331
  )
1002
1332
  selection = {
1003
- "app_key": app_key,
1004
- "record_id": record_id,
1005
- "workflow_node_id": workflow_node_id,
1333
+ "app_key": resolved_app_key,
1334
+ "record_id": record_id_text,
1335
+ "workflow_node_id": resolved_workflow_node_id,
1006
1336
  "report_id": report_id,
1007
1337
  "target_app_key": report_item.get("target_app_key"),
1008
1338
  "target_app_name": report_item.get("target_app_name"),
1009
1339
  "chart_key": report_item.get("chart_key"),
1010
1340
  "chart_name": report_item.get("chart_name"),
1011
1341
  }
1342
+ if task_id_text is not None:
1343
+ selection["task_id"] = task_id_text
1012
1344
  context_payload = {
1013
1345
  "match_rules": report_item.get("match_rules") or [],
1014
1346
  "resolved_filters": association_query.get("keyQueValues") or [],
@@ -1155,20 +1487,35 @@ class TaskContextTools(ToolBase):
1155
1487
  self,
1156
1488
  *,
1157
1489
  profile: str,
1158
- app_key: str,
1159
- record_id: int,
1160
- workflow_node_id: int,
1490
+ task_id: Any = None,
1491
+ app_key: str = "",
1492
+ record_id: Any = "",
1493
+ workflow_node_id: int = 0,
1161
1494
  ) -> dict[str, Any]:
1162
1495
  """执行任务相关逻辑。"""
1163
- self._require_app_record_and_node(app_key, record_id, workflow_node_id)
1496
+ if task_id in (None, ""):
1497
+ normalize_positive_id_int(record_id, field_name="record_id")
1164
1498
 
1165
1499
  def runner(session_profile, context):
1166
- task_context = self._build_task_context(
1500
+ locator = self._resolve_task_locator_input(
1167
1501
  profile=profile,
1168
- context=context,
1502
+ task_id=task_id,
1169
1503
  app_key=app_key,
1170
1504
  record_id=record_id,
1171
1505
  workflow_node_id=workflow_node_id,
1506
+ )
1507
+ task_id_text = locator["task_id"]
1508
+ resolved_app_key = str(locator["app_key"])
1509
+ resolved_record_id = int(locator["record_id"])
1510
+ record_id_text = str(locator["record_id_text"] or "")
1511
+ resolved_workflow_node_id = int(locator["workflow_node_id"])
1512
+ self._require_app_record_and_node(resolved_app_key, resolved_record_id, resolved_workflow_node_id)
1513
+ task_context = self._build_task_context(
1514
+ profile=profile,
1515
+ context=context,
1516
+ app_key=resolved_app_key,
1517
+ record_id=resolved_record_id,
1518
+ workflow_node_id=resolved_workflow_node_id,
1172
1519
  include_candidates=False,
1173
1520
  include_associated_reports=False,
1174
1521
  current_uid=session_profile.uid,
@@ -1177,7 +1524,7 @@ class TaskContextTools(ToolBase):
1177
1524
  if not visibility.get("audit_record_visible"):
1178
1525
  raise_tool_error(
1179
1526
  QingflowApiError.config_error(
1180
- f"workflow logs are not visible for app_key='{app_key}' record_id={record_id} workflow_node_id={workflow_node_id}"
1527
+ f"workflow logs are not visible for app_key='{resolved_app_key}' record_id={record_id_text} workflow_node_id={resolved_workflow_node_id}"
1181
1528
  )
1182
1529
  )
1183
1530
  page = self.backend.request(
@@ -1185,16 +1532,16 @@ class TaskContextTools(ToolBase):
1185
1532
  context,
1186
1533
  "/application/workflow/node/record",
1187
1534
  json_body={
1188
- "key": app_key,
1189
- "rowRecordId": record_id,
1190
- "nodeId": workflow_node_id,
1535
+ "key": resolved_app_key,
1536
+ "rowRecordId": resolved_record_id,
1537
+ "nodeId": resolved_workflow_node_id,
1191
1538
  "role": 3,
1192
1539
  "pageNum": 1,
1193
1540
  "pageSize": 200,
1194
1541
  },
1195
1542
  )
1196
1543
  items = self._normalize_workflow_logs(page)
1197
- return {
1544
+ result = {
1198
1545
  "profile": profile,
1199
1546
  "ws_id": session_profile.selected_ws_id,
1200
1547
  "ok": True,
@@ -1203,9 +1550,9 @@ class TaskContextTools(ToolBase):
1203
1550
  "output_profile": "normal",
1204
1551
  "data": {
1205
1552
  "selection": {
1206
- "app_key": app_key,
1207
- "record_id": record_id,
1208
- "workflow_node_id": workflow_node_id,
1553
+ "app_key": resolved_app_key,
1554
+ "record_id": record_id_text,
1555
+ "workflow_node_id": resolved_workflow_node_id,
1209
1556
  },
1210
1557
  "visibility": {
1211
1558
  "audit_record_visible": visibility.get("audit_record_visible"),
@@ -1214,6 +1561,11 @@ class TaskContextTools(ToolBase):
1214
1561
  "items": items,
1215
1562
  },
1216
1563
  }
1564
+ if task_id_text is not None:
1565
+ selection = result["data"].get("selection")
1566
+ if isinstance(selection, dict):
1567
+ selection["task_id"] = task_id_text
1568
+ return result
1217
1569
 
1218
1570
  return self._run(profile, runner)
1219
1571
 
@@ -1243,8 +1595,9 @@ class TaskContextTools(ToolBase):
1243
1595
  f"/app/{app_key}/apply/{record_id}",
1244
1596
  params={"role": 3, "listType": 1, "auditNodeId": workflow_node_id},
1245
1597
  )
1598
+ app_name = self._task_app_name(context=context, app_key=app_key, detail=detail, node_info=node_info)
1246
1599
  associated_report_visible = self._resolve_associated_report_visible(node_info, detail)
1247
- associated_reports = {"visible": associated_report_visible, "count": 0, "items": []}
1600
+ associated_reports = {"visible": associated_report_visible, "loaded": False, "count": 0, "items": []}
1248
1601
  if include_associated_reports and associated_report_visible:
1249
1602
  asos_chart_list = self.backend.request(
1250
1603
  "GET",
@@ -1259,11 +1612,21 @@ class TaskContextTools(ToolBase):
1259
1612
  ]
1260
1613
  associated_reports = {
1261
1614
  "visible": True,
1615
+ "loaded": True,
1262
1616
  "count": len(associated_items),
1263
1617
  "items": associated_items,
1264
1618
  }
1265
1619
  rollback_items: list[dict[str, Any]] = []
1266
1620
  transfer_items: list[dict[str, Any]] = []
1621
+ transfer_warnings: list[JSONObject] = []
1622
+ transfer_pagination: JSONObject = {
1623
+ "loaded": False,
1624
+ "page_size": 100,
1625
+ "fetched_pages": 0,
1626
+ "reported_total": None,
1627
+ "page_amount": None,
1628
+ "truncated": False,
1629
+ }
1267
1630
  if include_candidates:
1268
1631
  rollback_result = self.backend.request(
1269
1632
  "GET",
@@ -1272,13 +1635,13 @@ class TaskContextTools(ToolBase):
1272
1635
  params={"auditNodeId": workflow_node_id},
1273
1636
  )
1274
1637
  rollback_items = self._rollback_candidate_items(rollback_result)
1275
- transfer_result = self.backend.request(
1276
- "GET",
1638
+ transfer_items, transfer_warnings, transfer_pagination = self._transfer_candidate_items(
1277
1639
  context,
1278
- f"/app/{app_key}/apply/{record_id}/transfer/member",
1279
- params={"pageNum": 1, "pageSize": 20, "auditNodeId": workflow_node_id},
1640
+ app_key=app_key,
1641
+ record_id=record_id,
1642
+ workflow_node_id=workflow_node_id,
1643
+ current_uid=current_uid,
1280
1644
  )
1281
- transfer_items = self._filter_transfer_members(_approval_page_items(transfer_result), current_uid=current_uid)
1282
1645
 
1283
1646
  update_schema_state = self._build_task_update_schema(
1284
1647
  profile=profile,
@@ -1302,10 +1665,12 @@ class TaskContextTools(ToolBase):
1302
1665
  save_only_source=save_only_source,
1303
1666
  )
1304
1667
  visibility = self._build_visibility(node_info, detail)
1668
+ record_id_text = stringify_backend_id(record_id)
1305
1669
  return {
1306
1670
  "task": {
1307
1671
  "app_key": app_key,
1308
- "record_id": record_id,
1672
+ "app_name": app_name,
1673
+ "record_id": record_id_text,
1309
1674
  "workflow_node_id": workflow_node_id,
1310
1675
  "workflow_node_name": node_info.get("auditNodeName") or node_info.get("nodeName"),
1311
1676
  "actionable": True,
@@ -1316,7 +1681,7 @@ class TaskContextTools(ToolBase):
1316
1681
  "raw": dict(node_info),
1317
1682
  },
1318
1683
  "record": {
1319
- "apply_id": detail.get("applyId", record_id),
1684
+ "apply_id": stringify_backend_id(detail.get("applyId") or record_id),
1320
1685
  "apply_status": detail.get("applyStatus"),
1321
1686
  "apply_num": detail.get("applyNum"),
1322
1687
  "custom_apply_num": detail.get("customApplyNum"),
@@ -1336,6 +1701,9 @@ class TaskContextTools(ToolBase):
1336
1701
  "candidates": {
1337
1702
  "rollback_nodes": rollback_items,
1338
1703
  "transfer_members": transfer_items,
1704
+ "loaded": include_candidates,
1705
+ "transfer_pagination": transfer_pagination,
1706
+ "warnings": transfer_warnings,
1339
1707
  },
1340
1708
  "workflow_log_summary": {
1341
1709
  "visible": visibility["audit_record_visible"],
@@ -1346,32 +1714,365 @@ class TaskContextTools(ToolBase):
1346
1714
  "update_schema": update_schema,
1347
1715
  }
1348
1716
 
1349
- def _normalize_task_item(self, raw: dict[str, Any], *, task_box: str, flow_status: str) -> dict[str, Any]:
1717
+ def _compact_task_get_context(self, data: dict[str, Any]) -> dict[str, Any]:
1718
+ task = data.get("task") if isinstance(data.get("task"), dict) else {}
1719
+ record = data.get("record") if isinstance(data.get("record"), dict) else {}
1720
+ capabilities = data.get("capabilities") if isinstance(data.get("capabilities"), dict) else {}
1721
+ update_schema = data.get("update_schema") if isinstance(data.get("update_schema"), dict) else {}
1722
+ associated_reports = data.get("associated_reports") if isinstance(data.get("associated_reports"), dict) else {}
1723
+ candidates = data.get("candidates") if isinstance(data.get("candidates"), dict) else {}
1724
+ workflow_log = data.get("workflow_log_summary") if isinstance(data.get("workflow_log_summary"), dict) else {}
1725
+
1726
+ available_actions = [
1727
+ str(item)
1728
+ for item in (capabilities.get("available_actions") or [])
1729
+ if str(item).strip()
1730
+ ]
1731
+ writable_fields = update_schema.get("writable_fields") if isinstance(update_schema.get("writable_fields"), list) else []
1732
+ rollback_items = [
1733
+ self._compact_rollback_candidate(item)
1734
+ for item in (candidates.get("rollback_nodes") or [])
1735
+ if isinstance(item, dict)
1736
+ ]
1737
+ transfer_items = [
1738
+ self._compact_transfer_member(item)
1739
+ for item in (candidates.get("transfer_members") or [])
1740
+ if isinstance(item, dict)
1741
+ ]
1742
+ associated_items = [
1743
+ self._compact_associated_report(item)
1744
+ for item in (associated_reports.get("items") or [])
1745
+ if isinstance(item, dict)
1746
+ ]
1747
+ transfer_pagination = candidates.get("transfer_pagination") if isinstance(candidates.get("transfer_pagination"), dict) else {}
1748
+ compact: dict[str, Any] = {
1749
+ "task": {
1750
+ "app_key": task.get("app_key"),
1751
+ "app_name": task.get("app_name"),
1752
+ "record_id": stringify_backend_id(task.get("record_id")),
1753
+ "workflow_node_id": task.get("workflow_node_id"),
1754
+ "workflow_node_name": task.get("workflow_node_name"),
1755
+ "initiator": self._compact_initiator(record.get("apply_user")),
1756
+ "actionable": task.get("actionable"),
1757
+ },
1758
+ "record_summary": {
1759
+ "apply_status": record.get("apply_status"),
1760
+ "apply_num": record.get("apply_num"),
1761
+ "custom_apply_num": record.get("custom_apply_num"),
1762
+ "apply_time": record.get("apply_time"),
1763
+ "last_update_time": record.get("last_update_time"),
1764
+ "core_fields": self._task_record_core_fields(record.get("answers") or []),
1765
+ "all_fields": self._task_record_all_fields(record.get("answers") or []),
1766
+ },
1767
+ "available_actions": available_actions,
1768
+ "editable_fields": [
1769
+ self._compact_task_editable_field(item, update_schema)
1770
+ for item in writable_fields
1771
+ if isinstance(item, dict)
1772
+ ],
1773
+ "extras": {
1774
+ "workflow_log": {
1775
+ "available": bool(workflow_log.get("available")),
1776
+ "qrobot_log_visible": bool(workflow_log.get("qrobot_log_visible")),
1777
+ "history_count": workflow_log.get("history_count"),
1778
+ },
1779
+ "associated_reports": {
1780
+ "available": bool(associated_reports.get("visible")),
1781
+ "loaded": bool(associated_reports.get("loaded")),
1782
+ "count": len(associated_items),
1783
+ "items": associated_items,
1784
+ },
1785
+ "rollback_candidates": {
1786
+ "available": "rollback" in available_actions,
1787
+ "loaded": bool(candidates.get("loaded")),
1788
+ "count": len(rollback_items),
1789
+ "items": rollback_items,
1790
+ },
1791
+ "transfer_candidates": {
1792
+ "available": "transfer" in available_actions,
1793
+ "loaded": bool(transfer_pagination.get("loaded")),
1794
+ "count": len(transfer_items),
1795
+ "items": transfer_items,
1796
+ "pagination": transfer_pagination,
1797
+ "warnings": candidates.get("warnings") or [],
1798
+ },
1799
+ },
1800
+ }
1801
+ action_metadata = self._compact_task_action_metadata(capabilities)
1802
+ if action_metadata:
1803
+ compact["action_metadata"] = action_metadata
1804
+ editable_metadata = self._compact_task_editable_metadata(update_schema)
1805
+ if editable_metadata:
1806
+ compact["editable_metadata"] = editable_metadata
1807
+ return compact
1808
+
1809
+ def _compact_task_action_metadata(self, capabilities: dict[str, Any]) -> dict[str, Any]:
1810
+ constraints = capabilities.get("action_constraints") if isinstance(capabilities.get("action_constraints"), dict) else {}
1811
+ metadata: dict[str, Any] = {}
1812
+ feedback_required_for = constraints.get("feedback_required_for") if isinstance(constraints.get("feedback_required_for"), list) else []
1813
+ if feedback_required_for:
1814
+ metadata["feedback_required_for"] = feedback_required_for
1815
+ visible_but_unimplemented = capabilities.get("visible_but_unimplemented_actions")
1816
+ if visible_but_unimplemented:
1817
+ metadata["visible_but_unimplemented_actions"] = visible_but_unimplemented
1818
+ if capabilities.get("save_only_source"):
1819
+ metadata["save_only_source"] = capabilities.get("save_only_source")
1820
+ if capabilities.get("warnings"):
1821
+ metadata["warnings"] = capabilities.get("warnings")
1822
+ return metadata
1823
+
1824
+ def _compact_task_editable_metadata(self, update_schema: dict[str, Any]) -> dict[str, Any]:
1825
+ metadata: dict[str, Any] = {}
1826
+ blockers = update_schema.get("blockers") if isinstance(update_schema.get("blockers"), list) else []
1827
+ warnings = update_schema.get("warnings") if isinstance(update_schema.get("warnings"), list) else []
1828
+ if blockers:
1829
+ metadata["blockers"] = blockers
1830
+ if warnings:
1831
+ metadata["warnings"] = warnings
1832
+ return metadata
1833
+
1834
+ def _compact_initiator(self, payload: Any) -> dict[str, Any] | None:
1835
+ if not isinstance(payload, dict):
1836
+ return None
1837
+ compact = {
1838
+ "uid": payload.get("uid"),
1839
+ "displayName": payload.get("displayName") or payload.get("name") or payload.get("nickName"),
1840
+ "email": payload.get("email"),
1841
+ "mobile": payload.get("mobile"),
1842
+ "headImg": payload.get("headImg"),
1843
+ }
1844
+ return {key: value for key, value in compact.items() if value not in (None, "", [])} or None
1845
+
1846
+ def _task_app_name(
1847
+ self,
1848
+ *,
1849
+ context: BackendRequestContext,
1850
+ app_key: str,
1851
+ detail: dict[str, Any],
1852
+ node_info: dict[str, Any],
1853
+ ) -> Any:
1854
+ for source in (detail, node_info):
1855
+ for key in ("formTitle", "appName", "worksheetName", "appTitle"):
1856
+ value = source.get(key)
1857
+ if value not in (None, ""):
1858
+ if app_key:
1859
+ self._app_name_cache[app_key] = str(value)
1860
+ return value
1861
+ normalized_app_key = str(app_key or "").strip()
1862
+ if not normalized_app_key:
1863
+ return None
1864
+ if normalized_app_key in self._app_name_cache:
1865
+ return self._app_name_cache[normalized_app_key]
1866
+ resolved = self._resolve_task_app_name_from_base_info(context=context, app_key=normalized_app_key)
1867
+ if resolved is None:
1868
+ resolved = self._resolve_task_app_name_from_visible_apps(context=context, app_key=normalized_app_key)
1869
+ self._app_name_cache[normalized_app_key] = resolved
1870
+ return resolved
1871
+
1872
+ def _resolve_task_app_name_from_base_info(
1873
+ self,
1874
+ *,
1875
+ context: BackendRequestContext,
1876
+ app_key: str,
1877
+ ) -> str | None:
1878
+ try:
1879
+ base_info = self.backend.request("GET", context, f"/app/{app_key}/baseInfo")
1880
+ except QingflowApiError:
1881
+ return None
1882
+ if not isinstance(base_info, dict):
1883
+ return None
1884
+ for key in ("formTitle", "title", "appName", "name"):
1885
+ value = str(base_info.get(key) or "").strip()
1886
+ if value:
1887
+ return value
1888
+ return None
1889
+
1890
+ def _resolve_task_app_name_from_visible_apps(
1891
+ self,
1892
+ *,
1893
+ context: BackendRequestContext,
1894
+ app_key: str,
1895
+ ) -> str | None:
1896
+ try:
1897
+ visible_apps = self.backend.request("GET", context, "/tag/apps")
1898
+ except QingflowApiError:
1899
+ return None
1900
+ return self._find_task_app_name_in_visible_apps(visible_apps, app_key=app_key)
1901
+
1902
+ def _find_task_app_name_in_visible_apps(self, payload: Any, *, app_key: str) -> str | None:
1903
+ if isinstance(payload, list):
1904
+ for item in payload:
1905
+ resolved = self._find_task_app_name_in_visible_apps(item, app_key=app_key)
1906
+ if resolved:
1907
+ return resolved
1908
+ return None
1909
+ if not isinstance(payload, dict):
1910
+ return None
1911
+ candidate_app_key = str(payload.get("appKey") or payload.get("app_key") or "").strip()
1912
+ if candidate_app_key == app_key:
1913
+ for key in ("formTitle", "title", "appName", "name"):
1914
+ value = str(payload.get(key) or "").strip()
1915
+ if value:
1916
+ return value
1917
+ for value in payload.values():
1918
+ if isinstance(value, (list, dict)):
1919
+ resolved = self._find_task_app_name_in_visible_apps(value, app_key=app_key)
1920
+ if resolved:
1921
+ return resolved
1922
+ return None
1923
+
1924
+ def _task_record_core_fields(self, answers: Any, *, limit: int = 12) -> dict[str, Any]:
1925
+ return self._task_record_field_map(answers, limit=limit, truncate_text=160)
1926
+
1927
+ def _task_record_all_fields(self, answers: Any) -> dict[str, Any]:
1928
+ return self._task_record_field_map(answers, limit=None, truncate_text=None)
1929
+
1930
+ def _task_record_field_map(
1931
+ self,
1932
+ answers: Any,
1933
+ *,
1934
+ limit: int | None,
1935
+ truncate_text: int | None,
1936
+ ) -> dict[str, Any]:
1937
+ if not isinstance(answers, list):
1938
+ return {}
1939
+ field_map: dict[str, Any] = {}
1940
+ for answer in answers:
1941
+ if not isinstance(answer, dict):
1942
+ continue
1943
+ title = answer.get("queTitle") or answer.get("title") or answer.get("fieldName")
1944
+ if not title:
1945
+ que_id = answer.get("queId")
1946
+ title = f"field_{que_id}" if que_id not in (None, "") else None
1947
+ if not title:
1948
+ continue
1949
+ table_values = answer.get("tableValues") if isinstance(answer.get("tableValues"), list) else []
1950
+ if table_values:
1951
+ value: Any = f"子表格 {len(table_values)} 行"
1952
+ else:
1953
+ values = self._extract_answer_values(answer)
1954
+ if not values:
1955
+ continue
1956
+ value = values[0] if len(values) == 1 else values
1957
+ if value in (None, "", []):
1958
+ continue
1959
+ field_map[str(title)] = self._compact_task_value(value, truncate_text=truncate_text)
1960
+ if limit is not None and len(field_map) >= limit:
1961
+ break
1962
+ return field_map
1963
+
1964
+ def _compact_task_value(self, value: Any, *, truncate_text: int | None = 160) -> Any:
1965
+ if isinstance(value, list):
1966
+ items = [self._compact_task_value(item, truncate_text=truncate_text) for item in value]
1967
+ if truncate_text is not None:
1968
+ return items[:8]
1969
+ return items
1970
+ text = re.sub(r"<[^>]+>", " ", str(value))
1971
+ text = re.sub(r"\s+", " ", text).strip()
1972
+ if truncate_text is None or len(text) <= truncate_text:
1973
+ return text
1974
+ if truncate_text <= 3:
1975
+ return text[:truncate_text]
1976
+ return text[: truncate_text - 3].rstrip() + "..."
1977
+
1978
+ def _compact_task_editable_field(self, field: dict[str, Any], update_schema: dict[str, Any]) -> dict[str, Any]:
1979
+ payload_template = update_schema.get("payload_template") if isinstance(update_schema.get("payload_template"), dict) else {}
1980
+ title = field.get("title")
1981
+ compact: dict[str, Any] = {}
1982
+ for key in ("field_id", "title", "kind", "required", "candidate_hint"):
1983
+ if key in field:
1984
+ compact[key] = field.get(key)
1985
+ if title in payload_template:
1986
+ compact["template"] = payload_template.get(title)
1987
+ return compact
1988
+
1989
+ def _compact_associated_report(self, item: dict[str, Any]) -> dict[str, Any]:
1990
+ return {
1991
+ key: value
1992
+ for key, value in {
1993
+ "report_id": item.get("report_id"),
1994
+ "chart_key": item.get("chart_key"),
1995
+ "chart_name": item.get("chart_name"),
1996
+ "graph_type": item.get("graph_type"),
1997
+ "source_type": item.get("source_type"),
1998
+ "target_app_key": item.get("target_app_key"),
1999
+ "target_app_name": item.get("target_app_name"),
2000
+ }.items()
2001
+ if value not in (None, "", [])
2002
+ }
2003
+
2004
+ def _compact_rollback_candidate(self, item: dict[str, Any]) -> dict[str, Any]:
2005
+ return {
2006
+ key: value
2007
+ for key, value in {
2008
+ "workflow_node_id": item.get("auditNodeId") or item.get("nodeId"),
2009
+ "workflow_node_name": item.get("auditNodeName") or item.get("nodeName"),
2010
+ }.items()
2011
+ if value not in (None, "", [])
2012
+ }
2013
+
2014
+ def _compact_transfer_member(self, item: dict[str, Any]) -> dict[str, Any]:
2015
+ uid = item.get("uid")
2016
+ if uid is None:
2017
+ uid = item.get("userId") or item.get("memberId") or item.get("id")
2018
+ return {
2019
+ key: value
2020
+ for key, value in {
2021
+ "uid": uid,
2022
+ "name": item.get("name") or item.get("userName") or item.get("memberName") or item.get("realName"),
2023
+ "email": item.get("email") or item.get("mail"),
2024
+ "department_id": item.get("departmentId") or item.get("deptId"),
2025
+ "department_name": item.get("departmentName") or item.get("deptName"),
2026
+ }.items()
2027
+ if value not in (None, "", [])
2028
+ }
2029
+
2030
+ def _normalize_task_item(self, raw: dict[str, Any]) -> dict[str, Any]:
1350
2031
  """执行内部辅助逻辑。"""
1351
2032
  app_key = raw.get("appKey") or raw.get("app_key")
1352
2033
  record_id = raw.get("rowRecordId") or raw.get("recordId") or raw.get("applyId")
1353
2034
  workflow_node_id = raw.get("nodeId") or raw.get("auditNodeId")
1354
- apply_user = raw.get("applyUser")
1355
- if apply_user is None:
1356
- user_uid = raw.get("applyUserUid")
1357
- user_name = raw.get("applyUserName")
1358
- if user_uid is not None or user_name is not None:
1359
- apply_user = {"uid": user_uid, "name": user_name}
1360
2035
  return {
1361
- "task_id": raw.get("id") or raw.get("taskId") or record_id,
2036
+ "task_id": stringify_backend_id(raw.get("id") or raw.get("taskId") or record_id),
1362
2037
  "app_key": app_key,
1363
2038
  "app_name": raw.get("formTitle") or raw.get("worksheetName") or raw.get("appName"),
1364
- "record_id": record_id,
2039
+ "record_id": stringify_backend_id(record_id),
1365
2040
  "workflow_node_id": workflow_node_id,
1366
2041
  "workflow_node_name": raw.get("nodeName") or raw.get("auditNodeName"),
1367
- "title": raw.get("title") or raw.get("applyTitle") or raw.get("name") or raw.get("formTitle"),
1368
- "apply_user": apply_user,
1369
2042
  "apply_time": raw.get("applyTime") or raw.get("receiveTime"),
1370
- "task_box": task_box,
1371
- "flow_status": flow_status,
1372
- "actionable": task_box == "todo" and bool(record_id) and bool(workflow_node_id),
2043
+ "summary_fields": self._normalize_task_summary_fields(raw.get("dataSnapshot")),
2044
+ }
2045
+
2046
+ def _public_task_item(self, item: dict[str, Any]) -> dict[str, Any]:
2047
+ return {
2048
+ "task_id": item.get("task_id"),
2049
+ "app_name": item.get("app_name"),
2050
+ "workflow_node_name": item.get("workflow_node_name"),
2051
+ "apply_time": item.get("apply_time"),
2052
+ "summary_fields": item.get("summary_fields") if isinstance(item.get("summary_fields"), list) else [],
1373
2053
  }
1374
2054
 
2055
+ def _normalize_task_summary_fields(self, raw: Any) -> list[dict[str, Any]]:
2056
+ """执行内部辅助逻辑。"""
2057
+ if not isinstance(raw, list):
2058
+ return []
2059
+ summary_fields: list[dict[str, Any]] = []
2060
+ for item in raw:
2061
+ if not isinstance(item, dict):
2062
+ continue
2063
+ summary_field: dict[str, Any] = {
2064
+ "field_id": item.get("fieldId"),
2065
+ "title": item.get("fieldTitle"),
2066
+ "type": item.get("fieldType"),
2067
+ "answer": item.get("fieldAnswer"),
2068
+ "desensitized": self._coerce_bool(item.get("beingDesensitized")),
2069
+ }
2070
+ associated_field_type = item.get("associatedQueType")
2071
+ if associated_field_type is not None:
2072
+ summary_field["associated_field_type"] = associated_field_type
2073
+ summary_fields.append(summary_field)
2074
+ return summary_fields
2075
+
1375
2076
  def _select_task_node(self, infos: Any, workflow_node_id: int, *, app_key: str, record_id: int) -> dict[str, Any]:
1376
2077
  """执行内部辅助逻辑。"""
1377
2078
  if not isinstance(infos, list) or not infos:
@@ -1454,6 +2155,7 @@ class TaskContextTools(ToolBase):
1454
2155
  current_answers: Any,
1455
2156
  ) -> dict[str, Any]:
1456
2157
  """执行内部辅助逻辑。"""
2158
+ record_id_text = stringify_backend_id(record_id)
1457
2159
  try:
1458
2160
  app_schema = self._record_tools._get_form_schema(profile, context, app_key, force_refresh=False)
1459
2161
  except QingflowApiError as error:
@@ -1470,7 +2172,7 @@ class TaskContextTools(ToolBase):
1470
2172
  ],
1471
2173
  "selection": {
1472
2174
  "app_key": app_key,
1473
- "record_id": record_id,
2175
+ "record_id": record_id_text,
1474
2176
  "workflow_node_id": workflow_node_id,
1475
2177
  },
1476
2178
  "transport_error": {
@@ -1524,16 +2226,16 @@ class TaskContextTools(ToolBase):
1524
2226
  write_hints = self._record_tools._schema_write_hints(editable_field)
1525
2227
  if not bool(write_hints.get("writable")):
1526
2228
  continue
1527
- writable_fields.append(
1528
- self._record_tools._ready_schema_field_payload(
1529
- profile,
1530
- context,
1531
- editable_field,
1532
- ws_id=context.ws_id,
1533
- required_override=False,
1534
- linkage_payloads_by_field_id=linkage_payloads_by_field_id,
1535
- )
2229
+ writable_field = self._record_tools._ready_schema_field_payload(
2230
+ profile,
2231
+ context,
2232
+ editable_field,
2233
+ ws_id=context.ws_id,
2234
+ required_override=False,
2235
+ linkage_payloads_by_field_id=linkage_payloads_by_field_id,
1536
2236
  )
2237
+ writable_field.setdefault("field_id", editable_field.que_id)
2238
+ writable_fields.append(writable_field)
1537
2239
  blockers: list[str] = []
1538
2240
  if not writable_fields:
1539
2241
  blockers.append("NO_TASK_EDITABLE_FIELDS")
@@ -1555,7 +2257,7 @@ class TaskContextTools(ToolBase):
1555
2257
  "warnings": schema_warnings,
1556
2258
  "selection": {
1557
2259
  "app_key": app_key,
1558
- "record_id": record_id,
2260
+ "record_id": record_id_text,
1559
2261
  "workflow_node_id": workflow_node_id,
1560
2262
  },
1561
2263
  }
@@ -1926,11 +2628,85 @@ class TaskContextTools(ToolBase):
1926
2628
  for item in items:
1927
2629
  if not isinstance(item, dict):
1928
2630
  continue
1929
- if current_uid is not None and item.get("uid") == current_uid:
2631
+ uid = _coerce_count(item.get("uid") or item.get("userId") or item.get("memberId") or item.get("id"))
2632
+ if current_uid is not None and uid == current_uid:
1930
2633
  continue
1931
2634
  filtered.append(item)
1932
2635
  return filtered
1933
2636
 
2637
+ def _transfer_candidate_items(
2638
+ self,
2639
+ context: BackendRequestContext,
2640
+ *,
2641
+ app_key: str,
2642
+ record_id: int,
2643
+ workflow_node_id: int,
2644
+ current_uid: int | None,
2645
+ ) -> tuple[list[dict[str, Any]], list[JSONObject], JSONObject]:
2646
+ page_size = 100
2647
+ max_pages = 100
2648
+ page_num = 1
2649
+ fetched_pages = 0
2650
+ fetched_raw_count = 0
2651
+ page_amount: int | None = None
2652
+ reported_total: int | None = None
2653
+ items: list[dict[str, Any]] = []
2654
+ seen_member_keys: set[str] = set()
2655
+ warnings: list[JSONObject] = []
2656
+
2657
+ while page_num <= max_pages:
2658
+ result = self.backend.request(
2659
+ "GET",
2660
+ context,
2661
+ f"/app/{app_key}/apply/{record_id}/transfer/member",
2662
+ params={"pageNum": page_num, "pageSize": page_size, "auditNodeId": workflow_node_id},
2663
+ )
2664
+ fetched_pages += 1
2665
+ raw_items = _approval_page_items(result)
2666
+ fetched_raw_count += len(raw_items)
2667
+ if page_amount is None:
2668
+ page_amount = _coerce_count(_approval_page_amount(result))
2669
+ if reported_total is None:
2670
+ reported_total = _coerce_count(_approval_page_total(result))
2671
+ for item in self._filter_transfer_members(raw_items, current_uid=current_uid):
2672
+ member_key = self._transfer_member_dedupe_key(item)
2673
+ if member_key in seen_member_keys:
2674
+ continue
2675
+ seen_member_keys.add(member_key)
2676
+ items.append(item)
2677
+ if not raw_items:
2678
+ break
2679
+ if page_amount is not None and page_num >= page_amount:
2680
+ break
2681
+ if reported_total is not None and fetched_raw_count >= reported_total:
2682
+ break
2683
+ page_num += 1
2684
+ truncated = page_num > max_pages
2685
+ if truncated:
2686
+ warnings.append(
2687
+ {
2688
+ "code": "TRANSFER_CANDIDATES_TRUNCATED",
2689
+ "message": "transfer candidates reached the MCP safety page cap; returned candidates may be incomplete.",
2690
+ "max_pages": max_pages,
2691
+ "page_size": page_size,
2692
+ }
2693
+ )
2694
+ pagination: JSONObject = {
2695
+ "loaded": True,
2696
+ "page_size": page_size,
2697
+ "fetched_pages": fetched_pages,
2698
+ "reported_total": reported_total,
2699
+ "page_amount": page_amount,
2700
+ "truncated": truncated,
2701
+ }
2702
+ return items, warnings, pagination
2703
+
2704
+ def _transfer_member_dedupe_key(self, item: dict[str, Any]) -> str:
2705
+ uid = item.get("uid") or item.get("userId") or item.get("memberId") or item.get("id")
2706
+ if uid not in (None, ""):
2707
+ return f"uid:{uid}"
2708
+ return json.dumps(item, ensure_ascii=False, sort_keys=True, default=str)
2709
+
1934
2710
  def _find_associated_report(self, task_context: dict[str, Any], report_id: int) -> dict[str, Any] | None:
1935
2711
  """执行内部辅助逻辑。"""
1936
2712
  associated_reports = ((task_context.get("associated_reports") or {}).get("items") or [])