@qingflow-tech/qingflow-app-user-mcp 1.0.10 → 1.0.12

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 (89) hide show
  1. package/README.md +9 -3
  2. package/docs/local-agent-install.md +54 -3
  3. package/entry_point.py +1 -1
  4. package/npm/bin/qingflow-skills.mjs +5 -0
  5. package/npm/lib/runtime.mjs +304 -13
  6. package/npm/scripts/postinstall.mjs +1 -5
  7. package/package.json +3 -2
  8. package/pyproject.toml +1 -1
  9. package/skills/qingflow-app-builder/SKILL.md +255 -0
  10. package/skills/qingflow-app-builder/agents/openai.yaml +4 -0
  11. package/skills/qingflow-app-builder/references/create-app.md +149 -0
  12. package/skills/qingflow-app-builder/references/environments.md +63 -0
  13. package/skills/qingflow-app-builder/references/flow-actors-and-permissions.md +123 -0
  14. package/skills/qingflow-app-builder/references/gotchas.md +107 -0
  15. package/skills/qingflow-app-builder/references/match-rules.md +114 -0
  16. package/skills/qingflow-app-builder/references/public-surface-sync.md +75 -0
  17. package/skills/qingflow-app-builder/references/solution-playbooks.md +52 -0
  18. package/skills/qingflow-app-builder/references/tool-selection.md +99 -0
  19. package/skills/qingflow-app-builder/references/update-flow.md +158 -0
  20. package/skills/qingflow-app-builder/references/update-layout.md +68 -0
  21. package/skills/qingflow-app-builder/references/update-schema.md +72 -0
  22. package/skills/qingflow-app-builder/references/update-views.md +284 -0
  23. package/skills/qingflow-app-builder-code-integrations/SKILL.md +137 -0
  24. package/skills/qingflow-app-builder-code-integrations/agents/openai.yaml +4 -0
  25. package/skills/qingflow-app-builder-code-integrations/references/code-block.md +66 -0
  26. package/skills/qingflow-app-builder-code-integrations/references/q-linker.md +77 -0
  27. package/skills/qingflow-app-user/SKILL.md +12 -11
  28. package/skills/qingflow-app-user/references/data-gotchas.md +2 -2
  29. package/skills/qingflow-app-user/references/public-surface-sync.md +3 -3
  30. package/skills/qingflow-app-user/references/record-patterns.md +5 -5
  31. package/skills/qingflow-app-user/references/workflow-usage.md +4 -5
  32. package/skills/qingflow-mcp-setup/SKILL.md +113 -0
  33. package/skills/qingflow-mcp-setup/agents/openai.yaml +4 -0
  34. package/skills/qingflow-mcp-setup/references/claude-desktop.md +34 -0
  35. package/skills/qingflow-mcp-setup/references/environments.md +62 -0
  36. package/skills/qingflow-mcp-setup/references/generic-stdio.md +32 -0
  37. package/skills/qingflow-mcp-setup/scripts/check_local_server.sh +38 -0
  38. package/skills/qingflow-record-analysis/SKILL.md +6 -7
  39. package/skills/qingflow-record-analysis/manifest.yaml +10 -0
  40. package/skills/qingflow-record-delete/SKILL.md +5 -3
  41. package/skills/qingflow-record-import/SKILL.md +6 -2
  42. package/skills/qingflow-record-insert/SKILL.md +48 -4
  43. package/skills/qingflow-record-insert/manifest.yaml +6 -0
  44. package/skills/qingflow-record-update/SKILL.md +36 -24
  45. package/skills/qingflow-task-ops/SKILL.md +25 -25
  46. package/skills/qingflow-task-ops/references/environments.md +0 -1
  47. package/skills/qingflow-task-ops/references/workflow-usage.md +4 -6
  48. package/src/qingflow_mcp/__main__.py +6 -2
  49. package/src/qingflow_mcp/builder_facade/models.py +41 -2
  50. package/src/qingflow_mcp/builder_facade/service.py +2743 -423
  51. package/src/qingflow_mcp/cli/commands/app.py +3 -16
  52. package/src/qingflow_mcp/cli/commands/builder.py +30 -4
  53. package/src/qingflow_mcp/cli/commands/exports.py +2 -2
  54. package/src/qingflow_mcp/cli/commands/imports.py +1 -1
  55. package/src/qingflow_mcp/cli/commands/record.py +54 -11
  56. package/src/qingflow_mcp/cli/context.py +0 -3
  57. package/src/qingflow_mcp/cli/formatters.py +238 -8
  58. package/src/qingflow_mcp/cli/main.py +47 -3
  59. package/src/qingflow_mcp/errors.py +43 -2
  60. package/src/qingflow_mcp/public_surface.py +24 -16
  61. package/src/qingflow_mcp/response_trim.py +119 -12
  62. package/src/qingflow_mcp/server.py +17 -14
  63. package/src/qingflow_mcp/server_app_builder.py +29 -7
  64. package/src/qingflow_mcp/server_app_user.py +23 -24
  65. package/src/qingflow_mcp/solution/compiler/icon_utils.py +294 -0
  66. package/src/qingflow_mcp/solution/executor.py +112 -15
  67. package/src/qingflow_mcp/tools/ai_builder_tools.py +497 -65
  68. package/src/qingflow_mcp/tools/app_tools.py +237 -51
  69. package/src/qingflow_mcp/tools/approval_tools.py +196 -34
  70. package/src/qingflow_mcp/tools/auth_tools.py +92 -16
  71. package/src/qingflow_mcp/tools/code_block_tools.py +296 -39
  72. package/src/qingflow_mcp/tools/custom_button_tools.py +64 -10
  73. package/src/qingflow_mcp/tools/directory_tools.py +236 -72
  74. package/src/qingflow_mcp/tools/export_tools.py +230 -33
  75. package/src/qingflow_mcp/tools/file_tools.py +7 -3
  76. package/src/qingflow_mcp/tools/import_tools.py +293 -40
  77. package/src/qingflow_mcp/tools/navigation_tools.py +91 -12
  78. package/src/qingflow_mcp/tools/package_tools.py +134 -8
  79. package/src/qingflow_mcp/tools/portal_tools.py +39 -3
  80. package/src/qingflow_mcp/tools/qingbi_report_tools.py +116 -7
  81. package/src/qingflow_mcp/tools/record_tools.py +2305 -442
  82. package/src/qingflow_mcp/tools/resource_read_tools.py +191 -39
  83. package/src/qingflow_mcp/tools/role_tools.py +80 -9
  84. package/src/qingflow_mcp/tools/solution_tools.py +57 -15
  85. package/src/qingflow_mcp/tools/task_context_tools.py +569 -119
  86. package/src/qingflow_mcp/tools/task_tools.py +113 -29
  87. package/src/qingflow_mcp/tools/view_tools.py +106 -3
  88. package/src/qingflow_mcp/tools/workflow_tools.py +17 -1
  89. package/src/qingflow_mcp/tools/workspace_tools.py +71 -3
@@ -14,7 +14,7 @@ from datetime import UTC, datetime, timedelta
14
14
  from decimal import Decimal, InvalidOperation
15
15
  from io import BytesIO
16
16
  from pathlib import Path
17
- from typing import Any, cast
17
+ from typing import Any, Callable, cast
18
18
  from urllib.parse import parse_qs, unquote, urlsplit
19
19
  from uuid import uuid4
20
20
  from xml.etree import ElementTree
@@ -22,7 +22,7 @@ from xml.etree import ElementTree
22
22
  from mcp.server.fastmcp import FastMCP
23
23
 
24
24
  from ..config import DEFAULT_PROFILE, DEFAULT_RECORD_LIST_TYPE, DEFAULT_USER_AGENT, get_mcp_home
25
- from ..errors import QingflowApiError, raise_tool_error
25
+ from ..errors import QingflowApiError, backend_code_int, is_auth_like_error, raise_tool_error
26
26
  from ..id_utils import normalize_positive_id_int, stringify_backend_id
27
27
  from ..json_types import JSONObject, JSONScalar, JSONValue
