@josephyan/qingflow-cli 0.2.0-beta.75 → 0.2.0-beta.77

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.
package/README.md CHANGED
@@ -3,13 +3,13 @@
3
3
  Install:
4
4
 
5
5
  ```bash
6
- npm install @josephyan/qingflow-cli@0.2.0-beta.75
6
+ npm install @josephyan/qingflow-cli@0.2.0-beta.77
7
7
  ```
8
8
 
9
9
  Run:
10
10
 
11
11
  ```bash
12
- npx -y -p @josephyan/qingflow-cli@0.2.0-beta.75 qingflow
12
+ npx -y -p @josephyan/qingflow-cli@0.2.0-beta.77 qingflow
13
13
  ```
14
14
 
15
15
  Environment:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@josephyan/qingflow-cli",
3
- "version": "0.2.0-beta.75",
3
+ "version": "0.2.0-beta.77",
4
4
  "description": "Human-friendly Qingflow command line interface for auth, record operations, import, tasks, and stable builder flows.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "qingflow-mcp"
7
- version = "0.2.0b75"
7
+ version = "0.2.0b76"
8
8
  description = "User-authenticated MCP server for Qingflow"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -3530,6 +3530,12 @@ class AiBuilderFacade:
3530
3530
  app_name=str(resolved["app_name"]),
3531
3531
  tag_ids=_coerce_int_list(resolved.get("tag_ids")),
3532
3532
  )
3533
+ requested_app_name = str(app_name or "").strip() or None
3534
+ requested_rename = requested_app_name if app_key else None
3535
+ requested_icon = str(icon or "").strip() or None
3536
+ requested_color = str(color or "").strip() or None
3537
+ requested_app_base_update = bool(requested_rename or requested_icon or requested_color)
3538
+ effective_app_name = target.app_name
3533
3539
  if not bool(resolved.get("created")):
3534
3540
  permission_outcome = self._guard_app_permission(
3535
3541
  profile=profile,
@@ -3540,21 +3546,35 @@ class AiBuilderFacade:
3540
3546
  if permission_outcome.block is not None:
3541
3547
  return permission_outcome.block
3542
3548
  permission_outcomes.append(permission_outcome)
3543
- visual_result = self._ensure_app_base_visuals(
3544
- profile=profile,
3545
- app_key=target.app_key,
3546
- fallback_title=target.app_name,
3547
- icon=icon,
3548
- color=color,
3549
- normalized_args=normalized_args,
3550
- )
3551
- if visual_result.get("status") == "failed":
3552
- return finalize(visual_result)
3549
+ if requested_app_base_update:
3550
+ visual_result = self._ensure_app_base_visuals(
3551
+ profile=profile,
3552
+ app_key=target.app_key,
3553
+ fallback_title=target.app_name,
3554
+ app_name=requested_rename,
3555
+ icon=requested_icon,
3556
+ color=requested_color,
3557
+ normalized_args=normalized_args,
3558
+ )
3559
+ if visual_result.get("status") == "failed":
3560
+ return finalize(visual_result)
3561
+ effective_app_name = str(visual_result.get("app_name_after") or target.app_name).strip() or target.app_name
3562
+ else:
3563
+ visual_result = {
3564
+ "status": "success",
3565
+ "updated": False,
3566
+ "app_icon": None,
3567
+ "app_name_before": target.app_name,
3568
+ "app_name_after": target.app_name,
3569
+ "request_id": None,
3570
+ }
3553
3571
  else:
3554
3572
  visual_result = {
3555
3573
  "status": "success",
3556
3574
  "updated": False,
3557
3575
  "app_icon": str(resolved.get("app_icon") or "").strip() or None,
3576
+ "app_name_before": target.app_name,
3577
+ "app_name_after": target.app_name,
3558
3578
  "request_id": None,
3559
3579
  }
3560
3580
  if bool(resolved.get("created")) and not requested_field_changes:
@@ -3576,10 +3596,14 @@ class AiBuilderFacade:
3576
3596
  "package_attached": None if package_tag_id is None else package_tag_id in target.tag_ids,
3577
3597
  "relation_field_limit_verified": True,
3578
3598
  "app_visuals_verified": True,
3599
+ "app_base_verified": True,
3579
3600
  "publish_skipped": True,
3580
3601
  },
3581
3602
  "app_key": target.app_key,
3582
3603
  "app_icon": str(resolved.get("app_icon") or visual_result.get("app_icon") or "").strip() or None,
3604
+ "app_name_before": str(visual_result.get("app_name_before") or target.app_name),
3605
+ "app_name_after": str(visual_result.get("app_name_after") or target.app_name),
3606
+ "app_base_updated": bool(visual_result.get("updated")),
3583
3607
  "created": True,
3584
3608
  "field_diff": {"added": [], "updated": [], "removed": []},
3585
3609
  "verified": True,
@@ -3602,7 +3626,7 @@ class AiBuilderFacade:
3602
3626
  details=_with_state_read_blocked_details({"app_key": target.app_key}, resource="schema", error=api_error),
3603
3627
  suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": target.app_key}},
3604
3628
  ))
3605
- schema_result = _empty_schema_result(target.app_name)
3629
+ schema_result = _empty_schema_result(effective_app_name)
3606
3630
  _schema_source = "synthetic_new_app"
3607
3631
  schema_readback_delayed = True
3608
3632
  parsed = _parse_schema(schema_result)
@@ -3759,13 +3783,19 @@ class AiBuilderFacade:
3759
3783
  )
3760
3784
 
3761
3785
  if not added and not updated and not removed and not normalized_code_block_fields and not bool(resolved.get("created")):
3762
- tag_ids_after = _coerce_int_list((self.apps.app_get_base(profile=profile, app_key=target.app_key, include_raw=True).get("result") or {}).get("tagIds"))
3786
+ base_info = self.apps.app_get_base(profile=profile, app_key=target.app_key, include_raw=True).get("result") or {}
3787
+ tag_ids_after = _coerce_int_list(base_info.get("tagIds"))
3763
3788
  package_attached = None if package_tag_id is None else package_tag_id in tag_ids_after
