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

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,
@@ -228,6 +230,28 @@ class TaskContextTools(ToolBase):
228
230
 
229
231
  return self._run(profile, runner)
230
232
 
233
+ def task_save_only(
234
+ self,
235
+ *,
236
+ profile: str,
237
+ app_key: str,
238
+ record_id: int,
239
+ workflow_node_id: int,
240
+ fields: dict[str, Any] | None = None,
241
+ ) -> dict[str, Any]:
242
+ field_updates = dict(fields or {})
243
+ if not field_updates:
244
+ raise_tool_error(QingflowApiError.config_error("fields is required and must be non-empty for task_save_only"))
245
+ return self.task_action_execute(
246
+ profile=profile,
247
+ app_key=app_key,
248
+ record_id=record_id,
249
+ workflow_node_id=workflow_node_id,
250
+ action="save_only",
251
+ payload={},
252
+ fields=field_updates,
253
+ )
254
+
231
255
  def task_action_execute(
232
256
  self,
233
257
  *,
@@ -297,6 +321,15 @@ class TaskContextTools(ToolBase):
297
321
  capabilities = task_context.get("capabilities") or {}
298
322
  available_actions = capabilities.get("available_actions") or []
299
323
  if normalized_action not in available_actions:
324
+ if normalized_action == "save_only":
325
+ capability_warnings = capabilities.get("warnings") or []
326
+ message = (
327
+ "task action 'save_only' is not currently available for the current node; "
328
+ "MCP only exposes save_only when backend editableQueIds returns a non-empty result"
329
+ )
330
+ if capability_warnings:
331
+ message += "; backend editableQueIds is unavailable or empty for this task context"
332
+ raise_tool_error(QingflowApiError.config_error(message))
300
333
  raise_tool_error(
301
334
  QingflowApiError.config_error(
302
335
  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 +459,14 @@ class TaskContextTools(ToolBase):
426
459
  prepared_fields: dict[str, Any] | None,
427
460
  ) -> dict[str, Any]:
428
461
  merged_answers = None
462
+ normalized_answers = None
429
463
  if isinstance(prepared_fields, dict):
430
464
  candidate_answers = prepared_fields.get("merged_answers")
431
465
  if isinstance(candidate_answers, list):
432
466
  merged_answers = candidate_answers
467
+ candidate_normalized = prepared_fields.get("normalized_answers")
468
+ if isinstance(candidate_normalized, list):
469
+ normalized_answers = candidate_normalized
433
470
  if normalized_action == "approve":
434
471
  action_payload = dict(payload)
435
472
  action_payload["nodeId"] = workflow_node_id
@@ -489,14 +526,14 @@ class TaskContextTools(ToolBase):
489
526
  payload=action_payload,
490
527
  )
491
528
  if normalized_action == "save_only":
492
- if merged_answers is None:
529
+ if normalized_answers is None:
493
530
  raise_tool_error(QingflowApiError.config_error("fields is required for action 'save_only'"))
494
531
  return self._task_save_only(
495
532
  profile=profile,
496
533
  app_key=app_key,
497
534
  record_id=record_id,
498
535
  workflow_node_id=workflow_node_id,
499
- merged_answers=merged_answers,
536
+ apply_answers=normalized_answers,
500
537
  )
501
538
  return self._task_tools.task_urge(
502
539
  profile=profile,
@@ -1223,9 +1260,16 @@ class TaskContextTools(ToolBase):
1223
1260
  current_answers=detail.get("answers") or [],
1224
1261
  )
1225
1262
  update_schema = update_schema_state["public_schema"]
1263
+ save_only_available, capability_warnings, save_only_source = self._resolve_task_save_only_availability(
1264
+ context,
1265
+ app_key=app_key,
1266
+ workflow_node_id=workflow_node_id,
1267
+ )
1226
1268
  capabilities = self._build_capabilities(
1227
1269
  node_info,
1228
- allow_save_only=bool(update_schema.get("writable_fields")),
1270
+ allow_save_only=save_only_available,
1271
+ warnings=capability_warnings,
1272
+ save_only_source=save_only_source,
1229
1273
  )
1230
1274
  visibility = self._build_visibility(node_info, detail)
1231
1275
  return {
@@ -1318,7 +1362,14 @@ class TaskContextTools(ToolBase):
1318
1362
  )
1319
1363
  )
1320
1364
 
1321
- def _build_capabilities(self, node_info: dict[str, Any], *, allow_save_only: bool) -> dict[str, Any]:
1365
+ def _build_capabilities(
1366
+ self,
1367
+ node_info: dict[str, Any],
1368
+ *,
1369
+ allow_save_only: bool,
1370
+ warnings: list[JSONObject] | None = None,
1371
+ save_only_source: str = "workflow_editable_que_ids",
1372
+ ) -> dict[str, Any]:
1322
1373
  available_actions = ["approve"]
1323
1374
  if self._coerce_bool(node_info.get("rejectBtnStatus")):
1324
1375
  available_actions.append("reject")
@@ -1347,6 +1398,8 @@ class TaskContextTools(ToolBase):
1347
1398
  return {
1348
1399
  "available_actions": available_actions,
1349
1400
  "visible_but_unimplemented_actions": visible_but_unimplemented_actions,
1401
+ "save_only_source": save_only_source,
1402
+ "warnings": list(warnings or []),
1350
1403
  "action_constraints": {
1351
1404
  "feedback_required_for": feedback_required_for,
1352
1405
  "submit_check_enabled": self._coerce_bool(node_info.get("beingSubmitCheck")),
@@ -1415,6 +1468,12 @@ class TaskContextTools(ToolBase):
1415
1468
  workflow_node_id=workflow_node_id,
1416
1469
  node_info=node_info,
1417
1470
  )
1471
+ index, augmentation_warnings = self._augment_task_editable_field_index(
1472
+ index=index,
1473
+ current_answers=current_answers,
1474
+ editable_question_ids=editable_question_ids,
1475
+ )
1476
+ schema_warnings.extend(augmentation_warnings)
1418
1477
  effective_editable_ids = set(editable_question_ids)
1419
1478
  for field in index.by_id.values():
1420
1479
  if field.que_type in SUBTABLE_QUE_TYPES and (_subtable_descendant_ids(field) & set(editable_question_ids)):
@@ -1474,6 +1533,44 @@ class TaskContextTools(ToolBase):
1474
1533
  "editable_question_ids_source": source,
1475
1534
  }