28
28
  from ..list_type_labels import (
@@ -45,6 +45,11 @@ RECORD_ACCESS_UNBOUNDED_ROW_THRESHOLD = 50_000
45
45
  RECORD_ACCESS_TIME_BUDGET_SECONDS = 55.0
46
46
  RECORD_ACCESS_MIN_REMAINING_SECONDS = 8.0
47
47
  RECORD_GET_DETAIL_LOG_PAGE_SIZE = 10
48
+ RECORD_LOGS_PAGE_SIZE = 200
49
+ RECORD_LOGS_PREVIEW_LIMIT = 10
50
+ RECORD_LOGS_MAX_ITEMS = 20_000
51
+ RECORD_LOGS_TIME_BUDGET_SECONDS = 55.0
52
+ RECORD_LOGS_MIN_REMAINING_SECONDS = 8.0
48
53
  RECORD_GET_MEDIA_MAX_IMAGES = 30
49
54
  RECORD_GET_MEDIA_MAX_IMAGE_BYTES = 20 * 1024 * 1024
50
55
  RECORD_GET_MEDIA_MAX_TOTAL_BYTES = 100 * 1024 * 1024
@@ -126,6 +131,27 @@ SCHEMA_LINKAGE_REFERENCE_SOURCE_MESSAGE = "updating this field may auto-fill or
126
131
  SCHEMA_LINKAGE_REFERENCE_TARGET_MESSAGE = "this field is usually filled from an upstream reference selection or default matching logic"
127
132
  SCHEMA_LINKAGE_REFERENCE_BOTH_MESSAGE = "this field participates in reference-driven auto-fill logic"
128
133
  SCHEMA_LINKAGE_FORMULA_MESSAGE = "this field is usually derived by formula or default auto-fill logic"
134
+ OPTIONAL_SCHEMA_PERMISSION_CODES = {40002, 40027, 404}
135
+ RECORD_PERMISSION_DENIED_CODES = {40002, 40027}
136
+ SYSTEM_VIEW_LIST_TYPES = {int(list_type) for _view_id, list_type, _name in SYSTEM_VIEW_DEFINITIONS}
137
+
138
+
139
+ def _is_optional_schema_permission_error(error: QingflowApiError) -> bool:
140
+ if is_auth_like_error(error):
141
+ return False
142
+ return backend_code_int(error) in OPTIONAL_SCHEMA_PERMISSION_CODES or error.http_status == 404
143
+
144
+
145
+ def _is_record_permission_denied_error(error: QingflowApiError) -> bool:
146
+ if is_auth_like_error(error):
147
+ return False
148
+ return backend_code_int(error) in RECORD_PERMISSION_DENIED_CODES
149
+
150
+
151
+ def _is_optional_record_auxiliary_lookup_error(error: QingflowApiError) -> bool:
152
+ if is_auth_like_error(error):
153
+ return False
154
+ return backend_code_int(error) in {40002, 40027, 404} or error.http_status == 404
129
155
 
130
156
 
131
157
  @dataclass(slots=True)
@@ -210,6 +236,13 @@ class AccessibleViewRoute:
210
236
  view_type: str | None = None
211
237
 
212
238
 
239
+ def _prefer_custom_update_routes(routes: list[AccessibleViewRoute]) -> list[AccessibleViewRoute]:
240
+ return [
241
+ *[route for route in routes if route.kind == "custom"],
242
+ *[route for route in routes if route.kind != "custom"],
243
+ ]
244
+
245
+
213
246
  @dataclass(slots=True)
214
247
  class RecordContextRouteProbe:
215
248
  route: AccessibleViewRoute
@@ -269,6 +302,30 @@ GENERIC_FIELD_ALIAS_OVERRIDES: dict[str, list[str]] = {
269
302
  FIELD_LOOKUP_STRIP_RE = re.compile(r"[\s_()()\[\]【】{}<>·/\\::-]+")
270
303
 
271
304
 
305
+ def _pick_route_payload(payload: JSONObject) -> JSONObject:
306
+ return {
307
+ key: payload[key]
308
+ for key in (
309
+ "route_type",
310
+ "endpoint_kind",
311
+ "status",
312
+ "role",
313
+ "task_id",
314
+ "workflow_node_id",
315
+ "view_id",
316
+ "view_key",
317
+ "view_name",
318
+ "error_code",
319
+ "backend_code",
320
+ "http_status",
321
+ "request_id",
322
+ "message",
323
+ "reason",
324
+ )
325
+ if key in payload and payload[key] not in (None, "", [], {})
326
+ }
327
+
328
+
272
329
  class RecordTools(ToolBase):
273
330
  """记录工具(中文名:记录读写与分析)。
274
331
 
@@ -442,6 +499,20 @@ class RecordTools(ToolBase):
442
499
  output_profile=output_profile,
443
500
  )
444
501
 
502
+ @mcp.tool(description="Read all visible data logs and workflow logs for one Qingflow record into local JSONL files. Requires the same view_id as the frontend record context. This tool hides pagination and returns file paths plus completeness metadata.")
503
+ def record_logs_get(
504
+ profile: str = DEFAULT_PROFILE,
505
+ app_key: str = "",
506
+ record_id: str = "",
507
+ view_id: str | None = None,
508
+ ) -> JSONObject:
509
+ return self.record_logs_get(
510
+ profile=profile,
511
+ app_key=app_key,
512
+ record_id=record_id,
513
+ view_id=view_id,
514
+ )
515
+
445
516
  @mcp.tool()
446
517
  def record_browse_schema_get(
447
518
  app_key: str = "",
@@ -459,12 +530,14 @@ class RecordTools(ToolBase):
459
530
  def record_update_schema_get(
460
531
  app_key: str = "",
461
532
  record_id: str = "",
533
+ view_id: str | None = None,
462
534
  output_profile: str = "normal",
463
535
  ) -> JSONObject:
464
536
  return self.record_update_schema_get_public(
465
537
  profile=DEFAULT_PROFILE,
466
538
  app_key=app_key,
467
539
  record_id=record_id,
540
+ view_id=view_id,
468
541
  output_profile=output_profile,
469
542
  )
470
543
 
@@ -493,8 +566,10 @@ class RecordTools(ToolBase):
493
566
  @mcp.tool(
494
567
  description=(
495
568
  "Update one Qingflow record using a field map. "
496
- "Use record_update_schema_get first. "
497
- "This tool automatically probes accessible views in order and uses the first safe match."
569
+ "For simple field changes, call this tool directly after the target record is clear. "
570
+ "Pass view_id when the frontend detail view is known; the tool will try that view first. "
571
+ "It first tries the data-manager direct route, then falls back to the frontend custom-view edit route when available. "
572
+ "Use record_update_schema_get for diagnostics or complex field-scope inspection."
498
573
  )
499
574
  )
500
575
  def record_update(
@@ -502,6 +577,7 @@ class RecordTools(ToolBase):
502
577
  record_id: str | None = None,
503
578
  fields: JSONObject | None = None,
504
579
  items: list[JSONObject] | None = None,
580
+ view_id: str | None = None,
505
581
  dry_run: bool = False,
506
582
  verify_write: bool = True,
507
583
  output_profile: str = "normal",
@@ -512,6 +588,7 @@ class RecordTools(ToolBase):
512
588
  record_id=record_id,
513
589
  fields=fields,
514
590
  items=items,
591
+ view_id=view_id,
515
592
  dry_run=dry_run,
516
593
  verify_write=verify_write,
517
594
  output_profile=output_profile,
@@ -520,13 +597,14 @@ class RecordTools(ToolBase):
520
597
  @mcp.tool(
521
598
  description=(
522
599
  "Delete Qingflow records by record_id or record_ids. "
523
- "This tool does not accept view selectors; resolve target record ids first, then delete by id."
600
+ "Pass view_id when deleting from a known system list route; custom view deletion is not supported by this backend route."
524
601
  )
525
602
  )
526
603
  def record_delete(
527
604
  app_key: str = "",
528
605
  record_id: str | None = None,
529
606
  record_ids: list[str] | None = None,
607
+ view_id: str | None = None,
530
608
  output_profile: str = "normal",
531
609
  ) -> JSONObject:
532
610
  return self.record_delete_public(
@@ -534,6 +612,7 @@ class RecordTools(ToolBase):
534
612
  app_key=app_key,
535
613
  record_id=record_id,
536
614
  record_ids=record_ids or [],
615
+ view_id=view_id,
537
616
  output_profile=output_profile,
538
617
  )
539
618
 
@@ -805,6 +884,7 @@ class RecordTools(ToolBase):
805
884
  profile: str = DEFAULT_PROFILE,
806
885
  app_key: str,
807
886
  record_id: Any,
887
+ view_id: str | None = None,
808
888
  output_profile: str = "normal",
809
889
  ) -> JSONObject:
810
890
  """执行记录相关逻辑。"""
@@ -816,21 +896,44 @@ class RecordTools(ToolBase):
816
896
  def runner(session_profile, context):
817
897
  request_route = self._request_route_payload(context)
818
898
  self._clear_record_schema_caches(profile=profile, app_key=app_key, clear_view_caches=True)
819
- app_schema = self._get_form_schema(profile, context, app_key, force_refresh=True)
820
- question_relations = _collect_question_relations(app_schema)
821
- app_index = _build_applicant_top_level_field_index(app_schema)
822
- linked_field_ids = _collect_linked_required_field_ids(question_relations)
823
- linked_field_ids.update(_collect_option_linked_field_ids(app_index))
824
- linked_hidden_index = _build_applicant_hidden_linked_top_level_field_index(
825
- app_schema,
826
- linked_field_ids=linked_field_ids,
827
- )
828
- app_index = _merge_field_indexes(app_index, linked_hidden_index)
829
- linkage_payloads_by_field_id = _build_static_schema_linkage_payloads(
830
- index=app_index,
831
- question_relations=question_relations,
832
- )
899
+ linkage_payloads_by_field_id: dict[str, JSONObject] = {}
900
+ try:
901
+ app_schema = self._get_form_schema(profile, context, app_key, force_refresh=True)
902
+ except QingflowApiError as exc:
903
+ if not _is_optional_schema_permission_error(exc):
904
+ raise
905
+ else:
906
+ question_relations = _collect_question_relations(app_schema)
907
+ app_index = _build_applicant_top_level_field_index(app_schema)
908
+ linked_field_ids = _collect_linked_required_field_ids(question_relations)
909
+ linked_field_ids.update(_collect_option_linked_field_ids(app_index))
910
+ linked_hidden_index = _build_applicant_hidden_linked_top_level_field_index(
911
+ app_schema,
912
+ linked_field_ids=linked_field_ids,
913
+ )
914
+ app_index = _merge_field_indexes(app_index, linked_hidden_index)
915
+ linkage_payloads_by_field_id = _build_static_schema_linkage_payloads(
916
+ index=app_index,
917
+ question_relations=question_relations,
918
+ )
919
+ preferred_view_id = _normalize_optional_text(view_id)
833
920
  candidate_routes = self._candidate_update_views(profile, context, app_key)
921
+ if preferred_view_id:
922
+ preferred_route = next(
923
+ (
924
+ route
925
+ for route in candidate_routes
926
+ if route.view_id == preferred_view_id
927
+ ),
928
+ None,
929
+ )
930
+ if preferred_route is None:
931
+ raise_tool_error(
932
+ QingflowApiError.config_error(
933
+ f"view_id '{preferred_view_id}' is not an accessible update candidate"
934
+ )
935
+ )
936
+ candidate_routes = [preferred_route]
834
937
  probes = self._probe_candidate_record_contexts(
835
938
  context,
836
939
  app_key=app_key,
@@ -932,6 +1035,7 @@ class RecordTools(ToolBase):
932
1035
  output_profile=normalized_output_profile,
933
1036
  view_probe_summary=probe_summary,
934
1037
  ambiguous_fields=[],
1038
+ preferred_view_id=preferred_view_id,
935
1039
  )
936
1040
 
937
1041
  ambiguous_field_ids: set[int] = set()
@@ -978,6 +1082,7 @@ class RecordTools(ToolBase):
978
1082
  output_profile=normalized_output_profile,
979
1083
  view_probe_summary=probe_summary,
980
1084
  ambiguous_fields=ambiguous_fields,
1085
+ preferred_view_id=preferred_view_id,
981
1086
  )
982
1087
 
983
1088
  response: JSONObject = {
@@ -995,15 +1100,61 @@ class RecordTools(ToolBase):
995
1100
  item["title"]: self._ready_schema_template_value(item)
996
1101
  for item in writable_fields
997
1102
  },
1103
+ "available_update_routes": self._record_update_schema_available_routes(matched_probes),
1104
+ "recommended_update_route": {
1105
+ "route_type": "auto",
1106
+ "order": ["admin_direct", "view_edit", "task_save_only"],
1107
+ "message": "record_update will try data-manager direct edit first, then a matching custom-view edit route, then a unique current-user todo save-only route when the target fields are editable on that workflow node.",
1108
+ },
998
1109
  }
1110
+ if preferred_view_id:
1111
+ response["preferred_view_id"] = preferred_view_id
999
1112
  if normalized_output_profile == "verbose":
1000
1113
  response["view_probe_summary"] = probe_summary
1001
1114
  response["record_context_probe"] = probe_summary
1002
1115
  response["ambiguous_fields"] = ambiguous_fields
1116
+ response["route_probe_summary"] = probe_summary
1003
1117
  return response
1004
1118
 
1005
1119
  return self._run_record_tool(profile, runner)
1006
1120
 
1121
+ def _record_update_schema_available_routes(self, matched_probes: list[RecordContextRouteProbe]) -> list[JSONObject]:
1122
+ routes: list[JSONObject] = [
1123
+ {
1124
+ "route_type": "admin_direct",
1125
+ "endpoint_kind": "app_apply_update",
1126
+ "role": 1,
1127
+ "availability": "attempted_on_update",
1128
+ "message": "Requires data-manager edit permission; record_update safely falls back if backend returns permission denied.",
1129
+ }
1130
+ ]
1131
+ for probe in matched_probes:
1132
+ if probe.route.kind != "custom":
1133
+ continue
1134
+ view_key = self._route_view_key(probe.route)
1135
+ if not view_key:
1136
+ continue
1137
+ routes.append(
1138
+ {
1139
+ "route_type": "view_edit",
1140
+ "endpoint_kind": "view_apply_update",
1141
+ "view_id": probe.route.view_id,
1142
+ "view_key": view_key,
1143
+ "view_name": probe.route.name,
1144
+ "availability": "candidate",
1145
+ "message": "Uses the same custom-view detail edit route as the frontend.",
1146
+ }
1147
+ )
1148
+ routes.append(
1149
+ {
1150
+ "route_type": "task_save_only",
1151
+ "endpoint_kind": "workflow_node_save_only",
1152
+ "availability": "auto_probe_on_update",
1153
+ "message": "record_update probes the current user's todo list and uses save-only only when exactly one matching task exists and the requested fields are editable on that workflow node.",
1154
+ }
1155
+ )
1156
+ return routes
1157
+
1007
1158
  def _record_update_schema_blocked_response(
1008
1159
  self,
1009
1160
  *,
@@ -1018,6 +1169,7 @@ class RecordTools(ToolBase):
1018
1169
  output_profile: str,
1019
1170
  view_probe_summary: list[JSONObject],
1020
1171
  ambiguous_fields: list[JSONObject],
1172
+ preferred_view_id: str | None = None,
1021
1173
  ) -> JSONObject:
1022
1174
  """执行内部辅助逻辑。"""
1023
1175
  response: JSONObject = {
@@ -1035,6 +1187,8 @@ class RecordTools(ToolBase):
1035
1187
  "payload_template": {},
1036
1188
  "recommended_next_actions": recommended_next_actions,
1037
1189
  }
1190
+ if preferred_view_id:
1191
+ response["preferred_view_id"] = preferred_view_id
1038
1192
  if output_profile == "verbose":
1039
1193
  response["view_probe_summary"] = view_probe_summary
1040
1194
  response["ambiguous_fields"] = ambiguous_fields
@@ -1352,24 +1506,58 @@ class RecordTools(ToolBase):
1352
1506
  )
1353
1507
  warnings: list[JSONObject] = []
1354
1508
  scope_source = "static_applicant_scope"
1355
- if runtime_lookup:
1356
- state = self._build_candidate_lookup_state(
1357
- profile,
1358
- context,
1509
+ try:
1510
+ if runtime_lookup:
1511
+ state = self._build_candidate_lookup_state(
1512
+ profile,
1513
+ context,
1514
+ app_key=app_key,
1515
+ record_id=record_id_int,
1516
+ workflow_node_id=workflow_node_id,
1517
+ fields=normalized_fields,
1518
+ )
1519
+ items = self._resolve_member_candidates_backend(context, field, keyword=keyword, state=state)
1520
+ scope_source = "backend_runtime_scope"
1521
+ else:
1522
+ items: list[JSONObject] | None = None
1523
+ if self._member_candidate_static_preview_should_use_backend(field):
1524
+ state = self._build_candidate_lookup_state(
1525
+ profile,
1526
+ context,
1527
+ app_key=app_key,
1528
+ record_id=None,
1529
+ workflow_node_id=None,
1530
+ fields={},
1531
+ )
1532
+ items = self._resolve_member_candidates_backend(context, field, keyword=keyword, state=state)
1533
+ scope_source = "backend_applicant_scope"
1534
+ if items is None:
1535
+ items = self._resolve_member_candidates(context, field, keyword=keyword)
1536
+ warnings.append(
1537
+ {
1538
+ "code": "CANDIDATE_SCOPE_PREVIEW_ONLY",
1539
+ "message": "candidate scope is a static applicant-node preview; pass record_id/workflow_node_id/fields to match runtime write scope",
1540
+ }
1541
+ )
1542
+ except (RecordInputError, QingflowApiError) as error:
1543
+ record_error = (
1544
+ error
1545
+ if isinstance(error, RecordInputError)
1546
+ else self._candidate_lookup_error(kind="member", field=field, value=keyword, error=error)
1547
+ )
1548
+ return self._candidate_lookup_failed_response(
1549
+ profile=profile,
1550
+ session_profile=session_profile,
1551
+ context=context,
1552
+ kind="member",
1553
+ error=record_error,
1554
+ field=field,
1359
1555
  app_key=app_key,
1360
- record_id=record_id_int,
1556
+ record_id_text=record_id_text,
1361
1557
  workflow_node_id=workflow_node_id,
1362
- fields=normalized_fields,
1363
- )
1364
- items = self._resolve_member_candidates_backend(context, field, keyword=keyword, state=state)
1365
- scope_source = "backend_runtime_scope"
1366
- else:
1367
- items = self._resolve_member_candidates(context, field, keyword=keyword)
1368
- warnings.append(
1369
- {
1370
- "code": "CANDIDATE_SCOPE_PREVIEW_ONLY",
1371
- "message": "candidate scope is a static applicant-node preview; pass record_id/workflow_node_id/fields to match runtime write scope",
1372
- }
1558
+ fields_present=bool(normalized_fields),
1559
+ keyword=keyword,
1560
+ scope_source=scope_source,
1373
1561
  )
1374
1562
  total = len(items)
1375
1563
  start = (page_num - 1) * page_size
@@ -1462,41 +1650,75 @@ class RecordTools(ToolBase):
1462
1650
  )
1463
1651
  warnings: list[JSONObject] = []
1464
1652
  scope_source = "static_applicant_scope"
1465
- if runtime_lookup:
1466
- state = self._build_candidate_lookup_state(
1467
- profile,
1468
- context,
1469
- app_key=app_key,
1470
- record_id=record_id_int,
1471
- workflow_node_id=workflow_node_id,
1472
- fields=normalized_fields,
1473
- )
1474
- items = self._resolve_department_candidates_backend(context, field, keyword=keyword, state=state)
1475
- scope_source = "backend_runtime_scope"
1476
- else:
1477
- items = self._resolve_department_candidates(context, field, keyword=keyword)
1478
- scope = field.dept_select_scope if isinstance(field.dept_select_scope, dict) else {}
1479
- if (
1480
- not items
1481
- and field.dept_select_scope_type == 2
1482
- and not _scope_has_dynamic_or_external(scope)
1483
- and not list(scope.get("depart") or [])
1484
- ):
1653
+ try:
1654
+ if runtime_lookup:
1485
1655
  state = self._build_candidate_lookup_state(
1486
1656
  profile,
1487
1657
  context,
1488
1658
  app_key=app_key,
1489
- record_id=None,
1490
- workflow_node_id=None,
1491
- fields={},
1659
+ record_id=record_id_int,
1660
+ workflow_node_id=workflow_node_id,
1661
+ fields=normalized_fields,
1492
1662
  )
1493
1663
  items = self._resolve_department_candidates_backend(context, field, keyword=keyword, state=state)
1494
1664
  scope_source = "backend_runtime_scope"
1495
- warnings.append(
1496
- {
1497
- "code": "CANDIDATE_SCOPE_PREVIEW_ONLY",
1498
- "message": "candidate scope is a static applicant-node preview; pass record_id/workflow_node_id/fields to match runtime write scope",
1499
- }
1665
+ else:
1666
+ items: list[JSONObject] | None = None
1667
+ if self._department_candidate_static_preview_should_use_backend(field):
1668
+ state = self._build_candidate_lookup_state(
1669
+ profile,
1670
+ context,
1671
+ app_key=app_key,
1672
+ record_id=None,
1673
+ workflow_node_id=None,
1674
+ fields={},
1675
+ )
1676
+ items = self._resolve_department_candidates_backend(context, field, keyword=keyword, state=state)
1677
+ scope_source = "backend_applicant_scope"
1678
+ if items is None:
1679
+ items = self._resolve_department_candidates(context, field, keyword=keyword)
1680
+ scope = field.dept_select_scope if isinstance(field.dept_select_scope, dict) else {}
1681
+ if (
1682
+ not items
1683
+ and field.dept_select_scope_type == 2
1684
+ and not _scope_has_dynamic_or_external(scope)
1685
+ and not list(scope.get("depart") or [])
1686
+ ):
1687
+ state = self._build_candidate_lookup_state(
1688
+ profile,
1689
+ context,
1690
+ app_key=app_key,
1691
+ record_id=None,
1692
+ workflow_node_id=None,
1693
+ fields={},
1694
+ )
1695
+ items = self._resolve_department_candidates_backend(context, field, keyword=keyword, state=state)
1696
+ scope_source = "backend_applicant_scope"
1697
+ warnings.append(
1698
+ {
1699
+ "code": "CANDIDATE_SCOPE_PREVIEW_ONLY",
1700
+ "message": "candidate scope is a static applicant-node preview; pass record_id/workflow_node_id/fields to match runtime write scope",
1701
+ }
1702
+ )
1703
+ except (RecordInputError, QingflowApiError) as error:
1704
+ record_error = (
1705
+ error
1706
+ if isinstance(error, RecordInputError)
1707
+ else self._candidate_lookup_error(kind="department", field=field, value=keyword, error=error)
1708
+ )
1709
+ return self._candidate_lookup_failed_response(
1710
+ profile=profile,
1711
+ session_profile=session_profile,
1712
+ context=context,
1713
+ kind="department",
1714
+ error=record_error,
1715
+ field=field,
1716
+ app_key=app_key,
1717
+ record_id_text=record_id_text,
1718
+ workflow_node_id=workflow_node_id,
1719
+ fields_present=bool(normalized_fields),
1720
+ keyword=keyword,
1721
+ scope_source=scope_source,
1500
1722
  )
1501
1723
  total = len(items)
1502
1724
  start = (page_num - 1) * page_size
@@ -1558,6 +1780,21 @@ class RecordTools(ToolBase):
1558
1780
  raise_tool_error(QingflowApiError.config_error("app_key is required"))
1559
1781
  if limit <= 0:
1560
1782
  raise_tool_error(QingflowApiError.config_error("limit must be positive"))
1783
+ if not (
1784
+ _normalize_optional_text(view_id)
1785
+ or list_type is not None
1786
+ or _normalize_optional_text(view_key)
1787
+ or _normalize_optional_text(view_name)
1788
+ ):
1789
+ raise_tool_error(
1790
+ QingflowApiError.config_error(
1791
+ "record_analyze requires view_id. Call app_get first and pass accessible_views[].view_id.",
1792
+ details={
1793
+ "error_code": "RECORD_ANALYZE_VIEW_REQUIRED",
1794
+ "fix_hint": "Call app_get first and choose one accessible_views[].view_id, then retry record_analyze with view_id.",
1795
+ },
1796
+ )
1797
+ )
1561
1798
  legacy_warnings = _detect_analyze_legacy_warnings(
1562
1799
  dimensions=dimensions,
1563
1800
  metrics=metrics,
@@ -1574,7 +1811,7 @@ class RecordTools(ToolBase):
1574
1811
  list_type=list_type,
1575
1812
  view_key=view_key,
1576
1813
  view_name=view_name,
1577
- allow_default=True,
1814
+ allow_default=False,
1578
1815
  )
1579
1816
  if not _view_type_supports_analysis(resolved_view.view_type):
1580
1817
  raise_tool_error(
@@ -1655,6 +1892,21 @@ class RecordTools(ToolBase):
1655
1892
  raise_tool_error(QingflowApiError.config_error("limit must be positive"))
1656
1893
  if page <= 0:
1657
1894
  raise_tool_error(QingflowApiError.config_error("page must be positive"))
1895
+ if not (
1896
+ _normalize_optional_text(view_id)
1897
+ or list_type is not None
1898
+ or _normalize_optional_text(view_key)
1899
+ or _normalize_optional_text(view_name)
1900
+ ):
1901
+ raise_tool_error(
1902
+ QingflowApiError.config_error(
1903
+ "record_list requires view_id. Call app_get first and pass accessible_views[].view_id.",
1904
+ details={
1905
+ "error_code": "RECORD_LIST_VIEW_REQUIRED",
1906
+ "fix_hint": "Call app_get first and choose one accessible_views[].view_id, then retry record_list with view_id.",
1907
+ },
1908
+ )
1909
+ )
1658
1910
  view_route, compatibility_warnings = self._resolve_accessible_view_route_for_public(
1659
1911
  profile=profile,
1660
1912
  app_key=app_key,
@@ -1662,7 +1914,7 @@ class RecordTools(ToolBase):
1662
1914
  list_type=list_type,
1663
1915
  view_key=view_key,
1664
1916
  view_name=view_name,
1665
- allow_default=True,
1917
+ allow_default=False,
1666
1918
  )
1667
1919
  if not _view_type_supports_analysis(view_route.view_type):
1668
1920
  raise_tool_error(
@@ -2136,6 +2388,7 @@ class RecordTools(ToolBase):
2136
2388
  requested_output_profile = _normalize_record_get_detail_output_profile(output_profile)
2137
2389
  record_id_int = normalize_positive_id_int(record_id, field_name="record_id")
2138
2390
  normalized_columns = _normalize_public_column_selectors(columns)
2391
+ explicit_view_id = _normalize_optional_text(view_id)
2139
2392
 
2140
2393
  def runner(session_profile, context):
2141
2394
  resolved_view, compatibility_warnings = self._resolve_accessible_view_route(
@@ -2161,89 +2414,323 @@ class RecordTools(ToolBase):
2161
2414
  "code": "OUTPUT_PROFILE_DEPRECATED_FOR_DETAIL_CONTEXT",
2162
2415
  "message": f"output_profile={requested_output_profile!r} is deprecated for record_get; detail_context is always returned.",
2163
2416
  })
2164
- return self._record_get_detail_context(
2165
- profile=profile,
2166
- session_profile=session_profile,
2167
- context=context,
2168
- app_key=app_key,
2169
- record_id_int=record_id_int,
2170
- resolved_view=resolved_view,
2171
- requested_focus_field_ids=normalized_columns,
2172
- workflow_node_id=workflow_node_id,
2173
- warnings=warnings,
2174
- )
2417
+ def get_detail_for_route(route: AccessibleViewRoute, route_warnings: list[JSONObject]) -> JSONObject:
2418
+ return self._record_get_detail_context(
2419
+ profile=profile,
2420
+ session_profile=session_profile,
2421
+ context=context,
2422
+ app_key=app_key,
2423
+ record_id_int=record_id_int,
2424
+ resolved_view=route,
2425
+ requested_focus_field_ids=normalized_columns,
2426
+ workflow_node_id=workflow_node_id,
2427
+ warnings=route_warnings,
2428
+ )
2429
+
2430
+ try:
2431
+ return get_detail_for_route(resolved_view, warnings)
2432
+ except QingflowApiError as exc:
2433
+ if explicit_view_id is not None:
2434
+ raise
2435
+ if not self._is_record_context_route_miss(exc):
2436
+ raise
2437
+ fallback_warnings = list(warnings)
2438
+ fallback_warnings.append(
2439
+ {
2440
+ "code": "DEFAULT_DETAIL_ROUTE_DENIED",
2441
+ "message": "record_get default system:all route was not readable; trying accessible views that match the frontend route model.",
2442
+ "backend_code": exc.backend_code,
2443
+ }
2444
+ )
2445
+ last_error = exc
2446
+ for candidate in self._candidate_update_views(profile, context, app_key):
2447
+ if candidate.view_id == resolved_view.view_id:
2448
+ continue
2449
+ try:
2450
+ return get_detail_for_route(candidate, fallback_warnings)
2451
+ except QingflowApiError as candidate_exc:
2452
+ if not self._is_record_context_route_miss(candidate_exc):
2453
+ raise
2454
+ last_error = candidate_exc
2455
+ raise last_error
2175
2456
 
2176
2457
  return self._run_record_tool(profile, runner)
2177
2458
 
2178
- def _record_get_detail_context(
2459
+ @tool_cn_name("记录全量日志")
2460
+ def record_logs_get(
2179
2461
  self,
2180
2462
  *,
2181
2463
  profile: str,
2182
- session_profile, # type: ignore[no-untyped-def]
2183
- context, # type: ignore[no-untyped-def]
2184
2464
  app_key: str,
2185
- record_id_int: int,
2186
- resolved_view: AccessibleViewRoute,
2187
- requested_focus_field_ids: list[int],
2188
- workflow_node_id: int | None,
2189
- warnings: list[JSONObject],
2465
+ record_id: Any,
2466
+ view_id: str | None = None,
2190
2467
  ) -> JSONObject:
2191
- """执行内部辅助逻辑。"""
2192
- schema = self._record_get_detail_schema(profile, context, app_key, resolved_view, force_refresh=False)
2193
- index = _build_top_level_field_index(schema)
2194
- audit_info = self._record_get_audit_info(
2195
- context,
2196
- app_key=app_key,
2197
- record_id=record_id_int,
2198
- resolved_view=resolved_view,
2199
- )
2200
- audit_context = _record_detail_audit_context(audit_info, workflow_node_id=workflow_node_id)
2201
- detail_result, used_list_type, used_role = self._record_get_apply_detail(
2202
- context,
2203
- app_key=app_key,
2204
- record_id=record_id_int,
2205
- resolved_view=resolved_view,
2206
- audit_node_id=cast(int | None, audit_context.get("audit_node_id")),
2207
- )
2208
- answer_list = _record_detail_answers(detail_result)
2209
- selected_fields = list(index.by_id.values())
2210
- row = _build_flat_row(cast(list[JSONValue], answer_list), selected_fields, apply_id=record_id_int)
2211
- normalized_record, _normalized_ambiguous_fields = _build_normalized_row_from_answers(
2212
- cast(list[JSONValue], answer_list),
2213
- selected_fields,
2214
- )
2215
- if self._record_get_needs_schema_refresh(
2216
- answer_list=cast(list[JSONValue], answer_list),
2217
- selected_fields=selected_fields,
2218
- record=row,
2219
- normalized_record=normalized_record,
2220
- ):
2221
- self._clear_record_schema_caches(
2222
- profile=profile,
2223
- app_key=app_key,
2224
- resolved_view=resolved_view,
2225
- clear_view_caches=True,
2468
+ """读取单条记录可见的全量数据日志和流程日志,写入本地 JSONL。"""
2469
+ record_id_int = normalize_positive_id_int(record_id, field_name="record_id")
2470
+ if not _normalize_optional_text(view_id):
2471
+ raise_tool_error(
2472
+ QingflowApiError.config_error(
2473
+ "record_logs_get requires view_id. Call app_get first and pass accessible_views[].view_id.",
2474
+ details={
2475
+ "error_code": "RECORD_LOGS_VIEW_REQUIRED",
2476
+ "fix_hint": "Call app_get first and choose one accessible_views[].view_id, then retry record_logs_get with view_id.",
2477
+ },
2478
+ )
2226
2479
  )
2227
- schema = self._record_get_detail_schema(profile, context, app_key, resolved_view, force_refresh=True)
2228
- index = _build_top_level_field_index(schema)
2229
- selected_fields = list(index.by_id.values())
2230
2480
 
2231
- unavailable_context: list[JSONObject] = []
2232
- dynamic_reference_answers, dynamic_reference_unavailable = self._record_get_dynamic_reference_answers(
2233
- context,
2234
- app_key=app_key,
2235
- record_id=record_id_int,
2236
- resolved_view=resolved_view,
2237
- role=used_role,
2238
- audit_node_id=cast(int | None, audit_context.get("audit_node_id")),
2239
- answer_list=cast(list[JSONValue], answer_list),
2240
- selected_fields=selected_fields,
2241
- )
2242
- unavailable_context.extend(dynamic_reference_unavailable)
2243
- if dynamic_reference_answers:
2244
- answer_list = _merge_record_detail_answers(
2245
- cast(list[JSONValue], answer_list),
2246
- dynamic_reference_answers,
2481
+ def runner(session_profile, context):
2482
+ resolved_view, compatibility_warnings = self._resolve_accessible_view_route(
2483
+ profile,
2484
+ context,
2485
+ app_key,
2486
+ view_id=view_id,
2487
+ list_type=None,
2488
+ view_key=None,
2489
+ view_name=None,
2490
+ allow_default=False,
2491
+ )
2492
+ warnings: list[JSONObject] = []
2493
+ warnings.extend(compatibility_warnings)
2494
+ warnings.extend(_view_filter_trust_warnings(resolved_view))
2495
+ unavailable_context: list[JSONObject] = []
2496
+
2497
+ schema: JSONObject = {}
2498
+ try:
2499
+ schema = self._record_get_detail_schema(profile, context, app_key, resolved_view, force_refresh=False)
2500
+ except QingflowApiError as exc:
2501
+ if not _is_optional_schema_permission_error(exc):
2502
+ raise
2503
+ unavailable_context.append(
2504
+ _record_detail_unavailable_context(
2505
+ "detail_schema",
2506
+ "记录日志字段结构辅助信息获取失败,已尝试使用详情主数据中的字段信息继续。",
2507
+ exc,
2508
+ )
2509
+ )
2510
+ index = _build_top_level_field_index(schema)
2511
+ try:
2512
+ audit_info = self._record_get_audit_info(
2513
+ context,
2514
+ app_key=app_key,
2515
+ record_id=record_id_int,
2516
+ resolved_view=resolved_view,
2517
+ )
2518
+ except QingflowApiError as exc:
2519
+ if not _is_optional_schema_permission_error(exc):
2520
+ raise
2521
+ audit_info = {}
2522
+ unavailable_context.append(
2523
+ _record_detail_unavailable_context(
2524
+ "audit_info",
2525
+ "记录审批节点辅助信息获取失败,已继续读取详情主数据和日志。",
2526
+ exc,
2527
+ )
2528
+ )
2529
+ audit_context = _record_detail_audit_context(audit_info, workflow_node_id=None)
2530
+ detail_result, used_list_type, used_role = self._record_get_apply_detail(
2531
+ context,
2532
+ app_key=app_key,
2533
+ record_id=record_id_int,
2534
+ resolved_view=resolved_view,
2535
+ audit_node_id=cast(int | None, audit_context.get("audit_node_id")),
2536
+ )
2537
+ answer_list = _record_detail_answers(detail_result)
2538
+ if not index.by_id:
2539
+ answer_index = _build_answer_backed_field_index(cast(list[JSONObject], answer_list))
2540
+ if answer_index.by_id:
2541
+ index = answer_index
2542
+ unavailable_context.append(
2543
+ {
2544
+ "section": "detail_schema",
2545
+ "message": "字段结构由详情 answers 回退构造;候选范围、只读状态、选项和联动信息可能不完整。",
2546
+ "category": "partial_context",
2547
+ }
2548
+ )
2549
+ selected_fields = list(index.by_id.values())
2550
+ fields = [
2551
+ _record_detail_field_payload(field, _find_answer_for_field(cast(list[JSONValue], answer_list), field), focus_id_set=set())
2552
+ for field in selected_fields
2553
+ ]
2554
+ app_name = self._record_get_detail_app_name(
2555
+ profile,
2556
+ context,
2557
+ app_key=app_key,
2558
+ schema=schema,
2559
+ used_list_type=used_list_type,
2560
+ )
2561
+ view_payload = _accessible_view_payload(resolved_view)
2562
+ record_payload = _record_detail_record_payload(
2563
+ app_key=app_key,
2564
+ record_id=record_id_int,
2565
+ detail=detail_result,
2566
+ answer_list=cast(list[JSONValue], answer_list),
2567
+ fields=fields,
2568
+ )
2569
+ log_visibility = self._record_get_log_visibility_context(
2570
+ context,
2571
+ app_key=app_key,
2572
+ record_id=record_id_int,
2573
+ resolved_view=resolved_view,
2574
+ role=used_role,
2575
+ audit_node_id=cast(int | None, audit_context.get("audit_node_id")),
2576
+ unavailable_context=unavailable_context,
2577
+ )
2578
+ run_dir = _record_logs_run_dir()
2579
+ run_dir.mkdir(parents=True, exist_ok=True)
2580
+ deadline = time.monotonic() + RECORD_LOGS_TIME_BUDGET_SECONDS
2581
+ data_logs = self._record_get_full_data_logs_context(
2582
+ context,
2583
+ app_key=app_key,
2584
+ record_id=record_id_int,
2585
+ role=used_role,
2586
+ log_visibility=log_visibility,
2587
+ unavailable_context=unavailable_context,
2588
+ run_dir=run_dir,
2589
+ deadline=deadline,
2590
+ )
2591
+ workflow_logs = self._record_get_full_workflow_logs_context(
2592
+ context,
2593
+ app_key=app_key,
2594
+ record_id=record_id_int,
2595
+ resolved_view=resolved_view,
2596
+ role=used_role,
2597
+ audit_node_id=cast(int | None, audit_context.get("audit_node_id")),
2598
+ log_visibility=log_visibility,
2599
+ unavailable_context=unavailable_context,
2600
+ run_dir=run_dir,
2601
+ deadline=deadline,
2602
+ )
2603
+ status = _record_logs_overall_status(data_logs=data_logs, workflow_logs=workflow_logs)
2604
+ context_integrity = _record_logs_context_integrity(data_logs=data_logs, workflow_logs=workflow_logs)
2605
+ payload: JSONObject = {
2606
+ "ok": True,
2607
+ "status": status,
2608
+ "output_profile": "record_logs",
2609
+ "app": {"app_key": app_key, "app_name": app_name},
2610
+ "view": view_payload,
2611
+ "record": record_payload,
2612
+ "local_dir": str(run_dir),
2613
+ "data_logs": data_logs,
2614
+ "workflow_logs": workflow_logs,
2615
+ "warnings": warnings,
2616
+ "unavailable_context": unavailable_context,
2617
+ "context_integrity": context_integrity,
2618
+ }
2619
+ summary_path = run_dir / "summary.json"
2620
+ summary_payload = deepcopy(payload)
2621
+ summary_payload.pop("request_route", None)
2622
+ summary_path.write_text(json.dumps(summary_payload, ensure_ascii=False, indent=2), encoding="utf-8")
2623
+ payload["summary_path"] = str(summary_path)
2624
+ return payload
2625
+
2626
+ return self._run_record_tool(profile, runner)
2627
+
2628
+ def _record_get_detail_context(
2629
+ self,
2630
+ *,
2631
+ profile: str,
2632
+ session_profile, # type: ignore[no-untyped-def]
2633
+ context, # type: ignore[no-untyped-def]
2634
+ app_key: str,
2635
+ record_id_int: int,
2636
+ resolved_view: AccessibleViewRoute,
2637
+ requested_focus_field_ids: list[int],
2638
+ workflow_node_id: int | None,
2639
+ warnings: list[JSONObject],
2640
+ ) -> JSONObject:
2641
+ """执行内部辅助逻辑。"""
2642
+ unavailable_context: list[JSONObject] = []
2643
+ schema: JSONObject = {}
2644
+ schema_available = True
2645
+ try:
2646
+ schema = self._record_get_detail_schema(profile, context, app_key, resolved_view, force_refresh=False)
2647
+ except QingflowApiError as exc:
2648
+ if not _is_optional_schema_permission_error(exc):
2649
+ raise
2650
+ schema_available = False
2651
+ unavailable_context.append(
2652
+ _record_detail_unavailable_context(
2653
+ "detail_schema",
2654
+ "记录详情字段结构辅助信息获取失败,已尝试使用详情主数据中的字段信息继续。",
2655
+ exc,
2656
+ )
2657
+ )
2658
+ index = _build_top_level_field_index(schema)
2659
+ try:
2660
+ audit_info = self._record_get_audit_info(
2661
+ context,
2662
+ app_key=app_key,
2663
+ record_id=record_id_int,
2664
+ resolved_view=resolved_view,
2665
+ )
2666
+ except QingflowApiError as exc:
2667
+ if not _is_optional_schema_permission_error(exc):
2668
+ raise
2669
+ audit_info = {}
2670
+ unavailable_context.append(
2671
+ _record_detail_unavailable_context(
2672
+ "audit_info",
2673
+ "记录审批节点辅助信息获取失败,已继续读取详情主数据。",
2674
+ exc,
2675
+ )
2676
+ )
2677
+ audit_context = _record_detail_audit_context(audit_info, workflow_node_id=workflow_node_id)
2678
+ detail_result, used_list_type, used_role = self._record_get_apply_detail(
2679
+ context,
2680
+ app_key=app_key,
2681
+ record_id=record_id_int,
2682
+ resolved_view=resolved_view,
2683
+ audit_node_id=cast(int | None, audit_context.get("audit_node_id")),
2684
+ )
2685
+ answer_list = _record_detail_answers(detail_result)
2686
+ if not index.by_id:
2687
+ answer_index = _build_answer_backed_field_index(cast(list[JSONObject], answer_list))
2688
+ if answer_index.by_id:
2689
+ index = answer_index
2690
+ unavailable_context.append(
2691
+ {
2692
+ "section": "detail_schema",
2693
+ "message": "字段结构由详情 answers 回退构造;候选范围、只读状态、选项和联动信息可能不完整。",
2694
+ "category": "partial_context",
2695
+ }
2696
+ )
2697
+ selected_fields = list(index.by_id.values())
2698
+ row = _build_flat_row(cast(list[JSONValue], answer_list), selected_fields, apply_id=record_id_int)
2699
+ normalized_record, _normalized_ambiguous_fields = _build_normalized_row_from_answers(
2700
+ cast(list[JSONValue], answer_list),
2701
+ selected_fields,
2702
+ )
2703
+ if schema_available and self._record_get_needs_schema_refresh(
2704
+ answer_list=cast(list[JSONValue], answer_list),
2705
+ selected_fields=selected_fields,
2706
+ record=row,
2707
+ normalized_record=normalized_record,
2708
+ ):
2709
+ self._clear_record_schema_caches(
2710
+ profile=profile,
2711
+ app_key=app_key,
2712
+ resolved_view=resolved_view,
2713
+ clear_view_caches=True,
2714
+ )
2715
+ schema = self._record_get_detail_schema(profile, context, app_key, resolved_view, force_refresh=True)
2716
+ index = _build_top_level_field_index(schema)
2717
+ selected_fields = list(index.by_id.values())
2718
+
2719
+ dynamic_reference_answers, dynamic_reference_unavailable = self._record_get_dynamic_reference_answers(
2720
+ context,
2721
+ app_key=app_key,
2722
+ record_id=record_id_int,
2723
+ resolved_view=resolved_view,
2724
+ role=used_role,
2725
+ audit_node_id=cast(int | None, audit_context.get("audit_node_id")),
2726
+ answer_list=cast(list[JSONValue], answer_list),
2727
+ selected_fields=selected_fields,
2728
+ )
2729
+ unavailable_context.extend(dynamic_reference_unavailable)
2730
+ if dynamic_reference_answers:
2731
+ answer_list = _merge_record_detail_answers(
2732
+ cast(list[JSONValue], answer_list),
2733
+ dynamic_reference_answers,
2247
2734
  )
2248
2735
 
2249
2736
  app_name = self._record_get_detail_app_name(
@@ -2387,7 +2874,20 @@ class RecordTools(ToolBase):
2387
2874
  ) -> JSONObject:
2388
2875
  """执行内部辅助逻辑。"""
2389
2876
  if resolved_view.kind == "custom" and resolved_view.view_selection is not None:
2390
- return self._get_view_form_schema(profile, context, resolved_view.view_selection.view_key, force_refresh=force_refresh)
2877
+ return self._get_custom_view_browse_schema(
2878
+ profile,
2879
+ context,
2880
+ resolved_view.view_selection.view_key,
2881
+ force_refresh=force_refresh,
2882
+ )
2883
+ if resolved_view.kind == "system" and resolved_view.list_type is not None:
2884
+ return self._get_system_browse_schema(
2885
+ profile,
2886
+ context,
2887
+ app_key,
2888
+ list_type=resolved_view.list_type,
2889
+ force_refresh=force_refresh,
2890
+ )
2391
2891
  return self._get_form_schema(profile, context, app_key, force_refresh=force_refresh)
2392
2892
 
2393
2893
  def _record_get_audit_info(
@@ -2448,7 +2948,7 @@ class RecordTools(ToolBase):
2448
2948
  )
2449
2949
  return result if isinstance(result, dict) else {"value": result}, list_type, role
2450
2950
  except QingflowApiError as exc:
2451
- if resolved_view.list_type is not None or exc.backend_code != 40002:
2951
+ if resolved_view.list_type is not None or not _is_record_permission_denied_error(exc):
2452
2952
  raise
2453
2953
  last_error: QingflowApiError = exc
2454
2954
  for fallback_list_type in (14, 1, 2, 12):
@@ -2466,7 +2966,7 @@ class RecordTools(ToolBase):
2466
2966
  return result if isinstance(result, dict) else {"value": result}, fallback_list_type, role
2467
2967
  except QingflowApiError as fallback_exc:
2468
2968
  last_error = fallback_exc
2469
- if fallback_exc.backend_code == 40002:
2969
+ if _is_record_permission_denied_error(fallback_exc):
2470
2970
  continue
2471
2971
  raise
2472
2972
  raise last_error
@@ -2564,6 +3064,8 @@ class RecordTools(ToolBase):
2564
3064
  if target_app_key == app_key and str(target_record_id) == str(source_record_id):
2565
3065
  reference_payload["self_reference"] = True
2566
3066
  except QingflowApiError as exc:
3067
+ if is_auth_like_error(exc):
3068
+ raise
2567
3069
  unavailable = _record_detail_unavailable_context(
2568
3070
  "reference_detail",
2569
3071
  f"引用字段「{field.que_title}」的目标记录详情获取失败。",
@@ -2661,6 +3163,8 @@ class RecordTools(ToolBase):
2661
3163
  json_body=body,
2662
3164
  )
2663
3165
  except QingflowApiError as exc:
3166
+ if is_auth_like_error(exc):
3167
+ raise
2664
3168
  unavailable = _record_detail_unavailable_context(
2665
3169
  "reference_runtime_match",
2666
3170
  "动态引用字段匹配数据获取失败。",
@@ -2715,6 +3219,8 @@ class RecordTools(ToolBase):
2715
3219
  },
2716
3220
  )
2717
3221
  except QingflowApiError as exc:
3222
+ if is_auth_like_error(exc):
3223
+ raise
2718
3224
  unavailable_context.append(_record_detail_unavailable_context("log_visibility", "侧边栏日志可见性获取失败。", exc))
2719
3225
  return {"status": "unavailable", "channel": channel, "data_log_visible": None, "workflow_log_visible": None}
2720
3226
  payload = visibility if isinstance(visibility, dict) else {}
@@ -2768,6 +3274,8 @@ class RecordTools(ToolBase):
2768
3274
  source="data_logs",
2769
3275
  )
2770
3276
  except QingflowApiError as exc:
3277
+ if is_auth_like_error(exc):
3278
+ raise
2771
3279
  unavailable_context.append(_record_detail_unavailable_context("data_logs", "最近数据日志获取失败。", exc))
2772
3280
  return _record_detail_log_unavailable_payload("data_logs", "fetch_unavailable")
2773
3281
 
@@ -2821,9 +3329,117 @@ class RecordTools(ToolBase):
2821
3329
  source="workflow_logs",
2822
3330
  )
2823
3331
  except QingflowApiError as exc:
3332
+ if is_auth_like_error(exc):
3333
+ raise
2824
3334
  unavailable_context.append(_record_detail_unavailable_context("workflow_logs", "流程日志本次获取失败。", exc))
2825
3335
  return _record_detail_log_unavailable_payload("workflow_logs", "fetch_unavailable")
2826
3336
 
3337
+ def _record_get_full_data_logs_context(
3338
+ self,
3339
+ context, # type: ignore[no-untyped-def]
3340
+ *,
3341
+ app_key: str,
3342
+ record_id: int,
3343
+ role: int,
3344
+ log_visibility: JSONObject,
3345
+ unavailable_context: list[JSONObject],
3346
+ run_dir: Path,
3347
+ deadline: float,
3348
+ ) -> JSONObject:
3349
+ """读取全量数据日志并写入 JSONL。"""
3350
+ if log_visibility.get("status") == "unavailable":
3351
+ return _record_logs_unavailable_payload("data_logs", "visibility_unavailable")
3352
+ if log_visibility.get("data_log_visible") is False:
3353
+ return _record_logs_hidden_payload("data_logs")
3354
+
3355
+ def fetch_page(page_num: int) -> JSONValue:
3356
+ return self.backend.request(
3357
+ "POST",
3358
+ context,
3359
+ f"/worksheet/data/log/{app_key}/{record_id}/page",
3360
+ json_body={
3361
+ "viewChannel": log_visibility.get("channel"),
3362
+ "role": role,
3363
+ "pageNum": page_num,
3364
+ "pageSize": RECORD_LOGS_PAGE_SIZE,
3365
+ },
3366
+ )
3367
+
3368
+ try:
3369
+ return _record_logs_fetch_all_to_jsonl(
3370
+ fetch_page=fetch_page,
3371
+ normalizer=_record_detail_data_log_item,
3372
+ source="data_logs",
3373
+ file_path=run_dir / "data-logs.jsonl",
3374
+ deadline=deadline,
3375
+ )
3376
+ except QingflowApiError as exc:
3377
+ if is_auth_like_error(exc):
3378
+ raise
3379
+ unavailable_context.append(_record_detail_unavailable_context("data_logs", "全量数据日志获取失败。", exc))
3380
+ return _record_logs_unavailable_payload("data_logs", "fetch_unavailable")
3381
+
3382
+ def _record_get_full_workflow_logs_context(
3383
+ self,
3384
+ context, # type: ignore[no-untyped-def]
3385
+ *,
3386
+ app_key: str,
3387
+ record_id: int,
3388
+ resolved_view: AccessibleViewRoute,
3389
+ role: int,
3390
+ audit_node_id: int | None,
3391
+ log_visibility: JSONObject,
3392
+ unavailable_context: list[JSONObject],
3393
+ run_dir: Path,
3394
+ deadline: float,
3395
+ ) -> JSONObject:
3396
+ """读取全量流程日志并写入 JSONL。"""
3397
+ if log_visibility.get("status") == "unavailable":
3398
+ return _record_logs_unavailable_payload("workflow_logs", "visibility_unavailable")
3399
+ if log_visibility.get("workflow_log_visible") is False:
3400
+ return _record_logs_hidden_payload("workflow_logs")
3401
+
3402
+ def fetch_page(page_num: int) -> JSONValue:
3403
+ if resolved_view.kind == "custom" and resolved_view.view_selection is not None:
3404
+ return self.backend.request(
3405
+ "POST",
3406
+ context,
3407
+ f"/viewGraph/{resolved_view.view_selection.view_key}/workflow/node/record",
3408
+ json_body={
3409
+ "key": resolved_view.view_selection.view_key,
3410
+ "rowRecordId": str(record_id),
3411
+ "pageNum": page_num,
3412
+ "pageSize": RECORD_LOGS_PAGE_SIZE,
3413
+ },
3414
+ )
3415
+ return self.backend.request(
3416
+ "POST",
3417
+ context,
3418
+ "/application/workflow/node/record",
3419
+ json_body={
3420
+ "key": app_key,
3421
+ "rowRecordId": str(record_id),
3422
+ "nodeId": audit_node_id,
3423
+ "role": role,
3424
+ "pageNum": page_num,
3425
+ "pageSize": RECORD_LOGS_PAGE_SIZE,
3426
+ },
3427
+ )
3428
+
3429
+ try:
3430
+ return _record_logs_fetch_all_to_jsonl(
3431
+ fetch_page=fetch_page,
3432
+ normalizer=_record_detail_workflow_log_item,
3433
+ source="workflow_logs",
3434
+ file_path=run_dir / "workflow-logs.jsonl",
3435
+ deadline=deadline,
3436
+ )
3437
+ except QingflowApiError as exc:
3438
+ if is_auth_like_error(exc):
3439
+ raise
3440
+ unavailable_context.append(_record_detail_unavailable_context("workflow_logs", "全量流程日志获取失败。", exc))
3441
+ return _record_logs_unavailable_payload("workflow_logs", "fetch_unavailable")
3442
+
2827
3443
  def _record_get_associated_resources(
2828
3444
  self,
2829
3445
  context, # type: ignore[no-untyped-def]
@@ -2853,6 +3469,8 @@ class RecordTools(ToolBase):
2853
3469
  params["auditNodeId"] = audit_node_id
2854
3470
  payload = self.backend.request("GET", context, f"/app/{app_key}/asosChart", params=params)
2855
3471
  except QingflowApiError as exc:
3472
+ if is_auth_like_error(exc):
3473
+ raise
2856
3474
  unavailable_context.append(_record_detail_unavailable_context("associated_resources", "关联资源获取失败。", exc))
2857
3475
  return []
2858
3476
  return [_record_detail_associated_resource(item) for item in _record_detail_associated_resource_items(payload)]
@@ -2890,16 +3508,17 @@ class RecordTools(ToolBase):
2890
3508
  refresh_source_url=refresh_source_url,
2891
3509
  )
2892
3510
  except Exception as exc: # defensive: media should never break the core record detail.
3511
+ warning: JSONObject = {
3512
+ "code": "MEDIA_ASSETS_UNAVAILABLE",
3513
+ "message": f"record_get could not collect media assets: {exc}",
3514
+ }
3515
+ if isinstance(exc, QingflowApiError):
3516
+ warning.update(_record_detail_error_warning_fields(exc))
2893
3517
  return {
2894
3518
  "status": "unavailable",
2895
3519
  "local_dir": None,
2896
3520
  "items": [],
2897
- "warnings": [
2898
- {
2899
- "code": "MEDIA_ASSETS_UNAVAILABLE",
2900
- "message": f"record_get could not collect media assets: {exc}",
2901
- }
2902
- ],
3521
+ "warnings": [warning],
2903
3522
  }
2904
3523
 
2905
3524
  def _record_get_file_assets(
@@ -2937,16 +3556,17 @@ class RecordTools(ToolBase):
2937
3556
  refresh_source_url=refresh_source_url,
2938
3557
  )
2939
3558
  except Exception as exc: # defensive: file assets should never break the core record detail.
3559
+ warning = {
3560
+ "code": "FILE_ASSETS_UNAVAILABLE",
3561
+ "message": f"record_get could not collect file assets: {exc}",
3562
+ }
3563
+ if isinstance(exc, QingflowApiError):
3564
+ warning.update(_record_detail_error_warning_fields(exc))
2940
3565
  return {
2941
3566
  "status": "unavailable",
2942
3567
  "local_dir": None,
2943
3568
  "items": [],
2944
- "warnings": [
2945
- {
2946
- "code": "FILE_ASSETS_UNAVAILABLE",
2947
- "message": f"record_get could not collect file assets: {exc}",
2948
- }
2949
- ],
3569
+ "warnings": [warning],
2950
3570
  }
2951
3571
 
2952
3572
  def _record_get_refreshed_media_source_url(
@@ -2958,7 +3578,7 @@ class RecordTools(ToolBase):
2958
3578
  resolved_view: AccessibleViewRoute,
2959
3579
  audit_node_id: int | None,
2960
3580
  candidate: JSONObject,
2961
- ) -> str | None:
3581
+ ) -> JSONValue | None:
2962
3582
  """Refresh the detail payload once to recover an expired attachment storage signature."""
2963
3583
  if candidate.get("source") not in {"attachment", "image_field", "subtable"}:
2964
3584
  return None
@@ -2974,8 +3594,15 @@ class RecordTools(ToolBase):
2974
3594
  resolved_view=resolved_view,
2975
3595
  audit_node_id=audit_node_id,
2976
3596
  )
2977
- except QingflowApiError:
2978
- return None
3597
+ except QingflowApiError as exc:
3598
+ return {
3599
+ "source_url": None,
3600
+ "warning": _record_detail_unavailable_context(
3601
+ "asset_url_refresh",
3602
+ "record_get could not refresh the record detail before downloading a private asset.",
3603
+ exc,
3604
+ ),
3605
+ }
2979
3606
  for answer in _record_detail_answers(detail_result):
2980
3607
  if not isinstance(answer, dict) or _coerce_count(answer.get("queId")) != field_id:
2981
3608
  continue
@@ -3490,6 +4117,7 @@ class RecordTools(ToolBase):
3490
4117
  record_id: Any | None,
3491
4118
  fields: JSONObject | None = None,
3492
4119
  items: list[JSONObject] | None = None,
4120
+ view_id: str | None = None,
3493
4121
  dry_run: bool = False,
3494
4122
  verify_write: bool = True,
3495
4123
  output_profile: str = "normal",
@@ -3510,91 +4138,793 @@ class RecordTools(ToolBase):
3510
4138
  profile=profile,
3511
4139
  app_key=app_key,
3512
4140
  items=normalized_items,
4141
+ view_id=view_id,
3513
4142
  dry_run=dry_run,
3514
4143
  verify_write=verify_write,
3515
4144
  output_profile=normalized_output_profile,
3516
4145
  )
3517
- if dry_run:
3518
- raise_tool_error(QingflowApiError.config_error("dry_run currently requires items"))
3519
- if record_id is None:
3520
- raise_tool_error(QingflowApiError.config_error("record_id is required"))
3521
- record_id_int = normalize_positive_id_int(record_id, field_name="record_id")
3522
- if fields is not None and not isinstance(fields, dict):
3523
- raise_tool_error(QingflowApiError.config_error("fields must be an object map keyed by field title"))
3524
- return self._record_update_public_single(
3525
- profile=profile,
3526
- app_key=app_key,
3527
- record_id=record_id_int,
3528
- fields=cast(JSONObject, fields or {}),
3529
- verify_write=verify_write,
3530
- output_profile=normalized_output_profile,
3531
- )
4146
+ if dry_run:
4147
+ raise_tool_error(QingflowApiError.config_error("dry_run currently requires items"))
4148
+ if record_id is None:
4149
+ raise_tool_error(QingflowApiError.config_error("record_id is required"))
4150
+ record_id_int = normalize_positive_id_int(record_id, field_name="record_id")
4151
+ if fields is not None and not isinstance(fields, dict):
4152
+ raise_tool_error(QingflowApiError.config_error("fields must be an object map keyed by field title"))
4153
+ return self._record_update_public_single(
4154
+ profile=profile,
4155
+ app_key=app_key,
4156
+ record_id=record_id_int,
4157
+ fields=cast(JSONObject, fields or {}),
4158
+ view_id=view_id,
4159
+ verify_write=verify_write,
4160
+ output_profile=normalized_output_profile,
4161
+ )
4162
+
4163
+ def _record_update_public_single(
4164
+ self,
4165
+ *,
4166
+ profile: str,
4167
+ app_key: str,
4168
+ record_id: int,
4169
+ fields: JSONObject,
4170
+ view_id: str | None,
4171
+ verify_write: bool,
4172
+ output_profile: str,
4173
+ capture_exceptions: bool = False,
4174
+ ) -> JSONObject:
4175
+ """执行内部辅助逻辑。"""
4176
+ write_state = {"attempted": False}
4177
+ try:
4178
+ return self._record_update_public_single_impl(
4179
+ profile=profile,
4180
+ app_key=app_key,
4181
+ record_id=record_id,
4182
+ fields=fields,
4183
+ view_id=view_id,
4184
+ verify_write=verify_write,
4185
+ output_profile=output_profile,
4186
+ write_attempted_ref=lambda value: write_state.__setitem__("attempted", value),
4187
+ )
4188
+ except (QingflowApiError, RuntimeError) as exc:
4189
+ if not capture_exceptions:
4190
+ raise
4191
+ return self._record_write_exception_response(
4192
+ exc,
4193
+ operation="update",
4194
+ profile=profile,
4195
+ app_key=app_key,
4196
+ record_id=record_id,
4197
+ output_profile=output_profile,
4198
+ human_review=True,
4199
+ write_executed=write_state["attempted"],
4200
+ )
4201
+
4202
+ def _record_update_public_single_impl(
4203
+ self,
4204
+ *,
4205
+ profile: str,
4206
+ app_key: str,
4207
+ record_id: int,
4208
+ fields: JSONObject,
4209
+ view_id: str | None,
4210
+ verify_write: bool,
4211
+ output_profile: str,
4212
+ write_attempted_ref: Callable[[bool], None],
4213
+ ) -> JSONObject:
4214
+ """执行内部辅助逻辑。"""
4215
+ preflight_kwargs: dict[str, Any] = {
4216
+ "profile": profile,
4217
+ "app_key": app_key,
4218
+ "record_id": record_id,
4219
+ "fields": fields,
4220
+ "force_refresh_form": False,
4221
+ }
4222
+ if view_id is not None:
4223
+ preflight_kwargs["preferred_view_id"] = view_id
4224
+ raw_preflight = self._preflight_record_update_with_auto_view(**preflight_kwargs)
4225
+ preflight_used_force_refresh = self._record_preflight_used_force_refresh(raw_preflight)
4226
+ preflight_data = cast(JSONObject, raw_preflight.get("data", {}))
4227
+ normalized_payload = self._record_write_normalized_payload(
4228
+ operation="update",
4229
+ record_id=record_id,
4230
+ record_ids=[],
4231
+ normalized_answers=cast(list[JSONObject], preflight_data.get("normalized_answers", [])),
4232
+ submit_type=1,
4233
+ selection=cast(JSONObject | None, preflight_data.get("selection") if isinstance(preflight_data.get("selection"), dict) else None),
4234
+ )
4235
+ if preflight_data.get("blockers"):
4236
+ return self._record_write_blocked_response(
4237
+ raw_preflight,
4238
+ operation="update",
4239
+ normalized_payload=normalized_payload,
4240
+ output_profile=output_profile,
4241
+ human_review=True,
4242
+ target_resource={"type": "record", "app_key": app_key, "record_id": record_id, "record_ids": []},
4243
+ )
4244
+ write_attempted_ref(True)
4245
+ route_apply, tried_routes, route_blocker = self._record_update_apply_with_auto_route(
4246
+ profile=profile,
4247
+ app_key=app_key,
4248
+ record_id=record_id,
4249
+ normalized_answers=cast(list[JSONObject], preflight_data.get("normalized_answers", [])),
4250
+ preflight_data=preflight_data,
4251
+ verify_write=verify_write,
4252
+ force_refresh_form=preflight_used_force_refresh,
4253
+ )
4254
+ if route_blocker is not None:
4255
+ return self._record_update_route_blocked_response(
4256
+ raw_preflight=raw_preflight,
4257
+ operation="update",
4258
+ normalized_payload=normalized_payload,
4259
+ output_profile=output_profile,
4260
+ human_review=True,
4261
+ app_key=app_key,
4262
+ record_id=record_id,
4263
+ tried_routes=tried_routes,
4264
+ route_blocker=route_blocker,
4265
+ )
4266
+ raw_apply = cast(JSONObject, route_apply)
4267
+ return self._record_write_apply_response(
4268
+ raw_apply,
4269
+ operation="update",
4270
+ normalized_payload=normalized_payload,
4271
+ output_profile=output_profile,
4272
+ human_review=True,
4273
+ preflight=raw_preflight,
4274
+ )
4275
+
4276
+ def _record_update_apply_with_auto_route(
4277
+ self,
4278
+ *,
4279
+ profile: str,
4280
+ app_key: str,
4281
+ record_id: int,
4282
+ normalized_answers: list[JSONObject],
4283
+ preflight_data: JSONObject,
4284
+ verify_write: bool,
4285
+ force_refresh_form: bool,
4286
+ ) -> tuple[JSONObject | None, list[JSONObject], JSONObject | None]:
4287
+ """Try record update routes in the same order a frontend user would expect."""
4288
+ tried_routes: list[JSONObject] = []
4289
+ admin_attempt = self._record_update_route_attempt(
4290
+ route_type="admin_direct",
4291
+ endpoint_kind="app_apply_update",
4292
+ role=1,
4293
+ reason="try data-manager direct edit first",
4294
+ )
4295
+ try:
4296
+ raw_apply = self.record_update(
4297
+ profile=profile,
4298
+ app_key=app_key,
4299
+ apply_id=record_id,
4300
+ answers=normalized_answers,
4301
+ fields={},
4302
+ role=1,
4303
+ verify_write=verify_write,
4304
+ force_refresh_form=force_refresh_form,
4305
+ )
4306
+ admin_attempt["status"] = "success"
4307
+ raw_apply["update_route"] = self._record_update_route_public(admin_attempt)
4308
+ raw_apply["tried_routes"] = [admin_attempt]
4309
+ return raw_apply, [admin_attempt], None
4310
+ except (QingflowApiError, RuntimeError) as exc:
4311
+ api_error = self._record_update_extract_api_error(exc)
4312
+ if api_error is None or not self._record_update_route_permission_denied(api_error):
4313
+ raise
4314
+ admin_attempt.update(self._record_update_route_error_payload(
4315
+ api_error,
4316
+ status="denied",
4317
+ error_code="ADMIN_UPDATE_PERMISSION_DENIED",
4318
+ ))
4319
+ tried_routes.append(admin_attempt)
4320
+
4321
+ view_route = self._record_update_selected_custom_view_route(preflight_data)
4322
+ if view_route is None:
4323
+ tried_routes.append(
4324
+ self._record_update_route_attempt(
4325
+ route_type="view_edit",
4326
+ endpoint_kind="view_apply_update",
4327
+ status="skipped",
4328
+ error_code="VIEW_UPDATE_ROUTE_NOT_AVAILABLE",
4329
+ reason="preflight did not select a single custom view route for this payload",
4330
+ )
4331
+ )
4332
+ else:
4333
+ view_attempt = self._record_update_route_attempt(
4334
+ route_type="view_edit",
4335
+ endpoint_kind="view_apply_update",
4336
+ view_id=cast(str, view_route.get("view_id")),
4337
+ view_key=cast(str, view_route.get("view_key")),
4338
+ view_name=_normalize_optional_text(view_route.get("name")),
4339
+ reason="fallback to frontend custom-view detail edit route",
4340
+ )
4341
+ try:
4342
+ raw_apply = self._record_update_via_custom_view(
4343
+ profile=profile,
4344
+ app_key=app_key,
4345
+ apply_id=record_id,
4346
+ view_key=cast(str, view_route["view_key"]),
4347
+ answers=normalized_answers,
4348
+ verify_write=verify_write,
4349
+ force_refresh_form=force_refresh_form,
4350
+ )
4351
+ view_attempt["status"] = "success"
4352
+ tried_routes.append(view_attempt)
4353
+ raw_apply["update_route"] = self._record_update_route_public(view_attempt)
4354
+ raw_apply["tried_routes"] = tried_routes
4355
+ return raw_apply, tried_routes, None
4356
+ except (QingflowApiError, RuntimeError) as exc:
4357
+ api_error = self._record_update_extract_api_error(exc)
4358
+ if api_error is None or not self._record_update_route_permission_denied(api_error):
4359
+ raise
4360
+ view_attempt.update(self._record_update_route_error_payload(
4361
+ api_error,
4362
+ status="denied",
4363
+ error_code="VIEW_UPDATE_PERMISSION_DENIED",
4364
+ ))
4365
+ tried_routes.append(view_attempt)
4366
+
4367
+ task_route = self._record_update_task_save_only_candidate(
4368
+ profile=profile,
4369
+ app_key=app_key,
4370
+ record_id=record_id,
4371
+ normalized_answers=normalized_answers,
4372
+ )
4373
+ task_attempt = cast(JSONObject, task_route.get("attempt") if isinstance(task_route.get("attempt"), dict) else {})
4374
+ if not task_route.get("available"):
4375
+ tried_routes.append(task_attempt or self._record_update_route_attempt(
4376
+ route_type="task_save_only",
4377
+ endpoint_kind="workflow_node_save_only",
4378
+ status="skipped",
4379
+ error_code="TASK_UPDATE_ROUTE_NOT_AVAILABLE",
4380
+ reason="no unique current-user todo task can edit the requested fields",
4381
+ ))
4382
+ else:
4383
+ task_attempt = self._record_update_route_attempt(
4384
+ route_type="task_save_only",
4385
+ endpoint_kind="workflow_node_save_only",
4386
+ role=3,
4387
+ task_id=_normalize_optional_text(task_route.get("task_id")),
4388
+ workflow_node_id=_coerce_count(task_route.get("workflow_node_id")),
4389
+ reason="fallback to current-user workflow todo save-only route",
4390
+ )
4391
+ try:
4392
+ raw_apply = self._record_update_via_task_save_only(
4393
+ profile=profile,
4394
+ app_key=app_key,
4395
+ apply_id=record_id,
4396
+ workflow_node_id=cast(int, task_route["workflow_node_id"]),
4397
+ answers=normalized_answers,
4398
+ verify_write=verify_write,
4399
+ force_refresh_form=force_refresh_form,
4400
+ )
4401
+ task_attempt["status"] = "success"
4402
+ tried_routes.append(task_attempt)
4403
+ raw_apply["update_route"] = self._record_update_route_public(task_attempt)
4404
+ raw_apply["tried_routes"] = tried_routes
4405
+ return raw_apply, tried_routes, None
4406
+ except (QingflowApiError, RuntimeError) as exc:
4407
+ api_error = self._record_update_extract_api_error(exc)
4408
+ if api_error is None or not self._record_update_route_permission_denied(api_error):
4409
+ raise
4410
+ task_attempt.update(self._record_update_route_error_payload(
4411
+ api_error,
4412
+ status="denied",
4413
+ error_code="TASK_UPDATE_PERMISSION_DENIED",
4414
+ ))
4415
+ tried_routes.append(task_attempt)
4416
+ return None, tried_routes, {
4417
+ "error_code": "NO_AVAILABLE_UPDATE_ROUTE",
4418
+ "message": "No available record update route could execute this payload for the current user.",
4419
+ "recommended_next_actions": [
4420
+ "If this user should edit the record as a data manager, grant data edit permission and retry record_update.",
4421
+ "If the record is editable from a specific table view in the UI, make sure the target fields are visible and editable in that view.",
4422
+ "If this is workflow work, use task_list -> task_get -> task_action_execute(action='save_only') with the current task context.",
4423
+ ],
4424
+ }
4425
+
4426
+ def _record_update_selected_custom_view_route(self, preflight_data: JSONObject) -> JSONObject | None:
4427
+ selection = preflight_data.get("selection")
4428
+ if not isinstance(selection, dict):
4429
+ return None
4430
+ view = selection.get("view")
4431
+ if not isinstance(view, dict):
4432
+ return None
4433
+ view_id = _normalize_optional_text(view.get("view_id"))
4434
+ if not view_id or not view_id.startswith("custom:"):
4435
+ return None
4436
+ view_key = _normalize_optional_text(view.get("view_key")) or view_id.split(":", 1)[1].strip()
4437
+ if not view_key:
4438
+ return None
4439
+ return {
4440
+ "view_id": view_id,
4441
+ "view_key": view_key,
4442
+ "name": view.get("name"),
4443
+ }
4444
+
4445
+ def _record_update_route_attempt(
4446
+ self,
4447
+ *,
4448
+ route_type: str,
4449
+ endpoint_kind: str,
4450
+ status: str = "attempted",
4451
+ role: int | None = None,
4452
+ task_id: str | None = None,
4453
+ workflow_node_id: int | None = None,
4454
+ view_id: str | None = None,
4455
+ view_key: str | None = None,
4456
+ view_name: str | None = None,
4457
+ error_code: str | None = None,
4458
+ reason: str | None = None,
4459
+ ) -> JSONObject:
4460
+ payload: JSONObject = {
4461
+ "route_type": route_type,
4462
+ "endpoint_kind": endpoint_kind,
4463
+ "status": status,
4464
+ }
4465
+ if role is not None:
4466
+ payload["role"] = role
4467
+ if task_id:
4468
+ payload["task_id"] = task_id
4469
+ if workflow_node_id is not None:
4470
+ payload["workflow_node_id"] = workflow_node_id
4471
+ if view_id:
4472
+ payload["view_id"] = view_id
4473
+ if view_key:
4474
+ payload["view_key"] = view_key
4475
+ if view_name:
4476
+ payload["view_name"] = view_name
4477
+ if error_code:
4478
+ payload["error_code"] = error_code
4479
+ if reason:
4480
+ payload["reason"] = reason
4481
+ return payload
4482
+
4483
+ def _record_update_route_public(self, attempt: JSONObject) -> JSONObject:
4484
+ return _pick_route_payload(attempt)
4485
+
4486
+ def _record_update_route_error_payload(
4487
+ self,
4488
+ exc: QingflowApiError,
4489
+ *,
4490
+ status: str,
4491
+ error_code: str,
4492
+ ) -> JSONObject:
4493
+ payload: JSONObject = {
4494
+ "status": status,
4495
+ "error_code": error_code,
4496
+ "message": exc.message,
4497
+ }
4498
+ if exc.backend_code is not None:
4499
+ payload["backend_code"] = exc.backend_code
4500
+ if exc.http_status is not None:
4501
+ payload["http_status"] = exc.http_status
4502
+ if exc.request_id is not None:
4503
+ payload["request_id"] = exc.request_id
4504
+ return payload
4505
+
4506
+ def _record_update_extract_api_error(self, exc: QingflowApiError | RuntimeError) -> QingflowApiError | None:
4507
+ if isinstance(exc, QingflowApiError):
4508
+ return exc
4509
+ try:
4510
+ payload = json.loads(str(exc))
4511
+ except json.JSONDecodeError:
4512
+ return None
4513
+ if not isinstance(payload, dict):
4514
+ return None
4515
+ return QingflowApiError(
4516
+ category=str(payload.get("category") or "backend"),
4517
+ message=str(payload.get("message") or exc),
4518
+ backend_code=payload.get("backend_code"),
4519
+ request_id=_normalize_optional_text(payload.get("request_id")),
4520
+ http_status=_coerce_count(payload.get("http_status")),
4521
+ details=cast(JSONObject | None, payload.get("details") if isinstance(payload.get("details"), dict) else None),
4522
+ )
4523
+
4524
+ def _record_update_route_permission_denied(self, exc: QingflowApiError) -> bool:
4525
+ if is_auth_like_error(exc):
4526
+ return False
4527
+ if backend_code_int(exc) in {40002, 40027, 40038, 404}:
4528
+ return True
4529
+ if exc.http_status == 404:
4530
+ return True
4531
+ return False
4532
+
4533
+ def _record_update_route_blocked_response(
4534
+ self,
4535
+ *,
4536
+ raw_preflight: JSONObject,
4537
+ operation: str,
4538
+ normalized_payload: JSONObject,
4539
+ output_profile: str,
4540
+ human_review: bool,
4541
+ app_key: str,
4542
+ record_id: int,
4543
+ tried_routes: list[JSONObject],
4544
+ route_blocker: JSONObject,
4545
+ ) -> JSONObject:
4546
+ plan_data = cast(JSONObject, raw_preflight.get("data", {}))
4547
+ validation = cast(JSONObject, plan_data.get("validation", {}))
4548
+ warnings_payload = validation.get("warnings", [])
4549
+ warnings = [{"code": "PREFLIGHT_WARNING", "message": str(item)} for item in warnings_payload] if isinstance(warnings_payload, list) else []
4550
+ warnings.append(
4551
+ {
4552
+ "code": cast(str, route_blocker.get("error_code") or "NO_AVAILABLE_UPDATE_ROUTE"),
4553
+ "message": cast(str, route_blocker.get("message") or "No update route could execute the write."),
4554
+ }
4555
+ )
4556
+ recommended = list(route_blocker.get("recommended_next_actions") or [])
4557
+ response: JSONObject = {
4558
+ "profile": raw_preflight.get("profile"),
4559
+ "ws_id": raw_preflight.get("ws_id"),
4560
+ "ok": False,
4561
+ "status": "blocked",
4562
+ "write_executed": False,
4563
+ "verification_status": "not_requested",
4564
+ "safe_to_retry": True,
4565
+ "request_route": raw_preflight.get("request_route"),
4566
+ "warnings": warnings,
4567
+ "output_profile": output_profile,
4568
+ "update_route": None,
4569
+ "tried_routes": tried_routes,
4570
+ "error_code": route_blocker.get("error_code"),
4571
+ "data": {
4572
+ "action": {"operation": operation, "executed": False},
4573
+ "resource": {"type": "record", "app_key": app_key, "record_id": stringify_backend_id(record_id), "record_ids": []},
4574
+ "verification": None,
4575
+ "normalized_payload": normalized_payload,
4576
+ "blockers": [route_blocker.get("error_code") or "NO_AVAILABLE_UPDATE_ROUTE"],
4577
+ "field_errors": [],
4578
+ "confirmation_requests": [],
4579
+ "resolved_fields": cast(list[JSONObject], plan_data.get("lookup_resolved_fields", [])),
4580
+ "recommended_next_actions": recommended,
4581
+ "human_review": self._record_write_human_review_payload(operation, enabled=human_review),
4582
+ "error": route_blocker,
4583
+ "update_route": None,
4584
+ "tried_routes": tried_routes,
4585
+ },
4586
+ }
4587
+ if output_profile == "verbose":
4588
+ response["data"]["debug"] = {"preflight": plan_data}
4589
+ return response
4590
+
4591
+ def _record_update_task_save_only_candidate(
4592
+ self,
4593
+ *,
4594
+ profile: str,
4595
+ app_key: str,
4596
+ record_id: int,
4597
+ normalized_answers: list[JSONObject],
4598
+ ) -> JSONObject:
4599
+ requested_question_ids = self._record_update_answer_question_ids(normalized_answers)
4600
+
4601
+ def unavailable(*, status: str = "skipped", error_code: str, reason: str, extra: JSONObject | None = None) -> JSONObject:
4602
+ attempt = self._record_update_route_attempt(
4603
+ route_type="task_save_only",
4604
+ endpoint_kind="workflow_node_save_only",
4605
+ status=status,
4606
+ error_code=error_code,
4607
+ reason=reason,
4608
+ )
4609
+ if extra:
4610
+ attempt.update(extra)
4611
+ return {"available": False, "attempt": attempt}
4612
+
4613
+ def runner(session_profile, context):
4614
+ matches: list[JSONObject] = []
4615
+ pages_scanned = 0
4616
+ for page_num in range(1, VERIFY_TASK_FALLBACK_MAX_PAGES + 1):
4617
+ try:
4618
+ task_page = self.backend.request(
4619
+ "POST",
4620
+ context,
4621
+ "/task/dynamic/page",
4622
+ json_body={
4623
+ "type": 1,
4624
+ "processStatus": 1,
4625
+ "appKey": app_key,
4626
+ "pageNum": page_num,
4627
+ "pageSize": VERIFY_TASK_FALLBACK_PAGE_SIZE,
4628
+ },
4629
+ )
4630
+ except QingflowApiError as exc:
4631
+ if not _is_optional_record_auxiliary_lookup_error(exc):
4632
+ raise
4633
+ return unavailable(
4634
+ error_code="TASK_UPDATE_ROUTE_NOT_AVAILABLE",
4635
+ reason="current-user todo task list is unavailable",
4636
+ extra=self._record_update_route_error_payload(
4637
+ exc,
4638
+ status="skipped",
4639
+ error_code="TASK_UPDATE_ROUTE_NOT_AVAILABLE",
4640
+ ),
4641
+ )
4642
+ pages_scanned += 1
4643
+ rows = task_page.get("list") if isinstance(task_page, dict) else None
4644
+ items = [item for item in rows if isinstance(item, dict)] if isinstance(rows, list) else []
4645
+ for item in items:
4646
+ candidate_record_id = _coerce_count(item.get("rowRecordId") or item.get("recordId") or item.get("applyId"))
4647
+ if candidate_record_id == record_id:
4648
+ matches.append(dict(item))
4649
+ if not _page_has_more(cast(JSONObject, task_page if isinstance(task_page, dict) else {}), page_num, VERIFY_TASK_FALLBACK_PAGE_SIZE, len(items)):
4650
+ break
4651
+
4652
+ if not matches:
4653
+ return unavailable(
4654
+ error_code="TASK_UPDATE_ROUTE_NOT_AVAILABLE",
4655
+ reason="no current-user todo task was found for this record",
4656
+ extra={"pages_scanned": pages_scanned},
4657
+ )
4658
+ if len(matches) > 1:
4659
+ return unavailable(
4660
+ error_code="TASK_UPDATE_ROUTE_AMBIGUOUS",
4661
+ reason="multiple current-user todo tasks match this record; refusing to guess workflow context",
4662
+ extra={"matched_tasks": [self._record_update_compact_task_match(item) for item in matches[:5]]},
4663
+ )
4664
+
4665
+ task = matches[0]
4666
+ workflow_node_id = _coerce_count(task.get("nodeId") or task.get("auditNodeId"))
4667
+ if workflow_node_id is None:
4668
+ return unavailable(
4669
+ error_code="TASK_UPDATE_ROUTE_NOT_AVAILABLE",
4670
+ reason="matched todo task does not expose a workflow node id",
4671
+ extra={"matched_task": self._record_update_compact_task_match(task)},
4672
+ )
4673
+ try:
4674
+ editable_payload = self.backend.request(
4675
+ "GET",
4676
+ context,
4677
+ f"/app/{app_key}/auditNode/{workflow_node_id}/editableQueIds",
4678
+ )
4679
+ except QingflowApiError as exc:
4680
+ if not _is_optional_record_auxiliary_lookup_error(exc):
4681
+ raise
4682
+ return unavailable(
4683
+ error_code="TASK_EDITABLE_FIELDS_UNAVAILABLE",
4684
+ reason="workflow node editable field list is unavailable; record_update will not guess task editability",
4685
+ extra=self._record_update_route_error_payload(
4686
+ exc,
4687
+ status="skipped",
4688
+ error_code="TASK_EDITABLE_FIELDS_UNAVAILABLE",
4689
+ ),
4690
+ )
4691
+ editable_question_ids = self._record_update_extract_question_ids(editable_payload)
4692
+ if not editable_question_ids:
4693
+ return unavailable(
4694
+ error_code="TASK_UPDATE_ROUTE_NOT_AVAILABLE",
4695
+ reason="workflow node editable field list is empty",
4696
+ extra={
4697
+ "task_id": stringify_backend_id(task.get("id") or task.get("taskId")),
4698
+ "workflow_node_id": workflow_node_id,
4699
+ },
4700
+ )
4701
+ effective_editable_question_ids = self._record_update_effective_task_editable_ids(
4702
+ editable_question_ids,
4703
+ normalized_answers=normalized_answers,
4704
+ )
4705
+ non_editable = sorted(
4706
+ question_id for question_id in requested_question_ids
4707
+ if question_id not in effective_editable_question_ids
4708
+ )
4709
+ if non_editable:
4710
+ return unavailable(
4711
+ status="denied",
4712
+ error_code="TASK_UPDATE_FIELD_NOT_EDITABLE",
4713
+ reason="one or more requested fields are not editable on the current workflow node",
4714
+ extra={
4715
+ "task_id": stringify_backend_id(task.get("id") or task.get("taskId")),
4716
+ "workflow_node_id": workflow_node_id,
4717
+ "non_editable_question_ids": non_editable,
4718
+ },
4719
+ )
4720
+ return {
4721
+ "available": True,
4722
+ "task_id": stringify_backend_id(task.get("id") or task.get("taskId")),
4723
+ "workflow_node_id": workflow_node_id,
4724
+ "matched_task": self._record_update_compact_task_match(task),
4725
+ "editable_question_ids": sorted(editable_question_ids),
4726
+ "effective_editable_question_ids": sorted(effective_editable_question_ids),
4727
+ }
4728
+
4729
+ return self._run_record_tool(profile, runner)
4730
+
4731
+ def _record_update_compact_task_match(self, item: JSONObject) -> JSONObject:
4732
+ return {
4733
+ key: value
4734
+ for key, value in {
4735
+ "task_id": stringify_backend_id(item.get("id") or item.get("taskId")),
4736
+ "record_id": stringify_backend_id(item.get("rowRecordId") or item.get("recordId") or item.get("applyId")),
4737
+ "workflow_node_id": item.get("nodeId") or item.get("auditNodeId"),
4738
+ "workflow_node_name": item.get("nodeName") or item.get("auditNodeName"),
4739
+ }.items()
4740
+ if value not in (None, "", [], {})
4741
+ }
4742
+
4743
+ def _record_update_answer_question_ids(self, answers: list[JSONObject]) -> set[int]:
4744
+ question_ids: set[int] = set()
4745
+ for answer in answers:
4746
+ if not isinstance(answer, dict):
4747
+ continue
4748
+ que_id = _coerce_count(answer.get("queId"))
4749
+ if que_id is not None and que_id > 0:
4750
+ question_ids.add(que_id)
4751
+ table_values = answer.get("tableValues")
4752
+ if not isinstance(table_values, list):
4753
+ continue
4754
+ for row in table_values:
4755
+ if not isinstance(row, list):
4756
+ continue
4757
+ for cell in row:
4758
+ if not isinstance(cell, dict):
4759
+ continue
4760
+ cell_que_id = _coerce_count(cell.get("queId"))
4761
+ if cell_que_id is not None and cell_que_id > 0:
4762
+ question_ids.add(cell_que_id)
4763
+ return question_ids
4764
+
4765
+ def _record_update_extract_question_ids(self, payload: JSONValue) -> set[int]:
4766
+ candidates: list[Any] = []
4767
+ if isinstance(payload, list):
4768
+ candidates = payload
4769
+ elif isinstance(payload, dict):
4770
+ for key in ("editableQueIds", "editableQuestionIds", "queIds", "questionIds", "ids", "list", "result"):
4771
+ value = payload.get(key)
4772
+ if isinstance(value, list):
4773
+ candidates = value
4774
+ break
4775
+ question_ids: set[int] = set()
4776
+ for item in candidates:
4777
+ value: Any = item
4778
+ if isinstance(item, dict):
4779
+ value = item.get("queId", item.get("questionId", item.get("id")))
4780
+ que_id = _coerce_count(value)
4781
+ if que_id is not None and que_id > 0:
4782
+ question_ids.add(que_id)
4783
+ return question_ids
4784
+
4785
+ def _record_update_effective_task_editable_ids(
4786
+ self,
4787
+ editable_question_ids: set[int],
4788
+ *,
4789
+ normalized_answers: list[JSONObject],
4790
+ ) -> set[int]:
4791
+ effective_editable_ids = set(editable_question_ids)
4792
+ for answer in normalized_answers:
4793
+ if not isinstance(answer, dict):
4794
+ continue
4795
+ parent_que_id = _coerce_count(answer.get("queId"))
4796
+ if parent_que_id is None or parent_que_id <= 0:
4797
+ continue
4798
+ table_values = answer.get("tableValues")
4799
+ if not isinstance(table_values, list) or not table_values:
4800
+ continue
4801
+ row_subfield_ids: set[int] = set()
4802
+ for row in table_values:
4803
+ if not isinstance(row, list):
4804
+ continue
4805
+ for cell in row:
4806
+ if not isinstance(cell, dict):
4807
+ continue
4808
+ cell_que_id = _coerce_count(cell.get("queId"))
4809
+ if cell_que_id is not None and cell_que_id > 0:
4810
+ row_subfield_ids.add(cell_que_id)
4811
+ if row_subfield_ids & editable_question_ids:
4812
+ effective_editable_ids.add(parent_que_id)
4813
+ return effective_editable_ids
4814
+
4815
+ def _record_update_via_custom_view(
4816
+ self,
4817
+ *,
4818
+ profile: str,
4819
+ app_key: str,
4820
+ apply_id: int,
4821
+ view_key: str,
4822
+ answers: list[JSONObject],
4823
+ verify_write: bool,
4824
+ force_refresh_form: bool,
4825
+ ) -> JSONObject:
4826
+ normalized_apply_id = self._validate_app_and_record(app_key, apply_id)
4827
+ normalized_view_key = view_key.strip()
4828
+ if not normalized_view_key:
4829
+ raise_tool_error(QingflowApiError.config_error("view_key is required for custom view update"))
4830
+
4831
+ def runner(session_profile, context):
4832
+ index = self._get_view_field_index(profile, context, normalized_view_key, force_refresh=force_refresh_form) if verify_write else None
4833
+ normalized_answers = [dict(item) for item in answers if isinstance(item, dict)]
4834
+ self._validate_record_write(app_key, normalized_answers, apply_id=normalized_apply_id)
4835
+ result = self.backend.request(
4836
+ "POST",
4837
+ context,
4838
+ f"/view/{normalized_view_key}/apply/{normalized_apply_id}",
4839
+ json_body={"answers": normalized_answers},
4840
+ )
4841
+ verification = self._verify_record_write_result(
4842
+ context,
4843
+ app_key=app_key,
4844
+ apply_id=normalized_apply_id,
4845
+ normalized_answers=normalized_answers,
4846
+ index=cast(FieldIndex, index),
4847
+ verify_view_key=normalized_view_key,
4848
+ ) if verify_write and index is not None else None
4849
+ verified = True if verification is None else bool(verification.get("verified"))
4850
+ return self._attach_human_review_notice(
4851
+ {
4852
+ "profile": profile,
4853
+ "ws_id": session_profile.selected_ws_id,
4854
+ "request_route": self._request_route_payload(context),
4855
+ "app_key": app_key,
4856
+ "apply_id": normalized_apply_id,
4857
+ "record_id": normalized_apply_id,
4858
+ "result": result,
4859
+ "normalized_answers": normalized_answers,
4860
+ "status": "completed" if verified else "verification_failed",
4861
+ "ok": True,
4862
+ "verify_write": verify_write,
4863
+ "write_verified": verified if verify_write else None,
4864
+ "verification": verification,
4865
+ "resource": _record_resource_payload(normalized_apply_id),
4866
+ },
4867
+ operation="update",
4868
+ target="record data",
4869
+ )
3532
4870
 
3533
- def _record_update_public_single(
4871
+ return self._run_record_tool(profile, runner)
4872
+
4873
+ def _record_update_via_task_save_only(
3534
4874
  self,
3535
4875
  *,
3536
4876
  profile: str,
3537
4877
  app_key: str,
3538
- record_id: int,
3539
- fields: JSONObject,
4878
+ apply_id: int,
4879
+ workflow_node_id: int,
4880
+ answers: list[JSONObject],
3540
4881
  verify_write: bool,
3541
- output_profile: str,
4882
+ force_refresh_form: bool,
3542
4883
  ) -> JSONObject:
3543
- """执行内部辅助逻辑。"""
3544
- raw_preflight = self._preflight_record_update_with_auto_view(
3545
- profile=profile,
3546
- app_key=app_key,
3547
- record_id=record_id,
3548
- fields=fields,
3549
- force_refresh_form=False,
3550
- )
3551
- preflight_used_force_refresh = self._record_preflight_used_force_refresh(raw_preflight)
3552
- preflight_data = cast(JSONObject, raw_preflight.get("data", {}))
3553
- normalized_payload = self._record_write_normalized_payload(
3554
- operation="update",
3555
- record_id=record_id,
3556
- record_ids=[],
3557
- normalized_answers=cast(list[JSONObject], preflight_data.get("normalized_answers", [])),
3558
- submit_type=1,
3559
- selection=cast(JSONObject | None, preflight_data.get("selection") if isinstance(preflight_data.get("selection"), dict) else None),
3560
- )
3561
- if preflight_data.get("blockers"):
3562
- return self._record_write_blocked_response(
3563
- raw_preflight,
3564
- operation="update",
3565
- normalized_payload=normalized_payload,
3566
- output_profile=output_profile,
3567
- human_review=True,
3568
- target_resource={"type": "record", "app_key": app_key, "record_id": record_id, "record_ids": []},
4884
+ normalized_apply_id = self._validate_app_and_record(app_key, apply_id)
4885
+ if workflow_node_id <= 0:
4886
+ raise_tool_error(QingflowApiError.config_error("workflow_node_id must be positive for task save-only update"))
4887
+
4888
+ def runner(session_profile, context):
4889
+ index = self._get_field_index(profile, context, app_key, force_refresh=force_refresh_form) if verify_write else None
4890
+ normalized_answers = [dict(item) for item in answers if isinstance(item, dict)]
4891
+ self._validate_record_write(app_key, normalized_answers, apply_id=normalized_apply_id)
4892
+ result = self.backend.request(
4893
+ "POST",
4894
+ context,
4895
+ f"/app/{app_key}/apply/{normalized_apply_id}",
4896
+ json_body={"role": 3, "auditNodeId": workflow_node_id, "answers": normalized_answers},
3569
4897
  )
3570
- try:
3571
- raw_apply = self.record_update(
3572
- profile=profile,
4898
+ verification = self._verify_record_write_result(
4899
+ context,
3573
4900
  app_key=app_key,
3574
- apply_id=record_id,
3575
- answers=cast(list[JSONObject], preflight_data.get("normalized_answers", [])),
3576
- fields={},
3577
- role=1,
3578
- verify_write=verify_write,
3579
- force_refresh_form=preflight_used_force_refresh,
3580
- )
3581
- except QingflowApiError as exc:
3582
- self._raise_record_write_permission_error(
3583
- exc,
4901
+ apply_id=normalized_apply_id,
4902
+ normalized_answers=normalized_answers,
4903
+ index=cast(FieldIndex, index),
4904
+ ) if verify_write and index is not None else None
4905
+ verified = True if verification is None else bool(verification.get("verified"))
4906
+ return self._attach_human_review_notice(
4907
+ {
4908
+ "profile": profile,
4909
+ "ws_id": session_profile.selected_ws_id,
4910
+ "request_route": self._request_route_payload(context),
4911
+ "app_key": app_key,
4912
+ "apply_id": normalized_apply_id,
4913
+ "record_id": normalized_apply_id,
4914
+ "result": result,
4915
+ "normalized_answers": normalized_answers,
4916
+ "status": "completed" if verified else "verification_failed",
4917
+ "ok": True,
4918
+ "verify_write": verify_write,
4919
+ "write_verified": verified if verify_write else None,
4920
+ "verification": verification,
4921
+ "resource": _record_resource_payload(normalized_apply_id),
4922
+ },
3584
4923
  operation="update",
3585
- app_key=app_key,
3586
- record_id=record_id,
3587
- selection=cast(JSONObject | None, preflight_data.get("selection") if isinstance(preflight_data.get("selection"), dict) else None),
4924
+ target="record data",
3588
4925
  )
3589
- raise
3590
- return self._record_write_apply_response(
3591
- raw_apply,
3592
- operation="update",
3593
- normalized_payload=normalized_payload,
3594
- output_profile=output_profile,
3595
- human_review=True,
3596
- preflight=raw_preflight,
3597
- )
4926
+
4927
+ return self._run_record_tool(profile, runner)
3598
4928
 
3599
4929
  def _record_update_public_batch(
3600
4930
  self,
@@ -3602,6 +4932,7 @@ class RecordTools(ToolBase):
3602
4932
  profile: str,
3603
4933
  app_key: str,
3604
4934
  items: list[JSONObject],
4935
+ view_id: str | None,
3605
4936
  dry_run: bool,
3606
4937
  verify_write: bool,
3607
4938
  output_profile: str,
@@ -3613,6 +4944,7 @@ class RecordTools(ToolBase):
3613
4944
  app_key=app_key,
3614
4945
  record_id=cast(int, item["record_id"]),
3615
4946
  fields=cast(JSONObject, item["fields"]),
4947
+ view_id=view_id,
3616
4948
  output_profile=output_profile,
3617
4949
  )
3618
4950
  for item in items
@@ -3641,8 +4973,10 @@ class RecordTools(ToolBase):
3641
4973
  app_key=app_key,
3642
4974
  record_id=record_id,
3643
4975
  fields=fields,
4976
+ view_id=view_id,
3644
4977
  verify_write=verify_write,
3645
4978
  output_profile=output_profile,
4979
+ capture_exceptions=True,
3646
4980
  )
3647
4981
  )
3648
4982
  except (QingflowApiError, RuntimeError) as exc:
@@ -3673,16 +5007,20 @@ class RecordTools(ToolBase):
3673
5007
  app_key: str,
3674
5008
  record_id: int,
3675
5009
  fields: JSONObject,
5010
+ view_id: str | None,
3676
5011
  output_profile: str,
3677
5012
  ) -> JSONObject:
3678
5013
  """执行内部辅助逻辑。"""
3679
- raw_preflight = self._preflight_record_update_with_auto_view(
3680
- profile=profile,
3681
- app_key=app_key,
3682
- record_id=record_id,
3683
- fields=fields,
3684
- force_refresh_form=False,
3685
- )
5014
+ preflight_kwargs: dict[str, Any] = {
5015
+ "profile": profile,
5016
+ "app_key": app_key,
5017
+ "record_id": record_id,
5018
+ "fields": fields,
5019
+ "force_refresh_form": False,
5020
+ }
5021
+ if view_id is not None:
5022
+ preflight_kwargs["preferred_view_id"] = view_id
5023
+ raw_preflight = self._preflight_record_update_with_auto_view(**preflight_kwargs)
3686
5024
  preflight_data = cast(JSONObject, raw_preflight.get("data", {}))
3687
5025
  normalized_payload = self._record_write_normalized_payload(
3688
5026
  operation="update",
@@ -3888,11 +5226,20 @@ class RecordTools(ToolBase):
3888
5226
  item: JSONObject = {
3889
5227
  "resource": data.get("resource"),
3890
5228
  "status": response.get("status"),
5229
+ "write_executed": bool(response.get("write_executed")),
5230
+ "safe_to_retry": bool(response.get("safe_to_retry", True)),
5231
+ "verification_status": response.get("verification_status", "not_requested"),
3891
5232
  "verification": data.get("verification"),
3892
5233
  "field_errors": cast(list[JSONObject], data.get("field_errors", [])),
3893
5234
  "confirmation_requests": cast(list[JSONObject], data.get("confirmation_requests", [])),
3894
5235
  "resolved_fields": cast(list[JSONObject], data.get("resolved_fields", [])),
3895
5236
  }
5237
+ update_route = response.get("update_route")
5238
+ if isinstance(update_route, dict):
5239
+ item["update_route"] = update_route
5240
+ tried_routes = response.get("tried_routes")
5241
+ if isinstance(tried_routes, list) and (output_profile == "verbose" or response.get("status") != "success"):
5242
+ item["tried_routes"] = tried_routes
3896
5243
  blockers = data.get("blockers")
3897
5244
  if isinstance(blockers, list) and blockers:
3898
5245
  item["blockers"] = blockers
@@ -3913,6 +5260,7 @@ class RecordTools(ToolBase):
3913
5260
  app_key: str,
3914
5261
  record_id: int,
3915
5262
  fields: JSONObject,
5263
+ preferred_view_id: str | None = None,
3916
5264
  force_refresh_form: bool,
3917
5265
  ) -> JSONObject:
3918
5266
  """执行内部辅助逻辑。"""
@@ -3920,6 +5268,25 @@ class RecordTools(ToolBase):
3920
5268
  request_route = self._request_route_payload(context)
3921
5269
  def build_once(*, effective_force_refresh: bool) -> JSONObject:
3922
5270
  candidate_routes = self._candidate_update_views(profile, context, app_key)
5271
+ normalized_preferred_view_id = _normalize_optional_text(preferred_view_id)
5272
+ if normalized_preferred_view_id:
5273
+ preferred_route = next(
5274
+ (
5275
+ route
5276
+ for route in candidate_routes
5277
+ if route.view_id == normalized_preferred_view_id
5278
+ ),
5279
+ None,
5280
+ )
5281
+ if preferred_route is None:
5282
+ raise_tool_error(
5283
+ QingflowApiError.config_error(
5284
+ f"view_id '{normalized_preferred_view_id}' is not an accessible update candidate"
5285
+ )
5286
+ )
5287
+ candidate_routes = [preferred_route]
5288
+ else:
5289
+ candidate_routes = _prefer_custom_update_routes(candidate_routes)
3923
5290
  probes = self._probe_candidate_record_contexts(
3924
5291
  context,
3925
5292
  app_key=app_key,
@@ -4133,41 +5500,24 @@ class RecordTools(ToolBase):
4133
5500
  "data": first_confirmation_plan,
4134
5501
  }
4135
5502
 
4136
- union_plan = self._build_record_update_union_preflight(
4137
- profile=profile,
4138
- context=context,
4139
- app_key=app_key,
4140
- record_id=record_id,
4141
- fields=fields,
4142
- current_answers=matched_answers_for_union or [],
4143
- matched_routes=matched_routes,
4144
- force_refresh_form=effective_force_refresh,
4145
- )
4146
- if union_plan is not None:
4147
- validation = union_plan.get("validation")
4148
- if isinstance(validation, dict):
4149
- warnings = validation.get("warnings")
4150
- if not isinstance(warnings, list):
4151
- warnings = []
4152
- validation["warnings"] = warnings
4153
- for message in fallback_warning_messages:
4154
- if message not in warnings:
4155
- warnings.append(message)
4156
- union_plan["view_probe_summary"] = probe_summary
4157
- union_plan["record_context_probe"] = probe_summary
5503
+ if normalized_preferred_view_id and first_blocked_plan is not None:
5504
+ first_blocked_plan["view_probe_summary"] = probe_summary
5505
+ first_blocked_plan["record_context_probe"] = probe_summary
4158
5506
  return {
4159
5507
  "profile": profile,
4160
5508
  "ws_id": session_profile.selected_ws_id,
4161
5509
  "ok": True,
4162
5510
  "request_route": request_route,
4163
- "data": union_plan,
5511
+ "data": first_blocked_plan,
4164
5512
  }
4165
5513
 
4166
5514
  blocked_data = self._build_auto_view_blocked_preflight_data(
4167
5515
  app_key=app_key,
4168
5516
  record_id=record_id,
4169
5517
  blockers=["NO_SINGLE_VIEW_CAN_UPDATE_ALL_FIELDS"],
4170
- warnings=[],
5518
+ warnings=[
5519
+ "record_update requires one executable frontend route for the full payload; it does not merge writable fields across multiple views."
5520
+ ],
4171
5521
  recommended_next_actions=[
4172
5522
  "Call record_update_schema_get first to inspect the overall writable field set for this record.",
4173
5523
  "Reduce the update payload until all requested fields fit inside one matched accessible view.",
@@ -4214,6 +5564,7 @@ class RecordTools(ToolBase):
4214
5564
  union_writable_field_ids: set[int] = set()
4215
5565
  union_visible_question_ids: set[int] = set()
4216
5566
  matched_view_payloads: list[JSONObject] = []
5567
+ union_index: FieldIndex | None = None
4217
5568
 
4218
5569
  for candidate in matched_routes:
4219
5570
  browse_scope = self._build_browse_write_scope(
@@ -4223,11 +5574,13 @@ class RecordTools(ToolBase):
4223
5574
  candidate,
4224
5575
  force_refresh=force_refresh_form,
4225
5576
  )
5577
+ browse_index = cast(FieldIndex, browse_scope["index"])
5578
+ union_index = browse_index if union_index is None else _merge_field_indexes(union_index, browse_index)
4226
5579
  union_writable_field_ids.update(cast(set[int], browse_scope["writable_field_ids"]))
4227
5580
  union_visible_question_ids.update(cast(set[int], browse_scope["visible_question_ids"]))
4228
5581
  matched_view_payloads.append(_accessible_view_payload(candidate))
4229
5582
 
4230
- if not union_writable_field_ids and not union_visible_question_ids:
5583
+ if union_index is None or (not union_writable_field_ids and not union_visible_question_ids):
4231
5584
  return None
4232
5585
 
4233
5586
  plan_data = self._build_record_write_preflight(
@@ -4244,10 +5597,9 @@ class RecordTools(ToolBase):
4244
5597
  view_key=None,
4245
5598
  view_name=None,
4246
5599
  existing_answers_override=current_answers,
5600
+ field_index_override=union_index,
4247
5601
  )
4248
5602
 
4249
- schema = self._get_form_schema(profile, context, app_key, force_refresh=force_refresh_form)
4250
- app_index = _build_applicant_top_level_field_index(schema)
4251
5603
  validation = cast(JSONObject, plan_data.get("validation", {}))
4252
5604
  invalid_fields = cast(list[JSONObject], validation.get("invalid_fields", []))
4253
5605
  missing_required_fields = cast(list[JSONObject], validation.get("missing_required_fields", []))
@@ -4258,12 +5610,21 @@ class RecordTools(ToolBase):
4258
5610
  invalid_fields.extend(
4259
5611
  self._validate_view_scoped_subtable_answers(
4260
5612
  normalized_answers=cast(list[JSONObject], plan_data.get("normalized_answers", [])),
4261
- full_index=app_index,
4262
- selector_index=app_index,
5613
+ full_index=union_index,
5614
+ selector_index=union_index,
4263
5615
  visible_question_ids=union_visible_question_ids,
4264
5616
  )
4265
5617
  )
4266
5618
 
5619
+ readonly_or_system_fields = [
5620
+ item
5621
+ for item in readonly_or_system_fields
5622
+ if not (
5623
+ isinstance(item, dict)
5624
+ and (que_id := _coerce_count(item.get("que_id"))) is not None
5625
+ and que_id in union_writable_field_ids
5626
+ )
5627
+ ]
4267
5628
  existing_readonly_ids = {
4268
5629
  str(_coerce_count(item.get("que_id")))
4269
5630
  for item in readonly_or_system_fields
@@ -4427,7 +5788,13 @@ class RecordTools(ToolBase):
4427
5788
  view_type=None,
4428
5789
  )
4429
5790
  )
4430
- for item in self._get_view_list(profile, context, app_key):
5791
+ try:
5792
+ view_items = self._get_view_list(profile, context, app_key)
5793
+ except QingflowApiError as exc:
5794
+ if not _is_record_permission_denied_error(exc):
5795
+ raise
5796
+ view_items = []
5797
+ for item in view_items:
4431
5798
  if not isinstance(item, dict):
4432
5799
  continue
4433
5800
  view_key = _normalize_optional_text(item.get("viewKey"))
@@ -4484,7 +5851,9 @@ class RecordTools(ToolBase):
4484
5851
  return payload
4485
5852
 
4486
5853
  def _is_record_context_route_miss(self, error: QingflowApiError) -> bool:
4487
- if error.backend_code in {40002, 40023, 40027, 40038, 404}:
5854
+ if is_auth_like_error(error):
5855
+ return False
5856
+ if backend_code_int(error) in {40002, 40023, 40027, 40038, 404}:
4488
5857
  return True
4489
5858
  if error.http_status == 404:
4490
5859
  return True
@@ -4514,11 +5883,12 @@ class RecordTools(ToolBase):
4514
5883
  used_list_type = None
4515
5884
  else:
4516
5885
  used_list_type = resolved_view.list_type if resolved_view.list_type is not None else DEFAULT_RECORD_LIST_TYPE
5886
+ role = _record_detail_role_for_list_type(used_list_type)
4517
5887
  record = self.backend.request(
4518
5888
  "GET",
4519
5889
  context,
4520
5890
  f"/app/{app_key}/apply/{apply_id}",
4521
- params={"role": 1, "listType": used_list_type},
5891
+ params={"role": role, "listType": used_list_type},
4522
5892
  )
4523
5893
  answers = record.get("answers") if isinstance(record, dict) else None
4524
5894
  normalized_answers = [item for item in answers if isinstance(item, dict)] if isinstance(answers, list) else []
@@ -4548,6 +5918,8 @@ class RecordTools(ToolBase):
4548
5918
  error_payload=None,
4549
5919
  )
4550
5920
  except QingflowApiError as exc:
5921
+ if not self._is_record_context_route_miss(exc):
5922
+ raise
4551
5923
  return RecordContextRouteProbe(
4552
5924
  route=resolved_view,
4553
5925
  answer_list=None,
@@ -4619,7 +5991,7 @@ class RecordTools(ToolBase):
4619
5991
  ]
4620
5992
 
4621
5993
  def _looks_like_generic_record_update_backend_failure(self, exc: QingflowApiError) -> bool:
4622
- if exc.backend_code == 500:
5994
+ if backend_code_int(exc) == 500:
4623
5995
  return True
4624
5996
  if exc.http_status is not None and exc.http_status >= 500:
4625
5997
  return True
@@ -4744,12 +6116,15 @@ class RecordTools(ToolBase):
4744
6116
  app_key: str,
4745
6117
  record_id: Any | None = None,
4746
6118
  record_ids: list[Any] | None = None,
6119
+ view_id: str | None = None,
6120
+ list_type: int | None = None,
4747
6121
  output_profile: str = "normal",
4748
6122
  ) -> JSONObject:
4749
6123
  """执行记录相关逻辑。"""
4750
6124
  normalized_output_profile = self._normalize_public_output_profile(output_profile)
4751
6125
  if not app_key:
4752
6126
  raise_tool_error(QingflowApiError.config_error("app_key is required"))
6127
+ delete_list_type = self._resolve_record_delete_list_type(view_id=view_id, list_type=list_type)
4753
6128
  normalized_record_ids: list[int] = []
4754
6129
  for index, item in enumerate(record_ids or []):
4755
6130
  normalized_record_ids.append(normalize_positive_id_int(item, field_name=f"record_ids[{index}]"))
@@ -4769,21 +6144,72 @@ class RecordTools(ToolBase):
4769
6144
  "record_ids": [stringify_backend_id(item) for item in delete_ids],
4770
6145
  "answers": [],
4771
6146
  "submit_type": 1,
6147
+ "selection": {"view_id": view_id, "list_type": delete_list_type},
4772
6148
  }
4773
6149
  return self._record_delete_public_batch(
4774
6150
  profile=profile,
4775
6151
  app_key=app_key,
4776
6152
  delete_ids=delete_ids,
6153
+ list_type=delete_list_type,
4777
6154
  normalized_payload=normalized_payload,
4778
6155
  output_profile=normalized_output_profile,
4779
6156
  )
4780
6157
 
6158
+ def _resolve_record_delete_list_type(self, *, view_id: str | None, list_type: int | None) -> int:
6159
+ normalized_view_id = _normalize_optional_text(view_id)
6160
+ if normalized_view_id:
6161
+ if normalized_view_id.startswith("custom:"):
6162
+ raise_tool_error(
6163
+ QingflowApiError.config_error(
6164
+ "record_delete does not support custom view deletion; the backend delete route accepts system listType only",
6165
+ details={
6166
+ "error_code": "RECORD_DELETE_CUSTOM_VIEW_UNSUPPORTED",
6167
+ "view_id": normalized_view_id,
6168
+ "fix_hint": (
6169
+ "Use a system view_id from app_get.accessible_views, or resolve target record_ids with "
6170
+ "record list/get first and retry delete without a custom view selector."
6171
+ ),
6172
+ },
6173
+ )
6174
+ )
6175
+ if not normalized_view_id.startswith("system:"):
6176
+ raise_tool_error(QingflowApiError.config_error("view_id must start with system: or custom:"))
6177
+ mapped_list_type = SYSTEM_VIEW_ID_TO_LIST_TYPE.get(normalized_view_id)
6178
+ if mapped_list_type is None:
6179
+ raise_tool_error(QingflowApiError.config_error(f"unsupported view_id '{normalized_view_id}'"))
6180
+ return mapped_list_type
6181
+ if list_type is not None:
6182
+ normalized_list_type = int(list_type)
6183
+ if normalized_list_type not in SYSTEM_VIEW_LIST_TYPES:
6184
+ raise_tool_error(
6185
+ QingflowApiError.config_error(
6186
+ "record_delete list_type must map to a supported system view",
6187
+ details={
6188
+ "error_code": "RECORD_DELETE_SYSTEM_VIEW_REQUIRED",
6189
+ "list_type": normalized_list_type,
6190
+ "supported_list_types": sorted(SYSTEM_VIEW_LIST_TYPES),
6191
+ "fix_hint": "Pass a system view_id from app_get.accessible_views instead of an arbitrary list_type.",
6192
+ },
6193
+ )
6194
+ )
6195
+ return normalized_list_type
6196
+ raise_tool_error(
6197
+ QingflowApiError.config_error(
6198
+ "record_delete requires a system view_id or list_type; deleting without frontend list context is ambiguous",
6199
+ details={
6200
+ "error_code": "RECORD_DELETE_VIEW_REQUIRED",
6201
+ "fix_hint": "Pass a system view_id from app_get.accessible_views, for example --view-id system:all. If the target came from a custom view, first confirm the record_id, then choose an accessible system view for deletion.",
6202
+ },
6203
+ )
6204
+ )
6205
+
4781
6206
  def _record_delete_public_batch(
4782
6207
  self,
4783
6208
  *,
4784
6209
  profile: str,
4785
6210
  app_key: str,
4786
6211
  delete_ids: list[int],
6212
+ list_type: int,
4787
6213
  normalized_payload: JSONObject,
4788
6214
  output_profile: str,
4789
6215
  ) -> JSONObject:
@@ -4793,7 +6219,7 @@ class RecordTools(ToolBase):
4793
6219
  for index, delete_id in enumerate(delete_ids):
4794
6220
  record_id_text = stringify_backend_id(delete_id)
4795
6221
  try:
4796
- raw_apply = self._record_delete_many(profile=profile, app_key=app_key, record_ids=[delete_id])
6222
+ raw_apply = self._record_delete_many(profile=profile, app_key=app_key, record_ids=[delete_id], list_type=list_type)
4797
6223
  request_route = cast(JSONObject, raw_apply.get("request_route")) if isinstance(raw_apply.get("request_route"), dict) else request_route
4798
6224
  ws_id = raw_apply.get("ws_id", ws_id)
4799
6225
  single_payload = {
@@ -4802,6 +6228,7 @@ class RecordTools(ToolBase):
4802
6228
  "record_ids": [record_id_text],
4803
6229
  "answers": [],
4804
6230
  "submit_type": 1,
6231
+ "selection": normalized_payload.get("selection"),
4805
6232
  }
4806
6233
  single_response = self._record_write_apply_response(
4807
6234
  raw_apply,
@@ -5084,12 +6511,13 @@ class RecordTools(ToolBase):
5084
6511
  preflight=raw_preflight,
5085
6512
  )
5086
6513
 
5087
- if uses_view_scope:
6514
+ if view_key is not None or view_name is not None:
5088
6515
  raise_tool_error(
5089
6516
  QingflowApiError.config_error(
5090
- "delete does not accept view selectors yet; resolve target record_ids from the selected view first, then call delete by record_id/record_ids"
6517
+ "delete does not support custom view selectors; use a system view_id/list_type or resolve target record_ids first"
5091
6518
  )
5092
6519
  )
6520
+ delete_list_type = self._resolve_record_delete_list_type(view_id=view_id, list_type=list_type)
5093
6521
  if normalized_values or normalized_set:
5094
6522
  raise_tool_error(QingflowApiError.config_error("delete must not include values or set"))
5095
6523
  delete_ids = normalized_record_ids or ([record_id] if record_id is not None and record_id > 0 else [])
@@ -5101,8 +6529,9 @@ class RecordTools(ToolBase):
5101
6529
  "record_ids": delete_ids,
5102
6530
  "answers": [],
5103
6531
  "submit_type": submit_type_value,
6532
+ "selection": {"view_id": view_id, "list_type": delete_list_type},
5104
6533
  }
5105
- raw_apply = self._record_delete_many(profile=profile, app_key=app_key, record_ids=delete_ids)
6534
+ raw_apply = self._record_delete_many(profile=profile, app_key=app_key, record_ids=delete_ids, list_type=delete_list_type)
5106
6535
  return self._record_write_apply_response(
5107
6536
  raw_apply,
5108
6537
  operation="delete",
@@ -5252,7 +6681,9 @@ class RecordTools(ToolBase):
5252
6681
  or _normalize_optional_text(payload.get("appName"))
5253
6682
  or _normalize_optional_text(payload.get("appTitle"))
5254
6683
  )
5255
- except QingflowApiError:
6684
+ except QingflowApiError as exc:
6685
+ if is_auth_like_error(exc):
6686
+ raise
5256
6687
  name = None
5257
6688
  self._app_name_cache[cache_key] = name
5258
6689
  return name
@@ -5406,7 +6837,9 @@ class RecordTools(ToolBase):
5406
6837
  try:
5407
6838
  result = self.backend.request("GET", context, "/data/baseInfo", params={"queId": field.que_id})
5408
6839
  payload = result if isinstance(result, dict) else None
5409
- except QingflowApiError:
6840
+ except QingflowApiError as exc:
6841
+ if is_auth_like_error(exc):
6842
+ raise
5410
6843
  payload = None
5411
6844
  self._relation_base_info_cache[cache_key] = payload or {}
5412
6845
  return payload
@@ -5679,6 +7112,26 @@ class RecordTools(ToolBase):
5679
7112
  or bool(fields)
5680
7113
  )
5681
7114
 
7115
+ def _member_candidate_static_preview_should_use_backend(self, field: FormField) -> bool:
7116
+ """Return true when the frontend field-scope endpoint is safer than directory expansion."""
7117
+ scope = field.member_select_scope if isinstance(field.member_select_scope, dict) else {}
7118
+ if field.member_select_scope_type != 2:
7119
+ return False
7120
+ return bool(
7121
+ _scope_has_dynamic_or_external(scope)
7122
+ or list(scope.get("depart") or [])
7123
+ or list(scope.get("role") or [])
7124
+ )
7125
+
7126
+ def _department_candidate_static_preview_should_use_backend(self, field: FormField) -> bool:
7127
+ """Return true when static preview would otherwise need ContactAuth-only directory APIs."""
7128
+ scope = field.dept_select_scope if isinstance(field.dept_select_scope, dict) else {}
7129
+ if field.dept_select_scope_type != 2:
7130
+ return False
7131
+ if _scope_has_dynamic_or_external(scope):
7132
+ return True
7133
+ return bool(_normalize_bool(scope.get("includeSubDeparts")) or not list(scope.get("depart") or []))
7134
+
5682
7135
  def _build_candidate_lookup_state(
5683
7136
  self,
5684
7137
  profile: str,
@@ -5697,7 +7150,9 @@ class RecordTools(ToolBase):
5697
7150
  if apply_id is not None:
5698
7151
  try:
5699
7152
  base_answers = self._load_record_answers_for_preflight(context, app_key=app_key, apply_id=apply_id)
5700
- except QingflowApiError:
7153
+ except QingflowApiError as exc:
7154
+ if not _is_optional_record_auxiliary_lookup_error(exc):
7155
+ raise
5701
7156
  context_complete = False
5702
7157
  state = LookupResolutionState(
5703
7158
  operation="update" if apply_id is not None else "insert",
@@ -6187,15 +7642,16 @@ class RecordTools(ToolBase):
6187
7642
  )
6188
7643
  if configured_candidate is not None:
6189
7644
  self._merge_department_candidate(merged, configured_candidate)
6190
- for dept in self._list_departments_by_scope(context, dept_id=dept_id, include_sub_departments=include_sub):
6191
- normalized = _normalize_candidate_department(
6192
- dept,
6193
- source_kind="department",
6194
- source_id=dept_id,
6195
- source_value=dept_name,
6196
- )
6197
- if normalized is not None:
6198
- self._merge_department_candidate(merged, normalized)
7645
+ if include_sub:
7646
+ for dept in self._list_departments_by_scope(context, dept_id=dept_id, include_sub_departments=True):
7647
+ normalized = _normalize_candidate_department(
7648
+ dept,
7649
+ source_kind="department",
7650
+ source_id=dept_id,
7651
+ source_value=dept_name,
7652
+ )
7653
+ if normalized is not None:
7654
+ self._merge_department_candidate(merged, normalized)
6199
7655
  filtered = _filter_department_candidates(list(merged.values()), keyword)
6200
7656
  filtered.sort(key=lambda item: (_normalize_optional_text(item.get("value")) or "", _coerce_count(item.get("id")) or 0))
6201
7657
  return filtered
@@ -7336,22 +8792,10 @@ class RecordTools(ToolBase):
7336
8792
  field_index_override: FieldIndex | None = None,
7337
8793
  ) -> JSONObject:
7338
8794
  """执行内部辅助逻辑。"""
7339
- schema = self._get_form_schema(profile, context, app_key, force_refresh=force_refresh_form)
7340
- base_index = field_index_override or _build_applicant_top_level_field_index(schema)
7341
- question_relations = _collect_question_relations(schema)
7342
- runtime_linked_field_ids = _collect_linked_required_field_ids(question_relations)
7343
- runtime_linked_field_ids.update(_collect_option_linked_field_ids(base_index))
7344
- index = base_index
7345
- if operation == "create" and field_index_override is None:
7346
- linked_hidden_index = _build_applicant_hidden_linked_top_level_field_index(
7347
- schema,
7348
- linked_field_ids=runtime_linked_field_ids,
7349
- )
7350
- index = _merge_field_indexes(base_index, linked_hidden_index)
7351
8795
  normalized_fields = fields or {}
7352
8796
  normalized_answers_input = answers or []
7353
8797
  resolved_view: AccessibleViewRoute | None = None
7354
- selector_index = index
8798
+ selector_index: FieldIndex | None = field_index_override
7355
8799
  browse_writable_field_ids: set[int] = set()
7356
8800
  visible_question_ids: set[int] = set()
7357
8801
  if any(item is not None for item in (view_id, list_type, view_key, view_name)):
@@ -7377,6 +8821,31 @@ class RecordTools(ToolBase):
7377
8821
  visible_question_ids = cast(set[int], browse_scope["visible_question_ids"])
7378
8822
  else:
7379
8823
  compatibility_warnings = []
8824
+ if field_index_override is not None:
8825
+ base_index = field_index_override
8826
+ question_relations: list[JSONObject] = []
8827
+ runtime_linked_field_ids: set[int] = set()
8828
+ index = base_index
8829
+ elif operation == "update" and resolved_view is not None:
8830
+ base_index = cast(FieldIndex, selector_index)
8831
+ question_relations = []
8832
+ runtime_linked_field_ids = set()
8833
+ index = base_index
8834
+ else:
8835
+ schema = self._get_form_schema(profile, context, app_key, force_refresh=force_refresh_form)
8836
+ base_index = _build_applicant_top_level_field_index(schema)
8837
+ question_relations = _collect_question_relations(schema)
8838
+ runtime_linked_field_ids = _collect_linked_required_field_ids(question_relations)
8839
+ runtime_linked_field_ids.update(_collect_option_linked_field_ids(base_index))
8840
+ index = base_index
8841
+ if operation == "create":
8842
+ linked_hidden_index = _build_applicant_hidden_linked_top_level_field_index(
8843
+ schema,
8844
+ linked_field_ids=runtime_linked_field_ids,
8845
+ )
8846
+ index = _merge_field_indexes(base_index, linked_hidden_index)
8847
+ if selector_index is None:
8848
+ selector_index = index
7380
8849
  resolved_fields = self._collect_write_plan_field_refs(fields=normalized_fields, answers=normalized_answers_input, index=selector_index)
7381
8850
  support_matrix = _summarize_write_support(resolved_fields)
7382
8851
  invalid_fields: list[JSONObject] = []
@@ -7420,7 +8889,9 @@ class RecordTools(ToolBase):
7420
8889
  apply_id=apply_id,
7421
8890
  )
7422
8891
  existing_answers_loaded = True
7423
- except QingflowApiError:
8892
+ except QingflowApiError as exc:
8893
+ if not _is_optional_record_auxiliary_lookup_error(exc):
8894
+ raise
7424
8895
  validation_warnings.append(
7425
8896
  "update preflight could not load the current record; required-field completeness and dynamic lookup context were not fully revalidated."
7426
8897
  )
@@ -8009,7 +9480,7 @@ class RecordTools(ToolBase):
8009
9480
  break
8010
9481
  except QingflowApiError as exc:
8011
9482
  last_error = exc
8012
- if exc.backend_code == 40002:
9483
+ if _is_record_permission_denied_error(exc):
8013
9484
  continue
8014
9485
  raise
8015
9486
  if result is None:
@@ -8112,7 +9583,21 @@ class RecordTools(ToolBase):
8112
9583
  normalized_apply_id = self._validate_app_and_record(app_key, apply_id)
8113
9584
 
8114
9585
  def runner(session_profile, context):
8115
- index = self._get_field_index(profile, context, app_key, force_refresh=force_refresh_form) if verify_write else None
9586
+ needs_index = verify_write or bool(fields) or _answers_need_resolution(answers or [])
9587
+ update_index = None
9588
+ if needs_index:
9589
+ update_index = (
9590
+ self._get_system_browse_field_index(
9591
+ profile,
9592
+ context,
9593
+ app_key,
9594
+ list_type=DEFAULT_RECORD_LIST_TYPE,
9595
+ force_refresh=force_refresh_form,
9596
+ )
9597
+ if role == 1
9598
+ else self._get_field_index(profile, context, app_key, force_refresh=force_refresh_form)
9599
+ )
9600
+ index = update_index if verify_write else None
8116
9601
  normalized_answers = self._resolve_answers(
8117
9602
  profile,
8118
9603
  context,
@@ -8120,6 +9605,7 @@ class RecordTools(ToolBase):
8120
9605
  answers=answers or [],
8121
9606
  fields=fields or {},
8122
9607
  force_refresh_form=force_refresh_form,
9608
+ field_index_override=update_index,
8123
9609
  )
8124
9610
  self._validate_record_write(app_key, normalized_answers, apply_id=normalized_apply_id)
8125
9611
  try:
@@ -8173,13 +9659,14 @@ class RecordTools(ToolBase):
8173
9659
  def record_delete(self, *, profile: str, app_key: str, apply_id: int, list_type: int) -> JSONObject:
8174
9660
  """执行记录相关逻辑。"""
8175
9661
  normalized_apply_id = self._validate_app_and_record(app_key, apply_id)
9662
+ delete_list_type = self._resolve_record_delete_list_type(view_id=None, list_type=list_type)
8176
9663
 
8177
9664
  def runner(session_profile, context):
8178
9665
  result = self.backend.request(
8179
9666
  "DELETE",
8180
9667
  context,
8181
9668
  f"/app/{app_key}/apply",
8182
- json_body={"type": list_type, "applyIds": [normalized_apply_id]},
9669
+ json_body={"type": delete_list_type, "applyIds": [normalized_apply_id]},
8183
9670
  )
8184
9671
  return self._attach_human_review_notice(
8185
9672
  {
@@ -8188,6 +9675,7 @@ class RecordTools(ToolBase):
8188
9675
  "request_route": self._request_route_payload(context),
8189
9676
  "app_key": app_key,
8190
9677
  "apply_id": normalized_apply_id,
9678
+ "list_type": delete_list_type,
8191
9679
  "result": result,
8192
9680
  },
8193
9681
  operation="delete",
@@ -8232,7 +9720,7 @@ class RecordTools(ToolBase):
8232
9720
  "GET",
8233
9721
  context,
8234
9722
  f"/app/{app_key}/apply/{apply_id}",
8235
- params={"role": 1, "listType": list_type},
9723
+ params={"role": _record_detail_role_for_list_type(list_type), "listType": list_type},
8236
9724
  )
8237
9725
  answers = result.get("answers") if isinstance(result, dict) else None
8238
9726
  answer_list = answers if isinstance(answers, list) else []
@@ -8591,7 +10079,7 @@ class RecordTools(ToolBase):
8591
10079
  used_list_type: int | None = None
8592
10080
  if view_selection is not None:
8593
10081
  fallback_list_types = [view_route.list_type if view_route.list_type is not None else DEFAULT_RECORD_LIST_TYPE]
8594
- elif view_route.list_type is not None and view_route.list_type != DEFAULT_RECORD_LIST_TYPE:
10082
+ elif view_route.list_type is not None:
8595
10083
  fallback_list_types = [view_route.list_type]
8596
10084
  else:
8597
10085
  fallback_list_types = [DEFAULT_RECORD_LIST_TYPE, 14, 1, 2, 12]
@@ -8822,7 +10310,7 @@ class RecordTools(ToolBase):
8822
10310
  try:
8823
10311
  payload = self.backend.request("GET", context, f"/view/{view_key}/viewConfig")
8824
10312
  except QingflowApiError as exc:
8825
- if exc.backend_code in {40002, 40027, 404} or exc.http_status == 404:
10313
+ if _is_optional_schema_permission_error(exc):
8826
10314
  self._view_config_cache[cache_key] = None
8827
10315
  return None
8828
10316
  raise
@@ -8943,7 +10431,12 @@ class RecordTools(ToolBase):
8943
10431
  )
8944
10432
  normalized = _normalize_data_list_base_info_schema(payload)
8945
10433
  if not isinstance(normalized.get("formQues"), list) or not normalized.get("formQues"):
8946
- return self._get_form_schema(profile, context, app_key, force_refresh=force_refresh)
10434
+ try:
10435
+ return self._get_form_schema(profile, context, app_key, force_refresh=force_refresh)
10436
+ except QingflowApiError as exc:
10437
+ if not _is_optional_schema_permission_error(exc):
10438
+ raise
10439
+ return normalized
8947
10440
  self._form_cache[cache_key] = normalized
8948
10441
  return normalized
8949
10442
 
@@ -8975,8 +10468,16 @@ class RecordTools(ToolBase):
8975
10468
  cache_key = (profile, f"view:{view_key}", "browse_view_base_info", None)
8976
10469
  if not force_refresh and cache_key in self._form_cache:
8977
10470
  return self._form_cache[cache_key]
8978
- payload = self.backend.request("GET", context, f"/view/{view_key}/apply/baseInfo")
8979
- normalized = _normalize_data_list_base_info_schema(payload)
10471
+ try:
10472
+ payload = self.backend.request("GET", context, f"/view/{view_key}/apply/baseInfo")
10473
+ normalized = _normalize_data_list_base_info_schema(payload)
10474
+ form_ques = normalized.get("formQues")
10475
+ if not isinstance(form_ques, list) or not form_ques:
10476
+ normalized = self._get_view_form_schema(profile, context, view_key, force_refresh=force_refresh)
10477
+ except QingflowApiError as exc:
10478
+ if not _is_optional_schema_permission_error(exc):
10479
+ raise
10480
+ normalized = self._get_view_form_schema(profile, context, view_key, force_refresh=force_refresh)
8980
10481
  self._form_cache[cache_key] = normalized
8981
10482
  return normalized
8982
10483
 
@@ -9032,22 +10533,6 @@ class RecordTools(ToolBase):
9032
10533
  force_refresh: bool,
9033
10534
  ) -> JSONObject:
9034
10535
  """Build the UI/table-view readable field scope from apply/baseInfo."""
9035
- applicant_index: FieldIndex | None
9036
- applicant_writable_field_ids: set[int]
9037
- try:
9038
- applicant_index = self._get_applicant_top_level_field_index(profile, context, app_key, force_refresh=force_refresh)
9039
- except QingflowApiError as exc:
9040
- if exc.backend_code != 40002:
9041
- raise
9042
- applicant_index = None
9043
- applicant_writable_field_ids = set()
9044
- else:
9045
- applicant_writable_field_ids = {
9046
- field.que_id
9047
- for field in applicant_index.by_id.values()
9048
- if bool(self._schema_write_hints(field)["writable"])
9049
- }
9050
-
9051
10536
  if resolved_view is not None and resolved_view.kind == "custom" and resolved_view.view_selection is not None:
9052
10537
  schema = self._get_custom_view_browse_schema(
9053
10538
  profile,
@@ -9056,6 +10541,16 @@ class RecordTools(ToolBase):
9056
10541
  force_refresh=force_refresh,
9057
10542
  )
9058
10543
  index = _build_top_level_field_index(schema)
10544
+ visible_question_ids = {field.que_id for field in index.by_id.values()}
10545
+ return {
10546
+ "index": index,
10547
+ "writable_field_ids": {
10548
+ field.que_id
10549
+ for field in index.by_id.values()
10550
+ if bool(self._schema_write_hints(field)["writable"])
10551
+ },
10552
+ "visible_question_ids": visible_question_ids,
10553
+ }
9059
10554
  elif resolved_view is not None and resolved_view.kind == "system" and resolved_view.list_type is not None:
9060
10555
  schema = self._get_system_browse_base_info_schema(
9061
10556
  profile,
@@ -9065,34 +10560,26 @@ class RecordTools(ToolBase):
9065
10560
  force_refresh=force_refresh,
9066
10561
  )
9067
10562
  index = _build_top_level_field_index(schema)
9068
- else:
9069
- index = applicant_index or _build_top_level_field_index(
9070
- self._get_form_schema(profile, context, app_key, force_refresh=force_refresh)
9071
- )
9072
-
9073
- if applicant_index is not None and index.by_id:
9074
- enriched_fields = [
9075
- _enrich_read_field_from_applicant(field, applicant_index.by_id.get(str(field.que_id)))
9076
- for field in index.by_id.values()
9077
- ]
9078
- index = _build_top_level_field_index({"formQues": [[_form_field_to_question(field) for field in enriched_fields]]})
10563
+ visible_question_ids = {field.que_id for field in index.by_id.values()}
10564
+ return {
10565
+ "index": index,
10566
+ "writable_field_ids": {
10567
+ field.que_id
10568
+ for field in index.by_id.values()
10569
+ if bool(self._schema_write_hints(field)["writable"])
10570
+ },
10571
+ "visible_question_ids": visible_question_ids,
10572
+ }
9079
10573
 
9080
- visible_question_ids = {field.que_id for field in index.by_id.values()}
9081
- if applicant_index is None:
9082
- writable_field_ids = {
10574
+ applicant_index = self._get_applicant_top_level_field_index(profile, context, app_key, force_refresh=force_refresh)
10575
+ visible_question_ids = {field.que_id for field in applicant_index.by_id.values()}
10576
+ return {
10577
+ "index": applicant_index,
10578
+ "writable_field_ids": {
9083
10579
  field.que_id
9084
- for field in index.by_id.values()
10580
+ for field in applicant_index.by_id.values()
9085
10581
  if bool(self._schema_write_hints(field)["writable"])
9086
- }
9087
- else:
9088
- writable_field_ids = {
9089
- field_id
9090
- for field_id in visible_question_ids
9091
- if field_id in applicant_writable_field_ids
9092
- }
9093
- return {
9094
- "index": index,
9095
- "writable_field_ids": writable_field_ids,
10582
+ },
9096
10583
  "visible_question_ids": visible_question_ids,
9097
10584
  }
9098
10585
 
@@ -9106,23 +10593,13 @@ class RecordTools(ToolBase):
9106
10593
  force_refresh: bool,
9107
10594
  ) -> JSONObject:
9108
10595
  """执行内部辅助逻辑。"""
9109
- applicant_index: FieldIndex | None
9110
- applicant_writable_field_ids: set[int]
9111
- try:
9112
- applicant_index = self._get_applicant_top_level_field_index(profile, context, app_key, force_refresh=force_refresh)
9113
- except QingflowApiError as exc:
9114
- if exc.backend_code != 40002:
9115
- raise
9116
- applicant_index = None
9117
- applicant_writable_field_ids = set()
9118
- else:
9119
- applicant_writable_field_ids = {
9120
- field.que_id
9121
- for field in applicant_index.by_id.values()
9122
- if bool(self._schema_write_hints(field)["writable"])
9123
- }
9124
10596
  if resolved_view is not None and resolved_view.kind == "custom" and resolved_view.view_selection is not None:
9125
- schema = self._get_view_form_schema(profile, context, resolved_view.view_selection.view_key, force_refresh=force_refresh)
10597
+ schema = self._get_custom_view_browse_schema(
10598
+ profile,
10599
+ context,
10600
+ resolved_view.view_selection.view_key,
10601
+ force_refresh=force_refresh,
10602
+ )
9126
10603
  index = _build_top_level_field_index(schema)
9127
10604
  visible_question_ids = self._get_view_question_ids(profile, context, resolved_view.view_selection.view_key)
9128
10605
  if not visible_question_ids:
@@ -9138,6 +10615,12 @@ class RecordTools(ToolBase):
9138
10615
  index = _build_top_level_field_index(schema)
9139
10616
  visible_question_ids = _question_ids_from_schema(schema)
9140
10617
  else:
10618
+ applicant_index = self._get_applicant_top_level_field_index(profile, context, app_key, force_refresh=force_refresh)
10619
+ applicant_writable_field_ids = {
10620
+ field.que_id
10621
+ for field in applicant_index.by_id.values()
10622
+ if bool(self._schema_write_hints(field)["writable"])
10623
+ }
9141
10624
  index = applicant_index or _build_top_level_field_index(
9142
10625
  self._get_form_schema(profile, context, app_key, force_refresh=force_refresh)
9143
10626
  )
@@ -9156,43 +10639,13 @@ class RecordTools(ToolBase):
9156
10639
  "visible_question_ids": set(visible_question_ids),
9157
10640
  }
9158
10641
 
9159
- if applicant_index is None:
9160
- return {
9161
- "index": index,
9162
- "writable_field_ids": {
9163
- field.que_id
9164
- for field in index.by_id.values()
9165
- if bool(self._schema_write_hints(field)["writable"])
9166
- },
9167
- "visible_question_ids": visible_question_ids,
9168
- }
9169
-
9170
- augmented_fields = [
9171
- _clone_form_field(applicant_index.by_id.get(str(field.que_id)) or field)
9172
- for field in index.by_id.values()
9173
- ]
9174
- augmented_field_ids = {field.que_id for field in augmented_fields}
9175
- writable_field_ids = {
9176
- field_id
9177
- for field_id in visible_question_ids
9178
- if field_id in applicant_writable_field_ids
9179
- }
9180
- for field in applicant_index.by_id.values():
9181
- descendant_ids = _subtable_descendant_ids(field)
9182
- field_visible = field.que_id in visible_question_ids
9183
- descendant_visible = bool(descendant_ids and (descendant_ids & visible_question_ids))
9184
- if not field_visible and not descendant_visible:
9185
- continue
9186
- if field.que_id not in augmented_field_ids:
9187
- augmented_fields.append(_clone_form_field(field))
9188
- augmented_field_ids.add(field.que_id)
9189
- if descendant_visible:
9190
- visible_question_ids.add(field.que_id)
9191
- if field.que_id in applicant_writable_field_ids and (field_visible or descendant_visible):
9192
- writable_field_ids.add(field.que_id)
9193
10642
  return {
9194
- "index": _build_top_level_field_index({"formQues": [[_form_field_to_question(field) for field in augmented_fields]]}),
9195
- "writable_field_ids": writable_field_ids,
10643
+ "index": index,
10644
+ "writable_field_ids": {
10645
+ field.que_id
10646
+ for field in index.by_id.values()
10647
+ if bool(self._schema_write_hints(field)["writable"])
10648
+ },
9196
10649
  "visible_question_ids": visible_question_ids,
9197
10650
  }
9198
10651
 
@@ -9257,7 +10710,7 @@ class RecordTools(ToolBase):
9257
10710
  try:
9258
10711
  payload = self.backend.request("GET", context, f"/view/{view_key}/question")
9259
10712
  except QingflowApiError as exc:
9260
- if exc.backend_code in {40002, 40027}:
10713
+ if _is_record_permission_denied_error(exc):
9261
10714
  return set()
9262
10715
  raise
9263
10716
  if not isinstance(payload, list):
@@ -9301,7 +10754,7 @@ class RecordTools(ToolBase):
9301
10754
  )
9302
10755
  return True
9303
10756
  except QingflowApiError as exc:
9304
- if exc.backend_code in {40002, 40027}:
10757
+ if _is_record_permission_denied_error(exc):
9305
10758
  return False
9306
10759
  raise
9307
10760
 
@@ -9454,7 +10907,12 @@ class RecordTools(ToolBase):
9454
10907
  requested_name = _normalize_optional_text(view_name)
9455
10908
  if requested_key is None and requested_name is None:
9456
10909
  return None
9457
- views = self._get_view_list(profile, context, app_key)
10910
+ try:
10911
+ views = self._get_view_list(profile, context, app_key)
10912
+ except QingflowApiError as exc:
10913
+ if requested_key is None or not _is_record_permission_denied_error(exc):
10914
+ raise
10915
+ views = []
9458
10916
  selected: JSONObject | None = None
9459
10917
  if requested_key is not None:
9460
10918
  selected = next((item for item in views if _normalize_optional_text(item.get("viewKey")) == requested_key), None)
@@ -9652,9 +11110,11 @@ class RecordTools(ToolBase):
9652
11110
 
9653
11111
  def _should_retry_list_type_fallback(self, error: QingflowApiError) -> bool:
9654
11112
  """执行内部辅助逻辑。"""
9655
- if error.backend_code in {40002, 40027, 404}:
11113
+ if is_auth_like_error(error):
11114
+ return False
11115
+ if backend_code_int(error) in {40002, 40027, 404}:
9656
11116
  return True
9657
- if error.http_status in {404, 500}:
11117
+ if error.http_status == 404:
9658
11118
  return True
9659
11119
  return False
9660
11120
 
@@ -10475,6 +11935,70 @@ class RecordTools(ToolBase):
10475
11935
  )
10476
11936
  )
10477
11937
 
11938
+ def _candidate_lookup_failed_response(
11939
+ self,
11940
+ *,
11941
+ profile: str,
11942
+ session_profile, # type: ignore[no-untyped-def]
11943
+ context, # type: ignore[no-untyped-def]
11944
+ kind: str,
11945
+ error: RecordInputError,
11946
+ field: FormField,
11947
+ app_key: str,
11948
+ record_id_text: str | None,
11949
+ workflow_node_id: int | None,
11950
+ fields_present: bool,
11951
+ keyword: str,
11952
+ scope_source: str,
11953
+ ) -> JSONObject:
11954
+ """Return a structured result when an optional field candidate lookup is unavailable."""
11955
+ error_payload = error.to_dict()
11956
+ error_details = error_payload.get("details") if isinstance(error_payload.get("details"), dict) else {}
11957
+ candidate_error = error_details.get("candidate_error") if isinstance(error_details.get("candidate_error"), dict) else {}
11958
+ warning_transport = {
11959
+ key: candidate_error.get(key)
11960
+ for key in ("backend_code", "http_status", "request_id")
11961
+ if candidate_error.get(key) is not None
11962
+ }
11963
+ selection: JSONObject = {
11964
+ "app_key": app_key,
11965
+ "field_id": field.que_id,
11966
+ "field_title": field.que_title,
11967
+ "record_id": record_id_text,
11968
+ "workflow_node_id": workflow_node_id,
11969
+ "fields_present": fields_present,
11970
+ "keyword": keyword,
11971
+ "permission_scope": "applicant_node",
11972
+ }
11973
+ return {
11974
+ "profile": profile,
11975
+ "ws_id": session_profile.selected_ws_id,
11976
+ "ok": False,
11977
+ "status": "failed",
11978
+ "error_code": error.error_code,
11979
+ "message": error.message,
11980
+ "request_route": self._request_route_payload(context),
11981
+ "warnings": [
11982
+ {
11983
+ "code": error.error_code,
11984
+ "message": error.fix_hint,
11985
+ "kind": kind,
11986
+ "field_id": field.que_id,
11987
+ "field_title": field.que_title,
11988
+ **warning_transport,
11989
+ }
11990
+ ],
11991
+ "output_profile": "normal",
11992
+ "data": {
11993
+ "items": [],
11994
+ "pagination": {"returned_items": 0},
11995
+ "selection": selection,
11996
+ "scope_source": scope_source,
11997
+ "fix_hint": error.fix_hint,
11998
+ },
11999
+ "details": error_details,
12000
+ }
12001
+
10478
12002
  def _request_route_payload(self, context) -> JSONObject: # type: ignore[no-untyped-def]
10479
12003
  """执行内部辅助逻辑。"""
10480
12004
  describe_route = getattr(self.backend, "describe_route", None)
@@ -10648,7 +12172,7 @@ class RecordTools(ToolBase):
10648
12172
  selection: JSONObject | None,
10649
12173
  ) -> None:
10650
12174
  """执行内部辅助逻辑。"""
10651
- if exc.backend_code != 40002:
12175
+ if not _is_record_permission_denied_error(exc):
10652
12176
  raise exc
10653
12177
  raise_tool_error(
10654
12178
  QingflowApiError(
@@ -10812,6 +12336,9 @@ class RecordTools(ToolBase):
10812
12336
  response_status = "verification_failed" if verification_status == "failed" else "success"
10813
12337
  if not bool(raw_apply.get("ok", True)) and verification_status != "failed":
10814
12338
  response_status = raw_status or "failed"
12339
+ update_route = raw_apply.get("update_route") if isinstance(raw_apply.get("update_route"), dict) else None
12340
+ tried_routes = raw_apply.get("tried_routes") if isinstance(raw_apply.get("tried_routes"), list) else []
12341
+ expose_tried_routes = output_profile == "verbose" or response_status != "success"
10815
12342
  response: JSONObject = {
10816
12343
  "profile": raw_apply.get("profile"),
10817
12344
  "ws_id": raw_apply.get("ws_id"),
@@ -10823,6 +12350,7 @@ class RecordTools(ToolBase):
10823
12350
  "request_route": raw_apply.get("request_route"),
10824
12351
  "warnings": warnings,
10825
12352
  "output_profile": output_profile,
12353
+ "update_route": update_route,
10826
12354
  "data": {
10827
12355
  "action": {"operation": operation, "executed": True},
10828
12356
  "resource": resource,
@@ -10833,8 +12361,12 @@ class RecordTools(ToolBase):
10833
12361
  "confirmation_requests": [],
10834
12362
  "resolved_fields": resolved_fields,
10835
12363
  "human_review": self._record_write_human_review_payload(operation, enabled=human_review),
12364
+ "update_route": update_route,
10836
12365
  },
10837
12366
  }
12367
+ if expose_tried_routes:
12368
+ response["tried_routes"] = tried_routes
12369
+ response["data"]["tried_routes"] = tried_routes
10838
12370
  if record_id is not None:
10839
12371
  response["record_id"] = record_id
10840
12372
  if apply_id is not None:
@@ -11014,7 +12546,7 @@ class RecordTools(ToolBase):
11014
12546
  )
11015
12547
  return errors
11016
12548
 
11017
- def _record_delete_many(self, *, profile: str, app_key: str, record_ids: list[int]) -> JSONObject:
12549
+ def _record_delete_many(self, *, profile: str, app_key: str, record_ids: list[int], list_type: int = DEFAULT_RECORD_LIST_TYPE) -> JSONObject:
11018
12550
  """执行内部辅助逻辑。"""
11019
12551
  if not app_key:
11020
12552
  raise_tool_error(QingflowApiError.config_error("app_key is required"))
@@ -11027,14 +12559,14 @@ class RecordTools(ToolBase):
11027
12559
  "DELETE",
11028
12560
  context,
11029
12561
  f"/app/{app_key}/apply",
11030
- json_body={"type": DEFAULT_RECORD_LIST_TYPE, "applyIds": normalized_ids},
12562
+ json_body={"type": list_type, "applyIds": normalized_ids},
11031
12563
  )
11032
12564
  return {
11033
12565
  "profile": profile,
11034
12566
  "ws_id": session_profile.selected_ws_id,
11035
12567
  "request_route": self._request_route_payload(context),
11036
12568
  "result": result,
11037
- "resource": {"type": "record", "apply_ids": normalized_ids},
12569
+ "resource": {"type": "record", "apply_ids": normalized_ids, "list_type": list_type},
11038
12570
  "ok": True,
11039
12571
  }
11040
12572
 
@@ -11608,6 +13140,30 @@ class RecordTools(ToolBase):
11608
13140
  },
11609
13141
  )
11610
13142
 
13143
+ def _candidate_lookup_error(
13144
+ self,
13145
+ *,
13146
+ kind: str,
13147
+ field: FormField,
13148
+ value: JSONValue,
13149
+ error: QingflowApiError,
13150
+ ) -> RecordInputError:
13151
+ """Build the standard candidate lookup failure without raising it."""
13152
+ field_kind = "member" if kind == "member" else "department"
13153
+ return RecordInputError(
13154
+ message=f"{field_kind} candidates for field '{field.que_title}' could not be loaded",
13155
+ error_code=f"{kind.upper()}_CANDIDATE_LOOKUP_FAILED",
13156
+ fix_hint=(
13157
+ f"Run record_{field_kind}_candidates again after the backend error is resolved, "
13158
+ "then choose one returned item exactly."
13159
+ ),
13160
+ details={
13161
+ "field": _field_ref_payload(field),
13162
+ "received_value": value,
13163
+ "candidate_error": error.to_dict(),
13164
+ },
13165
+ )
13166
+
11611
13167
  def _candidate_keyword_from_value(
11612
13168
  self,
11613
13169
  value: JSONValue,
@@ -11872,14 +13428,7 @@ class RecordTools(ToolBase):
11872
13428
 
11873
13429
  def _lookup_department_detail(self, context, keyword: str, *, purpose: str) -> JSONObject: # type: ignore[no-untyped-def]
11874
13430
  """执行内部辅助逻辑。"""
11875
- payload = self.backend.request(
11876
- "GET",
11877
- context,
11878
- "/contact/deptByPage",
11879
- params={"keyword": keyword, "pageNum": 1, "pageSize": 20},
11880
- )
11881
- rows = payload.get("list") if isinstance(payload, dict) else None
11882
- items = [item for item in rows if isinstance(item, dict)] if isinstance(rows, list) else []
13431
+ items = self._search_workspace_departments(context, keyword=keyword)
11883
13432
  normalized_keyword = keyword.strip()
11884
13433
  exact = [
11885
13434
  item for item in items
@@ -12404,6 +13953,8 @@ class RecordTools(ToolBase):
12404
13953
  normalized_answers: list[JSONObject],
12405
13954
  index: FieldIndex,
12406
13955
  verify_list_type: int = DEFAULT_RECORD_LIST_TYPE,
13956
+ verify_role: int | None = None,
13957
+ verify_view_key: str | None = None,
12407
13958
  ) -> JSONObject:
12408
13959
  """执行内部辅助逻辑。"""
12409
13960
  if apply_id is None:
@@ -12415,14 +13966,38 @@ class RecordTools(ToolBase):
12415
13966
  "count_mismatches": [],
12416
13967
  }
12417
13968
  try:
12418
- record = self.backend.request(
12419
- "GET",
12420
- context,
12421
- f"/app/{app_key}/apply/{apply_id}",
12422
- params={"role": 1, "listType": verify_list_type},
12423
- )
13969
+ if verify_view_key:
13970
+ record = self.backend.request(
13971
+ "GET",
13972
+ context,
13973
+ f"/view/{verify_view_key}/apply/{apply_id}",
13974
+ )
13975
+ else:
13976
+ role = verify_role if verify_role is not None else 1
13977
+ record = self.backend.request(
13978
+ "GET",
13979
+ context,
13980
+ f"/app/{app_key}/apply/{apply_id}",
13981
+ params={"role": role, "listType": verify_list_type},
13982
+ )
12424
13983
  except QingflowApiError as exc:
12425
- if exc.backend_code != 40002:
13984
+ if verify_view_key:
13985
+ warning: JSONObject = {
13986
+ "code": "WRITE_VERIFY_CUSTOM_VIEW_READBACK_FAILED",
13987
+ "message": "Write was sent through a custom view route, but the same view could not be re-read for field-level verification.",
13988
+ }
13989
+ warning.update(_record_detail_error_warning_fields(exc))
13990
+ return {
13991
+ "verified": False,
13992
+ "verification_mode": "custom_view_record_detail",
13993
+ "field_level_verified": False,
13994
+ "error": "custom_view_readback_failed",
13995
+ "missing_fields": [],
13996
+ "empty_fields": [],
13997
+ "count_mismatches": [],
13998
+ "warnings": [warning],
13999
+ }
14000
+ if not _is_record_permission_denied_error(exc):
12426
14001
  raise
12427
14002
  return self._verify_record_write_result_via_initiated_tasks(
12428
14003
  context,
@@ -12485,7 +14060,7 @@ class RecordTools(ToolBase):
12485
14060
  )
12486
14061
  return {
12487
14062
  "verified": not missing_fields and not empty_fields and not count_mismatches,
12488
- "verification_mode": "initiated_record_view",
14063
+ "verification_mode": "custom_view_record_detail" if verify_view_key else "initiated_record_view",
12489
14064
  "field_level_verified": True,
12490
14065
  "missing_fields": missing_fields,
12491
14066
  "empty_fields": empty_fields,
@@ -12716,6 +14291,8 @@ def _normalize_data_list_base_info_schema(payload: JSONValue) -> JSONObject:
12716
14291
  if not isinstance(payload, dict):
12717
14292
  return {}
12718
14293
  que_base_infos = payload.get("queBaseInfos")
14294
+ if not isinstance(que_base_infos, list) and isinstance(payload.get("formQues"), list):
14295
+ que_base_infos = payload.get("formQues")
12719
14296
  if not isinstance(que_base_infos, list):
12720
14297
  return {}
12721
14298
  return {
@@ -13102,6 +14679,44 @@ def _build_answer_backed_field_index(
13102
14679
  )
13103
14680
 
13104
14681
 
14682
+ def _merge_subtable_parent_field(primary: FormField, extra: FormField) -> FormField:
14683
+ if primary.que_type not in SUBTABLE_QUE_TYPES or extra.que_type not in SUBTABLE_QUE_TYPES:
14684
+ return primary
14685
+ primary_raw = dict(primary.raw) if isinstance(primary.raw, dict) else {}
14686
+ extra_raw = dict(extra.raw) if isinstance(extra.raw, dict) else {}
14687
+ primary_subquestions = primary_raw.get("subQuestions")
14688
+ extra_subquestions = extra_raw.get("subQuestions")
14689
+ if not isinstance(primary_subquestions, list) or not isinstance(extra_subquestions, list):
14690
+ return primary
14691
+ merged_subquestions = [item for item in primary_subquestions if isinstance(item, dict)]
14692
+ seen_ids = {
14693
+ _coerce_count(item.get("queId"))
14694
+ for item in merged_subquestions
14695
+ if isinstance(item, dict) and _coerce_count(item.get("queId")) is not None
14696
+ }
14697
+ for item in extra_subquestions:
14698
+ if not isinstance(item, dict):
14699
+ continue
14700
+ que_id = _coerce_count(item.get("queId"))
14701
+ if que_id is not None and que_id in seen_ids:
14702
+ continue
14703
+ merged_subquestions.append(item)
14704
+ if que_id is not None:
14705
+ seen_ids.add(que_id)
14706
+ if len(merged_subquestions) == len(primary_subquestions):
14707
+ return primary
14708
+ merged_raw = dict(primary_raw)
14709
+ merged_raw["subQuestions"] = merged_subquestions
14710
+ merged_field = _clone_form_field(primary)
14711
+ merged_field.raw = merged_raw
14712
+ return merged_field
14713
+
14714
+
14715
+ def _replace_field_in_lookup(index: dict[str, list[FormField]], field: FormField) -> None:
14716
+ for key, fields in list(index.items()):
14717
+ index[key] = [field if existing.que_id == field.que_id else existing for existing in fields]
14718
+
14719
+
13105
14720
  def _merge_field_indexes(primary: FieldIndex, extra: FieldIndex) -> FieldIndex:
13106
14721
  by_id = dict(primary.by_id)
13107
14722
  by_title = {key: list(value) for key, value in primary.by_title.items()}
@@ -13112,12 +14727,42 @@ def _merge_field_indexes(primary: FieldIndex, extra: FieldIndex) -> FieldIndex:
13112
14727
 
13113
14728
  for field_id, field in extra.by_id.items():
13114
14729
  if field_id in by_id:
14730
+ merged_field = _merge_subtable_parent_field(by_id[field_id], field)
14731
+ if merged_field is not by_id[field_id]:
14732
+ by_id[field_id] = merged_field
14733
+ _replace_field_in_lookup(by_title, merged_field)
14734
+ _replace_field_in_lookup(by_alias, merged_field)
13115
14735
  continue
13116
14736
  by_id[field_id] = field
13117
14737
  by_title.setdefault(_normalize_field_lookup_key(field.que_title), []).append(field)
13118
14738
  for alias in field.aliases:
13119
14739
  by_alias.setdefault(_normalize_field_lookup_key(alias), []).append(field)
13120
14740
 
14741
+ for field_id, fields in extra.subtable_leaf_by_id.items():
14742
+ merged = subtable_leaf_by_id.setdefault(field_id, [])
14743
+ existing = {(ref.field.que_id, ref.parent_field.que_id) for ref in merged}
14744
+ for field in fields:
14745
+ key = (field.field.que_id, field.parent_field.que_id)
14746
+ if key not in existing:
14747
+ merged.append(field)
14748
+ existing.add(key)
14749
+ for title, fields in extra.subtable_leaf_by_title.items():
14750
+ merged = subtable_leaf_by_title.setdefault(title, [])
14751
+ existing = {(ref.field.que_id, ref.parent_field.que_id) for ref in merged}
14752
+ for field in fields:
14753
+ key = (field.field.que_id, field.parent_field.que_id)
14754
+ if key not in existing:
14755
+ merged.append(field)
14756
+ existing.add(key)
14757
+ for alias, fields in extra.subtable_leaf_by_alias.items():
14758
+ merged = subtable_leaf_by_alias.setdefault(alias, [])
14759
+ existing = {(ref.field.que_id, ref.parent_field.que_id) for ref in merged}
14760
+ for field in fields:
14761
+ key = (field.field.que_id, field.parent_field.que_id)
14762
+ if key not in existing:
14763
+ merged.append(field)
14764
+ existing.add(key)
14765
+
13121
14766
  return FieldIndex(
13122
14767
  by_id=by_id,
13123
14768
  by_title=by_title,
@@ -13397,6 +15042,13 @@ def _record_access_run_dir() -> Path:
13397
15042
  return base_dir / run_id
13398
15043
 
13399
15044
 
15045
+ def _record_logs_run_dir() -> Path:
15046
+ custom_home = os.getenv("QINGFLOW_MCP_RECORD_LOGS_HOME")
15047
+ base_dir = Path(custom_home).expanduser() if custom_home else get_mcp_home() / "record-logs"
15048
+ run_id = f"{datetime.now(UTC).strftime('%Y%m%dT%H%M%SZ')}-{uuid4().hex[:8]}"
15049
+ return base_dir / run_id
15050
+
15051
+
13400
15052
  def _record_access_field_payload(field: FormField) -> JSONObject:
13401
15053
  return {
13402
15054
  "field_id": field.que_id,
@@ -14165,6 +15817,159 @@ def _record_detail_log_unavailable_payload(source: str, reason: str) -> JSONObje
14165
15817
  }
14166
15818
 
14167
15819
 
15820
+ def _record_logs_hidden_payload(source: str) -> JSONObject:
15821
+ return {
15822
+ "status": "hidden",
15823
+ "visible": False,
15824
+ "source": source,
15825
+ "complete": False,
15826
+ "items_count": 0,
15827
+ "pages_fetched": 0,
15828
+ "reported_total": None,
15829
+ "local_path": None,
15830
+ "preview_items": [],
15831
+ "warnings": [],
15832
+ }
15833
+
15834
+
15835
+ def _record_logs_unavailable_payload(source: str, reason: str) -> JSONObject:
15836
+ return {
15837
+ "status": "unavailable",
15838
+ "visible": None,
15839
+ "source": source,
15840
+ "reason": reason,
15841
+ "complete": False,
15842
+ "items_count": 0,
15843
+ "pages_fetched": 0,
15844
+ "reported_total": None,
15845
+ "local_path": None,
15846
+ "preview_items": [],
15847
+ "warnings": [],
15848
+ }
15849
+
15850
+
15851
+ def _record_logs_fetch_all_to_jsonl(
15852
+ *,
15853
+ fetch_page,
15854
+ normalizer,
15855
+ source: str,
15856
+ file_path: Path,
15857
+ deadline: float,
15858
+ ) -> JSONObject: # type: ignore[no-untyped-def]
15859
+ file_path.parent.mkdir(parents=True, exist_ok=True)
15860
+ page_num = 1
15861
+ pages_fetched = 0
15862
+ items_count = 0
15863
+ reported_total: int | None = None
15864
+ preview_items: list[JSONObject] = []
15865
+ warnings: list[JSONObject] = []
15866
+ stopped_reason: str | None = None
15867
+ complete = True
15868
+
15869
+ with file_path.open("w", encoding="utf-8") as handle:
15870
+ while True:
15871
+ if _record_logs_time_budget_exceeded(deadline=deadline):
15872
+ complete = False
15873
+ stopped_reason = "time_budget_exceeded"
15874
+ warnings.append(_record_logs_time_budget_warning(source=source, pages_fetched=pages_fetched, items_count=items_count))
15875
+ break
15876
+ payload = fetch_page(page_num)
15877
+ pages_fetched += 1
15878
+ items = _record_detail_page_items(payload)
15879
+ if reported_total is None:
15880
+ reported_total = _record_detail_page_total(payload)
15881
+ if not items:
15882
+ break
15883
+ for item in items:
15884
+ normalized = normalizer(item)
15885
+ handle.write(json.dumps(normalized, ensure_ascii=False) + "\n")
15886
+ items_count += 1
15887
+ if len(preview_items) < RECORD_LOGS_PREVIEW_LIMIT:
15888
+ preview_items.append(normalized)
15889
+ if items_count >= RECORD_LOGS_MAX_ITEMS:
15890
+ complete = False
15891
+ stopped_reason = "item_limit_exceeded"
15892
+ warnings.append(_record_logs_item_limit_warning(source=source, item_limit=RECORD_LOGS_MAX_ITEMS))
15893
+ break
15894
+ if stopped_reason:
15895
+ break
15896
+ if reported_total is not None and items_count >= reported_total:
15897
+ break
15898
+ if reported_total is None and len(items) < RECORD_LOGS_PAGE_SIZE:
15899
+ break
15900
+ page_num += 1
15901
+
15902
+ return {
15903
+ "status": "ok" if complete else "partial",
15904
+ "visible": True,
15905
+ "source": source,
15906
+ "complete": complete,
15907
+ "items_count": items_count,
15908
+ "pages_fetched": pages_fetched,
15909
+ "page_size": RECORD_LOGS_PAGE_SIZE,
15910
+ "reported_total": reported_total,
15911
+ "local_path": str(file_path),
15912
+ "preview_items": preview_items,
15913
+ "warnings": warnings,
15914
+ "stopped_reason": stopped_reason,
15915
+ }
15916
+
15917
+
15918
+ def _record_logs_time_budget_exceeded(*, deadline: float) -> bool:
15919
+ return time.monotonic() + RECORD_LOGS_MIN_REMAINING_SECONDS >= deadline
15920
+
15921
+
15922
+ def _record_logs_time_budget_warning(*, source: str, pages_fetched: int, items_count: int) -> JSONObject:
15923
+ return {
15924
+ "code": "RECORD_LOGS_TIME_BUDGET_EXCEEDED",
15925
+ "source": source,
15926
+ "message": "record_logs_get stopped early to return partial JSONL files before the caller timeout.",
15927
+ "pages_fetched": pages_fetched,
15928
+ "items_count": items_count,
15929
+ }
15930
+
15931
+
15932
+ def _record_logs_item_limit_warning(*, source: str, item_limit: int) -> JSONObject:
15933
+ return {
15934
+ "code": "RECORD_LOGS_ITEM_LIMIT_EXCEEDED",
15935
+ "source": source,
15936
+ "message": f"record_logs_get stopped after the internal {item_limit} item limit.",
15937
+ "item_limit": item_limit,
15938
+ }
15939
+
15940
+
15941
+ def _record_logs_overall_status(*, data_logs: JSONObject, workflow_logs: JSONObject) -> str:
15942
+ statuses = {str(data_logs.get("status") or ""), str(workflow_logs.get("status") or "")}
15943
+ if statuses == {"unavailable"}:
15944
+ return "unavailable"
15945
+ if "partial" in statuses or "unavailable" in statuses:
15946
+ return "partial"
15947
+ return "success"
15948
+
15949
+
15950
+ def _record_logs_context_integrity(*, data_logs: JSONObject, workflow_logs: JSONObject) -> JSONObject:
15951
+ data_integrity = _record_logs_section_integrity(data_logs)
15952
+ workflow_integrity = _record_logs_section_integrity(workflow_logs)
15953
+ return {
15954
+ "data_logs": data_integrity,
15955
+ "workflow_logs": workflow_integrity,
15956
+ "safe_for_full_log_conclusion": data_integrity == "full" and workflow_integrity == "full",
15957
+ }
15958
+
15959
+
15960
+ def _record_logs_section_integrity(section: JSONObject) -> str:
15961
+ status = str(section.get("status") or "")
15962
+ if status == "ok" and section.get("complete") is True:
15963
+ return "full"
15964
+ if status == "hidden":
15965
+ return "hidden"
15966
+ if status == "partial":
15967
+ return "partial"
15968
+ if status == "unavailable":
15969
+ return "unavailable"
15970
+ return "unknown"
15971
+
15972
+
14168
15973
  def _record_detail_log_page_payload(payload: JSONValue, *, normalizer, source: str) -> JSONObject: # type: ignore[no-untyped-def]
14169
15974
  items = _record_detail_page_items(payload)
14170
15975
  total = _record_detail_page_total(payload)
@@ -14348,6 +16153,28 @@ def _record_detail_unavailable_context(section: str, message: str, exc: Qingflow
14348
16153
  "message": message,
14349
16154
  "category": exc.category,
14350
16155
  }
16156
+ if is_auth_like_error(exc):
16157
+ payload["auth_like"] = True
16158
+ payload["error_code"] = "AUTH_REQUIRED"
16159
+ if exc.backend_code is not None:
16160
+ payload["backend_code"] = exc.backend_code
16161
+ if exc.http_status is not None:
16162
+ payload["http_status"] = exc.http_status
16163
+ request_id = getattr(exc, "request_id", None)
16164
+ if request_id:
16165
+ payload["request_id"] = request_id
16166
+ details = exc.details if isinstance(exc.details, dict) else {}
16167
+ error_code = details.get("error_code")
16168
+ if error_code and not payload.get("error_code"):
16169
+ payload["error_code"] = error_code
16170
+ return payload
16171
+
16172
+
16173
+ def _record_detail_error_warning_fields(exc: QingflowApiError) -> JSONObject:
16174
+ payload: JSONObject = {"category": exc.category}
16175
+ if is_auth_like_error(exc):
16176
+ payload["auth_like"] = True
16177
+ payload["error_code"] = "AUTH_REQUIRED"
14351
16178
  if exc.backend_code is not None:
14352
16179
  payload["backend_code"] = exc.backend_code
14353
16180
  if exc.http_status is not None:
@@ -14357,11 +16184,35 @@ def _record_detail_unavailable_context(section: str, message: str, exc: Qingflow
14357
16184
  payload["request_id"] = request_id
14358
16185
  details = exc.details if isinstance(exc.details, dict) else {}
14359
16186
  error_code = details.get("error_code")
14360
- if error_code:
16187
+ if error_code and not payload.get("error_code"):
14361
16188
  payload["error_code"] = error_code
14362
16189
  return payload
14363
16190
 
14364
16191
 
16192
+ def _record_detail_refreshed_source_url(refresh_result: Any) -> str | None:
16193
+ if isinstance(refresh_result, dict):
16194
+ return _normalize_optional_text(refresh_result.get("source_url"))
16195
+ return _normalize_optional_text(refresh_result)
16196
+
16197
+
16198
+ def _record_detail_append_refresh_warning(
16199
+ warnings: list[JSONObject],
16200
+ refresh_result: Any,
16201
+ *,
16202
+ id_key: str,
16203
+ id_value: str,
16204
+ ) -> None:
16205
+ if not isinstance(refresh_result, dict):
16206
+ return
16207
+ warning = refresh_result.get("warning")
16208
+ if not isinstance(warning, dict):
16209
+ return
16210
+ payload: JSONObject = dict(warning)
16211
+ payload.setdefault("code", "ASSET_STORAGE_URL_REFRESH_FAILED")
16212
+ payload.setdefault(id_key, id_value)
16213
+ warnings.append(payload)
16214
+
16215
+
14365
16216
  _RECORD_MEDIA_IMG_SRC_RE = re.compile(r"""<img\b[^>]*\bsrc\s*=\s*["']?([^"'\s>]+)""", re.IGNORECASE)
14366
16217
  _RECORD_MEDIA_MD_IMAGE_RE = re.compile(r"""!\[[^\]]*]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)""")
14367
16218
  _RECORD_MEDIA_URL_RE = re.compile(r"""https?://[^\s<>"')\]]+""", re.IGNORECASE)
@@ -14516,7 +16367,14 @@ def _record_detail_media_assets_payload(
14516
16367
  except QingflowApiError as exc:
14517
16368
  blocked = exc.http_status in {401, 403}
14518
16369
  if blocked and download_strategy != "referer_acl" and callable(refresh_source_url):
14519
- refreshed_url = _normalize_optional_text(refresh_source_url(candidate))
16370
+ refresh_result = refresh_source_url(candidate)
16371
+ _record_detail_append_refresh_warning(
16372
+ warnings,
16373
+ refresh_result,
16374
+ id_key="asset_id",
16375
+ id_value=asset_id,
16376
+ )
16377
+ refreshed_url = _record_detail_refreshed_source_url(refresh_result)
14520
16378
  if refreshed_url and refreshed_url != source_url:
14521
16379
  refreshed_strategy = _record_detail_media_download_strategy(refreshed_url)
14522
16380
  try:
@@ -14558,14 +16416,13 @@ def _record_detail_media_assets_payload(
14558
16416
  "readable_by_agent": False,
14559
16417
  }
14560
16418
  )
14561
- warnings.append(
14562
- {
14563
- "code": warning_code,
14564
- "asset_id": asset_id,
14565
- "message": f"record_get could not download image asset {asset_id}: {exc.message}",
14566
- "http_status": exc.http_status,
14567
- }
14568
- )
16419
+ warning: JSONObject = {
16420
+ "code": warning_code,
16421
+ "asset_id": asset_id,
16422
+ "message": f"record_get could not download image asset {asset_id}: {exc.message}",
16423
+ }
16424
+ warning.update(_record_detail_error_warning_fields(exc))
16425
+ warnings.append(warning)
14569
16426
  continue
14570
16427
 
14571
16428
  if not isinstance(content, bytes):
@@ -14783,7 +16640,14 @@ def _record_detail_file_assets_payload(
14783
16640
  except QingflowApiError as exc:
14784
16641
  blocked = exc.http_status in {401, 403}
14785
16642
  if blocked and download_strategy != "referer_acl" and callable(refresh_source_url):
14786
- refreshed_url = _normalize_optional_text(refresh_source_url(candidate))
16643
+ refresh_result = refresh_source_url(candidate)
16644
+ _record_detail_append_refresh_warning(
16645
+ warnings,
16646
+ refresh_result,
16647
+ id_key="file_asset_id",
16648
+ id_value=file_asset_id,
16649
+ )
16650
+ refreshed_url = _record_detail_refreshed_source_url(refresh_result)
14787
16651
  if refreshed_url and refreshed_url != source_url:
14788
16652
  refreshed_strategy = _record_detail_media_download_strategy(refreshed_url)
14789
16653
  try:
@@ -14830,14 +16694,13 @@ def _record_detail_file_assets_payload(
14830
16694
  "extraction": {"status": "failed", "text_path": None, "preview": None},
14831
16695
  }
14832
16696
  )
14833
- warnings.append(
14834
- {
14835
- "code": warning_code,
14836
- "file_asset_id": file_asset_id,
14837
- "message": f"record_get could not download file asset {file_asset_id}: {exc.message}",
14838
- "http_status": exc.http_status,
14839
- }
14840
- )
16697
+ warning = {
16698
+ "code": warning_code,
16699
+ "file_asset_id": file_asset_id,
16700
+ "message": f"record_get could not download file asset {file_asset_id}: {exc.message}",
16701
+ }
16702
+ warning.update(_record_detail_error_warning_fields(exc))
16703
+ warnings.append(warning)
14841
16704
  continue
14842
16705
 
14843
16706
  if not isinstance(content, bytes):