@josephyan/qingflow-cli 0.2.0-beta.61 → 0.2.0-beta.62

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.61
6
+ npm install @josephyan/qingflow-cli@0.2.0-beta.62
7
7
  ```
8
8
 
9
9
  Run:
10
10
 
11
11
  ```bash
12
- npx -y -p @josephyan/qingflow-cli@0.2.0-beta.61 qingflow
12
+ npx -y -p @josephyan/qingflow-cli@0.2.0-beta.62 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.61",
3
+ "version": "0.2.0-beta.62",
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.0b61"
7
+ version = "0.2.0b62"
8
8
  description = "User-authenticated MCP server for Qingflow"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -26,7 +26,7 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
26
26
  get.add_argument("--workflow-node-id", required=True, type=int)
27
27
  get.add_argument("--include-candidates", action=argparse.BooleanOptionalAction, default=True)
28
28
  get.add_argument("--include-associated-reports", action=argparse.BooleanOptionalAction, default=True)
29
- get.set_defaults(handler=_handle_get, format_hint="")
29
+ get.set_defaults(handler=_handle_get, format_hint="task_get")
30
30
 
31
31
  action = task_subparsers.add_parser("action", help="执行待办动作")
32
32
  action.add_argument("--app-key", required=True)
@@ -34,6 +34,7 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
34
34
  action.add_argument("--workflow-node-id", required=True, type=int)
35
35
  action.add_argument("--action", required=True)
36
36
  action.add_argument("--payload-file")
37
+ action.add_argument("--fields-file")
37
38
  action.set_defaults(handler=_handle_action, format_hint="")
38
39
 
39
40
  log = task_subparsers.add_parser("log", help="读取流程日志")
@@ -75,6 +76,7 @@ def _handle_action(args: argparse.Namespace, context: CliContext) -> dict:
75
76
  workflow_node_id=args.workflow_node_id,
76
77
  action=args.action,
77
78
  payload=load_object_arg(args.payload_file, option_name="--payload-file") or {},
79
+ fields=load_object_arg(args.fields_file, option_name="--fields-file") or {},
78
80
  )
79
81
 
80
82
 
@@ -137,6 +137,41 @@ def _format_task_list(result: dict[str, Any]) -> str:
137
137
  return "\n".join(lines) + "\n"
138
138
 
139
139
 
140
+ def _format_task_get(result: dict[str, Any]) -> str:
141
+ data = result.get("data") if isinstance(result.get("data"), dict) else {}
142
+ task = data.get("task") if isinstance(data.get("task"), dict) else {}
143
+ record = data.get("record") if isinstance(data.get("record"), dict) else {}
144
+ capabilities = data.get("capabilities") if isinstance(data.get("capabilities"), dict) else {}
145
+ update_schema = data.get("update_schema") if isinstance(data.get("update_schema"), dict) else {}
146
+ writable_fields = update_schema.get("writable_fields") if isinstance(update_schema.get("writable_fields"), list) else []
147
+ lines = [
148
+ f"Task: {task.get('app_key') or '-'} / {task.get('record_id') or '-'} / {task.get('workflow_node_id') or '-'}",
149
+ f"Node: {task.get('workflow_node_name') or '-'}",
150
+ f"Apply Status: {record.get('apply_status')}",
151
+ f"Available Actions: {', '.join(str(item) for item in (capabilities.get('available_actions') or [])) or '-'}",
152
+ f"Editable Fields: {len(writable_fields)}",
153
+ ]
154
+ if writable_fields:
155
+ for item in writable_fields[:10]:
156
+ if isinstance(item, dict):
157
+ lines.append(f"- {item.get('title') or '-'} ({item.get('kind') or 'field'})")
158
+ blockers = update_schema.get("blockers") if isinstance(update_schema.get("blockers"), list) else []
159
+ if blockers:
160
+ lines.append("Update Schema Blockers:")
161
+ for item in blockers:
162
+ lines.append(f"- {item}")
163
+ schema_warnings = update_schema.get("warnings") if isinstance(update_schema.get("warnings"), list) else []
164
+ if schema_warnings:
165
+ lines.append("Update Schema Warnings:")
166
+ for item in schema_warnings:
167
+ if isinstance(item, dict):
168
+ lines.append(f"- {item.get('code') or 'WARNING'}: {item.get('message') or ''}".rstrip())
169
+ else:
170
+ lines.append(f"- {item}")
171
+ _append_warnings(lines, result.get("warnings"))
172
+ return "\n".join(lines) + "\n"
173
+
174
+
140
175
  def _format_import_verify(result: dict[str, Any]) -> str:
141
176
  lines = [
142
177
  f"App Key: {result.get('app_key') or '-'}",
@@ -263,6 +298,7 @@ _FORMATTERS = {
263
298
  "app_get": _format_app_get,
264
299
  "record_list": _format_record_list,
265
300
  "task_list": _format_task_list,
301
+ "task_get": _format_task_get,
266
302
  "import_verify": _format_import_verify,
267
303
  "import_status": _format_import_status,
268
304
  "builder_summary": _format_builder_summary,
@@ -69,12 +69,24 @@ class ApprovalTools(ToolBase):
69
69
  return self.record_comment_mark_read(profile=profile, app_key=app_key, apply_id=record_id)
70
70
 
71
71
  @mcp.tool(description=self._high_risk_tool_description(operation="approve", target="workflow task"))
72
- def task_approve(profile: str = DEFAULT_PROFILE, app_key: str = "", record_id: int = 0, payload: dict[str, Any] | None = None) -> dict[str, Any]:
73
- return self.task_approve(profile=profile, app_key=app_key, record_id=record_id, payload=payload or {})
72
+ def task_approve(
73
+ profile: str = DEFAULT_PROFILE,
74
+ app_key: str = "",
75
+ record_id: int = 0,
76
+ payload: dict[str, Any] | None = None,
77
+ fields: dict[str, Any] | None = None,
78
+ ) -> dict[str, Any]:
79
+ return self.task_approve(profile=profile, app_key=app_key, record_id=record_id, payload=payload or {}, fields=fields or {})
74
80
 
75
81
  @mcp.tool(description=self._high_risk_tool_description(operation="reject", target="workflow task"))
76
- def task_reject(profile: str = DEFAULT_PROFILE, app_key: str = "", record_id: int = 0, payload: dict[str, Any] | None = None) -> dict[str, Any]:
77
- return self.task_reject(profile=profile, app_key=app_key, record_id=record_id, payload=payload or {})
82
+ def task_reject(
83
+ profile: str = DEFAULT_PROFILE,
84
+ app_key: str = "",
85
+ record_id: int = 0,
86
+ payload: dict[str, Any] | None = None,
87
+ fields: dict[str, Any] | None = None,
88
+ ) -> dict[str, Any]:
89
+ return self.task_reject(profile=profile, app_key=app_key, record_id=record_id, payload=payload or {}, fields=fields or {})
78
90
 
79
91
  @mcp.tool()
80
92
  def task_rollback_candidates(
@@ -86,12 +98,40 @@ class ApprovalTools(ToolBase):
86
98
  return self.task_rollback_candidates(profile=profile, app_key=app_key, record_id=record_id, workflow_node_id=workflow_node_id)
87
99
 
88
100
  @mcp.tool()
89
- def task_rollback(profile: str = DEFAULT_PROFILE, app_key: str = "", record_id: int = 0, payload: dict[str, Any] | None = None) -> dict[str, Any]:
90
- return self.task_rollback(profile=profile, app_key=app_key, record_id=record_id, payload=payload or {})
101
+ def task_rollback(
102
+ profile: str = DEFAULT_PROFILE,
103
+ app_key: str = "",
104
+ record_id: int = 0,
105
+ payload: dict[str, Any] | None = None,
106
+ fields: dict[str, Any] | None = None,
107
+ ) -> dict[str, Any]:
108
+ return self.task_rollback(profile=profile, app_key=app_key, record_id=record_id, payload=payload or {}, fields=fields or {})
91
109
 
92
110
  @mcp.tool()
93
- def task_transfer(profile: str = DEFAULT_PROFILE, app_key: str = "", record_id: int = 0, payload: dict[str, Any] | None = None) -> dict[str, Any]:
94
- return self.task_transfer(profile=profile, app_key=app_key, record_id=record_id, payload=payload or {})
111
+ def task_transfer(
112
+ profile: str = DEFAULT_PROFILE,
113
+ app_key: str = "",
114
+ record_id: int = 0,
115
+ payload: dict[str, Any] | None = None,
116
+ fields: dict[str, Any] | None = None,
117
+ ) -> dict[str, Any]:
118
+ return self.task_transfer(profile=profile, app_key=app_key, record_id=record_id, payload=payload or {}, fields=fields or {})
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
+ )
95
135
 
96
136
  @mcp.tool()
97
137
  def task_transfer_candidates(
@@ -156,7 +196,17 @@ class ApprovalTools(ToolBase):
156
196
  selection={"app_key": app_key, "record_id": record_id, "list_type": list_type, "keyword": keyword},
157
197
  )
158
198
 
159
- def task_approve(self, *, profile: str, app_key: str, record_id: int, payload: dict[str, Any]) -> dict[str, Any]:
199
+ def task_approve(self, *, profile: str, app_key: str, record_id: int, payload: dict[str, Any], fields: dict[str, Any] | None = None) -> dict[str, Any]:
200
+ if fields:
201
+ return self._delegate_task_action(
202
+ profile=profile,
203
+ app_key=app_key,
204
+ record_id=record_id,
205
+ action="approve",
206
+ payload=payload,
207
+ fields=fields,
208
+ public_action="task_approve",
209
+ )
160
210
  raw = self.record_approve(profile=profile, app_key=app_key, apply_id=record_id, payload=payload)
161
211
  return self._public_action_response(
162
212
  raw,
@@ -166,7 +216,17 @@ class ApprovalTools(ToolBase):
166
216
  human_review=True,
167
217
  )
168
218
 
169
- def task_reject(self, *, profile: str, app_key: str, record_id: int, payload: dict[str, Any]) -> dict[str, Any]:
219
+ def task_reject(self, *, profile: str, app_key: str, record_id: int, payload: dict[str, Any], fields: dict[str, Any] | None = None) -> dict[str, Any]:
220
+ if fields:
221
+ return self._delegate_task_action(
222
+ profile=profile,
223
+ app_key=app_key,
224
+ record_id=record_id,
225
+ action="reject",
226
+ payload=payload,
227
+ fields=fields,
228
+ public_action="task_reject",
229
+ )
170
230
  raw = self.record_reject(profile=profile, app_key=app_key, apply_id=record_id, payload=payload)
171
231
  return self._public_action_response(
172
232
  raw,
@@ -186,7 +246,17 @@ class ApprovalTools(ToolBase):
186
246
  selection={"app_key": app_key, "record_id": record_id, "workflow_node_id": workflow_node_id},
187
247
  )
188
248
 
189
- def task_rollback(self, *, profile: str, app_key: str, record_id: int, payload: dict[str, Any]) -> dict[str, Any]:
249
+ def task_rollback(self, *, profile: str, app_key: str, record_id: int, payload: dict[str, Any], fields: dict[str, Any] | None = None) -> dict[str, Any]:
250
+ if fields:
251
+ return self._delegate_task_action(
252
+ profile=profile,
253
+ app_key=app_key,
254
+ record_id=record_id,
255
+ action="rollback",
256
+ payload=payload,
257
+ fields=fields,
258
+ public_action="task_rollback",
259
+ )
190
260
  raw = self.record_rollback(profile=profile, app_key=app_key, apply_id=record_id, payload=payload)
191
261
  return self._public_action_response(
192
262
  raw,
@@ -196,7 +266,17 @@ class ApprovalTools(ToolBase):
196
266
  human_review=True,
197
267
  )
198
268
 
199
- def task_transfer(self, *, profile: str, app_key: str, record_id: int, payload: dict[str, Any]) -> dict[str, Any]:
269
+ def task_transfer(self, *, profile: str, app_key: str, record_id: int, payload: dict[str, Any], fields: dict[str, Any] | None = None) -> dict[str, Any]:
270
+ if fields:
271
+ return self._delegate_task_action(
272
+ profile=profile,
273
+ app_key=app_key,
274
+ record_id=record_id,
275
+ action="transfer",
276
+ payload=payload,
277
+ fields=fields,
278
+ public_action="task_transfer",
279
+ )
200
280
  raw = self.record_transfer(profile=profile, app_key=app_key, apply_id=record_id, payload=payload)
201
281
  return self._public_action_response(
202
282
  raw,
@@ -206,6 +286,26 @@ class ApprovalTools(ToolBase):
206
286
  human_review=True,
207
287
  )
208
288
 
289
+ def task_save_only(
290
+ self,
291
+ *,
292
+ profile: str,
293
+ app_key: str,
294
+ record_id: int,
295
+ workflow_node_id: int,
296
+ fields: dict[str, Any],
297
+ ) -> dict[str, Any]:
298
+ return self._delegate_task_action(
299
+ profile=profile,
300
+ app_key=app_key,
301
+ record_id=record_id,
302
+ action="save_only",
303
+ payload={},
304
+ fields=fields,
305
+ public_action="task_save_only",
306
+ workflow_node_id=workflow_node_id,
307
+ )
308
+
209
309
  def task_transfer_candidates(
210
310
  self,
211
311
  *,
@@ -749,6 +849,41 @@ class ApprovalTools(ToolBase):
749
849
  if payload.get("handSignImageUrl"):
750
850
  raise_tool_error(QingflowApiError.not_supported("NOT_SUPPORTED_IN_V1: handSignImageUrl is not supported"))
751
851
 
852
+ def _delegate_task_action(
853
+ self,
854
+ *,
855
+ profile: str,
856
+ app_key: str,
857
+ record_id: int,
858
+ action: str,
859
+ payload: dict[str, Any],
860
+ fields: dict[str, Any],
861
+ public_action: str,
862
+ workflow_node_id: int | None = None,
863
+ ) -> dict[str, Any]:
864
+ from .task_context_tools import TaskContextTools
865
+
866
+ node_id = workflow_node_id
867
+ if node_id is None:
868
+ node_payload = dict(payload or {})
869
+ node_id = self._extract_node_id(node_payload)
870
+ delegated = TaskContextTools(self.sessions, self.backend).task_action_execute(
871
+ profile=profile,
872
+ app_key=app_key,
873
+ record_id=record_id,
874
+ workflow_node_id=node_id,
875
+ action=action,
876
+ payload=payload or {},
877
+ fields=fields or {},
878
+ )
879
+ response = dict(delegated)
880
+ data = response.get("data")
881
+ if isinstance(data, dict):
882
+ payload_data = dict(data)
883
+ payload_data["action"] = public_action
884
+ response["data"] = payload_data
885
+ return response
886
+
752
887
  def _public_page_response(
753
888
  self,
754
889
  raw: dict[str, Any],
@@ -13,6 +13,24 @@ from ..json_types import JSONObject
13
13
  from .approval_tools import ApprovalTools, _approval_page_amount, _approval_page_items, _approval_page_total
14
14
  from .base import ToolBase
15
15
  from .qingbi_report_tools import _qingbi_base_url
16
+ from .record_tools import (
17
+ LAYOUT_ONLY_QUE_TYPES,
18
+ SUBTABLE_QUE_TYPES,
19
+ RecordTools,
20
+ _build_applicant_hidden_linked_top_level_field_index,
21
+ _build_applicant_top_level_field_index,
22
+ _build_static_schema_linkage_payloads,
23
+ _canonical_value_is_empty,
24
+ _canonicalize_answer_value_for_compare,
25
+ _clone_form_field,
26
+ _coerce_count,
27
+ _collect_linked_required_field_ids,
28
+ _collect_option_linked_field_ids,
29
+ _collect_question_relations,
30
+ _field_ref_payload,
31
+ _merge_field_indexes,
32
+ _subtable_descendant_ids,
33
+ )
16
34
  from .task_tools import TaskTools, _task_page_amount, _task_page_items, _task_page_total
17
35
 
18
36
 
@@ -21,6 +39,7 @@ class TaskContextTools(ToolBase):
21
39
  super().__init__(sessions, backend)
22
40
  self._task_tools = TaskTools(sessions, backend)
23
41
  self._approval_tools = ApprovalTools(sessions, backend)
42
+ self._record_tools = RecordTools(sessions, backend)
24
43
 
25
44
  def register(self, mcp: FastMCP) -> None:
26
45
  @mcp.tool()
@@ -71,6 +90,7 @@ class TaskContextTools(ToolBase):
71
90
  workflow_node_id: int = 0,
72
91
  action: str = "",
73
92
  payload: dict[str, Any] | None = None,
93
+ fields: dict[str, Any] | None = None,
74
94
  ) -> dict[str, Any]:
75
95
  return self.task_action_execute(
76
96
  profile=profile,
@@ -79,6 +99,7 @@ class TaskContextTools(ToolBase):
79
99
  workflow_node_id=workflow_node_id,
80
100
  action=action,
81
101
  payload=payload or {},
102
+ fields=fields or {},
82
103
  )
83
104
 
84
105
  @mcp.tool()
@@ -186,7 +207,8 @@ class TaskContextTools(ToolBase):
186
207
 
187
208
  def runner(session_profile, context):
188
209
  data = self._build_task_context(
189
- context,
210
+ profile=profile,
211
+ context=context,
190
212
  app_key=app_key,
191
213
  record_id=record_id,
192
214
  workflow_node_id=workflow_node_id,
@@ -214,21 +236,30 @@ class TaskContextTools(ToolBase):
214
236
  workflow_node_id: int,
215
237
  action: str,
216
238
  payload: dict[str, Any],
239
+ fields: dict[str, Any] | None = None,
217
240
  ) -> dict[str, Any]:
218
241
  self._require_app_record_and_node(app_key, record_id, workflow_node_id)
219
242
  normalized_action = (action or "").strip().lower()
220
- if normalized_action not in {"approve", "reject", "rollback", "transfer", "urge"}:
243
+ if normalized_action not in {"approve", "reject", "rollback", "transfer", "urge", "save_only"}:
221
244
  raise_tool_error(
222
245
  QingflowApiError.not_supported(
223
- "TASK_ACTION_UNSUPPORTED: action must be one of approve, reject, rollback, transfer, or urge"
246
+ "TASK_ACTION_UNSUPPORTED: action must be one of approve, reject, rollback, transfer, urge, or save_only"
224
247
  )
225
248
  )
226
249
  body = dict(payload or {})
250
+ field_updates = dict(fields or {})
251
+ if field_updates and body.get("answers") is not None:
252
+ raise_tool_error(
253
+ QingflowApiError.config_error(
254
+ "task actions must not provide payload.answers and fields at the same time; pass field changes through fields only"
255
+ )
256
+ )
227
257
 
228
258
  def runner(session_profile, context):
229
259
  try:
230
260
  task_context = self._build_task_context(
231
- context,
261
+ profile=profile,
262
+ context=context,
232
263
  app_key=app_key,
233
264
  record_id=record_id,
234
265
  workflow_node_id=workflow_node_id,
@@ -249,6 +280,10 @@ class TaskContextTools(ToolBase):
249
280
  before_apply_status=None,
250
281
  )
251
282
  raise
283
+ if normalized_action == "save_only" and not field_updates:
284
+ raise_tool_error(
285
+ QingflowApiError.config_error("fields is required and must be non-empty for action 'save_only'")
286
+ )
252
287
  capabilities = task_context.get("capabilities") or {}
253
288
  available_actions = capabilities.get("available_actions") or []
254
289
  if normalized_action not in available_actions:
@@ -264,14 +299,33 @@ class TaskContextTools(ToolBase):
264
299
  f"payload.audit_feedback is required for action '{normalized_action}' on the current node"
265
300
  )
266
301
  )
302
+ if normalized_action == "urge" and field_updates:
303
+ raise_tool_error(
304
+ QingflowApiError.not_supported(
305
+ "TASK_ACTION_FIELDS_NOT_SUPPORTED: action 'urge' does not support fields because the downstream route does not accept task answers"
306
+ )
307
+ )
308
+ prepared_fields = None
309
+ if field_updates:
310
+ prepared_fields = self._prepare_task_field_update(
311
+ profile=profile,
312
+ context=context,
313
+ app_key=app_key,
314
+ record_id=record_id,
315
+ workflow_node_id=workflow_node_id,
316
+ task_context=task_context,
317
+ fields=field_updates,
318
+ )
267
319
  before_apply_status = ((task_context.get("record") or {}).get("apply_status"))
268
- runtime_baseline = self._capture_task_runtime_baseline(
269
- profile=profile,
270
- context=context,
271
- app_key=app_key,
272
- record_id=record_id,
273
- workflow_node_id=workflow_node_id,
274
- )
320
+ runtime_baseline = None
321
+ if normalized_action != "save_only":
322
+ runtime_baseline = self._capture_task_runtime_baseline(
323
+ profile=profile,
324
+ context=context,
325
+ app_key=app_key,
326
+ record_id=record_id,
327
+ workflow_node_id=workflow_node_id,
328
+ )
275
329
  try:
276
330
  raw = self._execute_task_action(
277
331
  profile=profile,
@@ -280,6 +334,7 @@ class TaskContextTools(ToolBase):
280
334
  workflow_node_id=workflow_node_id,
281
335
  normalized_action=normalized_action,
282
336
  payload=body,
337
+ prepared_fields=prepared_fields,
283
338
  )
284
339
  except QingflowApiError as error:
285
340
  if error.backend_code == 46001:
@@ -296,24 +351,39 @@ class TaskContextTools(ToolBase):
296
351
  )
