@qingflow-tech/qingflow-app-builder-mcp 1.0.10 → 1.0.11

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.
@@ -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
@@ -269,6 +274,30 @@ GENERIC_FIELD_ALIAS_OVERRIDES: dict[str, list[str]] = {
269
274
  FIELD_LOOKUP_STRIP_RE = re.compile(r"[\s_()()\[\]【】{}<>·/\\::-]+")
270
275
 
271
276
 
277
+ def _pick_route_payload(payload: JSONObject) -> JSONObject:
278
+ return {
279
+ key: payload[key]
280
+ for key in (
281
+ "route_type",
282
+ "endpoint_kind",
283
+ "status",
284
+ "role",
285
+ "task_id",
286
+ "workflow_node_id",
287
+ "view_id",
288
+ "view_key",
289
+ "view_name",
290
+ "error_code",
291
+ "backend_code",
292
+ "http_status",
293
+ "request_id",
294
+ "message",
295
+ "reason",
296
+ )
297
+ if key in payload and payload[key] not in (None, "", [], {})
298
+ }
299
+
300
+
272
301
  class RecordTools(ToolBase):
273
302
  """记录工具(中文名:记录读写与分析)。
274
303
 
@@ -442,6 +471,20 @@ class RecordTools(ToolBase):
442
471
  output_profile=output_profile,
443
472
  )
444
473
 
474
+ @mcp.tool(description="Read all visible data logs and workflow logs for one Qingflow record into local JSONL files. This tool hides pagination and returns file paths plus completeness metadata.")
475
+ def record_logs_get(
476
+ profile: str = DEFAULT_PROFILE,
477
+ app_key: str = "",
478
+ record_id: str = "",
479
+ view_id: str | None = None,
480
+ ) -> JSONObject:
481
+ return self.record_logs_get(
482
+ profile=profile,
483
+ app_key=app_key,
484
+ record_id=record_id,
485
+ view_id=view_id,
486
+ )
487
+
445
488
  @mcp.tool()
446
489
  def record_browse_schema_get(
447
490
  app_key: str = "",
@@ -493,8 +536,9 @@ class RecordTools(ToolBase):
493
536
  @mcp.tool(
494
537
  description=(
495
538
  "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."
539
+ "For simple field changes, call this tool directly after the target record is clear. "
540
+ "It first tries the data-manager direct route, then falls back to the frontend custom-view edit route when available. "
541
+ "Use record_update_schema_get for diagnostics or complex field-scope inspection."
498
542
  )
499
543
  )
500
544
  def record_update(
@@ -995,15 +1039,59 @@ class RecordTools(ToolBase):
995
1039
  item["title"]: self._ready_schema_template_value(item)
996
1040
  for item in writable_fields
997
1041
  },
1042
+ "available_update_routes": self._record_update_schema_available_routes(matched_probes),
1043
+ "recommended_update_route": {
1044
+ "route_type": "auto",
1045
+ "order": ["admin_direct", "view_edit", "task_save_only"],
1046
+ "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.",
1047
+ },
998
1048
  }
999
1049
  if normalized_output_profile == "verbose":
1000
1050
  response["view_probe_summary"] = probe_summary
1001
1051
  response["record_context_probe"] = probe_summary
1002
1052
  response["ambiguous_fields"] = ambiguous_fields
1053
+ response["route_probe_summary"] = probe_summary
1003
1054
  return response
1004
1055
 
1005
1056
  return self._run_record_tool(profile, runner)
1006
1057
 
1058
+ def _record_update_schema_available_routes(self, matched_probes: list[RecordContextRouteProbe]) -> list[JSONObject]:
1059
+ routes: list[JSONObject] = [
1060
+ {
1061
+ "route_type": "admin_direct",
1062
+ "endpoint_kind": "app_apply_update",
1063
+ "role": 1,
1064
+ "availability": "attempted_on_update",
1065
+ "message": "Requires data-manager edit permission; record_update safely falls back if backend returns permission denied.",
1066
+ }
1067
+ ]
1068
+ for probe in matched_probes:
1069
+ if probe.route.kind != "custom":
1070
+ continue
1071
+ view_key = self._route_view_key(probe.route)
1072
+ if not view_key:
1073
+ continue
1074
+ routes.append(
1075
+ {
1076
+ "route_type": "view_edit",
1077
+ "endpoint_kind": "view_apply_update",
1078
+ "view_id": probe.route.view_id,
1079
+ "view_key": view_key,
1080
+ "view_name": probe.route.name,
1081
+ "availability": "candidate",
1082
+ "message": "Uses the same custom-view detail edit route as the frontend.",
1083
+ }
1084
+ )
1085
+ routes.append(
1086
+ {
1087
+ "route_type": "task_save_only",
1088
+ "endpoint_kind": "workflow_node_save_only",
1089
+ "availability": "auto_probe_on_update",
1090
+ "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.",
1091
+ }
1092
+ )
1093
+ return routes
1094
+
1007
1095
  def _record_update_schema_blocked_response(
1008
1096
  self,
1009
1097
  *,
@@ -2175,6 +2263,130 @@ class RecordTools(ToolBase):
2175
2263
 
2176
2264
  return self._run_record_tool(profile, runner)
2177
2265
 
2266
+ @tool_cn_name("记录全量日志")
2267
+ def record_logs_get(
2268
+ self,
2269
+ *,
2270
+ profile: str,
2271
+ app_key: str,
2272
+ record_id: Any,
2273
+ view_id: str | None = None,
2274
+ ) -> JSONObject:
2275
+ """读取单条记录可见的全量数据日志和流程日志,写入本地 JSONL。"""
2276
+ record_id_int = normalize_positive_id_int(record_id, field_name="record_id")
2277
+
2278
+ def runner(session_profile, context):
2279
+ resolved_view, compatibility_warnings = self._resolve_accessible_view_route(
2280
+ profile,
2281
+ context,
2282
+ app_key,
2283
+ view_id=view_id,
2284
+ list_type=None,
2285
+ view_key=None,
2286
+ view_name=None,
2287
+ allow_default=True,
2288
+ )
2289
+ warnings: list[JSONObject] = []
2290
+ warnings.extend(compatibility_warnings)
2291
+ warnings.extend(_view_filter_trust_warnings(resolved_view))
2292
+ unavailable_context: list[JSONObject] = []
2293
+
2294
+ schema = self._record_get_detail_schema(profile, context, app_key, resolved_view, force_refresh=False)
2295
+ index = _build_top_level_field_index(schema)
2296
+ audit_info = self._record_get_audit_info(
2297
+ context,
2298
+ app_key=app_key,
2299
+ record_id=record_id_int,
2300
+ resolved_view=resolved_view,
2301
+ )
2302
+ audit_context = _record_detail_audit_context(audit_info, workflow_node_id=None)
2303
+ detail_result, used_list_type, used_role = self._record_get_apply_detail(
2304
+ context,
2305
+ app_key=app_key,
2306
+ record_id=record_id_int,
2307
+ resolved_view=resolved_view,
2308
+ audit_node_id=cast(int | None, audit_context.get("audit_node_id")),
2309
+ )
2310
+ answer_list = _record_detail_answers(detail_result)
2311
+ selected_fields = list(index.by_id.values())
2312
+ fields = [
2313
+ _record_detail_field_payload(field, _find_answer_for_field(cast(list[JSONValue], answer_list), field), focus_id_set=set())
2314
+ for field in selected_fields
2315
+ ]
2316
+ app_name = self._record_get_detail_app_name(
2317
+ profile,
2318
+ context,
2319
+ app_key=app_key,
2320
+ schema=schema,
2321
+ used_list_type=used_list_type,
2322
+ )
2323
+ view_payload = _accessible_view_payload(resolved_view)
2324
+ record_payload = _record_detail_record_payload(
2325
+ app_key=app_key,
2326
+ record_id=record_id_int,
2327
+ detail=detail_result,
2328
+ answer_list=cast(list[JSONValue], answer_list),
2329
+ fields=fields,
2330
+ )
2331
+ log_visibility = self._record_get_log_visibility_context(
2332
+ context,
2333
+ app_key=app_key,
2334
+ record_id=record_id_int,
2335
+ resolved_view=resolved_view,
2336
+ role=used_role,
2337
+ audit_node_id=cast(int | None, audit_context.get("audit_node_id")),
2338
+ unavailable_context=unavailable_context,
2339
+ )
2340
+ run_dir = _record_logs_run_dir()
2341
+ run_dir.mkdir(parents=True, exist_ok=True)
2342
+ deadline = time.monotonic() + RECORD_LOGS_TIME_BUDGET_SECONDS
2343
+ data_logs = self._record_get_full_data_logs_context(
2344
+ context,
2345
+ app_key=app_key,
2346
+ record_id=record_id_int,
2347
+ role=used_role,
2348
+ log_visibility=log_visibility,
2349
+ unavailable_context=unavailable_context,
2350
+ run_dir=run_dir,
2351
+ deadline=deadline,
2352
+ )
2353
+ workflow_logs = self._record_get_full_workflow_logs_context(
2354
+ context,
2355
+ app_key=app_key,
2356
+ record_id=record_id_int,
2357
+ resolved_view=resolved_view,
2358
+ role=used_role,
2359
+ audit_node_id=cast(int | None, audit_context.get("audit_node_id")),
2360
+ log_visibility=log_visibility,
2361
+ unavailable_context=unavailable_context,
2362
+ run_dir=run_dir,
2363
+ deadline=deadline,
2364
+ )
2365
+ status = _record_logs_overall_status(data_logs=data_logs, workflow_logs=workflow_logs)
2366
+ context_integrity = _record_logs_context_integrity(data_logs=data_logs, workflow_logs=workflow_logs)
2367
+ payload: JSONObject = {
2368
+ "ok": True,
2369
+ "status": status,
2370
+ "output_profile": "record_logs",
2371
+ "app": {"app_key": app_key, "app_name": app_name},
2372
+ "view": view_payload,
2373
+ "record": record_payload,
2374
+ "local_dir": str(run_dir),
2375
+ "data_logs": data_logs,
2376
+ "workflow_logs": workflow_logs,
2377
+ "warnings": warnings,
2378
+ "unavailable_context": unavailable_context,
2379
+ "context_integrity": context_integrity,
2380
+ }
2381
+ summary_path = run_dir / "summary.json"
2382
+ summary_payload = deepcopy(payload)
2383
+ summary_payload.pop("request_route", None)
2384
+ summary_path.write_text(json.dumps(summary_payload, ensure_ascii=False, indent=2), encoding="utf-8")
2385
+ payload["summary_path"] = str(summary_path)
2386
+ return payload
2387
+
2388
+ return self._run_record_tool(profile, runner)
2389
+
2178
2390
  def _record_get_detail_context(
2179
2391
  self,
2180
2392
  *,
@@ -2824,6 +3036,108 @@ class RecordTools(ToolBase):
2824
3036
  unavailable_context.append(_record_detail_unavailable_context("workflow_logs", "流程日志本次获取失败。", exc))
2825
3037
  return _record_detail_log_unavailable_payload("workflow_logs", "fetch_unavailable")
2826
3038
 
3039
+ def _record_get_full_data_logs_context(
3040
+ self,
3041
+ context, # type: ignore[no-untyped-def]
3042
+ *,
3043
+ app_key: str,
3044
+ record_id: int,
3045
+ role: int,
3046
+ log_visibility: JSONObject,
3047
+ unavailable_context: list[JSONObject],
3048
+ run_dir: Path,
3049
+ deadline: float,
3050
+ ) -> JSONObject:
3051
+ """读取全量数据日志并写入 JSONL。"""
3052
+ if log_visibility.get("status") == "unavailable":
3053
+ return _record_logs_unavailable_payload("data_logs", "visibility_unavailable")
3054
+ if log_visibility.get("data_log_visible") is False:
3055
+ return _record_logs_hidden_payload("data_logs")
3056
+
3057
+ def fetch_page(page_num: int) -> JSONValue:
3058
+ return self.backend.request(
3059
+ "POST",
3060
+ context,
3061
+ f"/worksheet/data/log/{app_key}/{record_id}/page",
3062
+ json_body={
3063
+ "viewChannel": log_visibility.get("channel"),
3064
+ "role": role,
3065
+ "pageNum": page_num,
3066
+ "pageSize": RECORD_LOGS_PAGE_SIZE,
3067
+ },
3068
+ )
3069
+
3070
+ try:
3071
+ return _record_logs_fetch_all_to_jsonl(
3072
+ fetch_page=fetch_page,
3073
+ normalizer=_record_detail_data_log_item,
3074
+ source="data_logs",
3075
+ file_path=run_dir / "data-logs.jsonl",
3076
+ deadline=deadline,
3077
+ )
3078
+ except QingflowApiError as exc:
3079
+ unavailable_context.append(_record_detail_unavailable_context("data_logs", "全量数据日志获取失败。", exc))
3080
+ return _record_logs_unavailable_payload("data_logs", "fetch_unavailable")
3081
+
3082
+ def _record_get_full_workflow_logs_context(
3083
+ self,
3084
+ context, # type: ignore[no-untyped-def]
3085
+ *,
3086
+ app_key: str,
3087
+ record_id: int,
3088
+ resolved_view: AccessibleViewRoute,
3089
+ role: int,
3090
+ audit_node_id: int | None,
3091
+ log_visibility: JSONObject,
3092
+ unavailable_context: list[JSONObject],
3093
+ run_dir: Path,
3094
+ deadline: float,
3095
+ ) -> JSONObject:
3096
+ """读取全量流程日志并写入 JSONL。"""
3097
+ if log_visibility.get("status") == "unavailable":
3098
+ return _record_logs_unavailable_payload("workflow_logs", "visibility_unavailable")
3099
+ if log_visibility.get("workflow_log_visible") is False:
3100
+ return _record_logs_hidden_payload("workflow_logs")
3101
+
3102
+ def fetch_page(page_num: int) -> JSONValue:
3103
+ if resolved_view.kind == "custom" and resolved_view.view_selection is not None:
3104
+ return self.backend.request(
3105
+ "POST",
3106
+ context,
3107
+ f"/viewGraph/{resolved_view.view_selection.view_key}/workflow/node/record",
3108
+ json_body={
3109
+ "key": resolved_view.view_selection.view_key,
3110
+ "rowRecordId": str(record_id),
3111
+ "pageNum": page_num,
3112
+ "pageSize": RECORD_LOGS_PAGE_SIZE,
3113
+ },
3114
+ )
3115
+ return self.backend.request(
3116
+ "POST",
3117
+ context,
3118
+ "/application/workflow/node/record",
3119
+ json_body={
3120
+ "key": app_key,
3121
+ "rowRecordId": str(record_id),
3122
+ "nodeId": audit_node_id,
3123
+ "role": role,
3124
+ "pageNum": page_num,
3125
+ "pageSize": RECORD_LOGS_PAGE_SIZE,
3126
+ },
3127
+ )
3128
+
3129
+ try:
3130
+ return _record_logs_fetch_all_to_jsonl(
3131
+ fetch_page=fetch_page,
3132
+ normalizer=_record_detail_workflow_log_item,
3133
+ source="workflow_logs",
3134
+ file_path=run_dir / "workflow-logs.jsonl",
3135
+ deadline=deadline,
3136
+ )
3137
+ except QingflowApiError as exc:
3138
+ unavailable_context.append(_record_detail_unavailable_context("workflow_logs", "全量流程日志获取失败。", exc))
3139
+ return _record_logs_unavailable_payload("workflow_logs", "fetch_unavailable")
3140
+
2827
3141
  def _record_get_associated_resources(
2828
3142
  self,
2829
3143
  context, # type: ignore[no-untyped-def]
@@ -3485,116 +3799,765 @@ class RecordTools(ToolBase):
3485
3799
  def record_update_public(
3486
3800
  self,
3487
3801
  *,
3488
- profile: str = DEFAULT_PROFILE,
3802
+ profile: str = DEFAULT_PROFILE,
3803
+ app_key: str,
3804
+ record_id: Any | None,
3805
+ fields: JSONObject | None = None,
3806
+ items: list[JSONObject] | None = None,
3807
+ dry_run: bool = False,
3808
+ verify_write: bool = True,
3809
+ output_profile: str = "normal",
3810
+ ) -> JSONObject:
3811
+ """执行记录相关逻辑。"""
3812
+ normalized_output_profile = self._normalize_public_output_profile(output_profile)
3813
+ if not app_key:
3814
+ raise_tool_error(QingflowApiError.config_error("app_key is required"))
3815
+ if items is not None:
3816
+ if dry_run not in {True, False}:
3817
+ raise_tool_error(QingflowApiError.config_error("dry_run must be boolean"))
3818
+ normalized_items = self._normalize_public_record_update_batch_items(
3819
+ record_id=record_id,
3820
+ fields=fields,
3821
+ items=items,
3822
+ )
3823
+ return self._record_update_public_batch(
3824
+ profile=profile,
3825
+ app_key=app_key,
3826
+ items=normalized_items,
3827
+ dry_run=dry_run,
3828
+ verify_write=verify_write,
3829
+ output_profile=normalized_output_profile,
3830
+ )
3831
+ if dry_run:
3832
+ raise_tool_error(QingflowApiError.config_error("dry_run currently requires items"))
3833
+ if record_id is None:
3834
+ raise_tool_error(QingflowApiError.config_error("record_id is required"))
3835
+ record_id_int = normalize_positive_id_int(record_id, field_name="record_id")
3836
+ if fields is not None and not isinstance(fields, dict):
3837
+ raise_tool_error(QingflowApiError.config_error("fields must be an object map keyed by field title"))
3838
+ return self._record_update_public_single(
3839
+ profile=profile,
3840
+ app_key=app_key,
3841
+ record_id=record_id_int,
3842
+ fields=cast(JSONObject, fields or {}),
3843
+ verify_write=verify_write,
3844
+ output_profile=normalized_output_profile,
3845
+ )
3846
+
3847
+ def _record_update_public_single(
3848
+ self,
3849
+ *,
3850
+ profile: str,
3851
+ app_key: str,
3852
+ record_id: int,
3853
+ fields: JSONObject,
3854
+ verify_write: bool,
3855
+ output_profile: str,
3856
+ ) -> JSONObject:
3857
+ """执行内部辅助逻辑。"""
3858
+ raw_preflight = self._preflight_record_update_with_auto_view(
3859
+ profile=profile,
3860
+ app_key=app_key,
3861
+ record_id=record_id,
3862
+ fields=fields,
3863
+ force_refresh_form=False,
3864
+ )
3865
+ preflight_used_force_refresh = self._record_preflight_used_force_refresh(raw_preflight)
3866
+ preflight_data = cast(JSONObject, raw_preflight.get("data", {}))
3867
+ normalized_payload = self._record_write_normalized_payload(
3868
+ operation="update",
3869
+ record_id=record_id,
3870
+ record_ids=[],
3871
+ normalized_answers=cast(list[JSONObject], preflight_data.get("normalized_answers", [])),
3872
+ submit_type=1,
3873
+ selection=cast(JSONObject | None, preflight_data.get("selection") if isinstance(preflight_data.get("selection"), dict) else None),
3874
+ )
3875
+ if preflight_data.get("blockers"):
3876
+ return self._record_write_blocked_response(
3877
+ raw_preflight,
3878
+ operation="update",
3879
+ normalized_payload=normalized_payload,
3880
+ output_profile=output_profile,
3881
+ human_review=True,
3882
+ target_resource={"type": "record", "app_key": app_key, "record_id": record_id, "record_ids": []},
3883
+ )
3884
+ route_apply, tried_routes, route_blocker = self._record_update_apply_with_auto_route(
3885
+ profile=profile,
3886
+ app_key=app_key,
3887
+ record_id=record_id,
3888
+ normalized_answers=cast(list[JSONObject], preflight_data.get("normalized_answers", [])),
3889
+ preflight_data=preflight_data,
3890
+ verify_write=verify_write,
3891
+ force_refresh_form=preflight_used_force_refresh,
3892
+ )
3893
+ if route_blocker is not None:
3894
+ return self._record_update_route_blocked_response(
3895
+ raw_preflight=raw_preflight,
3896
+ operation="update",
3897
+ normalized_payload=normalized_payload,
3898
+ output_profile=output_profile,
3899
+ human_review=True,
3900
+ app_key=app_key,
3901
+ record_id=record_id,
3902
+ tried_routes=tried_routes,
3903
+ route_blocker=route_blocker,
3904
+ )
3905
+ raw_apply = cast(JSONObject, route_apply)
3906
+ return self._record_write_apply_response(
3907
+ raw_apply,
3908
+ operation="update",
3909
+ normalized_payload=normalized_payload,
3910
+ output_profile=output_profile,
3911
+ human_review=True,
3912
+ preflight=raw_preflight,
3913
+ )
3914
+
3915
+ def _record_update_apply_with_auto_route(
3916
+ self,
3917
+ *,
3918
+ profile: str,
3919
+ app_key: str,
3920
+ record_id: int,
3921
+ normalized_answers: list[JSONObject],
3922
+ preflight_data: JSONObject,
3923
+ verify_write: bool,
3924
+ force_refresh_form: bool,
3925
+ ) -> tuple[JSONObject | None, list[JSONObject], JSONObject | None]:
3926
+ """Try record update routes in the same order a frontend user would expect."""
3927
+ tried_routes: list[JSONObject] = []
3928
+ admin_attempt = self._record_update_route_attempt(
3929
+ route_type="admin_direct",
3930
+ endpoint_kind="app_apply_update",
3931
+ role=1,
3932
+ reason="try data-manager direct edit first",
3933
+ )
3934
+ try:
3935
+ raw_apply = self.record_update(
3936
+ profile=profile,
3937
+ app_key=app_key,
3938
+ apply_id=record_id,
3939
+ answers=normalized_answers,
3940
+ fields={},
3941
+ role=1,
3942
+ verify_write=verify_write,
3943
+ force_refresh_form=force_refresh_form,
3944
+ )
3945
+ admin_attempt["status"] = "success"
3946
+ raw_apply["update_route"] = self._record_update_route_public(admin_attempt)
3947
+ raw_apply["tried_routes"] = [admin_attempt]
3948
+ return raw_apply, [admin_attempt], None
3949
+ except (QingflowApiError, RuntimeError) as exc:
3950
+ api_error = self._record_update_extract_api_error(exc)
3951
+ if api_error is None or not self._record_update_route_permission_denied(api_error):
3952
+ raise
3953
+ admin_attempt.update(self._record_update_route_error_payload(
3954
+ api_error,
3955
+ status="denied",
3956
+ error_code="ADMIN_UPDATE_PERMISSION_DENIED",
3957
+ ))
3958
+ tried_routes.append(admin_attempt)
3959
+
3960
+ view_route = self._record_update_selected_custom_view_route(preflight_data)
3961
+ if view_route is None:
3962
+ tried_routes.append(
3963
+ self._record_update_route_attempt(
3964
+ route_type="view_edit",
3965
+ endpoint_kind="view_apply_update",
3966
+ status="skipped",
3967
+ error_code="VIEW_UPDATE_ROUTE_NOT_AVAILABLE",
3968
+ reason="preflight did not select a single custom view route for this payload",
3969
+ )
3970
+ )
3971
+ else:
3972
+ view_attempt = self._record_update_route_attempt(
3973
+ route_type="view_edit",
3974
+ endpoint_kind="view_apply_update",
3975
+ view_id=cast(str, view_route.get("view_id")),
3976
+ view_key=cast(str, view_route.get("view_key")),
3977
+ view_name=_normalize_optional_text(view_route.get("name")),
3978
+ reason="fallback to frontend custom-view detail edit route",
3979
+ )
3980
+ try:
3981
+ raw_apply = self._record_update_via_custom_view(
3982
+ profile=profile,
3983
+ app_key=app_key,
3984
+ apply_id=record_id,
3985
+ view_key=cast(str, view_route["view_key"]),
3986
+ answers=normalized_answers,
3987
+ verify_write=verify_write,
3988
+ force_refresh_form=force_refresh_form,
3989
+ )
3990
+ view_attempt["status"] = "success"
3991
+ tried_routes.append(view_attempt)
3992
+ raw_apply["update_route"] = self._record_update_route_public(view_attempt)
3993
+ raw_apply["tried_routes"] = tried_routes
3994
+ return raw_apply, tried_routes, None
3995
+ except (QingflowApiError, RuntimeError) as exc:
3996
+ api_error = self._record_update_extract_api_error(exc)
3997
+ if api_error is None or not self._record_update_route_permission_denied(api_error):
3998
+ raise
3999
+ view_attempt.update(self._record_update_route_error_payload(
4000
+ api_error,
4001
+ status="denied",
4002
+ error_code="VIEW_UPDATE_PERMISSION_DENIED",
4003
+ ))
4004
+ tried_routes.append(view_attempt)
4005
+
4006
+ task_route = self._record_update_task_save_only_candidate(
4007
+ profile=profile,
4008
+ app_key=app_key,
4009
+ record_id=record_id,
4010
+ normalized_answers=normalized_answers,
4011
+ )
4012
+ task_attempt = cast(JSONObject, task_route.get("attempt") if isinstance(task_route.get("attempt"), dict) else {})
4013
+ if not task_route.get("available"):
4014
+ tried_routes.append(task_attempt or self._record_update_route_attempt(
4015
+ route_type="task_save_only",
4016
+ endpoint_kind="workflow_node_save_only",
4017
+ status="skipped",
4018
+ error_code="TASK_UPDATE_ROUTE_NOT_AVAILABLE",
4019
+ reason="no unique current-user todo task can edit the requested fields",
4020
+ ))
4021
+ else:
4022
+ task_attempt = self._record_update_route_attempt(
4023
+ route_type="task_save_only",
4024
+ endpoint_kind="workflow_node_save_only",
4025
+ role=3,
4026
+ task_id=_normalize_optional_text(task_route.get("task_id")),
4027
+ workflow_node_id=_coerce_count(task_route.get("workflow_node_id")),
4028
+ reason="fallback to current-user workflow todo save-only route",
4029
+ )
4030
+ try:
4031
+ raw_apply = self._record_update_via_task_save_only(
4032
+ profile=profile,
4033
+ app_key=app_key,
4034
+ apply_id=record_id,
4035
+ workflow_node_id=cast(int, task_route["workflow_node_id"]),
4036
+ answers=normalized_answers,
4037
+ verify_write=verify_write,
4038
+ force_refresh_form=force_refresh_form,
4039
+ )
4040
+ task_attempt["status"] = "success"
4041
+ tried_routes.append(task_attempt)
4042
+ raw_apply["update_route"] = self._record_update_route_public(task_attempt)
4043
+ raw_apply["tried_routes"] = tried_routes
4044
+ return raw_apply, tried_routes, None
4045
+ except (QingflowApiError, RuntimeError) as exc:
4046
+ api_error = self._record_update_extract_api_error(exc)
4047
+ if api_error is None or not self._record_update_route_permission_denied(api_error):
4048
+ raise
4049
+ task_attempt.update(self._record_update_route_error_payload(
4050
+ api_error,
4051
+ status="denied",
4052
+ error_code="TASK_UPDATE_PERMISSION_DENIED",
4053
+ ))
4054
+ tried_routes.append(task_attempt)
4055
+ return None, tried_routes, {
4056
+ "error_code": "NO_AVAILABLE_UPDATE_ROUTE",
4057
+ "message": "No available record update route could execute this payload for the current user.",
4058
+ "recommended_next_actions": [
4059
+ "If this user should edit the record as a data manager, grant data edit permission and retry record_update.",
4060
+ "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.",
4061
+ "If this is workflow work, use task_list -> task_get -> task_action_execute(action='save_only') with the current task context.",
4062
+ ],
4063
+ }
4064
+
4065
+ def _record_update_selected_custom_view_route(self, preflight_data: JSONObject) -> JSONObject | None:
4066
+ selection = preflight_data.get("selection")
4067
+ if not isinstance(selection, dict):
4068
+ return None
4069
+ view = selection.get("view")
4070
+ if not isinstance(view, dict):
4071
+ return None
4072
+ view_id = _normalize_optional_text(view.get("view_id"))
4073
+ if not view_id or not view_id.startswith("custom:"):
4074
+ return None
4075
+ view_key = _normalize_optional_text(view.get("view_key")) or view_id.split(":", 1)[1].strip()
4076
+ if not view_key:
4077
+ return None
4078
+ return {
4079
+ "view_id": view_id,
4080
+ "view_key": view_key,
4081
+ "name": view.get("name"),
4082
+ }
4083
+
4084
+ def _record_update_route_attempt(
4085
+ self,
4086
+ *,
4087
+ route_type: str,
4088
+ endpoint_kind: str,
4089
+ status: str = "attempted",
4090
+ role: int | None = None,
4091
+ task_id: str | None = None,
4092
+ workflow_node_id: int | None = None,
4093
+ view_id: str | None = None,
4094
+ view_key: str | None = None,
4095
+ view_name: str | None = None,
4096
+ error_code: str | None = None,
4097
+ reason: str | None = None,
4098
+ ) -> JSONObject:
4099
+ payload: JSONObject = {
4100
+ "route_type": route_type,
4101
+ "endpoint_kind": endpoint_kind,
4102
+ "status": status,
4103
+ }
4104
+ if role is not None:
4105
+ payload["role"] = role
4106
+ if task_id:
4107
+ payload["task_id"] = task_id
4108
+ if workflow_node_id is not None:
4109
+ payload["workflow_node_id"] = workflow_node_id
4110
+ if view_id:
4111
+ payload["view_id"] = view_id
4112
+ if view_key:
4113
+ payload["view_key"] = view_key
4114
+ if view_name:
4115
+ payload["view_name"] = view_name
4116
+ if error_code:
4117
+ payload["error_code"] = error_code
4118
+ if reason:
4119
+ payload["reason"] = reason
4120
+ return payload
4121
+
4122
+ def _record_update_route_public(self, attempt: JSONObject) -> JSONObject:
4123
+ return _pick_route_payload(attempt)
4124
+
4125
+ def _record_update_route_error_payload(
4126
+ self,
4127
+ exc: QingflowApiError,
4128
+ *,
4129
+ status: str,
4130
+ error_code: str,
4131
+ ) -> JSONObject:
4132
+ payload: JSONObject = {
4133
+ "status": status,
4134
+ "error_code": error_code,
4135
+ "message": exc.message,
4136
+ }
4137
+ if exc.backend_code is not None:
4138
+ payload["backend_code"] = exc.backend_code
4139
+ if exc.http_status is not None:
4140
+ payload["http_status"] = exc.http_status
4141
+ if exc.request_id is not None:
4142
+ payload["request_id"] = exc.request_id
4143
+ return payload
4144
+
4145
+ def _record_update_extract_api_error(self, exc: QingflowApiError | RuntimeError) -> QingflowApiError | None:
4146
+ if isinstance(exc, QingflowApiError):
4147
+ return exc
4148
+ try:
4149
+ payload = json.loads(str(exc))
4150
+ except json.JSONDecodeError:
4151
+ return None
4152
+ if not isinstance(payload, dict):
4153
+ return None
4154
+ return QingflowApiError(
4155
+ category=str(payload.get("category") or "backend"),
4156
+ message=str(payload.get("message") or exc),
4157
+ backend_code=payload.get("backend_code"),
4158
+ request_id=_normalize_optional_text(payload.get("request_id")),
4159
+ http_status=_coerce_count(payload.get("http_status")),
4160
+ details=cast(JSONObject | None, payload.get("details") if isinstance(payload.get("details"), dict) else None),
4161
+ )
4162
+
4163
+ def _record_update_route_permission_denied(self, exc: QingflowApiError) -> bool:
4164
+ if exc.backend_code in {40002, 40027, 40038, 404}:
4165
+ return True
4166
+ if exc.http_status == 404:
4167
+ return True
4168
+ return False
4169
+
4170
+ def _record_update_route_blocked_response(
4171
+ self,
4172
+ *,
4173
+ raw_preflight: JSONObject,
4174
+ operation: str,
4175
+ normalized_payload: JSONObject,
4176
+ output_profile: str,
4177
+ human_review: bool,
4178
+ app_key: str,
4179
+ record_id: int,
4180
+ tried_routes: list[JSONObject],
4181
+ route_blocker: JSONObject,
4182
+ ) -> JSONObject:
4183
+ plan_data = cast(JSONObject, raw_preflight.get("data", {}))
4184
+ validation = cast(JSONObject, plan_data.get("validation", {}))
4185
+ warnings_payload = validation.get("warnings", [])
4186
+ warnings = [{"code": "PREFLIGHT_WARNING", "message": str(item)} for item in warnings_payload] if isinstance(warnings_payload, list) else []
4187
+ warnings.append(
4188
+ {
4189
+ "code": cast(str, route_blocker.get("error_code") or "NO_AVAILABLE_UPDATE_ROUTE"),
4190
+ "message": cast(str, route_blocker.get("message") or "No update route could execute the write."),
4191
+ }
4192
+ )
4193
+ recommended = list(route_blocker.get("recommended_next_actions") or [])
4194
+ response: JSONObject = {
4195
+ "profile": raw_preflight.get("profile"),
4196
+ "ws_id": raw_preflight.get("ws_id"),
4197
+ "ok": False,
4198
+ "status": "blocked",
4199
+ "write_executed": False,
4200
+ "verification_status": "not_requested",
4201
+ "safe_to_retry": True,
4202
+ "request_route": raw_preflight.get("request_route"),
4203
+ "warnings": warnings,
4204
+ "output_profile": output_profile,
4205
+ "update_route": None,
4206
+ "tried_routes": tried_routes,
4207
+ "error_code": route_blocker.get("error_code"),
4208
+ "data": {
4209
+ "action": {"operation": operation, "executed": False},
4210
+ "resource": {"type": "record", "app_key": app_key, "record_id": stringify_backend_id(record_id), "record_ids": []},
4211
+ "verification": None,
4212
+ "normalized_payload": normalized_payload,
4213
+ "blockers": [route_blocker.get("error_code") or "NO_AVAILABLE_UPDATE_ROUTE"],
4214
+ "field_errors": [],
4215
+ "confirmation_requests": [],
4216
+ "resolved_fields": cast(list[JSONObject], plan_data.get("lookup_resolved_fields", [])),
4217
+ "recommended_next_actions": recommended,
4218
+ "human_review": self._record_write_human_review_payload(operation, enabled=human_review),
4219
+ "error": route_blocker,
4220
+ "update_route": None,
4221
+ "tried_routes": tried_routes,
4222
+ },
4223
+ }
4224
+ if output_profile == "verbose":
4225
+ response["data"]["debug"] = {"preflight": plan_data}
4226
+ return response
4227
+
4228
+ def _record_update_task_save_only_candidate(
4229
+ self,
4230
+ *,
4231
+ profile: str,
4232
+ app_key: str,
4233
+ record_id: int,
4234
+ normalized_answers: list[JSONObject],
4235
+ ) -> JSONObject:
4236
+ requested_question_ids = self._record_update_answer_question_ids(normalized_answers)
4237
+
4238
+ def unavailable(*, status: str = "skipped", error_code: str, reason: str, extra: JSONObject | None = None) -> JSONObject:
4239
+ attempt = self._record_update_route_attempt(
4240
+ route_type="task_save_only",
4241
+ endpoint_kind="workflow_node_save_only",
4242
+ status=status,
4243
+ error_code=error_code,
4244
+ reason=reason,
4245
+ )
4246
+ if extra:
4247
+ attempt.update(extra)
4248
+ return {"available": False, "attempt": attempt}
4249
+
4250
+ def runner(session_profile, context):
4251
+ matches: list[JSONObject] = []
4252
+ pages_scanned = 0
4253
+ for page_num in range(1, VERIFY_TASK_FALLBACK_MAX_PAGES + 1):
4254
+ try:
4255
+ task_page = self.backend.request(
4256
+ "POST",
4257
+ context,
4258
+ "/task/dynamic/page",
4259
+ json_body={
4260
+ "type": 1,
4261
+ "processStatus": 1,
4262
+ "appKey": app_key,
4263
+ "pageNum": page_num,
4264
+ "pageSize": VERIFY_TASK_FALLBACK_PAGE_SIZE,
4265
+ },
4266
+ )
4267
+ except QingflowApiError as exc:
4268
+ return unavailable(
4269
+ error_code="TASK_UPDATE_ROUTE_NOT_AVAILABLE",
4270
+ reason="current-user todo task list is unavailable",
4271
+ extra=self._record_update_route_error_payload(
4272
+ exc,
4273
+ status="skipped",
4274
+ error_code="TASK_UPDATE_ROUTE_NOT_AVAILABLE",
4275
+ ),
4276
+ )
4277
+ pages_scanned += 1
4278
+ rows = task_page.get("list") if isinstance(task_page, dict) else None
4279
+ items = [item for item in rows if isinstance(item, dict)] if isinstance(rows, list) else []
4280
+ for item in items:
4281
+ candidate_record_id = _coerce_count(item.get("rowRecordId") or item.get("recordId") or item.get("applyId"))
4282
+ if candidate_record_id == record_id:
4283
+ matches.append(dict(item))
4284
+ if not _page_has_more(cast(JSONObject, task_page if isinstance(task_page, dict) else {}), page_num, VERIFY_TASK_FALLBACK_PAGE_SIZE, len(items)):
4285
+ break
4286
+
4287
+ if not matches:
4288
+ return unavailable(
4289
+ error_code="TASK_UPDATE_ROUTE_NOT_AVAILABLE",
4290
+ reason="no current-user todo task was found for this record",
4291
+ extra={"pages_scanned": pages_scanned},
4292
+ )
4293
+ if len(matches) > 1:
4294
+ return unavailable(
4295
+ error_code="TASK_UPDATE_ROUTE_AMBIGUOUS",
4296
+ reason="multiple current-user todo tasks match this record; refusing to guess workflow context",
4297
+ extra={"matched_tasks": [self._record_update_compact_task_match(item) for item in matches[:5]]},
4298
+ )
4299
+
4300
+ task = matches[0]
4301
+ workflow_node_id = _coerce_count(task.get("nodeId") or task.get("auditNodeId"))
4302
+ if workflow_node_id is None:
4303
+ return unavailable(
4304
+ error_code="TASK_UPDATE_ROUTE_NOT_AVAILABLE",
4305
+ reason="matched todo task does not expose a workflow node id",
4306
+ extra={"matched_task": self._record_update_compact_task_match(task)},
4307
+ )
4308
+ try:
4309
+ editable_payload = self.backend.request(
4310
+ "GET",
4311
+ context,
4312
+ f"/app/{app_key}/auditNode/{workflow_node_id}/editableQueIds",
4313
+ )
4314
+ except QingflowApiError as exc:
4315
+ return unavailable(
4316
+ error_code="TASK_EDITABLE_FIELDS_UNAVAILABLE",
4317
+ reason="workflow node editable field list is unavailable; record_update will not guess task editability",
4318
+ extra=self._record_update_route_error_payload(
4319
+ exc,
4320
+ status="skipped",
4321
+ error_code="TASK_EDITABLE_FIELDS_UNAVAILABLE",
4322
+ ),
4323
+ )
4324
+ editable_question_ids = self._record_update_extract_question_ids(editable_payload)
4325
+ if not editable_question_ids:
4326
+ return unavailable(
4327
+ error_code="TASK_UPDATE_ROUTE_NOT_AVAILABLE",
4328
+ reason="workflow node editable field list is empty",
4329
+ extra={
4330
+ "task_id": stringify_backend_id(task.get("id") or task.get("taskId")),
4331
+ "workflow_node_id": workflow_node_id,
4332
+ },
4333
+ )
4334
+ effective_editable_question_ids = self._record_update_effective_task_editable_ids(
4335
+ editable_question_ids,
4336
+ normalized_answers=normalized_answers,
4337
+ )
4338
+ non_editable = sorted(
4339
+ question_id for question_id in requested_question_ids
4340
+ if question_id not in effective_editable_question_ids
4341
+ )
4342
+ if non_editable:
4343
+ return unavailable(
4344
+ status="denied",
4345
+ error_code="TASK_UPDATE_FIELD_NOT_EDITABLE",
4346
+ reason="one or more requested fields are not editable on the current workflow node",
4347
+ extra={
4348
+ "task_id": stringify_backend_id(task.get("id") or task.get("taskId")),
4349
+ "workflow_node_id": workflow_node_id,
4350
+ "non_editable_question_ids": non_editable,
4351
+ },
4352
+ )
4353
+ return {
4354
+ "available": True,
4355
+ "task_id": stringify_backend_id(task.get("id") or task.get("taskId")),
4356
+ "workflow_node_id": workflow_node_id,
4357
+ "matched_task": self._record_update_compact_task_match(task),
4358
+ "editable_question_ids": sorted(editable_question_ids),
4359
+ "effective_editable_question_ids": sorted(effective_editable_question_ids),
4360
+ }
4361
+
4362
+ return self._run_record_tool(profile, runner)
4363
+
4364
+ def _record_update_compact_task_match(self, item: JSONObject) -> JSONObject:
4365
+ return {
4366
+ key: value
4367
+ for key, value in {
4368
+ "task_id": stringify_backend_id(item.get("id") or item.get("taskId")),
4369
+ "record_id": stringify_backend_id(item.get("rowRecordId") or item.get("recordId") or item.get("applyId")),
4370
+ "workflow_node_id": item.get("nodeId") or item.get("auditNodeId"),
4371
+ "workflow_node_name": item.get("nodeName") or item.get("auditNodeName"),
4372
+ }.items()
4373
+ if value not in (None, "", [], {})
4374
+ }
4375
+
4376
+ def _record_update_answer_question_ids(self, answers: list[JSONObject]) -> set[int]:
4377
+ question_ids: set[int] = set()
4378
+ for answer in answers:
4379
+ if not isinstance(answer, dict):
4380
+ continue
4381
+ que_id = _coerce_count(answer.get("queId"))
4382
+ if que_id is not None and que_id > 0:
4383
+ question_ids.add(que_id)
4384
+ table_values = answer.get("tableValues")
4385
+ if not isinstance(table_values, list):
4386
+ continue
4387
+ for row in table_values:
4388
+ if not isinstance(row, list):
4389
+ continue
4390
+ for cell in row:
4391
+ if not isinstance(cell, dict):
4392
+ continue
4393
+ cell_que_id = _coerce_count(cell.get("queId"))
4394
+ if cell_que_id is not None and cell_que_id > 0:
4395
+ question_ids.add(cell_que_id)
4396
+ return question_ids
4397
+
4398
+ def _record_update_extract_question_ids(self, payload: JSONValue) -> set[int]:
4399
+ candidates: list[Any] = []
4400
+ if isinstance(payload, list):
4401
+ candidates = payload
4402
+ elif isinstance(payload, dict):
4403
+ for key in ("editableQueIds", "editableQuestionIds", "queIds", "questionIds", "ids", "list", "result"):
4404
+ value = payload.get(key)
4405
+ if isinstance(value, list):
4406
+ candidates = value
4407
+ break
4408
+ question_ids: set[int] = set()
4409
+ for item in candidates:
4410
+ value: Any = item
4411
+ if isinstance(item, dict):
4412
+ value = item.get("queId", item.get("questionId", item.get("id")))
4413
+ que_id = _coerce_count(value)
4414
+ if que_id is not None and que_id > 0:
4415
+ question_ids.add(que_id)
4416
+ return question_ids
4417
+
4418
+ def _record_update_effective_task_editable_ids(
4419
+ self,
4420
+ editable_question_ids: set[int],
4421
+ *,
4422
+ normalized_answers: list[JSONObject],
4423
+ ) -> set[int]:
4424
+ effective_editable_ids = set(editable_question_ids)
4425
+ for answer in normalized_answers:
4426
+ if not isinstance(answer, dict):
4427
+ continue
4428
+ parent_que_id = _coerce_count(answer.get("queId"))
4429
+ if parent_que_id is None or parent_que_id <= 0:
4430
+ continue
4431
+ table_values = answer.get("tableValues")
4432
+ if not isinstance(table_values, list) or not table_values:
4433
+ continue
4434
+ row_subfield_ids: set[int] = set()
4435
+ for row in table_values:
4436
+ if not isinstance(row, list):
4437
+ continue
4438
+ for cell in row:
4439
+ if not isinstance(cell, dict):
4440
+ continue
4441
+ cell_que_id = _coerce_count(cell.get("queId"))
4442
+ if cell_que_id is not None and cell_que_id > 0:
4443
+ row_subfield_ids.add(cell_que_id)
4444
+ if row_subfield_ids & editable_question_ids:
4445
+ effective_editable_ids.add(parent_que_id)
4446
+ return effective_editable_ids
4447
+
4448
+ def _record_update_via_custom_view(
4449
+ self,
4450
+ *,
4451
+ profile: str,
3489
4452
  app_key: str,
3490
- record_id: Any | None,
3491
- fields: JSONObject | None = None,
3492
- items: list[JSONObject] | None = None,
3493
- dry_run: bool = False,
3494
- verify_write: bool = True,
3495
- output_profile: str = "normal",
4453
+ apply_id: int,
4454
+ view_key: str,
4455
+ answers: list[JSONObject],
4456
+ verify_write: bool,
4457
+ force_refresh_form: bool,
3496
4458
  ) -> JSONObject:
3497
- """执行记录相关逻辑。"""
3498
- normalized_output_profile = self._normalize_public_output_profile(output_profile)
3499
- if not app_key:
3500
- raise_tool_error(QingflowApiError.config_error("app_key is required"))
3501
- if items is not None:
3502
- if dry_run not in {True, False}:
3503
- raise_tool_error(QingflowApiError.config_error("dry_run must be boolean"))
3504
- normalized_items = self._normalize_public_record_update_batch_items(
3505
- record_id=record_id,
3506
- fields=fields,
3507
- items=items,
4459
+ normalized_apply_id = self._validate_app_and_record(app_key, apply_id)
4460
+ normalized_view_key = view_key.strip()
4461
+ if not normalized_view_key:
4462
+ raise_tool_error(QingflowApiError.config_error("view_key is required for custom view update"))
4463
+
4464
+ def runner(session_profile, context):
4465
+ index = self._get_field_index(profile, context, app_key, force_refresh=force_refresh_form) if verify_write else None
4466
+ normalized_answers = [dict(item) for item in answers if isinstance(item, dict)]
4467
+ self._validate_record_write(app_key, normalized_answers, apply_id=normalized_apply_id)
4468
+ result = self.backend.request(
4469
+ "POST",
4470
+ context,
4471
+ f"/view/{normalized_view_key}/apply/{normalized_apply_id}",
4472
+ json_body={"answers": normalized_answers},
3508
4473
  )
3509
- return self._record_update_public_batch(
3510
- profile=profile,
4474
+ verification = self._verify_record_write_result(
4475
+ context,
3511
4476
  app_key=app_key,
3512
- items=normalized_items,
3513
- dry_run=dry_run,
3514
- verify_write=verify_write,
3515
- output_profile=normalized_output_profile,
4477
+ apply_id=normalized_apply_id,
4478
+ normalized_answers=normalized_answers,
4479
+ index=cast(FieldIndex, index),
4480
+ verify_view_key=normalized_view_key,
4481
+ ) if verify_write and index is not None else None
4482
+ verified = True if verification is None else bool(verification.get("verified"))
4483
+ return self._attach_human_review_notice(
4484
+ {
4485
+ "profile": profile,
4486
+ "ws_id": session_profile.selected_ws_id,
4487
+ "request_route": self._request_route_payload(context),
4488
+ "app_key": app_key,
4489
+ "apply_id": normalized_apply_id,
4490
+ "record_id": normalized_apply_id,
4491
+ "result": result,
4492
+ "normalized_answers": normalized_answers,
4493
+ "status": "completed" if verified else "verification_failed",
4494
+ "ok": True,
4495
+ "verify_write": verify_write,
4496
+ "write_verified": verified if verify_write else None,
4497
+ "verification": verification,
4498
+ "resource": _record_resource_payload(normalized_apply_id),
4499
+ },
4500
+ operation="update",
4501
+ target="record data",
3516
4502
  )
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
- )
3532
4503
 
3533
- def _record_update_public_single(
4504
+ return self._run_record_tool(profile, runner)
4505
+
4506
+ def _record_update_via_task_save_only(
3534
4507
  self,
3535
4508
  *,
3536
4509
  profile: str,
3537
4510
  app_key: str,
3538
- record_id: int,
3539
- fields: JSONObject,
4511
+ apply_id: int,
4512
+ workflow_node_id: int,
4513
+ answers: list[JSONObject],
3540
4514
  verify_write: bool,
3541
- output_profile: str,
4515
+ force_refresh_form: bool,
3542
4516
  ) -> 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": []},
4517
+ normalized_apply_id = self._validate_app_and_record(app_key, apply_id)
4518
+ if workflow_node_id <= 0:
4519
+ raise_tool_error(QingflowApiError.config_error("workflow_node_id must be positive for task save-only update"))
4520
+
4521
+ def runner(session_profile, context):
4522
+ index = self._get_field_index(profile, context, app_key, force_refresh=force_refresh_form) if verify_write else None
4523
+ normalized_answers = [dict(item) for item in answers if isinstance(item, dict)]
4524
+ self._validate_record_write(app_key, normalized_answers, apply_id=normalized_apply_id)
4525
+ result = self.backend.request(
4526
+ "POST",
4527
+ context,
4528
+ f"/app/{app_key}/apply/{normalized_apply_id}",
4529
+ json_body={"role": 3, "auditNodeId": workflow_node_id, "answers": normalized_answers},
3569
4530
  )
3570
- try:
3571
- raw_apply = self.record_update(
3572
- profile=profile,
4531
+ verification = self._verify_record_write_result(
4532
+ context,
3573
4533
  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,
4534
+ apply_id=normalized_apply_id,
4535
+ normalized_answers=normalized_answers,
4536
+ index=cast(FieldIndex, index),
4537
+ ) if verify_write and index is not None else None
4538
+ verified = True if verification is None else bool(verification.get("verified"))
4539
+ return self._attach_human_review_notice(
4540
+ {
4541
+ "profile": profile,
4542
+ "ws_id": session_profile.selected_ws_id,
4543
+ "request_route": self._request_route_payload(context),
4544
+ "app_key": app_key,
4545
+ "apply_id": normalized_apply_id,
4546
+ "record_id": normalized_apply_id,
4547
+ "result": result,
4548
+ "normalized_answers": normalized_answers,
4549
+ "status": "completed" if verified else "verification_failed",
4550
+ "ok": True,
4551
+ "verify_write": verify_write,
4552
+ "write_verified": verified if verify_write else None,
4553
+ "verification": verification,
4554
+ "resource": _record_resource_payload(normalized_apply_id),
4555
+ },
3584
4556
  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),
4557
+ target="record data",
3588
4558
  )
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
- )
4559
+
4560
+ return self._run_record_tool(profile, runner)
3598
4561
 
3599
4562
  def _record_update_public_batch(
3600
4563
  self,
@@ -3893,6 +4856,12 @@ class RecordTools(ToolBase):
3893
4856
  "confirmation_requests": cast(list[JSONObject], data.get("confirmation_requests", [])),
3894
4857
  "resolved_fields": cast(list[JSONObject], data.get("resolved_fields", [])),
3895
4858
  }
4859
+ update_route = response.get("update_route")
4860
+ if isinstance(update_route, dict):
4861
+ item["update_route"] = update_route
4862
+ tried_routes = response.get("tried_routes")
4863
+ if isinstance(tried_routes, list):
4864
+ item["tried_routes"] = tried_routes
3896
4865
  blockers = data.get("blockers")
3897
4866
  if isinstance(blockers, list) and blockers:
3898
4867
  item["blockers"] = blockers
@@ -10812,6 +11781,8 @@ class RecordTools(ToolBase):
10812
11781
  response_status = "verification_failed" if verification_status == "failed" else "success"
10813
11782
  if not bool(raw_apply.get("ok", True)) and verification_status != "failed":
10814
11783
  response_status = raw_status or "failed"
11784
+ update_route = raw_apply.get("update_route") if isinstance(raw_apply.get("update_route"), dict) else None
11785
+ tried_routes = raw_apply.get("tried_routes") if isinstance(raw_apply.get("tried_routes"), list) else []
10815
11786
  response: JSONObject = {
10816
11787
  "profile": raw_apply.get("profile"),
10817
11788
  "ws_id": raw_apply.get("ws_id"),
@@ -10823,6 +11794,8 @@ class RecordTools(ToolBase):
10823
11794
  "request_route": raw_apply.get("request_route"),
10824
11795
  "warnings": warnings,
10825
11796
  "output_profile": output_profile,
11797
+ "update_route": update_route,
11798
+ "tried_routes": tried_routes,
10826
11799
  "data": {
10827
11800
  "action": {"operation": operation, "executed": True},
10828
11801
  "resource": resource,
@@ -10833,6 +11806,8 @@ class RecordTools(ToolBase):
10833
11806
  "confirmation_requests": [],
10834
11807
  "resolved_fields": resolved_fields,
10835
11808
  "human_review": self._record_write_human_review_payload(operation, enabled=human_review),
11809
+ "update_route": update_route,
11810
+ "tried_routes": tried_routes,
10836
11811
  },
10837
11812
  }
10838
11813
  if record_id is not None:
@@ -12404,6 +13379,7 @@ class RecordTools(ToolBase):
12404
13379
  normalized_answers: list[JSONObject],
12405
13380
  index: FieldIndex,
12406
13381
  verify_list_type: int = DEFAULT_RECORD_LIST_TYPE,
13382
+ verify_view_key: str | None = None,
12407
13383
  ) -> JSONObject:
12408
13384
  """执行内部辅助逻辑。"""
12409
13385
  if apply_id is None:
@@ -12415,13 +13391,36 @@ class RecordTools(ToolBase):
12415
13391
  "count_mismatches": [],
12416
13392
  }
12417
13393
  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
- )
13394
+ if verify_view_key:
13395
+ record = self.backend.request(
13396
+ "GET",
13397
+ context,
13398
+ f"/view/{verify_view_key}/apply/{apply_id}",
13399
+ )
13400
+ else:
13401
+ record = self.backend.request(
13402
+ "GET",
13403
+ context,
13404
+ f"/app/{app_key}/apply/{apply_id}",
13405
+ params={"role": 1, "listType": verify_list_type},
13406
+ )
12424
13407
  except QingflowApiError as exc:
13408
+ if verify_view_key:
13409
+ return {
13410
+ "verified": False,
13411
+ "verification_mode": "custom_view_record_detail",
13412
+ "field_level_verified": False,
13413
+ "error": "custom_view_readback_failed",
13414
+ "missing_fields": [],
13415
+ "empty_fields": [],
13416
+ "count_mismatches": [],
13417
+ "warnings": [{
13418
+ "code": "WRITE_VERIFY_CUSTOM_VIEW_READBACK_FAILED",
13419
+ "message": "Write was sent through a custom view route, but the same view could not be re-read for field-level verification.",
13420
+ "backend_code": exc.backend_code,
13421
+ "http_status": exc.http_status,
13422
+ }],
13423
+ }
12425
13424
  if exc.backend_code != 40002:
12426
13425
  raise
12427
13426
  return self._verify_record_write_result_via_initiated_tasks(
@@ -12485,7 +13484,7 @@ class RecordTools(ToolBase):
12485
13484
  )
12486
13485
  return {
12487
13486
  "verified": not missing_fields and not empty_fields and not count_mismatches,
12488
- "verification_mode": "initiated_record_view",
13487
+ "verification_mode": "custom_view_record_detail" if verify_view_key else "initiated_record_view",
12489
13488
  "field_level_verified": True,
12490
13489
  "missing_fields": missing_fields,
12491
13490
  "empty_fields": empty_fields,
@@ -13397,6 +14396,13 @@ def _record_access_run_dir() -> Path:
13397
14396
  return base_dir / run_id
13398
14397
 
13399
14398
 
14399
+ def _record_logs_run_dir() -> Path:
14400
+ custom_home = os.getenv("QINGFLOW_MCP_RECORD_LOGS_HOME")
14401
+ base_dir = Path(custom_home).expanduser() if custom_home else get_mcp_home() / "record-logs"
14402
+ run_id = f"{datetime.now(UTC).strftime('%Y%m%dT%H%M%SZ')}-{uuid4().hex[:8]}"
14403
+ return base_dir / run_id
14404
+
14405
+
13400
14406
  def _record_access_field_payload(field: FormField) -> JSONObject:
13401
14407
  return {
13402
14408
  "field_id": field.que_id,
@@ -14165,6 +15171,159 @@ def _record_detail_log_unavailable_payload(source: str, reason: str) -> JSONObje
14165
15171
  }
14166
15172
 
14167
15173
 
15174
+ def _record_logs_hidden_payload(source: str) -> JSONObject:
15175
+ return {
15176
+ "status": "hidden",
15177
+ "visible": False,
15178
+ "source": source,
15179
+ "complete": False,
15180
+ "items_count": 0,
15181
+ "pages_fetched": 0,
15182
+ "reported_total": None,
15183
+ "local_path": None,
15184
+ "preview_items": [],
15185
+ "warnings": [],
15186
+ }
15187
+
15188
+
15189
+ def _record_logs_unavailable_payload(source: str, reason: str) -> JSONObject:
15190
+ return {
15191
+ "status": "unavailable",
15192
+ "visible": None,
15193
+ "source": source,
15194
+ "reason": reason,
15195
+ "complete": False,
15196
+ "items_count": 0,
15197
+ "pages_fetched": 0,
15198
+ "reported_total": None,
15199
+ "local_path": None,
15200
+ "preview_items": [],
15201
+ "warnings": [],
15202
+ }
15203
+
15204
+
15205
+ def _record_logs_fetch_all_to_jsonl(
15206
+ *,
15207
+ fetch_page,
15208
+ normalizer,
15209
+ source: str,
15210
+ file_path: Path,
15211
+ deadline: float,
15212
+ ) -> JSONObject: # type: ignore[no-untyped-def]
15213
+ file_path.parent.mkdir(parents=True, exist_ok=True)
15214
+ page_num = 1
15215
+ pages_fetched = 0
15216
+ items_count = 0
15217
+ reported_total: int | None = None
15218
+ preview_items: list[JSONObject] = []
15219
+ warnings: list[JSONObject] = []
15220
+ stopped_reason: str | None = None
15221
+ complete = True
15222
+
15223
+ with file_path.open("w", encoding="utf-8") as handle:
15224
+ while True:
15225
+ if _record_logs_time_budget_exceeded(deadline=deadline):
15226
+ complete = False
15227
+ stopped_reason = "time_budget_exceeded"
15228
+ warnings.append(_record_logs_time_budget_warning(source=source, pages_fetched=pages_fetched, items_count=items_count))
15229
+ break
15230
+ payload = fetch_page(page_num)
15231
+ pages_fetched += 1
15232
+ items = _record_detail_page_items(payload)
15233
+ if reported_total is None:
15234
+ reported_total = _record_detail_page_total(payload)
15235
+ if not items:
15236
+ break
15237
+ for item in items:
15238
+ normalized = normalizer(item)
15239
+ handle.write(json.dumps(normalized, ensure_ascii=False) + "\n")
15240
+ items_count += 1
15241
+ if len(preview_items) < RECORD_LOGS_PREVIEW_LIMIT:
15242
+ preview_items.append(normalized)
15243
+ if items_count >= RECORD_LOGS_MAX_ITEMS:
15244
+ complete = False
15245
+ stopped_reason = "item_limit_exceeded"
15246
+ warnings.append(_record_logs_item_limit_warning(source=source, item_limit=RECORD_LOGS_MAX_ITEMS))
15247
+ break
15248
+ if stopped_reason:
15249
+ break
15250
+ if reported_total is not None and items_count >= reported_total:
15251
+ break
15252
+ if reported_total is None and len(items) < RECORD_LOGS_PAGE_SIZE:
15253
+ break
15254
+ page_num += 1
15255
+
15256
+ return {
15257
+ "status": "ok" if complete else "partial",
15258
+ "visible": True,
15259
+ "source": source,
15260
+ "complete": complete,
15261
+ "items_count": items_count,
15262
+ "pages_fetched": pages_fetched,
15263
+ "page_size": RECORD_LOGS_PAGE_SIZE,
15264
+ "reported_total": reported_total,
15265
+ "local_path": str(file_path),
15266
+ "preview_items": preview_items,
15267
+ "warnings": warnings,
15268
+ "stopped_reason": stopped_reason,
15269
+ }
15270
+
15271
+
15272
+ def _record_logs_time_budget_exceeded(*, deadline: float) -> bool:
15273
+ return time.monotonic() + RECORD_LOGS_MIN_REMAINING_SECONDS >= deadline
15274
+
15275
+
15276
+ def _record_logs_time_budget_warning(*, source: str, pages_fetched: int, items_count: int) -> JSONObject:
15277
+ return {
15278
+ "code": "RECORD_LOGS_TIME_BUDGET_EXCEEDED",
15279
+ "source": source,
15280
+ "message": "record_logs_get stopped early to return partial JSONL files before the caller timeout.",
15281
+ "pages_fetched": pages_fetched,
15282
+ "items_count": items_count,
15283
+ }
15284
+
15285
+
15286
+ def _record_logs_item_limit_warning(*, source: str, item_limit: int) -> JSONObject:
15287
+ return {
15288
+ "code": "RECORD_LOGS_ITEM_LIMIT_EXCEEDED",
15289
+ "source": source,
15290
+ "message": f"record_logs_get stopped after the internal {item_limit} item limit.",
15291
+ "item_limit": item_limit,
15292
+ }
15293
+
15294
+
15295
+ def _record_logs_overall_status(*, data_logs: JSONObject, workflow_logs: JSONObject) -> str:
15296
+ statuses = {str(data_logs.get("status") or ""), str(workflow_logs.get("status") or "")}
15297
+ if statuses == {"unavailable"}:
15298
+ return "unavailable"
15299
+ if "partial" in statuses or "unavailable" in statuses:
15300
+ return "partial"
15301
+ return "success"
15302
+
15303
+
15304
+ def _record_logs_context_integrity(*, data_logs: JSONObject, workflow_logs: JSONObject) -> JSONObject:
15305
+ data_integrity = _record_logs_section_integrity(data_logs)
15306
+ workflow_integrity = _record_logs_section_integrity(workflow_logs)
15307
+ return {
15308
+ "data_logs": data_integrity,
15309
+ "workflow_logs": workflow_integrity,
15310
+ "safe_for_full_log_conclusion": data_integrity == "full" and workflow_integrity == "full",
15311
+ }
15312
+
15313
+
15314
+ def _record_logs_section_integrity(section: JSONObject) -> str:
15315
+ status = str(section.get("status") or "")
15316
+ if status == "ok" and section.get("complete") is True:
15317
+ return "full"
15318
+ if status == "hidden":
15319
+ return "hidden"
15320
+ if status == "partial":
15321
+ return "partial"
15322
+ if status == "unavailable":
15323
+ return "unavailable"
15324
+ return "unknown"
15325
+
15326
+
14168
15327
  def _record_detail_log_page_payload(payload: JSONValue, *, normalizer, source: str) -> JSONObject: # type: ignore[no-untyped-def]
14169
15328
  items = _record_detail_page_items(payload)
14170
15329
  total = _record_detail_page_total(payload)