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

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.
@@ -14,9 +14,11 @@ from .approval_tools import ApprovalTools, _approval_page_amount, _approval_page
14
14
  from .base import ToolBase
15
15
  from .qingbi_report_tools import _qingbi_base_url
16
16
  from .record_tools import (
17
+ FieldIndex,
17
18
  LAYOUT_ONLY_QUE_TYPES,
18
19
  SUBTABLE_QUE_TYPES,
19
20
  RecordTools,
21
+ _build_answer_backed_field_index,
20
22
  _build_applicant_hidden_linked_top_level_field_index,
21
23
  _build_applicant_top_level_field_index,
22
24
  _build_static_schema_linkage_payloads,
@@ -102,6 +104,22 @@ class TaskContextTools(ToolBase):
102
104
  fields=fields or {},
103
105
  )
104
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
+
105
123
  @mcp.tool()
106
124
  def task_associated_report_detail_get(
107
125
  profile: str = DEFAULT_PROFILE,
@@ -228,6 +246,28 @@ class TaskContextTools(ToolBase):
228
246
 
229
247
  return self._run(profile, runner)
230
248
 
249
+ def task_save_only(
250
+ self,
251
+ *,
252
+ profile: str,
253
+ app_key: str,
254
+ record_id: int,
255
+ workflow_node_id: int,
256
+ fields: dict[str, Any] | None = None,
257
+ ) -> dict[str, Any]:
258
+ field_updates = dict(fields or {})
259
+ if not field_updates:
260
+ raise_tool_error(QingflowApiError.config_error("fields is required and must be non-empty for task_save_only"))
261
+ return self.task_action_execute(
262
+ profile=profile,
263
+ app_key=app_key,
264
+ record_id=record_id,
265
+ workflow_node_id=workflow_node_id,
266
+ action="save_only",
267
+ payload={},
268
+ fields=field_updates,
269
+ )
270
+
231
271
  def task_action_execute(
232
272
  self,
233
273
  *,
@@ -297,6 +337,15 @@ class TaskContextTools(ToolBase):
297
337
  capabilities = task_context.get("capabilities") or {}
298
338
  available_actions = capabilities.get("available_actions") or []
299
339
  if normalized_action not in available_actions:
340
+ if normalized_action == "save_only":
341
+ capability_warnings = capabilities.get("warnings") or []
342
+ message = (
343
+ "task action 'save_only' is not currently available for the current node; "
344
+ "MCP only exposes save_only when backend editableQueIds returns a non-empty result"
345
+ )
346
+ if capability_warnings:
347
+ message += "; backend editableQueIds is unavailable or empty for this task context"
348
+ raise_tool_error(QingflowApiError.config_error(message))
300
349
  raise_tool_error(
301
350
  QingflowApiError.config_error(
302
351
  f"task action '{normalized_action}' is not currently available for app_key='{app_key}' record_id={record_id} workflow_node_id={workflow_node_id}"
@@ -426,10 +475,14 @@ class TaskContextTools(ToolBase):
426
475
  prepared_fields: dict[str, Any] | None,
427
476
  ) -> dict[str, Any]:
428
477
  merged_answers = None
478
+ normalized_answers = None
429
479
  if isinstance(prepared_fields, dict):
430
480
  candidate_answers = prepared_fields.get("merged_answers")
431
481
  if isinstance(candidate_answers, list):
432
482
  merged_answers = candidate_answers
483
+ candidate_normalized = prepared_fields.get("normalized_answers")
484
+ if isinstance(candidate_normalized, list):
485
+ normalized_answers = candidate_normalized
433
486
  if normalized_action == "approve":
434
487
  action_payload = dict(payload)
435
488
  action_payload["nodeId"] = workflow_node_id
@@ -489,14 +542,14 @@ class TaskContextTools(ToolBase):
489
542
  payload=action_payload,
490
543
  )
491
544
  if normalized_action == "save_only":
492
- if merged_answers is None:
545
+ if normalized_answers is None:
493
546
  raise_tool_error(QingflowApiError.config_error("fields is required for action 'save_only'"))
494
547
  return self._task_save_only(
495
548
  profile=profile,
496
549
  app_key=app_key,
497
550
  record_id=record_id,
498
551
  workflow_node_id=workflow_node_id,
499
- merged_answers=merged_answers,
552
+ apply_answers=normalized_answers,
500
553
  )
501
554
  return self._task_tools.task_urge(
502
555
  profile=profile,
@@ -1223,9 +1276,16 @@ class TaskContextTools(ToolBase):
1223
1276
  current_answers=detail.get("answers") or [],
1224
1277
  )
1225
1278
  update_schema = update_schema_state["public_schema"]
1279
+ save_only_available, capability_warnings, save_only_source = self._resolve_task_save_only_availability(
1280
+ context,
1281
+ app_key=app_key,
1282
+ workflow_node_id=workflow_node_id,
1283
+ )
1226
1284
  capabilities = self._build_capabilities(
1227
1285
  node_info,
1228
- allow_save_only=bool(update_schema.get("writable_fields")),
1286
+ allow_save_only=save_only_available,
1287
+ warnings=capability_warnings,
1288
+ save_only_source=save_only_source,
1229
1289
  )
1230
1290
  visibility = self._build_visibility(node_info, detail)
1231
1291
  return {
@@ -1318,7 +1378,14 @@ class TaskContextTools(ToolBase):
1318
1378
  )
1319
1379
  )
1320
1380
 
1321
- def _build_capabilities(self, node_info: dict[str, Any], *, allow_save_only: bool) -> dict[str, Any]:
1381
+ def _build_capabilities(
1382
+ self,
1383
+ node_info: dict[str, Any],
1384
+ *,
1385
+ allow_save_only: bool,
1386
+ warnings: list[JSONObject] | None = None,
1387
+ save_only_source: str = "workflow_editable_que_ids",
1388
+ ) -> dict[str, Any]:
1322
1389
  available_actions = ["approve"]
1323
1390
  if self._coerce_bool(node_info.get("rejectBtnStatus")):
1324
1391
  available_actions.append("reject")
@@ -1347,6 +1414,8 @@ class TaskContextTools(ToolBase):
1347
1414
  return {
1348
1415
  "available_actions": available_actions,
1349
1416
  "visible_but_unimplemented_actions": visible_but_unimplemented_actions,
1417
+ "save_only_source": save_only_source,
1418
+ "warnings": list(warnings or []),
1350
1419
  "action_constraints": {
1351
1420
  "feedback_required_for": feedback_required_for,
1352
1421
  "submit_check_enabled": self._coerce_bool(node_info.get("beingSubmitCheck")),
@@ -1415,6 +1484,12 @@ class TaskContextTools(ToolBase):
1415
1484
  workflow_node_id=workflow_node_id,
1416
1485
  node_info=node_info,
1417
1486
  )
1487
+ index, augmentation_warnings = self._augment_task_editable_field_index(
1488
+ index=index,
1489
+ current_answers=current_answers,
1490
+ editable_question_ids=editable_question_ids,
1491
+ )
1492
+ schema_warnings.extend(augmentation_warnings)
1418
1493
  effective_editable_ids = set(editable_question_ids)
1419
1494
  for field in index.by_id.values():
1420
1495
  if field.que_type in SUBTABLE_QUE_TYPES and (_subtable_descendant_ids(field) & set(editable_question_ids)):
@@ -1474,6 +1549,44 @@ class TaskContextTools(ToolBase):
1474
1549
  "editable_question_ids_source": source,
1475
1550
  }
1476
1551
 
1552
+ def _augment_task_editable_field_index(
1553
+ self,
1554
+ *,
1555
+ index: FieldIndex,
1556
+ current_answers: Any,
1557
+ editable_question_ids: set[int],
1558
+ ) -> tuple[FieldIndex, list[JSONObject]]:
1559
+ if not editable_question_ids:
1560
+ return index, []
1561
+ missing_field_ids = {
1562
+ str(que_id)
1563
+ for que_id in editable_question_ids
1564
+ if que_id > 0 and str(que_id) not in index.by_id
1565
+ }
1566
+ if not missing_field_ids:
1567
+ return index, []
1568
+ answer_backed_index = _build_answer_backed_field_index(
1569
+ current_answers,
1570
+ field_id_filter=missing_field_ids,
1571
+ )
1572
+ if not answer_backed_index.by_id:
1573
+ return index, []
1574
+ augmented_index = _merge_field_indexes(index, answer_backed_index)
1575
+ return augmented_index, [
1576
+ {
1577
+ "code": "TASK_RUNTIME_EDITABLE_FIELDS_AUGMENTED",
1578
+ "message": "task update schema added backend-editable fields from current task answers because applicant schema did not expose them.",
1579
+ "fields": [
1580
+ {
1581
+ "field_id": field.que_id,
1582
+ "title": field.que_title,
1583
+ "que_type": field.que_type,
1584
+ }
1585
+ for field in answer_backed_index.by_id.values()
1586
+ ],
1587
+ }
1588
+ ]
1589
+
1477
1590
  def _resolve_task_editable_question_ids(
1478
1591
  self,
1479
1592
  context: BackendRequestContext,
@@ -1504,6 +1617,31 @@ class TaskContextTools(ToolBase):
1504
1617
  fallback_ids = self._editable_ids_from_que_auth_setting(node_info.get("queAuthSetting"))
1505
1618
  return fallback_ids, warnings, "que_auth_setting"
1506
1619
 
1620
+ def _resolve_task_save_only_availability(
1621
+ self,
1622
+ context: BackendRequestContext,
1623
+ *,
1624
+ app_key: str,
1625
+ workflow_node_id: int,
1626
+ ) -> tuple[bool, list[JSONObject], str]:
1627
+ try:
1628
+ payload = self.backend.request(
1629
+ "GET",
1630
+ context,
1631
+ f"/app/{app_key}/auditNode/{workflow_node_id}/editableQueIds",
1632
+ )
1633
+ except QingflowApiError as error:
1634
+ warning: JSONObject = {
1635
+ "code": "TASK_SAVE_ONLY_SIGNAL_UNAVAILABLE",
1636
+ "message": "save_only is hidden because backend editableQueIds is unavailable for the current node; MCP no longer infers save_only from local schema reconstruction.",
1637
+ }
1638
+ if error.backend_code is not None:
1639
+ warning["backend_code"] = error.backend_code
1640
+ if error.http_status is not None:
1641
+ warning["http_status"] = error.http_status
1642
+ return False, [warning], "backend_editable_que_ids_unavailable"
1643
+ return bool(self._extract_question_ids(payload)), [], "workflow_editable_que_ids"
1644
+
1507
1645
  def _extract_question_ids(self, payload: Any) -> set[int]:
1508
1646
  candidates: list[Any] = []
1509
1647
  if isinstance(payload, list):
@@ -1587,6 +1725,7 @@ class TaskContextTools(ToolBase):
1587
1725
  },
1588
1726
  )