297
352
  raise
298
353
 
299
- verification, warnings = self._verify_task_action_runtime(
300
- profile=profile,
301
- context=context,
302
- app_key=app_key,
303
- record_id=record_id,
304
- workflow_node_id=workflow_node_id,
305
- action=normalized_action,
306
- before_apply_status=before_apply_status,
307
- runtime_baseline=runtime_baseline,
308
- )
309
- runtime_verified = bool(verification.get("runtime_continuation_verified"))
310
- status = "success" if runtime_verified else "partial_success"
354
+ if normalized_action == "save_only":
355
+ verification, warnings = self._verify_task_save_only(
356
+ context=context,
357
+ app_key=app_key,
358
+ record_id=record_id,
359
+ workflow_node_id=workflow_node_id,
360
+ before_apply_status=before_apply_status,
361
+ expected_answers=((prepared_fields or {}).get("normalized_answers") or []),
362
+ task_context=task_context,
363
+ )
364
+ save_verified = bool(verification.get("fields_saved_verified")) and bool(verification.get("task_still_actionable"))
365
+ status = "success" if save_verified else "failed"
366
+ error_code = None if save_verified else "TASK_SAVE_ONLY_VERIFICATION_FAILED"
367
+ else:
368
+ verification, warnings = self._verify_task_action_runtime(
369
+ profile=profile,
370
+ context=context,
371
+ app_key=app_key,
372
+ record_id=record_id,
373
+ workflow_node_id=workflow_node_id,
374
+ action=normalized_action,
375
+ before_apply_status=before_apply_status,
376
+ runtime_baseline=runtime_baseline,
377
+ )
378
+ runtime_verified = bool(verification.get("runtime_continuation_verified"))
379
+ status = "success" if runtime_verified else "partial_success"
380
+ error_code = None if runtime_verified else "WORKFLOW_CONTINUATION_UNVERIFIED"
311
381
  return {
312
382
  "profile": raw.get("profile", profile),
313
383
  "ws_id": raw.get("ws_id", session_profile.selected_ws_id),
314
- "ok": bool(raw.get("ok", True)),
384
+ "ok": bool(raw.get("ok", True)) and status != "failed",
315
385
  "status": status,
316
- "error_code": None if runtime_verified else "WORKFLOW_CONTINUATION_UNVERIFIED",
386
+ "error_code": error_code,
317
387
  "request_route": raw.get("request_route") or self._request_route_payload(context),
318
388
  "warnings": warnings,
319
389
  "verification": verification,
@@ -328,6 +398,7 @@ class TaskContextTools(ToolBase):
328
398
  "selection": {"action": normalized_action},
329
399
  "result": raw.get("result"),
330
400
  "human_review": True,
401
+ "field_update_applied": bool(field_updates),
331
402
  },