1476
1535
 
1536
+ def _augment_task_editable_field_index(
1537
+ self,
1538
+ *,
1539
+ index: FieldIndex,
1540
+ current_answers: Any,
1541
+ editable_question_ids: set[int],
1542
+ ) -> tuple[FieldIndex, list[JSONObject]]:
1543
+ if not editable_question_ids:
1544
+ return index, []
1545
+ missing_field_ids = {
1546
+ str(que_id)
1547
+ for que_id in editable_question_ids
1548
+ if que_id > 0 and str(que_id) not in index.by_id
1549
+ }
1550
+ if not missing_field_ids:
1551
+ return index, []
1552
+ answer_backed_index = _build_answer_backed_field_index(
1553
+ current_answers,
1554
+ field_id_filter=missing_field_ids,
1555
+ )
1556
+ if not answer_backed_index.by_id:
1557
+ return index, []
1558
+ augmented_index = _merge_field_indexes(index, answer_backed_index)
1559
+ return augmented_index, [
1560
+ {
1561
+ "code": "TASK_RUNTIME_EDITABLE_FIELDS_AUGMENTED",
1562
+ "message": "task update schema added backend-editable fields from current task answers because applicant schema did not expose them.",
1563
+ "fields": [
1564
+ {
1565
+ "field_id": field.que_id,
1566
+ "title": field.que_title,
1567
+ "que_type": field.que_type,
1568
+ }
1569
+ for field in answer_backed_index.by_id.values()
1570
+ ],
1571
+ }
1572
+ ]
1573
+
1477
1574
  def _resolve_task_editable_question_ids(
1478
1575
  self,
1479
1576
  context: BackendRequestContext,
@@ -1504,6 +1601,31 @@ class TaskContextTools(ToolBase):
1504
1601
  fallback_ids = self._editable_ids_from_que_auth_setting(node_info.get("queAuthSetting"))
1505
1602
  return fallback_ids, warnings, "que_auth_setting"
1506
1603
 
1604
+ def _resolve_task_save_only_availability(
1605
+ self,
1606
+ context: BackendRequestContext,
1607
+ *,
1608
+ app_key: str,
1609
+ workflow_node_id: int,
1610
+ ) -> tuple[bool, list[JSONObject], str]:
1611
+ try:
1612
+ payload = self.backend.request(
1613
+ "GET",
1614
+ context,
1615
+ f"/app/{app_key}/auditNode/{workflow_node_id}/editableQueIds",
1616
+ )
1617
+ except QingflowApiError as error:
1618
+ warning: JSONObject = {
1619
+ "code": "TASK_SAVE_ONLY_SIGNAL_UNAVAILABLE",
1620
+ "message": "save_only is hidden because backend editableQueIds is unavailable for the current node; MCP no longer infers save_only from local schema reconstruction.",
1621
+ }
1622
+ if error.backend_code is not None:
1623
+ warning["backend_code"] = error.backend_code
1624
+ if error.http_status is not None:
1625
+ warning["http_status"] = error.http_status
1626
+ return False, [warning], "backend_editable_que_ids_unavailable"
1627
+ return bool(self._extract_question_ids(payload)), [], "workflow_editable_que_ids"
1628
+
1507
1629
  def _extract_question_ids(self, payload: Any) -> set[int]:
1508
1630
  candidates: list[Any] = []
1509
1631
  if isinstance(payload, list):
@@ -1587,6 +1709,7 @@ class TaskContextTools(ToolBase):
1587
1709
  },
1588
1710
  )
1589
1711
  )
1712
+ index = schema_state["index"]
1590
1713
  preflight = self._record_tools._build_record_write_preflight(
1591
1714
  profile=profile,
1592
1715
  context=context,
@@ -1601,8 +1724,8 @@ class TaskContextTools(ToolBase):
1601
1724
  view_key=None,
1602
1725
  view_name=None,
1603
1726
  existing_answers_override=current_answers,
1727
+ field_index_override=index,
1604
1728
  )
1605
- index = schema_state["index"]
1606
1729
  effective_editable_ids = set(schema_state["effective_editable_question_ids"])
1607
1730
  scoped_field_errors = self._task_scope_field_errors(
1608
1731
  normalized_answers=preflight.get("normalized_answers") or [],
@@ -1692,14 +1815,14 @@ class TaskContextTools(ToolBase):
1692
1815
  app_key: str,
1693
1816
  record_id: int,
1694
1817
  workflow_node_id: int,
1695
- merged_answers: list[dict[str, Any]],
1818
+ apply_answers: list[dict[str, Any]],
1696
1819
  ) -> dict[str, Any]:
1697
1820
  def runner(session_profile, context):
1698
1821
  result = self.backend.request(
1699
1822
  "POST",
1700
1823
  context,
1701
1824
  f"/app/{app_key}/apply/{record_id}",
1702
- json_body={"role": 3, "auditNodeId": workflow_node_id, "answers": merged_answers},
1825
+ json_body={"role": 3, "auditNodeId": workflow_node_id, "answers": apply_answers},
1703
1826
  )
1704
1827
  return {
1705
1828
  "profile": profile,