3789
+ actual_app_name = str(base_info.get("formTitle") or effective_app_name).strip() or effective_app_name
3790
+ actual_app_icon = str(base_info.get("appIcon") or visual_result.get("app_icon") or "").strip() or None
3791
+ expected_app_icon = str(visual_result.get("app_icon") or "").strip() or None
3792
+ app_base_verified = actual_app_name == effective_app_name and actual_app_icon == expected_app_icon
3793
+ verified = app_base_verified and relation_target_metadata_verified
3764
3794
  response = {
3765
- "status": "success",
3766
- "error_code": None,
3767
- "recoverable": False,
3768
- "message": "updated app visuals; schema already matches requested state" if bool(visual_result.get("updated")) else "schema already matches requested state",
3795
+ "status": "success" if verified else "partial_success",
3796
+ "error_code": None if verified else "APP_BASE_READBACK_PENDING",
3797
+ "recoverable": not verified,
3798
+ "message": "updated app base metadata; schema already matches requested state" if bool(visual_result.get("updated")) else "schema already matches requested state",
3769
3799
  "normalized_args": normalized_args,
3770
3800
  "missing_fields": [],
3771
3801
  "allowed_values": {"field_types": [item.value for item in PublicFieldType]},
@@ -3777,14 +3807,18 @@ class AiBuilderFacade:
3777
3807
  "verification": {
3778
3808
  "fields_verified": True,
3779
3809
  "relation_field_limit_verified": relation_limit_verified,
3780
- "app_visuals_verified": True,
3810
+ "app_visuals_verified": app_base_verified,
3811
+ "app_base_verified": app_base_verified,
3781
3812
  "relation_target_metadata_verified": relation_target_metadata_verified,
3782
3813
  },
3783
3814
  "app_key": target.app_key,
3784
3815
  "app_icon": str(visual_result.get("app_icon") or "").strip() or None,
3816
+ "app_name_before": str(visual_result.get("app_name_before") or target.app_name),
3817
+ "app_name_after": effective_app_name,
3818
+ "app_base_updated": bool(visual_result.get("updated")),
3785
3819
  "created": False,
3786
3820
  "field_diff": {"added": [], "updated": [], "removed": []},
3787
- "verified": True,
3821
+ "verified": verified,
3788
3822
  "tag_ids_after": tag_ids_after,
3789
3823
  "package_attached": package_attached,
3790
3824
  }
@@ -3796,7 +3830,7 @@ class AiBuilderFacade:
3796
3830
  return finalize(self._append_publish_result(profile=profile, app_key=target.app_key, publish=publish, response=response))
3797
3831
 
3798
3832
  payload = _build_form_payload_from_fields(
3799
- title=schema_result.get("formTitle") or target.app_name,
3833
+ title=effective_app_name,
3800
3834
  current_schema=schema_result,
3801
3835
  fields=current_fields,
3802
3836
  layout=layout,
@@ -3904,7 +3938,7 @@ class AiBuilderFacade:
3904
3938
  suggested_next_call={"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": target.app_key}},
3905
3939
  )
3906
3940
  rebound_payload = _build_form_payload_from_fields(
3907
- title=rebound_schema.get("formTitle") or target.app_name,
3941
+ title=effective_app_name,
3908
3942
  current_schema=rebound_schema,
3909
3943
  fields=rebound_fields,
3910
3944
  layout=rebound_layout,
@@ -3948,11 +3982,15 @@ class AiBuilderFacade:
3948
3982
  "fields_verified": False,
3949
3983
  "package_attached": None,
3950
3984
  "app_visuals_verified": True,
3985
+ "app_base_verified": True,
3951
3986
  "relation_field_limit_verified": relation_limit_verified,
3952
3987
  "relation_target_metadata_verified": relation_target_metadata_verified,
3953
3988
  },
3954
3989
  "app_key": target.app_key,
3955
3990
  "app_icon": str(visual_result.get("app_icon") or resolved.get("app_icon") or "").strip() or None,
3991
+ "app_name_before": str(visual_result.get("app_name_before") or target.app_name),
3992
+ "app_name_after": effective_app_name,
3993
+ "app_base_updated": bool(visual_result.get("updated")),
3956
3994
  "created": bool(resolved.get("created")),
3957
3995
  "field_diff": {
3958
3996
  "added": added,
@@ -3999,16 +4037,23 @@ class AiBuilderFacade:
3999
4037
  base_info = self.apps.app_get_base(profile=profile, app_key=target.app_key, include_raw=True).get("result") or {}
4000
4038
  tag_ids_after = _coerce_int_list(base_info.get("tagIds"))
4001
4039
  package_attached = None if package_tag_id is None else package_tag_id in tag_ids_after
4040
+ actual_app_name = str(base_info.get("formTitle") or effective_app_name).strip() or effective_app_name
4041
+ actual_app_icon = str(base_info.get("appIcon") or visual_result.get("app_icon") or "").strip() or None
4042
+ expected_app_icon = str(visual_result.get("app_icon") or "").strip() or None
4043
+ app_base_verified = actual_app_name == effective_app_name and actual_app_icon == expected_app_icon
4002
4044
  except (QingflowApiError, RuntimeError) as error:
4003
4045
  base_error = _coerce_api_error(error)
4004
4046
  if verification_error is None:
4005
4047
  verification_error = base_error
4006
4048
  tag_ids_after = []
4007
4049
  package_attached = None if package_tag_id is None else False
4050
+ app_base_verified = False
4008
4051
  response["verification"]["fields_verified"] = verification_ok
4009
4052
  response["verification"]["package_attached"] = package_attached
4053
+ response["verification"]["app_visuals_verified"] = app_base_verified
4054
+ response["verification"]["app_base_verified"] = app_base_verified
4010
4055
  response["verification"]["relation_field_limit_verified"] = relation_limit_verified
4011
- response["verified"] = verification_ok
4056
+ response["verified"] = verification_ok and app_base_verified
4012
4057
  response["tag_ids_after"] = tag_ids_after
4013
4058
  response["package_attached"] = package_attached
4014
4059
  if package_attached is False:
@@ -4018,14 +4063,18 @@ class AiBuilderFacade:
4018
4063
  "profile": profile,
4019
4064
  "tag_id": package_tag_id,
4020
4065
  "app_key": target.app_key,
4021
- "app_title": app_name or target.app_name,
4066
+ "app_title": effective_app_name,
4022
4067
  },
4023
4068
  }
4024
4069
  publish_failed = bool(response.get("publish_requested")) and not bool(response.get("published"))
4025
- if verification_ok and package_attached is not False and not publish_failed:
4070
+ if verification_ok and app_base_verified and package_attached is not False and not publish_failed:
4026
4071
  response["status"] = "success"
4027
4072
  else:
4028
4073
  response["status"] = "partial_success"
4074
+ if not app_base_verified and verification_error is None:
4075
+ response["recoverable"] = True
4076
+ response["error_code"] = response.get("error_code") or "APP_BASE_READBACK_PENDING"
4077
+ response["message"] = f"{response.get('message') or 'apply succeeded'}; app base readback pending"
4029
4078
  if verification_error is not None:
4030
4079
  response["recoverable"] = True
4031
4080
  response["error_code"] = response.get("error_code") or (
@@ -6535,17 +6584,11 @@ class AiBuilderFacade:
6535
6584
  profile: str,
6536
6585
  app_key: str,
6537
6586
  fallback_title: str,
6587
+ app_name: str | None,
6538
6588
  icon: str | None,
6539
6589
  color: str | None,
6540
6590
  normalized_args: dict[str, Any],
6541
6591
  ) -> JSONObject:
6542
- if not icon and not color:
6543
- return {
6544
- "status": "success",
6545
- "updated": False,
6546
- "app_icon": None,
6547
- "request_id": None,
6548
- }
6549
6592
  try:
6550
6593
  base_result = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True)
6551
6594
  except (QingflowApiError, RuntimeError) as error:
@@ -6559,22 +6602,25 @@ class AiBuilderFacade:
6559
6602
  )
6560
6603
  raw_base = base_result.get("result") if isinstance(base_result.get("result"), dict) else {}
6561
6604
  effective_title = str(raw_base.get("formTitle") or fallback_title or "未命名应用").strip() or "未命名应用"
6605
+ desired_title = str(app_name or effective_title).strip() or effective_title
6562
6606
  existing_icon = str(raw_base.get("appIcon") or "").strip() or None
6563
6607
  desired_icon = encode_workspace_icon_with_defaults(
6564
6608
  icon=icon,
6565
6609
  color=color,
6566
- title=effective_title,
6610
+ title=desired_title,
6567
6611
  fallback_icon_name="template",
6568
6612
  existing_icon=existing_icon,
6569
6613
  )
6570
- if desired_icon == existing_icon:
6614
+ if desired_icon == existing_icon and desired_title == effective_title:
6571
6615
  return {
6572
6616
  "status": "success",
6573
6617
  "updated": False,
6574
- "app_icon": desired_icon,
6618
+ "app_icon": desired_icon or existing_icon,
6619
+ "app_name_before": effective_title,
6620
+ "app_name_after": desired_title,
6575
6621
  "request_id": None,
6576
6622
  }
6577
- payload = self._build_app_base_update_payload(raw_base=raw_base, form_title=effective_title, app_icon=desired_icon)
6623
+ payload = self._build_app_base_update_payload(raw_base=raw_base, form_title=desired_title, app_icon=desired_icon)
6578
6624
  if payload is None:
6579
6625
  return _failed(
6580
6626
  "APP_VISUAL_UPDATE_UNSUPPORTED",
@@ -6598,6 +6644,8 @@ class AiBuilderFacade:
6598
6644
  "status": "success",
6599
6645
  "updated": True,
6600
6646
  "app_icon": desired_icon,
6647
+ "app_name_before": effective_title,
6648
+ "app_name_after": desired_title,
6601
6649
  "request_id": update_result.get("request_id") if isinstance(update_result, dict) else None,
6602
6650
  }
6603
6651
 
@@ -458,13 +458,13 @@ def _handle_schema_apply(args: argparse.Namespace, context: CliContext) -> dict:
458
458
  has_app_title = bool((args.app_title or "").strip())
459
459
  has_package_tag_id = args.package_tag_id is not None
460
460
  if has_app_key:
461
- if args.create_if_missing or has_app_name or has_package_tag_id or has_app_title:
461
+ if args.create_if_missing or has_package_tag_id:
462
462
  raise_config_error(
463
- "schema apply edit mode only accepts --app-key as the resource selector.",
464
- fix_hint="For existing apps, pass `--app-key` only. For create mode, use `--package-tag-id --app-name --create-if-missing`.",
463
+ "schema apply edit mode accepts --app-key and optional --app-name only.",
464
+ fix_hint="For existing apps, use `--app-key` and optionally `--app-name`. For create mode, use `--package-tag-id --app-name --create-if-missing`.",
465
465
  )
466
466
  else:
467
- if not args.create_if_missing or not has_package_tag_id or not has_app_name:
467
+ if not args.create_if_missing or not has_package_tag_id or not (has_app_name or has_app_title):
468
468
  raise_config_error(
469
469
  "schema apply create mode requires --package-tag-id, --app-name, and --create-if-missing.",
470
470
  fix_hint="Use `--app-key` for existing apps, or pass `--package-tag-id --app-name --create-if-missing` to create a new app.",
@@ -73,8 +73,10 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
73
73
 
74
74
  update = record_subparsers.add_parser("update", help="更新记录")
75
75
  update.add_argument("--app-key", required=True)
76
- update.add_argument("--record-id", required=True, type=int)
77
- update.add_argument("--fields-file", required=True)
76
+ update.add_argument("--record-id", type=int)
77
+ update.add_argument("--fields-file")
78
+ update.add_argument("--items-file")
79
+ update.add_argument("--dry-run", action=argparse.BooleanOptionalAction, default=False)
78
80
  update.add_argument("--verify-write", action=argparse.BooleanOptionalAction, default=True)
79
81
  update.set_defaults(handler=_handle_update, format_hint="")
80
82
 
@@ -242,6 +244,31 @@ def _handle_insert(args: argparse.Namespace, context: CliContext) -> dict:
242
244
 
243
245
 
244
246
  def _handle_update(args: argparse.Namespace, context: CliContext) -> dict:
247
+ if args.items_file:
248
+ if args.record_id is not None or args.fields_file:
249
+ raise_config_error(
250
+ "record update batch mode does not accept --record-id or --fields-file.",
251
+ fix_hint="Use `record update --app-key APP_KEY --items-file ITEMS.json [--dry-run]` for batch updates.",
252
+ )
253
+ return context.record.record_update_public(
254
+ profile=args.profile,
255
+ app_key=args.app_key,
256
+ record_id=None,
257
+ fields=None,
258
+ items=require_list_arg(args.items_file, option_name="--items-file"),
259
+ dry_run=bool(args.dry_run),
260
+ verify_write=bool(args.verify_write),
261
+ )
262
+ if args.dry_run:
263
+ raise_config_error(
264
+ "record update --dry-run currently requires --items-file.",
265
+ fix_hint="Use `record update --app-key APP_KEY --items-file ITEMS.json --dry-run` for batch preflight.",
266
+ )
267
+ if args.record_id is None or not args.fields_file:
268
+ raise_config_error(
269
+ "record update single mode requires --record-id and --fields-file.",
270
+ fix_hint="Use `record update --app-key APP_KEY --record-id RECORD_ID --fields-file FIELDS.json`.",
271
+ )
245
272
  return context.record.record_update_public(
246
273
  profile=args.profile,
247
274
  app_key=args.app_key,
@@ -37,13 +37,6 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
37
37
  action.add_argument("--fields-file")
38
38
  action.set_defaults(handler=_handle_action, format_hint="")
39
39
 
40
- save_only = task_subparsers.add_parser("save-only", help="仅保存当前待办字段,不提交流程")
41
- save_only.add_argument("--app-key", required=True)
42
- save_only.add_argument("--record-id", required=True, type=int)
43
- save_only.add_argument("--workflow-node-id", required=True, type=int)
44
- save_only.add_argument("--fields-file", required=True)
45
- save_only.set_defaults(handler=_handle_save_only, format_hint="")
46
-
47
40
  log = task_subparsers.add_parser("log", help="读取流程日志")
48
41
  log.add_argument("--app-key", required=True)
49
42
  log.add_argument("--record-id", required=True, type=int)
@@ -87,16 +80,6 @@ def _handle_action(args: argparse.Namespace, context: CliContext) -> dict:
87
80
  )
88
81
 
89
82
 
90
- def _handle_save_only(args: argparse.Namespace, context: CliContext) -> dict:
91
- return context.task.task_save_only(
92
- profile=args.profile,
93
- app_key=args.app_key,
94
- record_id=args.record_id,
95
- workflow_node_id=args.workflow_node_id,
96
- fields=load_object_arg(args.fields_file, option_name="--fields-file") or {},
97
- )
98
-
99
-
100
83
  def _handle_log(args: argparse.Namespace, context: CliContext) -> dict:
101
84
  return context.task.task_workflow_log_get(
102
85
  profile=args.profile,
@@ -91,7 +91,6 @@ USER_PUBLIC_TOOL_SPECS: tuple[PublicToolSpec, ...] = (
91
91
  PublicToolSpec(USER_DOMAIN, "record_code_block_run", ("record_code_block_run",), ("record", "code-block-run")),
92
92
  PublicToolSpec(USER_DOMAIN, "task_list", ("task_list",), ("task", "list")),
93
93
  PublicToolSpec(USER_DOMAIN, "task_get", ("task_get",), ("task", "get")),
94
- PublicToolSpec(USER_DOMAIN, "task_save_only", ("task_save_only",), ("task", "save-only")),
95
94
  PublicToolSpec(USER_DOMAIN, "task_action_execute", ("task_action_execute",), ("task", "action")),
96
95
  PublicToolSpec(USER_DOMAIN, "task_associated_report_detail_get", ("task_associated_report_detail_get",), cli_public=False),
97
96
  PublicToolSpec(USER_DOMAIN, "task_workflow_log_get", ("task_workflow_log_get",), ("task", "log")),
@@ -413,7 +413,6 @@ _register_policy(
413
413
  (USER_DOMAIN,),
414
414
  (
415
415
  "task_get",
416
- "task_save_only",
417
416
  "task_action_execute",
418
417
  "task_associated_report_detail_get",
419
418
  "task_workflow_log_get",
@@ -399,12 +399,12 @@ def build_builder_server() -> FastMCP:
399
399
  has_app_title = bool((app_title or "").strip())
400
400
  has_package_tag_id = package_tag_id is not None
401
401
  if has_app_key:
402
- if create_if_missing or has_app_name or has_package_tag_id or has_app_title:
402
+ if create_if_missing or has_package_tag_id:
403
403
  return _config_failure(
404
- "app_schema_apply edit mode only accepts app_key as the resource selector.",
405
- fix_hint="For existing apps, pass `app_key` only. For create mode, use `package_tag_id + app_name + create_if_missing=true`.",
404
+ "app_schema_apply edit mode accepts app_key and optional app_name rename only.",
405
+ fix_hint="For existing apps, use `app_key` and optionally `app_name`. For create mode, use `package_tag_id + app_name + create_if_missing=true`.",
406
406
  )
407
- elif not (create_if_missing and has_package_tag_id and has_app_name):
407
+ elif not (create_if_missing and has_package_tag_id and (has_app_name or has_app_title)):
408
408
  return _config_failure(
409
409
  "app_schema_apply create mode requires package_tag_id, app_name, and create_if_missing=true.",
410
410
  fix_hint="Use `app_key` for existing apps, or pass `package_tag_id + app_name + create_if_missing=true` to create a new app.",
@@ -144,13 +144,13 @@ Use `record_code_block_run` when the user wants to execute a form code-block fie
144
144
 
145
145
  ## Task Workflow Path
146
146
 
147
- `task_list -> task_get -> task_save_only / task_action_execute`
147
+ `task_list -> task_get -> task_action_execute`
148
148
 
149
149
  - Use `task_associated_report_detail_get` for associated view or report details.
150
150
  - Use `task_workflow_log_get` for full workflow log history.
151
151
  - Task actions operate on `app_key + record_id + workflow_node_id`, not `task_id`.
152
152
  - Treat `task_action_execute` as the tool-level action enum surface; the current task's real actions are only the ones listed in `task_get.capabilities.available_actions`.
153
- - Use `task_save_only` when the user wants to save editable field changes on the current node without advancing the workflow.
153
+ - Use `task_action_execute(action="save_only", fields=...)` when the user wants to save editable field changes on the current node without advancing the workflow.
154
154
  - `save_only` is exposed only when the backend current-node `editableQueIds` signal returns a non-empty result; MCP no longer infers `save_only` from local schema reconstruction.
155
155
 
156
156
  ## Time Handling
@@ -299,13 +299,13 @@ class AiBuilderTools(ToolBase):
299
299
  has_app_title = bool((app_title or "").strip())
300
300
  has_package_tag_id = package_tag_id is not None
301
301
  if has_app_key:
302
- if create_if_missing or has_app_name or has_package_tag_id or has_app_title:
302
+ if create_if_missing or has_package_tag_id:
303
303
  return _config_failure(
304
304
  tool_name="app_schema_apply",
305
- message="app_schema_apply edit mode only accepts app_key as the resource selector.",
306
- fix_hint="For existing apps, pass `app_key` only. For create mode, use `package_tag_id + app_name + create_if_missing=true`.",
305
+ message="app_schema_apply edit mode accepts app_key and optional app_name rename only.",
306
+ fix_hint="For existing apps, use `app_key` and optionally `app_name`. For create mode, use `package_tag_id + app_name + create_if_missing=true`.",
307
307
  )
308
- elif not (create_if_missing and has_package_tag_id and has_app_name):
308
+ elif not (create_if_missing and has_package_tag_id and (has_app_name or has_app_title)):
309
309
  return _config_failure(
310
310
  tool_name="app_schema_apply",
311
311
  message="app_schema_apply create mode requires package_tag_id, app_name, and create_if_missing=true.",
@@ -2365,7 +2365,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
2365
2365
  },
2366
2366
  "execution_notes": [
2367
2367
  "use exactly one resource mode",
2368
- "edit mode: app_key",
2368
+ "edit mode: app_key, optional app_name to rename the existing app",
2369
2369
  "create mode: package_tag_id + app_name + create_if_missing=true",
2370
2370
  "multiple relation fields are backend-risky; read verification.relation_field_limit_verified and warnings before declaring the schema stable",
2371
2371
  "backend 49614 is normalized to MULTIPLE_RELATION_FIELDS_UNSUPPORTED with a workaround message",
@@ -2392,6 +2392,15 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
2392
2392
  "update_fields": [],
2393
2393
  "remove_fields": [],
2394
2394
  },
2395
+ "rename_example": {
2396
+ "profile": "default",
2397
+ "app_key": "APP_PROJECT",
2398
+ "app_name": "项目主数据",
2399
+ "publish": True,
2400
+ "add_fields": [],
2401
+ "update_fields": [],
2402
+ "remove_fields": [],
2403
+ },
2395
2404
  "relation_example": {
2396
2405
  "profile": "default",
2397
2406
  "app_key": "APP_ITERATION",
@@ -117,22 +117,6 @@ class ApprovalTools(ToolBase):
117
117
  ) -> dict[str, Any]:
118
118
  return self.task_transfer(profile=profile, app_key=app_key, record_id=record_id, payload=payload or {}, fields=fields or {})
119
119
 
120
- @mcp.tool(description=self._high_risk_tool_description(operation="save", target="workflow task fields without advancing the workflow"))
121
- def task_save_only(
122
- profile: str = DEFAULT_PROFILE,
123
- app_key: str = "",
124
- record_id: int = 0,
125
- workflow_node_id: int = 0,
126
- fields: dict[str, Any] | None = None,
127
- ) -> dict[str, Any]:
128
- return self.task_save_only(
129
- profile=profile,
130
- app_key=app_key,
131
- record_id=record_id,
132
- workflow_node_id=workflow_node_id,
133
- fields=fields or {},
134
- )
135
-
136
120
  @mcp.tool()
137
121
  def task_transfer_candidates(
138
122
  profile: str = DEFAULT_PROFILE,
@@ -460,6 +460,8 @@ class RecordTools(ToolBase):
460
460
  app_key: str = "",
461
461
  record_id: int | None = None,
462
462
  fields: JSONObject | None = None,
463
+ items: list[JSONObject] | None = None,
464
+ dry_run: bool = False,
463
465
  verify_write: bool = True,
464
466
  output_profile: str = "normal",
465
467
  ) -> JSONObject:
@@ -467,7 +469,9 @@ class RecordTools(ToolBase):
467
469
  profile=DEFAULT_PROFILE,
468
470
  app_key=app_key,
469
471
  record_id=record_id,
470
- fields=fields or {},
472
+ fields=fields,
473
+ items=items,
474
+ dry_run=dry_run,
471
475
  verify_write=verify_write,
472
476
  output_profile=output_profile,
473
477
  )
@@ -1640,6 +1644,8 @@ class RecordTools(ToolBase):
1640
1644
  ),
1641
1645
  }
1642
1646
  )
1647
+ rows = list_data.get("rows", [])
1648
+ normalized_public_rows = _normalize_public_record_rows(rows if isinstance(rows, list) else [])
1643
1649
  response: JSONObject = {
1644
1650
  "profile": profile,
1645
1651
  "ws_id": raw.get("ws_id"),
@@ -1650,7 +1656,7 @@ class RecordTools(ToolBase):
1650
1656
  "output_profile": normalized_output_profile,
1651
1657
  "data": {
1652
1658
  "app_key": app_key,
1653
- "items": list_data.get("rows", []),
1659
+ "items": normalized_public_rows,
1654
1660
  "pagination": {
1655
1661
  "page": page,
1656
1662
  "limit": limit,
@@ -1818,7 +1824,7 @@ class RecordTools(ToolBase):
1818
1824
  "output_profile": normalized_output_profile,
1819
1825
  "data": {
1820
1826
  "app_key": app_key,
1821
- "record_id": record_id,
1827
+ "record_id": _public_record_id_text(record_id),
1822
1828
  "record": row,
1823
1829
  "selection": {
1824
1830
  "columns": [_column_selector_payload(field_id) for field_id in normalized_columns] if normalized_columns else [],
@@ -1918,21 +1924,60 @@ class RecordTools(ToolBase):
1918
1924
  app_key: str,
1919
1925
  record_id: int | None,
1920
1926
  fields: JSONObject | None = None,
1927
+ items: list[JSONObject] | None = None,
1928
+ dry_run: bool = False,
1921
1929
  verify_write: bool = True,
1922
1930
  output_profile: str = "normal",
1923
1931
  ) -> JSONObject:
1924
1932
  normalized_output_profile = self._normalize_public_output_profile(output_profile)
1925
1933
  if not app_key:
1926
1934
  raise_tool_error(QingflowApiError.config_error("app_key is required"))
1935
+ if items is not None:
1936
+ if dry_run not in {True, False}:
1937
+ raise_tool_error(QingflowApiError.config_error("dry_run must be boolean"))
1938
+ normalized_items = self._normalize_public_record_update_batch_items(
1939
+ record_id=record_id,
1940
+ fields=fields,
1941
+ items=items,
1942
+ )
1943
+ return self._record_update_public_batch(
1944
+ profile=profile,
1945
+ app_key=app_key,
1946
+ items=normalized_items,
1947
+ dry_run=dry_run,
1948
+ verify_write=verify_write,
1949
+ output_profile=normalized_output_profile,
1950
+ )
1951
+ if dry_run:
1952
+ raise_tool_error(QingflowApiError.config_error("dry_run currently requires items"))
1927
1953
  if record_id is None or record_id <= 0:
1928
1954
  raise_tool_error(QingflowApiError.config_error("record_id is required"))
1929
1955
  if fields is not None and not isinstance(fields, dict):
1930
1956
  raise_tool_error(QingflowApiError.config_error("fields must be an object map keyed by field title"))
1931
- raw_preflight = self._preflight_record_update_with_auto_view(
1957
+ return self._record_update_public_single(
1932
1958
  profile=profile,
1933
1959
  app_key=app_key,
1934
1960
  record_id=record_id,
1935
1961
  fields=cast(JSONObject, fields or {}),
1962
+ verify_write=verify_write,
1963
+ output_profile=normalized_output_profile,
1964
+ )
1965
+
1966
+ def _record_update_public_single(
1967
+ self,
1968
+ *,
1969
+ profile: str,
1970
+ app_key: str,
1971
+ record_id: int,
1972
+ fields: JSONObject,
1973
+ verify_write: bool,
1974
+ output_profile: str,
1975
+ ) -> JSONObject:
1976
+ raw_preflight = self._preflight_record_update_with_auto_view(
1977
+ profile=profile,
1978
+ app_key=app_key,
1979
+ record_id=record_id,
1980
+ fields=fields,
1936
1981
  force_refresh_form=False,
1937
1982
  )
1938
1983
  preflight_used_force_refresh = self._record_preflight_used_force_refresh(raw_preflight)
@@ -1950,7 +1995,7 @@ class RecordTools(ToolBase):
1950
1995
  raw_preflight,
1951
1996
  operation="update",
1952
1997
  normalized_payload=normalized_payload,
1953
- output_profile=normalized_output_profile,
1998
+ output_profile=output_profile,
1954
1999
  human_review=True,
1955
2000
  target_resource={"type": "record", "app_key": app_key, "record_id": record_id, "record_ids": []},
1956
2001
  )
@@ -1978,11 +2023,257 @@ class RecordTools(ToolBase):
1978
2023
  raw_apply,
1979
2024
  operation="update",
1980
2025
  normalized_payload=normalized_payload,
1981
- output_profile=normalized_output_profile,
2026
+ output_profile=output_profile,
1982
2027
  human_review=True,
1983
2028
  preflight=raw_preflight,
1984
2029
  )
1985
2030
 
2031
+ def _record_update_public_batch(
2032
+ self,
2033
+ *,
2034
+ profile: str,
2035
+ app_key: str,
2036
+ items: list[JSONObject],
2037
+ dry_run: bool,
2038
+ verify_write: bool,
2039
+ output_profile: str,
2040
+ ) -> JSONObject:
2041
+ preflight_responses = [
2042
+ self._record_update_public_preflight_response(
2043
+ profile=profile,
2044
+ app_key=app_key,
2045
+ record_id=cast(int, item["record_id"]),
2046
+ fields=cast(JSONObject, item["fields"]),
2047
+ output_profile=output_profile,
2048
+ )
2049
+ for item in items
2050
+ ]
2051
+ has_preflight_blockers = any(
2052
+ str(response.get("status") or "").lower() in {"blocked", "needs_confirmation"}
2053
+ for response in preflight_responses
2054
+ )
2055
+ if dry_run or has_preflight_blockers:
2056
+ return self._record_update_batch_response(
2057
+ profile=profile,
2058
+ app_key=app_key,
2059
+ responses=preflight_responses,
2060
+ output_profile=output_profile,
2061
+ dry_run=dry_run,
2062
+ )
2063
+
2064
+ apply_responses: list[JSONObject] = []
2065
+ for item in items:
2066
+ record_id = cast(int, item["record_id"])
2067
+ fields = cast(JSONObject, item["fields"])
2068
+ try:
2069
+ apply_responses.append(
2070
+ self._record_update_public_single(
2071
+ profile=profile,
2072
+ app_key=app_key,
2073
+ record_id=record_id,
2074
+ fields=fields,
2075
+ verify_write=verify_write,
2076
+ output_profile=output_profile,
2077
+ )
2078
+ )
2079
+ except (QingflowApiError, RuntimeError) as exc:
2080
+ apply_responses.append(
2081
+ self._record_write_exception_response(
2082
+ exc,
2083
+ operation="update",
2084
+ profile=profile,
2085
+ app_key=app_key,
2086
+ record_id=record_id,
2087
+ output_profile=output_profile,
2088
+ human_review=True,
2089
+ )
2090
+ )
2091
+
2092
+ return self._record_update_batch_response(
2093
+ profile=profile,
2094
+ app_key=app_key,
2095
+ responses=apply_responses,
2096
+ output_profile=output_profile,
2097
+ dry_run=False,
2098
+ )
2099
+
2100
+ def _record_update_public_preflight_response(
2101
+ self,
2102
+ *,
2103
+ profile: str,
2104
+ app_key: str,
2105
+ record_id: int,
2106
+ fields: JSONObject,
2107
+ output_profile: str,
2108
+ ) -> JSONObject:
2109
+ raw_preflight = self._preflight_record_update_with_auto_view(
2110
+ profile=profile,
2111
+ app_key=app_key,
2112
+ record_id=record_id,
2113
+ fields=fields,
2114
+ force_refresh_form=False,
2115
+ )
2116
+ preflight_data = cast(JSONObject, raw_preflight.get("data", {}))
2117
+ normalized_payload = self._record_write_normalized_payload(
2118
+ operation="update",
2119
+ record_id=record_id,
2120
+ record_ids=[],
2121
+ normalized_answers=cast(list[JSONObject], preflight_data.get("normalized_answers", [])),
2122
+ submit_type=1,
2123
+ selection=cast(JSONObject | None, preflight_data.get("selection") if isinstance(preflight_data.get("selection"), dict) else None),
2124
+ )
2125
+ confirmation_requests = cast(list[JSONObject], preflight_data.get("confirmation_requests", []))
2126
+ if preflight_data.get("blockers") or confirmation_requests:
2127
+ return self._record_write_blocked_response(
2128
+ raw_preflight,
2129
+ operation="update",
2130
+ normalized_payload=normalized_payload,
2131
+ output_profile=output_profile,
2132
+ human_review=True,
2133
+ target_resource={"type": "record", "app_key": app_key, "record_id": record_id, "record_ids": []},
2134
+ )
2135
+ return self._record_write_ready_response(
2136
+ raw_preflight,
2137
+ operation="update",
2138
+ normalized_payload=normalized_payload,
2139
+ output_profile=output_profile,
2140
+ human_review=True,
2141
+ target_resource={"type": "record", "app_key": app_key, "record_id": record_id, "record_ids": []},
2142
+ )
2143
+
2144
+ def _normalize_public_record_update_batch_items(
2145
+ self,
2146
+ *,
2147
+ record_id: int | None,
2148
+ fields: JSONObject | None,
2149
+ items: list[JSONObject] | None,
2150
+ ) -> list[JSONObject]:
2151
+ if record_id is not None or fields is not None:
2152
+ raise_tool_error(
2153
+ QingflowApiError.config_error("record_update batch mode does not accept record_id or fields")
2154
+ )
2155
+ if not isinstance(items, list) or not items:
2156
+ raise_tool_error(QingflowApiError.config_error("items must be a non-empty list"))
2157
+ normalized_items: list[JSONObject] = []
2158
+ seen_record_ids: set[int] = set()
2159
+ for index, item in enumerate(items):
2160
+ if not isinstance(item, dict):
2161
+ raise_tool_error(QingflowApiError.config_error(f"items[{index}] must be an object"))
2162
+ normalized_record_id = _coerce_count(item.get("record_id"))
2163
+ if normalized_record_id is None or normalized_record_id <= 0:
2164
+ raise_tool_error(QingflowApiError.config_error(f"items[{index}].record_id must be a positive integer"))
2165
+ if normalized_record_id in seen_record_ids:
2166
+ raise_tool_error(
2167
+ QingflowApiError.config_error(f"duplicate record_id in items: {normalized_record_id}")
2168
+ )
2169
+ item_fields = item.get("fields")
2170
+ if not isinstance(item_fields, dict):
2171
+ raise_tool_error(QingflowApiError.config_error(f"items[{index}].fields must be an object map keyed by field title"))
2172
+ seen_record_ids.add(normalized_record_id)
2173
+ normalized_items.append({"record_id": normalized_record_id, "fields": cast(JSONObject, item_fields)})
2174
+ return normalized_items
2175
+
2176
+ def _record_update_batch_response(
2177
+ self,
2178
+ *,
2179
+ profile: str,
2180
+ app_key: str,
2181
+ responses: list[JSONObject],
2182
+ output_profile: str,
2183
+ dry_run: bool,
2184
+ ) -> JSONObject:
2185
+ summary = self._record_update_batch_summary(responses)
2186
+ batch_items = [self._record_update_batch_item_from_response(response, output_profile=output_profile) for response in responses]
2187
+ status, ok, message = self._record_update_batch_envelope_status(summary=summary, dry_run=dry_run)
2188
+ first_response = responses[0] if responses else {}
2189
+ return {
2190
+ "profile": first_response.get("profile", profile),
2191
+ "ws_id": first_response.get("ws_id"),
2192
+ "ok": ok,
2193
+ "status": status,
2194
+ "request_route": first_response.get("request_route"),
2195
+ "warnings": [],
2196
+ "output_profile": output_profile,
2197
+ "data": {
2198
+ "app_key": app_key,
2199
+ "mode": "batch",
2200
+ "dry_run": dry_run,
2201
+ "summary": summary,
2202
+ "items": batch_items,
2203
+ },
2204
+ "message": message,
2205
+ }
2206
+
2207
+ def _record_update_batch_summary(self, responses: list[JSONObject]) -> JSONObject:
2208
+ summary: JSONObject = {
2209
+ "total": len(responses),
2210
+ "ready_count": 0,
2211
+ "blocked_count": 0,
2212
+ "confirmation_count": 0,
2213
+ "applied_count": 0,
2214
+ "verified_count": 0,
2215
+ "field_level_verified_count": 0,
2216
+ "failed_count": 0,
2217
+ }
2218
+ for response in responses:
2219
+ status = str(response.get("status") or "").lower()
2220
+ data = cast(JSONObject, response.get("data", {}))
2221
+ action = cast(JSONObject, data.get("action", {}))
2222
+ verification = cast(JSONObject, data.get("verification", {})) if isinstance(data.get("verification"), dict) else {}
2223
+ executed = bool(action.get("executed"))
2224
+ if status == "ready":
2225
+ summary["ready_count"] = int(summary["ready_count"]) + 1
2226
+ continue
2227
+ if status == "blocked":
2228
+ summary["blocked_count"] = int(summary["blocked_count"]) + 1
2229
+ continue
2230
+ if status == "needs_confirmation":
2231
+ summary["confirmation_count"] = int(summary["confirmation_count"]) + 1
2232
+ continue
2233
+ if executed:
2234
+ summary["applied_count"] = int(summary["applied_count"]) + 1
2235
+ if bool(verification.get("verified")):
2236
+ summary["verified_count"] = int(summary["verified_count"]) + 1
2237
+ if bool(verification.get("field_level_verified")):
2238
+ summary["field_level_verified_count"] = int(summary["field_level_verified_count"]) + 1
2239
+ if status not in {"success"}:
2240
+ summary["failed_count"] = int(summary["failed_count"]) + 1
2241
+ return summary
2242
+
2243
+ def _record_update_batch_envelope_status(self, *, summary: JSONObject, dry_run: bool) -> tuple[str, bool, str]:
2244
+ if int(summary["blocked_count"]) > 0:
2245
+ return "blocked", False, "batch update preflight blocked"
2246
+ if int(summary["confirmation_count"]) > 0:
2247
+ return "needs_confirmation", False, "batch update requires confirmation before write"
2248
+ if dry_run:
2249
+ return "ready", True, "batch update dry run ready"
2250
+ if int(summary["failed_count"]) > 0:
2251
+ return "partial_success", False, "batch update completed with partial failures"
2252
+ return "success", True, "batch update completed"
2253
+
2254
+ def _record_update_batch_item_from_response(self, response: JSONObject, *, output_profile: str) -> JSONObject:
2255
+ data = cast(JSONObject, response.get("data", {}))
2256
+ item: JSONObject = {
2257
+ "resource": data.get("resource"),
2258
+ "status": response.get("status"),
2259
+ "verification": data.get("verification"),
2260
+ "field_errors": cast(list[JSONObject], data.get("field_errors", [])),
2261
+ "confirmation_requests": cast(list[JSONObject], data.get("confirmation_requests", [])),
2262
+ "resolved_fields": cast(list[JSONObject], data.get("resolved_fields", [])),
2263
+ }
2264
+ blockers = data.get("blockers")
2265
+ if isinstance(blockers, list) and blockers:
2266
+ item["blockers"] = blockers
2267
+ warnings = response.get("warnings")
2268
+ if isinstance(warnings, list) and warnings:
2269
+ item["warnings"] = warnings
2270
+ error = data.get("error")
2271
+ if isinstance(error, dict):
2272
+ item["error"] = error
2273
+ if output_profile == "verbose" and isinstance(data.get("debug"), dict):
2274
+ item["debug"] = data.get("debug")
2275
+ return item
2276
+
1986
2277
  def _preflight_record_update_with_auto_view(
1987
2278
  self,
1988
2279
  *,
@@ -7944,6 +8235,48 @@ class RecordTools(ToolBase):
7944
8235
  }
7945
8236
  return response
7946
8237
 
8238
+ def _record_write_ready_response(
8239
+ self,
8240
+ raw_preflight: JSONObject,
8241
+ *,
8242
+ operation: str,
8243
+ normalized_payload: JSONObject,
8244
+ output_profile: str,
8245
+ human_review: bool,
8246
+ target_resource: JSONObject,
8247
+ ) -> JSONObject:
8248
+ plan_data = cast(JSONObject, raw_preflight.get("data", {}))
8249
+ validation = cast(JSONObject, plan_data.get("validation", {}))
8250
+ resolved_fields = cast(list[JSONObject], plan_data.get("lookup_resolved_fields", []))
8251
+ warnings_payload = validation.get("warnings", [])
8252
+ warnings = [{"code": "PREFLIGHT_WARNING", "message": str(item)} for item in warnings_payload] if isinstance(warnings_payload, list) else []
8253
+ response: JSONObject = {
8254
+ "profile": raw_preflight.get("profile"),
8255
+ "ws_id": raw_preflight.get("ws_id"),
8256
+ "ok": True,
8257
+ "status": "ready",
8258
+ "request_route": raw_preflight.get("request_route"),
8259
+ "warnings": warnings,
8260
+ "output_profile": output_profile,
8261
+ "data": {
8262
+ "action": {"operation": operation, "executed": False},
8263
+ "resource": target_resource,
8264
+ "verification": None,
8265
+ "normalized_payload": normalized_payload,
8266
+ "blockers": [],
8267
+ "field_errors": [],
8268
+ "confirmation_requests": [],
8269
+ "resolved_fields": resolved_fields,
8270
+ "recommended_next_actions": cast(list[JSONValue], plan_data.get("recommended_next_actions", [])),
8271
+ "human_review": self._record_write_human_review_payload(operation, enabled=human_review),
8272
+ },
8273
+ }
8274
+ if output_profile == "verbose":
8275
+ response["data"]["debug"] = {
8276
+ "preflight": plan_data,
8277
+ }
8278
+ return response
8279
+
7947
8280
  def _record_write_apply_response(
7948
8281
  self,
7949
8282
  raw_apply: JSONObject,
@@ -7994,6 +8327,67 @@ class RecordTools(ToolBase):
7994
8327
  response["data"]["debug"] = debug
7995
8328
  return response
7996
8329
 
8330
+ def _record_write_exception_response(
8331
+ self,
8332
+ exc: QingflowApiError | RuntimeError,
8333
+ *,
8334
+ operation: str,
8335
+ profile: str,
8336
+ app_key: str,
8337
+ record_id: int,
8338
+ output_profile: str,
8339
+ human_review: bool,
8340
+ ) -> JSONObject:
8341
+ error_payload: JSONObject = {
8342
+ "error_code": "RECORD_WRITE_EXECUTION_FAILED",
8343
+ "message": str(exc),
8344
+ }
8345
+ request_route: JSONObject | None = None
8346
+ if isinstance(exc, QingflowApiError):
8347
+ error_payload["message"] = exc.message
8348
+ if exc.backend_code is not None:
8349
+ error_payload["backend_code"] = exc.backend_code
8350
+ if exc.request_id is not None:
8351
+ error_payload["request_id"] = exc.request_id
8352
+ else:
8353
+ try:
8354
+ parsed = json.loads(str(exc))
8355
+ except json.JSONDecodeError:
8356
+ parsed = None
8357
+ if isinstance(parsed, dict):
8358
+ error_payload["error_code"] = parsed.get("error_code") or cast(JSONObject, parsed.get("details", {})).get("error_code") or error_payload["error_code"]
8359
+ error_payload["message"] = parsed.get("message") or error_payload["message"]
8360
+ if parsed.get("backend_code") is not None:
8361
+ error_payload["backend_code"] = parsed.get("backend_code")
8362
+ if parsed.get("request_id") is not None:
8363
+ error_payload["request_id"] = parsed.get("request_id")
8364
+ if isinstance(parsed.get("request_route"), dict):
8365
+ request_route = cast(JSONObject, parsed.get("request_route"))
8366
+ response: JSONObject = {
8367
+ "profile": profile,
8368
+ "ws_id": None,
8369
+ "ok": False,
8370
+ "status": "failed",
8371
+ "request_route": request_route,
8372
+ "warnings": [],
8373
+ "output_profile": output_profile,
8374
+ "data": {
8375
+ "action": {"operation": operation, "executed": True},
8376
+ "resource": {"type": "record", "app_key": app_key, "record_id": record_id, "record_ids": []},
8377
+ "verification": None,
8378
+ "normalized_payload": None,
8379
+ "blockers": [],
8380
+ "field_errors": [],
8381
+ "confirmation_requests": [],
8382
+ "resolved_fields": [],
8383
+ "human_review": self._record_write_human_review_payload(operation, enabled=human_review),
8384
+ "error": error_payload,
8385
+ },
8386
+ }
8387
+ if output_profile == "verbose":
8388
+ response["data"]["debug"] = {"exception": error_payload}
8389
+ return response
8390
+
7997
8391
  def _record_write_public_field_errors(self, plan_data: JSONObject) -> list[JSONObject]:
7998
8392
  existing = plan_data.get("field_errors")
7999
8393
  if isinstance(existing, list):
@@ -10221,12 +10615,36 @@ def _view_selection_supported_by_search_ids(view_selection: ViewSelection, searc
10221
10615
 
10222
10616
 
10223
10617
  def _build_flat_row(answer_list: list[JSONValue], fields: list[FormField], *, apply_id: int | None) -> JSONObject:
10224
- row: JSONObject = {"apply_id": apply_id}
10618
+ public_record_id = _public_record_id_text(apply_id)
10619
+ row: JSONObject = {"apply_id": public_record_id, "record_id": public_record_id}
10225
10620
  for field in fields:
10226
10621
  row[field.que_title] = _extract_field_value(answer_list, field)
10227
10622
  return row
10228
10623
 
10229
10624
 
10625
+ def _public_record_id_text(record_id: int | None) -> str | None:
10626
+ if record_id is None or record_id <= 0:
10627
+ return None
10628
+ return str(record_id)
10629
+
10630
+
10631
+ def _normalize_public_record_rows(rows: list[JSONValue]) -> list[JSONObject]:
10632
+ normalized_rows: list[JSONObject] = []
10633
+ for item in rows:
10634
+ if not isinstance(item, dict):
10635
+ continue
10636
+ row = dict(item)
10637
+ normalized_record_id = _coerce_count(row.get("apply_id"))
10638
+ if normalized_record_id is None:
10639
+ normalized_record_id = _coerce_count(row.get("record_id"))
10640
+ if normalized_record_id is not None and normalized_record_id > 0:
10641
+ public_record_id = _public_record_id_text(normalized_record_id)
10642
+ row["apply_id"] = public_record_id
10643
+ row["record_id"] = public_record_id
10644
+ normalized_rows.append(row)
10645
+ return normalized_rows
10646
+
10647
+
10230
10648
  def _merge_answer_lists_by_field_id(existing: list[JSONValue], extra: list[JSONValue]) -> list[JSONValue]:
10231
10649
  merged: dict[str, JSONValue] = {}
10232
10650
  order: list[str] = []
@@ -104,22 +104,6 @@ class TaskContextTools(ToolBase):
104
104
  fields=fields or {},
105
105
  )
106
106
 
107
- @mcp.tool()
108
- def task_save_only(
109
- profile: str = DEFAULT_PROFILE,
110
- app_key: str = "",
111
- record_id: int = 0,
112
- workflow_node_id: int = 0,
113
- fields: dict[str, Any] | None = None,
114
- ) -> dict[str, Any]:
115
- return self.task_save_only(
116
- profile=profile,
117
- app_key=app_key,
118
- record_id=record_id,
119
- workflow_node_id=workflow_node_id,
120
- fields=fields or {},
121
- )
122
-
123
107
  @mcp.tool()
124
108
  def task_associated_report_detail_get(
125
109
  profile: str = DEFAULT_PROFILE,