332
403
  }
333
404
 
@@ -342,10 +413,18 @@ class TaskContextTools(ToolBase):
342
413
  workflow_node_id: int,
343
414
  normalized_action: str,
344
415
  payload: dict[str, Any],
416
+ prepared_fields: dict[str, Any] | None,
345
417
  ) -> dict[str, Any]:
418
+ merged_answers = None
419
+ if isinstance(prepared_fields, dict):
420
+ candidate_answers = prepared_fields.get("merged_answers")
421
+ if isinstance(candidate_answers, list):
422
+ merged_answers = candidate_answers
346
423
  if normalized_action == "approve":
347
424
  action_payload = dict(payload)
348
425
  action_payload["nodeId"] = workflow_node_id
426
+ if merged_answers is not None:
427
+ action_payload["answers"] = merged_answers
349
428
  return self._approval_tools.record_approve(
350
429
  profile=profile,
351
430
  app_key=app_key,
@@ -355,6 +434,8 @@ class TaskContextTools(ToolBase):
355
434
  if normalized_action == "reject":
356
435
  action_payload = dict(payload)
357
436
  action_payload["nodeId"] = workflow_node_id
437
+ if merged_answers is not None:
438
+ action_payload["answers"] = merged_answers
358
439
  if not self._extract_audit_feedback(action_payload):
359
440
  raise_tool_error(QingflowApiError.config_error("payload.audit_feedback is required for reject"))
360
441
  return self._approval_tools.record_reject(
@@ -372,6 +453,8 @@ class TaskContextTools(ToolBase):
372
453
  audit_feedback = self._extract_audit_feedback(payload)
373
454
  if audit_feedback:
374
455
  action_payload["auditFeedback"] = audit_feedback
456
+ if merged_answers is not None:
457
+ action_payload["answers"] = merged_answers
375
458
  return self._approval_tools.record_rollback(
376
459
  profile=profile,
377
460
  app_key=app_key,
@@ -387,12 +470,24 @@ class TaskContextTools(ToolBase):
387
470
  audit_feedback = self._extract_audit_feedback(payload)
388
471
  if audit_feedback:
389
472
  action_payload["auditFeedback"] = audit_feedback
473
+ if merged_answers is not None:
474
+ action_payload["answers"] = merged_answers
390
475
  return self._approval_tools.record_transfer(
391
476
  profile=profile,
392
477
  app_key=app_key,
393
478
  apply_id=record_id,
394
479
  payload=action_payload,
395
480
  )
481
+ if normalized_action == "save_only":
482
+ if merged_answers is None:
483
+ raise_tool_error(QingflowApiError.config_error("fields is required for action 'save_only'"))
484
+ return self._task_save_only(
485
+ profile=profile,
486
+ app_key=app_key,
487
+ record_id=record_id,
488
+ workflow_node_id=workflow_node_id,
489
+ merged_answers=merged_answers,
490
+ )
396
491
  return self._task_tools.task_urge(
397
492
  profile=profile,
398
493
  app_key=app_key,
@@ -522,6 +617,121 @@ class TaskContextTools(ToolBase):
522
617
  )
523
618
  return verification, warnings
524
619
 
620
+ def _verify_task_save_only(
621
+ self,
622
+ *,
623
+ context: BackendRequestContext,
624
+ app_key: str,
625
+ record_id: int,
626
+ workflow_node_id: int,
627
+ before_apply_status: Any,
628
+ expected_answers: list[dict[str, Any]],
629
+ task_context: dict[str, Any],
630
+ ) -> tuple[dict[str, Any], list[dict[str, Any]]]:
631
+ verification: dict[str, Any] = {
632
+ "action_executed": True,
633
+ "scope": "task_field_save",
634
+ "runtime_continuation_verified": False,
635
+ "task_context_visibility_verified": True,
636
+ "fields_saved_verified": False,
637
+ "task_still_actionable": False,
638
+ "workflow_not_advanced": False,
639
+ "before_apply_status": before_apply_status,
640
+ }
641
+ warnings: list[dict[str, Any]] = []
642
+ try:
643
+ detail = self.backend.request(
644
+ "GET",
645
+ context,
646
+ f"/app/{app_key}/apply/{record_id}",
647
+ params={"role": 3, "listType": 1, "auditNodeId": workflow_node_id},
648
+ )
649
+ except QingflowApiError as error:
650
+ verification["record_state_readable"] = False
651
+ verification["task_context_visibility_verified"] = False
652
+ verification["transport_error"] = {
653
+ "http_status": error.http_status,
654
+ "backend_code": error.backend_code,
655
+ "category": error.category,
656
+ }
657
+ warnings.append(
658
+ {
659
+ "code": "TASK_SAVE_ONLY_UNVERIFIED",
660
+ "message": "save_only write was sent, but MCP could not re-read the current task context to verify that the node remains actionable and fields were saved.",
661
+ }
662
+ )
663
+ return verification, warnings
664
+
665
+ verification["record_state_readable"] = True
666
+ verification["task_still_actionable"] = True
667
+ after_apply_status = detail.get("applyStatus") if isinstance(detail, dict) else None
668
+ verification["after_apply_status"] = after_apply_status
669
+ verification["workflow_not_advanced"] = after_apply_status == before_apply_status
670
+ current_record = task_context.get("record") if isinstance(task_context.get("record"), dict) else {}
671
+ actual_answers = detail.get("answers") if isinstance(detail, dict) and isinstance(detail.get("answers"), list) else []
672
+ expected_by_id = {
673
+ que_id: answer
674
+ for answer in expected_answers
675
+ if isinstance(answer, dict) and (que_id := _coerce_count(answer.get("queId"))) is not None and que_id > 0
676
+ }
677
+ actual_by_id = {
678
+ que_id: answer
679
+ for answer in actual_answers
680
+ if isinstance(answer, dict) and (que_id := _coerce_count(answer.get("queId"))) is not None and que_id > 0
681
+ }
682
+ update_schema = task_context.get("update_schema") if isinstance(task_context.get("update_schema"), dict) else {}
683
+ writable_titles = {
684
+ item.get("title"): item
685
+ for item in (update_schema.get("writable_fields") or [])
686
+ if isinstance(item, dict) and item.get("title")
687
+ }
688
+ missing_fields: list[dict[str, Any]] = []
689
+ mismatched_fields: list[dict[str, Any]] = []
690
+ for que_id, expected in expected_by_id.items():
691
+ actual = actual_by_id.get(que_id)
692
+ title = None
693
+ if isinstance(current_record, dict):
694
+ for answer in (current_record.get("answers") or []):
695
+ if isinstance(answer, dict) and _coerce_count(answer.get("queId")) == que_id:
696
+ title = answer.get("queTitle")
697
+ break
698
+ if title is None:
699
+ title = next((key for key, item in writable_titles.items() if isinstance(item, dict) and item.get("field_id") == que_id), None)
700
+ field_payload = {"que_id": que_id, "que_title": title}
701
+ if actual is None:
702
+ missing_fields.append(field_payload)
703
+ continue
704
+ expected_value = _canonicalize_answer_value_for_compare(expected, None)
705
+ actual_value = _canonicalize_answer_value_for_compare(actual, None)
706
+ if _canonical_value_is_empty(expected_value):
707
+ continue
708
+ if actual_value != expected_value:
709
+ mismatched_fields.append(
710
+ {
711
+ **field_payload,
712
+ "expected": expected_value,
713
+ "actual": actual_value,
714
+ }
715
+ )
716
+ verification["missing_fields"] = missing_fields
717
+ verification["mismatched_fields"] = mismatched_fields
718
+ verification["fields_saved_verified"] = not missing_fields and not mismatched_fields
719
+ if not verification["workflow_not_advanced"]:
720
+ warnings.append(
721
+ {
722
+ "code": "TASK_SAVE_ONLY_ADVANCED_WORKFLOW",
723
+ "message": "save_only unexpectedly changed the workflow runtime state; the task should have remained on the current node.",
724
+ }
725
+ )
726
+ if not verification["fields_saved_verified"]:
727
+ warnings.append(
728
+ {
729
+ "code": "TASK_SAVE_ONLY_FIELD_VERIFICATION_FAILED",
730
+ "message": "save_only completed, but MCP could not verify that all requested field changes were persisted on the current task node.",
731
+ }
732
+ )
733
+ return verification, warnings
734
+
525
735
  def _capture_task_runtime_baseline(
526
736
  self,
527
737
  *,
@@ -695,7 +905,8 @@ class TaskContextTools(ToolBase):
695
905
 
696
906
  def runner(session_profile, context):
697
907
  task_context = self._build_task_context(
698
- context,
908
+ profile=profile,
909
+ context=context,
699
910
  app_key=app_key,
700
911
  record_id=record_id,
701
912
  workflow_node_id=workflow_node_id,
@@ -876,7 +1087,8 @@ class TaskContextTools(ToolBase):
876
1087
 
877
1088
  def runner(session_profile, context):
878
1089
  task_context = self._build_task_context(
879
- context,
1090
+ profile=profile,
1091
+ context=context,
880
1092
  app_key=app_key,
881
1093
  record_id=record_id,
882
1094
  workflow_node_id=workflow_node_id,
@@ -929,6 +1141,7 @@ class TaskContextTools(ToolBase):
929
1141
 
930
1142
  def _build_task_context(
931
1143
  self,
1144
+ profile: str,
932
1145
  context: BackendRequestContext,
933
1146
  *,
934
1147
  app_key: str,
@@ -987,7 +1200,20 @@ class TaskContextTools(ToolBase):
987
1200
  )
988
1201
  transfer_items = _approval_page_items(transfer_result)
989
1202
 
990
- capabilities = self._build_capabilities(node_info)
1203
+ update_schema_state = self._build_task_update_schema(
1204
+ profile=profile,
1205
+ context=context,
1206
+ app_key=app_key,
1207
+ record_id=record_id,
1208
+ workflow_node_id=workflow_node_id,
1209
+ node_info=node_info,
1210
+ current_answers=detail.get("answers") or [],
1211
+ )
1212
+ update_schema = update_schema_state["public_schema"]
1213
+ capabilities = self._build_capabilities(
1214
+ node_info,
1215
+ allow_save_only=bool(update_schema.get("writable_fields")),
1216
+ )
991
1217
  visibility = self._build_visibility(node_info, detail)
992
1218
  return {
993
1219
  "task": {
@@ -997,6 +1223,11 @@ class TaskContextTools(ToolBase):
997
1223
  "workflow_node_name": node_info.get("auditNodeName") or node_info.get("nodeName"),
998
1224
  "actionable": True,
999
1225
  },
1226
+ "node": {
1227
+ "workflow_node_id": workflow_node_id,
1228
+ "workflow_node_name": node_info.get("auditNodeName") or node_info.get("nodeName"),
1229
+ "raw": dict(node_info),
1230
+ },
1000
1231
  "record": {
1001
1232
  "apply_id": detail.get("applyId", record_id),
1002
1233
  "apply_status": detail.get("applyStatus"),
@@ -1010,6 +1241,8 @@ class TaskContextTools(ToolBase):
1010
1241
  "capabilities": capabilities,
1011
1242
  "field_permissions": {
1012
1243
  "que_auth_setting": node_info.get("queAuthSetting") or [],
1244
+ "editable_question_ids": update_schema_state["editable_question_ids"],
1245
+ "editable_question_ids_source": update_schema_state["editable_question_ids_source"],
1013
1246
  },
1014
1247
  "visibility": visibility,
1015
1248
  "associated_reports": associated_reports,
@@ -1023,6 +1256,7 @@ class TaskContextTools(ToolBase):
1023
1256
  "history_count": None,
1024
1257
  "qrobot_log_visible": visibility["qrobot_record_visible"],
1025
1258
  },
1259
+ "update_schema": update_schema,
1026
1260
  }
1027
1261
 
1028
1262
  def _normalize_task_item(self, raw: dict[str, Any], *, task_box: str, flow_status: str) -> dict[str, Any]:
@@ -1071,7 +1305,7 @@ class TaskContextTools(ToolBase):
1071
1305
  )
1072
1306
  )
1073
1307
 
1074
- def _build_capabilities(self, node_info: dict[str, Any]) -> dict[str, Any]:
1308
+ def _build_capabilities(self, node_info: dict[str, Any], *, allow_save_only: bool) -> dict[str, Any]:
1075
1309
  available_actions = ["approve"]
1076
1310
  if self._coerce_bool(node_info.get("rejectBtnStatus")):
1077
1311
  available_actions.append("reject")
@@ -1081,6 +1315,8 @@ class TaskContextTools(ToolBase):
1081
1315
  available_actions.append("transfer")
1082
1316
  if self._coerce_bool(node_info.get("canUrge")):
1083
1317
  available_actions.append("urge")
1318
+ if allow_save_only:
1319
+ available_actions.append("save_only")
1084
1320
 
1085
1321
  visible_but_unimplemented_actions: list[str] = []
1086
1322
  if self._coerce_bool(node_info.get("canRevoke")):
@@ -1107,6 +1343,362 @@ class TaskContextTools(ToolBase):
1107
1343
  },
1108
1344
  }
1109
1345
 
1346
+ def _build_task_update_schema(
1347
+ self,
1348
+ profile: str,
1349
+ context: BackendRequestContext,
1350
+ *,
1351
+ app_key: str,
1352
+ record_id: int,
1353
+ workflow_node_id: int,
1354
+ node_info: dict[str, Any],
1355
+ current_answers: Any,
1356
+ ) -> dict[str, Any]:
1357
+ try:
1358
+ app_schema = self._record_tools._get_form_schema(profile, context, app_key, force_refresh=False)
1359
+ except QingflowApiError as error:
1360
+ public_schema: JSONObject = {
1361
+ "schema_scope": "task_update_ready",
1362
+ "writable_fields": [],
1363
+ "payload_template": {},
1364
+ "blockers": ["TASK_UPDATE_SCHEMA_UNAVAILABLE"],
1365
+ "warnings": [
1366
+ {
1367
+ "code": "TASK_UPDATE_SCHEMA_UNAVAILABLE",
1368
+ "message": "task detail could not load the form schema for the current app, so node-scoped update schema is unavailable.",
1369
+ }
1370
+ ],
1371
+ "selection": {
1372
+ "app_key": app_key,
1373
+ "record_id": record_id,
1374
+ "workflow_node_id": workflow_node_id,
1375
+ },
1376
+ "transport_error": {
1377
+ "http_status": error.http_status,
1378
+ "backend_code": error.backend_code,
1379
+ "category": error.category,
1380
+ },
1381
+ }
1382
+ return {
1383
+ "public_schema": public_schema,
1384
+ "index": None,
1385
+ "editable_question_ids": [],
1386
+ "effective_editable_question_ids": [],
1387
+ "editable_question_ids_source": "schema_unavailable",
1388
+ }
1389
+
1390
+ question_relations = _collect_question_relations(app_schema)
1391
+ linked_field_ids = _collect_linked_required_field_ids(question_relations)
1392
+ base_index = _build_applicant_top_level_field_index(app_schema)
1393
+ linked_field_ids.update(_collect_option_linked_field_ids(base_index))
1394
+ linked_hidden_index = _build_applicant_hidden_linked_top_level_field_index(
1395
+ app_schema,
1396
+ linked_field_ids=linked_field_ids,
1397
+ )
1398
+ index = _merge_field_indexes(base_index, linked_hidden_index)
1399
+ editable_question_ids, schema_warnings, source = self._resolve_task_editable_question_ids(
1400
+ context,
1401
+ app_key=app_key,
1402
+ workflow_node_id=workflow_node_id,
1403
+ node_info=node_info,
1404
+ )
1405
+ effective_editable_ids = set(editable_question_ids)
1406
+ for field in index.by_id.values():
1407
+ if field.que_type in SUBTABLE_QUE_TYPES and (_subtable_descendant_ids(field) & set(editable_question_ids)):
1408
+ effective_editable_ids.add(field.que_id)
1409
+ writable_fields: list[JSONObject] = []
1410
+ linkage_payloads_by_field_id = _build_static_schema_linkage_payloads(
1411
+ index=index,
1412
+ question_relations=question_relations,
1413
+ )
1414
+ for field in index.by_id.values():
1415
+ if field.que_type in LAYOUT_ONLY_QUE_TYPES or field.que_id not in effective_editable_ids:
1416
+ continue
1417
+ editable_field = _clone_form_field(field, readonly=False)
1418
+ write_hints = self._record_tools._schema_write_hints(editable_field)
1419
+ if not bool(write_hints.get("writable")):
1420
+ continue
1421
+ writable_fields.append(
1422
+ self._record_tools._ready_schema_field_payload(
1423
+ profile,
1424
+ context,
1425
+ editable_field,
1426
+ ws_id=context.ws_id,
1427
+ required_override=False,
1428
+ linkage_payloads_by_field_id=linkage_payloads_by_field_id,
1429
+ )
1430
+ )
1431
+ blockers: list[str] = []
1432
+ if not writable_fields:
1433
+ blockers.append("NO_TASK_EDITABLE_FIELDS")
1434
+ schema_warnings.append(
1435
+ {
1436
+ "code": "NO_TASK_EDITABLE_FIELDS",
1437
+ "message": "the current task node does not expose any writable fields for task-scoped edits.",
1438
+ }
1439
+ )
1440
+ public_schema: JSONObject = {
1441
+ "schema_scope": "task_update_ready",
1442
+ "writable_fields": writable_fields,
1443
+ "payload_template": {
1444
+ item["title"]: self._record_tools._ready_schema_template_value(item)
1445
+ for item in writable_fields
1446
+ if isinstance(item, dict) and item.get("title")
1447
+ },
1448
+ "blockers": blockers,
1449
+ "warnings": schema_warnings,
1450
+ "selection": {
1451
+ "app_key": app_key,
1452
+ "record_id": record_id,
1453
+ "workflow_node_id": workflow_node_id,
1454
+ },
1455
+ }
1456
+ return {
1457
+ "public_schema": public_schema,
1458
+ "index": index,
1459
+ "editable_question_ids": sorted(editable_question_ids),
1460
+ "effective_editable_question_ids": sorted(effective_editable_ids),
1461
+ "editable_question_ids_source": source,
1462
+ }
1463
+
1464
+ def _resolve_task_editable_question_ids(
1465
+ self,
1466
+ context: BackendRequestContext,
1467
+ *,
1468
+ app_key: str,
1469
+ workflow_node_id: int,
1470
+ node_info: dict[str, Any],
1471
+ ) -> tuple[set[int], list[JSONObject], str]:
1472
+ warnings: list[JSONObject] = []
1473
+ try:
1474
+ payload = self.backend.request(
1475
+ "GET",
1476
+ context,
1477
+ f"/app/{app_key}/auditNode/{workflow_node_id}/editableQueIds",
1478
+ )
1479
+ question_ids = self._extract_question_ids(payload)
1480
+ if question_ids:
1481
+ return question_ids, warnings, "workflow_editable_que_ids"
1482
+ except QingflowApiError as error:
1483
+ if error.backend_code not in {40002, 40027, 404} and error.http_status != 404:
1484
+ raise
1485
+ warnings.append(
1486
+ {
1487
+ "code": "TASK_EDITABLE_IDS_FALLBACK",
1488
+ "message": "editable question ids endpoint is unavailable in the current route; task update schema fell back to queAuthSetting and may be conservative.",
1489
+ }
1490
+ )
1491
+ fallback_ids = self._editable_ids_from_que_auth_setting(node_info.get("queAuthSetting"))
1492
+ return fallback_ids, warnings, "que_auth_setting"
1493
+
1494
+ def _extract_question_ids(self, payload: Any) -> set[int]:
1495
+ candidates: list[Any] = []
1496
+ if isinstance(payload, list):
1497
+ candidates = payload
1498
+ elif isinstance(payload, dict):
1499
+ for key in ("editableQueIds", "editableQuestionIds", "queIds", "questionIds", "ids", "list", "result"):
1500
+ value = payload.get(key)
1501
+ if isinstance(value, list):
1502
+ candidates = value
1503
+ break
1504
+ question_ids: set[int] = set()
1505
+ for item in candidates:
1506
+ if isinstance(item, int) and item > 0:
1507
+ question_ids.add(item)
1508
+ continue
1509
+ if isinstance(item, dict):
1510
+ for key in ("queId", "questionId", "id"):
1511
+ value = _coerce_count(item.get(key))
1512
+ if value is not None and value > 0:
1513
+ question_ids.add(value)
1514
+ break
1515
+ return question_ids
1516
+
1517
+ def _editable_ids_from_que_auth_setting(self, payload: Any) -> set[int]:
1518
+ if not isinstance(payload, list):
1519
+ return set()
1520
+ editable_ids: set[int] = set()
1521
+ for item in payload:
1522
+ if not isinstance(item, dict):
1523
+ continue
1524
+ que_id = _coerce_count(item.get("queId") or item.get("questionId"))
1525
+ if que_id is None or que_id <= 0:
1526
+ continue
1527
+ explicit_editable_keys = ("editable", "writable", "canEdit", "editStatus", "beingEditable")
1528
+ explicit_readonly_keys = ("readonly", "beingReadonly")
1529
+ if any(key in item for key in explicit_editable_keys):
1530
+ if any(bool(item.get(key)) for key in explicit_editable_keys):
1531
+ editable_ids.add(que_id)
1532
+ continue
1533
+ if any(bool(item.get(key)) for key in explicit_readonly_keys):
1534
+ continue
1535
+ if item.get("readable") is False:
1536
+ continue
1537
+ if item.get("readable") is True or item.get("visible") is True:
1538
+ editable_ids.add(que_id)
1539
+ return editable_ids
1540
+
1541
+ def _prepare_task_field_update(
1542
+ self,
1543
+ *,
1544
+ profile: str,
1545
+ context: BackendRequestContext,
1546
+ app_key: str,
1547
+ record_id: int,
1548
+ workflow_node_id: int,
1549
+ task_context: dict[str, Any],
1550
+ fields: dict[str, Any],
1551
+ ) -> dict[str, Any]:
1552
+ record = task_context.get("record") if isinstance(task_context.get("record"), dict) else {}
1553
+ current_answers = record.get("answers") if isinstance(record.get("answers"), list) else []
1554
+ node = task_context.get("node") if isinstance(task_context.get("node"), dict) else {}
1555
+ node_info = node.get("raw") if isinstance(node.get("raw"), dict) else {}
1556
+ schema_state = self._build_task_update_schema(
1557
+ profile=profile,
1558
+ context=context,
1559
+ app_key=app_key,
1560
+ record_id=record_id,
1561
+ workflow_node_id=workflow_node_id,
1562
+ node_info=node_info,
1563
+ current_answers=current_answers,
1564
+ )
1565
+ update_schema = schema_state["public_schema"]
1566
+ if update_schema.get("blockers"):
1567
+ raise_tool_error(
1568
+ QingflowApiError(
1569
+ category="config",
1570
+ message="task field update is blocked because the current node does not expose a usable update schema",
1571
+ details={
1572
+ "error_code": "TASK_UPDATE_SCHEMA_BLOCKED",
1573
+ "update_schema": update_schema,
1574
+ },
1575
+ )
1576
+ )
1577
+ preflight = self._record_tools._build_record_write_preflight(
1578
+ profile=profile,
1579
+ context=context,
1580
+ operation="update",
1581
+ app_key=app_key,
1582
+ apply_id=record_id,
1583
+ answers=[],
1584
+ fields=fields,
1585
+ force_refresh_form=False,
1586
+ view_id=None,
1587
+ list_type=None,
1588
+ view_key=None,
1589
+ view_name=None,
1590
+ existing_answers_override=current_answers,
1591
+ )
1592
+ index = schema_state["index"]
1593
+ effective_editable_ids = set(schema_state["effective_editable_question_ids"])
1594
+ scoped_field_errors = self._task_scope_field_errors(
1595
+ normalized_answers=preflight.get("normalized_answers") or [],
1596
+ index=index,
1597
+ effective_editable_ids=effective_editable_ids,
1598
+ )
1599
+ field_errors = list(preflight.get("field_errors") or [])
1600
+ field_errors.extend(scoped_field_errors)
1601
+ blockers = list(preflight.get("blockers") or [])
1602
+ if scoped_field_errors:
1603
+ blockers.append("payload writes fields that are not editable on the current task node")
1604
+ confirmation_requests = list(preflight.get("confirmation_requests") or [])
1605
+ if field_errors or confirmation_requests or blockers:
1606
+ raise_tool_error(
1607
+ QingflowApiError(
1608
+ category="config",
1609
+ message="task field update preflight was blocked",
1610
+ details={
1611
+ "error_code": "TASK_FIELD_PLAN_BLOCKED",
1612
+ "blockers": blockers,
1613
+ "field_errors": field_errors,
1614
+ "confirmation_requests": confirmation_requests,
1615
+ "update_schema": update_schema,
1616
+ "recommended_next_actions": preflight.get("recommended_next_actions") or [],
1617
+ },
1618
+ )
1619
+ )
1620
+ normalized_answers = [item for item in (preflight.get("normalized_answers") or []) if isinstance(item, dict)]
1621
+ merged_answers = self._record_tools._merge_record_answers(current_answers, normalized_answers)
1622
+ return {
1623
+ "update_schema": update_schema,
1624
+ "normalized_answers": normalized_answers,
1625
+ "merged_answers": merged_answers,
1626
+ }
1627
+
1628
+ def _task_scope_field_errors(
1629
+ self,
1630
+ *,
1631
+ normalized_answers: list[dict[str, Any]],
1632
+ index: Any,
1633
+ effective_editable_ids: set[int],
1634
+ ) -> list[dict[str, Any]]:
1635
+ if index is None:
1636
+ return []
1637
+ field_errors: list[dict[str, Any]] = []
1638
+ for answer in normalized_answers:
1639
+ que_id = _coerce_count(answer.get("queId"))
1640
+ if que_id is None or que_id <= 0:
1641
+ continue
1642
+ field = index.by_id.get(str(que_id))
1643
+ field_payload = _field_ref_payload(field) if field is not None else {"que_id": que_id}
1644
+ if que_id not in effective_editable_ids:
1645
+ field_errors.append(
1646
+ {
1647
+ "location": field.que_title if field is not None else str(que_id),
1648
+ "message": "field is not editable on the current task node",
1649
+ "error_code": "TASK_FIELD_NOT_EDITABLE",
1650
+ "field": field_payload,
1651
+ }
1652
+ )
1653
+ continue
1654
+ if field is None or field.que_type not in SUBTABLE_QUE_TYPES:
1655
+ continue
1656
+ table_values = answer.get("tableValues") if isinstance(answer.get("tableValues"), list) else []
1657
+ subtable_index = self._record_tools._subtable_field_index_optional(field)
1658
+ for row_ordinal, row in enumerate(table_values, start=1):
1659
+ row_cells = [item for item in row if isinstance(item, dict)] if isinstance(row, list) else []
1660
+ for cell in row_cells:
1661
+ cell_que_id = _coerce_count(cell.get("queId"))
1662
+ if cell_que_id is None or cell_que_id <= 0 or cell_que_id in effective_editable_ids:
1663
+ continue
1664
+ subfield = subtable_index.by_id.get(str(cell_que_id)) if subtable_index is not None else None
1665
+ field_errors.append(
1666
+ {
1667
+ "location": f"{field.que_title}[{row_ordinal}].{subfield.que_title if subfield is not None else cell_que_id}",
1668
+ "message": "subtable field is not editable on the current task node",
1669
+ "error_code": "TASK_FIELD_NOT_EDITABLE",
1670
+ "field": _field_ref_payload(subfield) if subfield is not None else {"que_id": cell_que_id},
1671
+ }
1672
+ )
1673
+ return field_errors
1674
+
1675
+ def _task_save_only(
1676
+ self,
1677
+ *,
1678
+ profile: str,
1679
+ app_key: str,
1680
+ record_id: int,
1681
+ workflow_node_id: int,
1682
+ merged_answers: list[dict[str, Any]],
1683
+ ) -> dict[str, Any]:
1684
+ def runner(session_profile, context):
1685
+ result = self.backend.request(
1686
+ "POST",
1687
+ context,
1688
+ f"/app/{app_key}/apply/{record_id}",
1689
+ json_body={"role": 3, "auditNodeId": workflow_node_id, "answers": merged_answers},
1690
+ )
1691
+ return {
1692
+ "profile": profile,
1693
+ "ws_id": session_profile.selected_ws_id,
1694
+ "app_key": app_key,
1695
+ "apply_id": record_id,
1696
+ "result": result,
1697
+ "request_route": self._request_route_payload(context),
1698
+ }
1699
+
1700
+ return self._run(profile, runner)
1701
+
1110
1702
  def _build_visibility(self, node_info: dict[str, Any], detail: dict[str, Any]) -> dict[str, bool]:
1111
1703
  return {
1112
1704
  "comment_visible": self._coerce_bool(node_info.get("commentStatus")),