1589
1727
  )
1728
+ index = schema_state["index"]
1590
1729
  preflight = self._record_tools._build_record_write_preflight(
1591
1730
  profile=profile,
1592
1731
  context=context,
@@ -1601,8 +1740,8 @@ class TaskContextTools(ToolBase):
1601
1740
  view_key=None,
1602
1741
  view_name=None,
1603
1742
  existing_answers_override=current_answers,
1743
+ field_index_override=index,
1604
1744
  )
1605
- index = schema_state["index"]
1606
1745
  effective_editable_ids = set(schema_state["effective_editable_question_ids"])
1607
1746
  scoped_field_errors = self._task_scope_field_errors(
1608
1747
  normalized_answers=preflight.get("normalized_answers") or [],
@@ -1692,14 +1831,14 @@ class TaskContextTools(ToolBase):
1692
1831
  app_key: str,
1693
1832
  record_id: int,
1694
1833
  workflow_node_id: int,
1695
- merged_answers: list[dict[str, Any]],
1834
+ apply_answers: list[dict[str, Any]],
1696
1835
  ) -> dict[str, Any]:
1697
1836
  def runner(session_profile, context):
1698
1837
  result = self.backend.request(
1699
1838
  "POST",
1700
1839
  context,
1701
1840
  f"/app/{app_key}/apply/{record_id}",
1702
- json_body={"role": 3, "auditNodeId": workflow_node_id, "answers": merged_answers},
1841
+ json_body={"role": 3, "auditNodeId": workflow_node_id, "answers": apply_answers},
1703
1842
  )
1704
1843
  return {
1705
1844
  "profile": profile,