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

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.
Files changed (60) hide show
  1. package/README.md +6 -3
  2. package/docs/local-agent-install.md +54 -3
  3. package/entry_point.py +1 -1
  4. package/npm/bin/qingflow-skills.mjs +5 -0
  5. package/npm/lib/runtime.mjs +304 -13
  6. package/npm/scripts/postinstall.mjs +1 -5
  7. package/package.json +3 -2
  8. package/pyproject.toml +1 -1
  9. package/skills/qingflow-app-builder/SKILL.md +12 -12
  10. package/skills/qingflow-app-builder/references/create-app.md +3 -3
  11. package/skills/qingflow-app-builder/references/environments.md +1 -1
  12. package/skills/qingflow-app-builder/references/gotchas.md +1 -1
  13. package/skills/qingflow-app-builder/references/public-surface-sync.md +75 -0
  14. package/skills/qingflow-app-builder/references/tool-selection.md +6 -5
  15. package/skills/qingflow-app-builder/references/update-views.md +1 -1
  16. package/skills/qingflow-app-builder-code-integrations/SKILL.md +3 -3
  17. package/skills/qingflow-app-builder-code-integrations/references/code-block.md +1 -1
  18. package/skills/qingflow-app-builder-code-integrations/references/q-linker.md +1 -1
  19. package/src/qingflow_mcp/__main__.py +6 -2
  20. package/src/qingflow_mcp/builder_facade/models.py +41 -2
  21. package/src/qingflow_mcp/builder_facade/service.py +2743 -423
  22. package/src/qingflow_mcp/cli/commands/app.py +3 -16
  23. package/src/qingflow_mcp/cli/commands/builder.py +30 -4
  24. package/src/qingflow_mcp/cli/commands/exports.py +2 -2
  25. package/src/qingflow_mcp/cli/commands/imports.py +1 -1
  26. package/src/qingflow_mcp/cli/commands/record.py +54 -11
  27. package/src/qingflow_mcp/cli/context.py +0 -3
  28. package/src/qingflow_mcp/cli/formatters.py +238 -8
  29. package/src/qingflow_mcp/cli/main.py +47 -3
  30. package/src/qingflow_mcp/errors.py +43 -2
  31. package/src/qingflow_mcp/public_surface.py +24 -16
  32. package/src/qingflow_mcp/response_trim.py +119 -12
  33. package/src/qingflow_mcp/server.py +17 -14
  34. package/src/qingflow_mcp/server_app_builder.py +29 -7
  35. package/src/qingflow_mcp/server_app_user.py +23 -24
  36. package/src/qingflow_mcp/solution/compiler/icon_utils.py +294 -0
  37. package/src/qingflow_mcp/solution/executor.py +112 -15
  38. package/src/qingflow_mcp/tools/ai_builder_tools.py +497 -65
  39. package/src/qingflow_mcp/tools/app_tools.py +237 -51
  40. package/src/qingflow_mcp/tools/approval_tools.py +196 -34
  41. package/src/qingflow_mcp/tools/auth_tools.py +92 -16
  42. package/src/qingflow_mcp/tools/code_block_tools.py +296 -39
  43. package/src/qingflow_mcp/tools/custom_button_tools.py +64 -10
  44. package/src/qingflow_mcp/tools/directory_tools.py +236 -72
  45. package/src/qingflow_mcp/tools/export_tools.py +230 -33
  46. package/src/qingflow_mcp/tools/file_tools.py +7 -3
  47. package/src/qingflow_mcp/tools/import_tools.py +293 -40
  48. package/src/qingflow_mcp/tools/navigation_tools.py +91 -12
  49. package/src/qingflow_mcp/tools/package_tools.py +134 -8
  50. package/src/qingflow_mcp/tools/portal_tools.py +39 -3
  51. package/src/qingflow_mcp/tools/qingbi_report_tools.py +116 -7
  52. package/src/qingflow_mcp/tools/record_tools.py +2305 -442
  53. package/src/qingflow_mcp/tools/resource_read_tools.py +191 -39
  54. package/src/qingflow_mcp/tools/role_tools.py +80 -9
  55. package/src/qingflow_mcp/tools/solution_tools.py +57 -15
  56. package/src/qingflow_mcp/tools/task_context_tools.py +569 -119
  57. package/src/qingflow_mcp/tools/task_tools.py +113 -29
  58. package/src/qingflow_mcp/tools/view_tools.py +106 -3
  59. package/src/qingflow_mcp/tools/workflow_tools.py +17 -1
  60. package/src/qingflow_mcp/tools/workspace_tools.py +71 -3
@@ -5,7 +5,7 @@ from typing import cast
5
5
  from mcp.server.fastmcp import FastMCP
6
6
 
7
7
  from ..config import DEFAULT_PROFILE, DEFAULT_RECORD_LIST_TYPE
8
- from ..errors import QingflowApiError, raise_tool_error
8
+ from ..errors import QingflowApiError, backend_code_int, backend_code_value_int, is_auth_like_error, message_looks_like_invalid_token, raise_tool_error
9
9
  from ..json_types import JSONObject, JSONValue
10
10
  from .base import tool_cn_name
11
11
  from .record_tools import (
@@ -19,9 +19,11 @@ from .record_tools import (
19
19
  FieldIndex,
20
20
  FormField,
21
21
  RecordTools,
22
+ _build_answer_backed_field_index,
22
23
  _coerce_count,
23
24
  _collect_question_relations,
24
25
  _field_ref_payload,
26
+ _merge_field_indexes,
25
27
  _normalize_optional_text,
26
28
  _relation_ids_from_answer,
27
29
  _stringify_json,
@@ -31,6 +33,7 @@ from .record_tools import (
31
33
  CODE_BLOCK_QUE_TYPE = 26
32
34
  CODE_BLOCK_RELATION_TYPE = 3
33
35
  SUPPORTED_CODE_BLOCK_ROLES = {1, 2, 3, 5}
36
+ _CODE_BLOCK_SCHEMA_PERMISSION_CODES = {40002, 40027, 404}
34
37
 
35
38
 
36
39
  class CodeBlockTools(RecordTools):
@@ -65,6 +68,36 @@ class CodeBlockTools(RecordTools):
65
68
  self._form_cache[cache_key] = normalized
66
69
  return normalized
67
70
 
71
+ def _get_code_block_relation_schema_optional(
72
+ self,
73
+ profile: str,
74
+ context, # type: ignore[no-untyped-def]
75
+ app_key: str,
76
+ *,
77
+ force_refresh: bool,
78
+ warnings: list[JSONObject],
79
+ ) -> JSONObject:
80
+ try:
81
+ return self._get_code_block_relation_schema(
82
+ profile,
83
+ context,
84
+ app_key,
85
+ force_refresh=force_refresh,
86
+ )
87
+ except QingflowApiError as exc:
88
+ if not _is_optional_code_block_schema_error(exc):
89
+ raise
90
+ warnings.append(
91
+ {
92
+ "code": "CODE_BLOCK_SCHEMA_UNAVAILABLE",
93
+ "message": "applicant form schema was not readable in this permission context; code-block execution will use record/task answers and skip schema-bound relation writeback.",
94
+ "backend_code": exc.backend_code,
95
+ "http_status": exc.http_status,
96
+ "request_id": exc.request_id,
97
+ }
98
+ )
99
+ return {}
100
+
68
101
  def register(self, mcp: FastMCP) -> None:
69
102
  """注册当前工具到 MCP 服务。"""
70
103
  super().register(mcp)
@@ -84,7 +117,8 @@ class CodeBlockTools(RecordTools):
84
117
  description=(
85
118
  "Run a form code-block field against the current record data, parse alias results, and optionally "
86
119
  "reuse Qingflow's existing relation-calculation chain to compute bound outputs and write them back. "
87
- "Use record_code_block_schema_get first and choose an exact code-block field selector. "
120
+ "Use record_code_block_schema_get when field selection or binding diagnostics are unclear; "
121
+ "if the exact code-block field id is known from record/task detail, run directly. "
88
122
  "For safe debugging, pass apply_writeback=false to inspect parsed results without writing back."
89
123
  )
90
124
  )
@@ -93,6 +127,7 @@ class CodeBlockTools(RecordTools):
93
127
  app_key: str = "",
94
128
  record_id: str = "",
95
129
  code_block_field: str = "",
130
+ view_id: str | None = None,
96
131
  role: int = 1,
97
132
  workflow_node_id: int | None = None,
98
133
  answers: list[JSONObject] | None = None,
@@ -108,6 +143,7 @@ class CodeBlockTools(RecordTools):
108
143
  app_key=app_key,
109
144
  record_id=record_id,
110
145
  code_block_field=code_block_field,
146
+ view_id=view_id,
111
147
  role=role,
112
148
  workflow_node_id=workflow_node_id,
113
149
  answers=answers or [],
@@ -133,8 +169,45 @@ class CodeBlockTools(RecordTools):
133
169
  normalized_output_profile = self._normalize_public_output_profile(output_profile)
134
170
 
135
171
  def runner(session_profile, context):
136
- relation_schema = self._get_code_block_relation_schema(profile, context, app_key, force_refresh=False)
137
- index = self._get_applicant_top_level_field_index(profile, context, app_key, force_refresh=False)
172
+ try:
173
+ relation_schema = self._get_code_block_relation_schema(profile, context, app_key, force_refresh=False)
174
+ index = self._get_applicant_top_level_field_index(profile, context, app_key, force_refresh=False)
175
+ except QingflowApiError as exc:
176
+ if not _is_optional_code_block_schema_error(exc):
177
+ raise
178
+ return {
179
+ "profile": profile,
180
+ "ws_id": session_profile.selected_ws_id,
181
+ "ok": False,
182
+ "status": "failed",
183
+ "error_code": "CODE_BLOCK_SCHEMA_UNAVAILABLE",
184
+ "message": (
185
+ "applicant form schema was not readable in this permission context; "
186
+ "record schema code-block is only a diagnostic helper. "
187
+ "If the code-block field is known from record/task detail, run record code-block-run directly."
188
+ ),
189
+ "backend_code": exc.backend_code,
190
+ "http_status": exc.http_status,
191
+ "request_id": exc.request_id,
192
+ "request_route": self._request_route_payload(context),
193
+ "warnings": [
194
+ {
195
+ "code": "CODE_BLOCK_SCHEMA_UNAVAILABLE",
196
+ "message": "schema diagnostic unavailable; code-block run can still use record/task answers when code_block_field is known.",
197
+ "backend_code": exc.backend_code,
198
+ "http_status": exc.http_status,
199
+ "request_id": exc.request_id,
200
+ }
201
+ ],
202
+ "app_key": app_key,
203
+ "schema_scope": "code_block_ready",
204
+ "code_block_fields": [],
205
+ "input_fields": [],
206
+ "suggested_next_call": {
207
+ "tool_name": "record_code_block_run",
208
+ "required": ["app_key", "record_id", "code_block_field"],
209
+ },
210
+ }
138
211
  input_fields = [
139
212
  self._ready_schema_field_payload(
140
213
  profile,
@@ -199,6 +272,7 @@ class CodeBlockTools(RecordTools):
199
272
  app_key: str,
200
273
  record_id: int | str,
201
274
  code_block_field: str,
275
+ view_id: str | None = None,
202
276
  role: int = 1,
203
277
  workflow_node_id: int | None = None,
204
278
  answers: list[JSONObject] | None = None,
@@ -220,14 +294,42 @@ class CodeBlockTools(RecordTools):
220
294
  raise_tool_error(QingflowApiError.config_error("code_block_field is required"))
221
295
 
222
296
  def runner(session_profile, context):
223
- relation_schema = self._get_code_block_relation_schema(
297
+ warnings: list[JSONObject] = []
298
+ current_answers = self._load_record_answers_for_code_block(
299
+ context,
300
+ profile=profile,
301
+ app_key=app_key,
302
+ apply_id=normalized_record_id,
303
+ view_id=view_id,
304
+ role=role,
305
+ audit_node_id=workflow_node_id,
306
+ )
307
+ answer_index = _build_answer_backed_field_index(current_answers)
308
+ schema_index: FieldIndex | None = None
309
+ relation_schema = self._get_code_block_relation_schema_optional(
224
310
  profile,
225
311
  context,
226
312
  app_key,
227
313
  force_refresh=force_refresh_form,
314
+ warnings=warnings,
228
315
  )
229
- index = self._get_field_index(profile, context, app_key, force_refresh=force_refresh_form)
230
- code_block = self._resolve_field_selector(code_block_field, index, location="code_block_field")
316
+ try:
317
+ schema_index = self._get_field_index(profile, context, app_key, force_refresh=force_refresh_form)
318
+ except QingflowApiError as exc:
319
+ if not _is_optional_code_block_schema_error(exc):
320
+ raise
321
+ if not any(item.get("code") == "CODE_BLOCK_SCHEMA_UNAVAILABLE" for item in warnings):
322
+ warnings.append(
323
+ {
324
+ "code": "CODE_BLOCK_SCHEMA_UNAVAILABLE",
325
+ "message": "applicant form schema was not readable in this permission context; code-block execution will use record/task answers and skip schema-bound relation writeback.",
326
+ "backend_code": exc.backend_code,
327
+ "http_status": exc.http_status,
328
+ "request_id": exc.request_id,
329
+ }
330
+ )
331
+ index = _merge_field_indexes(schema_index, answer_index) if schema_index is not None else answer_index
332
+ code_block = self._resolve_code_block_field_for_run(code_block_field, index)
231
333
  if code_block.que_type != CODE_BLOCK_QUE_TYPE:
232
334
  raise_tool_error(
233
335
  QingflowApiError(
@@ -241,14 +343,6 @@ class CodeBlockTools(RecordTools):
241
343
  },
242
344
  )
243
345
  )
244
-
245
- current_answers = self._load_record_answers_for_code_block(
246
- context,
247
- app_key=app_key,
248
- apply_id=normalized_record_id,
249
- role=role,
250
- audit_node_id=workflow_node_id,
251
- )
252
346
  override_answers = (
253
347
  self._resolve_answers(
254
348
  profile,
@@ -257,12 +351,13 @@ class CodeBlockTools(RecordTools):
257
351
  answers=answers or [],
258
352
  fields=fields or {},
259
353
  force_refresh_form=force_refresh_form,
354
+ field_index_override=index,
260
355
  )
261
356
  if answers or fields
262
357
  else []
263
358
  )
264
359
  merged_answers = self._merge_record_answers(current_answers, override_answers) if override_answers else current_answers
265
- key_que_values = self._answers_to_open_match_values(merged_answers, index)
360
+ key_que_values = self._answers_to_open_match_values(merged_answers, index, exclude_que_ids={code_block.que_id})
266
361
  run_body: JSONObject = {
267
362
  "role": role,
268
363
  "manual": bool(manual),
@@ -289,6 +384,7 @@ class CodeBlockTools(RecordTools):
289
384
  relation_items: list[JSONObject] = []
290
385
  calculated_answers: list[JSONObject] = []
291
386
  relation_result: JSONObject | None = None
387
+ relation_transport_error: JSONObject | None = None
292
388
  if relation_target_fields:
293
389
  relation_body: JSONObject = {
294
390
  "role": role,
@@ -306,26 +402,38 @@ class CodeBlockTools(RecordTools):
306
402
  relation_result = self.backend.request("POST", context, relation_route, json_body=relation_body)
307
403
  relation_items = _relation_result_items(relation_result)
308
404
  except QingflowApiError as exc:
309
- if exc.http_status != 404:
310
- raise
311
- relation_route = "/que/actuator"
312
- relation_result = self.backend.request("POST", context, relation_route, json_body=relation_body)
313
- relation_items = _relation_result_items(relation_result)
314
- if not relation_items:
405
+ if exc.http_status == 404:
406
+ relation_route = "/que/actuator"
407
+ try:
408
+ relation_result = self.backend.request("POST", context, relation_route, json_body=relation_body)
409
+ relation_items = _relation_result_items(relation_result)
410
+ except QingflowApiError as fallback_exc:
411
+ relation_transport_error = _code_block_transport_error(fallback_exc)
412
+ else:
413
+ relation_transport_error = _code_block_transport_error(exc)
414
+ if relation_transport_error is None and not relation_items:
315
415
  # Keep compatibility with legacy runtime deployments and lightweight test doubles
316
416
  # that still stub the older relation-calculation route only.
317
417
  relation_route = "/que/actuator"
318
- relation_result = self.backend.request("POST", context, relation_route, json_body=relation_body)
319
- relation_items = _relation_result_items(relation_result)
418
+ try:
419
+ relation_result = self.backend.request("POST", context, relation_route, json_body=relation_body)
420
+ relation_items = _relation_result_items(relation_result)
421
+ relation_transport_error = None
422
+ except QingflowApiError as exc:
423
+ relation_transport_error = relation_transport_error or _code_block_transport_error(exc)
320
424
  relation_errors = _relation_result_errors(relation_items)
321
425
  calculated_answers = _relation_result_answers(relation_items)
322
426
  write_result: JSONObject | None = None
427
+ write_error: JSONObject | None = None
323
428
  verification: JSONObject | None = None
324
429
  writeback_attempted = False
325
430
  writeback_applied = False
326
431
  status = "completed"
327
432
  ok = True
328
- if relation_errors:
433
+ if relation_transport_error is not None:
434
+ status = "relation_failed"
435
+ ok = False
436
+ elif relation_errors:
329
437
  status = "relation_failed"
330
438
  ok = False
331
439
  elif not apply_writeback:
@@ -335,21 +443,28 @@ class CodeBlockTools(RecordTools):
335
443
  if workflow_node_id is not None:
336
444
  write_body["auditNodeId"] = workflow_node_id
337
445
  writeback_attempted = True
338
- write_result = cast(
339
- JSONObject,
340
- self.backend.request(
341
- "POST",
342
- context,
343
- f"/app/{app_key}/apply/{normalized_record_id}",
344
- json_body=write_body,
345
- ),
346
- )
347
- writeback_applied = True
348
- if verify_writeback:
446
+ try:
447
+ write_result = cast(
448
+ JSONObject,
449
+ self.backend.request(
450
+ "POST",
451
+ context,
452
+ f"/app/{app_key}/apply/{normalized_record_id}",
453
+ json_body=write_body,
454
+ ),
455
+ )
456
+ writeback_applied = True
457
+ except QingflowApiError as exc:
458
+ write_error = _code_block_transport_error(exc)
459
+ status = "writeback_failed"
460
+ ok = False
461
+ if writeback_applied and verify_writeback:
349
462
  verification = self._verify_code_block_writeback_result(
350
463
  context,
464
+ profile=profile,
351
465
  app_key=app_key,
352
466
  apply_id=normalized_record_id,
467
+ view_id=view_id,
353
468
  expected_answers=calculated_answers,
354
469
  index=index,
355
470
  role=role,
@@ -357,7 +472,16 @@ class CodeBlockTools(RecordTools):
357
472
  )
358
473
  if not bool(verification.get("verified")):
359
474
  status = "verification_failed"
360
- ok = False
475
+ ok = True
476
+ warnings.append(
477
+ {
478
+ "code": "CODE_BLOCK_WRITEBACK_VERIFICATION_FAILED",
479
+ "message": (
480
+ "code-block execution and writeback completed, but field-level readback "
481
+ "could not verify the written values; do not treat this as writeback denial."
482
+ ),
483
+ }
484
+ )
361
485
  else:
362
486
  status = "no_writeback"
363
487
  response: JSONObject = {
@@ -369,9 +493,14 @@ class CodeBlockTools(RecordTools):
369
493
  "apply_id": normalized_record_id,
370
494
  "status": status,
371
495
  "ok": ok,
496
+ "write_executed": writeback_applied,
497
+ "write_succeeded": writeback_applied,
498
+ "safe_to_retry": not writeback_applied,
499
+ "warnings": warnings,
372
500
  "code_block_field": _field_ref_payload(code_block),
373
501
  "execution": {
374
502
  "executed": True,
503
+ "view_id": _normalize_optional_text(view_id),
375
504
  "role": role,
376
505
  "workflow_node_id": workflow_node_id,
377
506
  "manual": bool(manual),
@@ -389,6 +518,7 @@ class CodeBlockTools(RecordTools):
389
518
  "calculated_answer_count": len(calculated_answers),
390
519
  "calculated_answers_preview": calculated_answers,
391
520
  "errors": relation_errors,
521
+ "transport_error": relation_transport_error,
392
522
  },
393
523
  "writeback": {
394
524
  "enabled": bool(apply_writeback),
@@ -398,10 +528,15 @@ class CodeBlockTools(RecordTools):
398
528
  "verify_writeback": verify_writeback,
399
529
  "write_verified": bool(verification.get("verified")) if verification is not None else None,
400
530
  "result": write_result,
531
+ "error": write_error,
401
532
  "verification": verification,
402
533
  },
403
534
  "resource": {"apply_id": normalized_record_id},
404
535
  }
536
+ if not ok:
537
+ failure_error = relation_transport_error if relation_transport_error is not None else write_error
538
+ failure_context = "relation" if relation_transport_error is not None else "writeback"
539
+ response.update(_code_block_failure_fields(failure_error, context=failure_context))
405
540
  if normalized_output_profile == "verbose":
406
541
  response["debug"] = {
407
542
  "run_body": run_body,
@@ -415,16 +550,66 @@ class CodeBlockTools(RecordTools):
415
550
 
416
551
  return self._run_record_tool(profile, runner)
417
552
 
553
+ def _resolve_code_block_field_for_run(self, selector: str | int, index: FieldIndex) -> FormField:
554
+ field_id = _coerce_count(selector)
555
+ if field_id is not None and str(field_id) not in index.by_id:
556
+ return FormField(
557
+ que_id=field_id,
558
+ que_title=str(field_id),
559
+ que_type=CODE_BLOCK_QUE_TYPE,
560
+ required=False,
561
+ readonly=False,
562
+ system=False,
563
+ options=[],
564
+ aliases=[],
565
+ target_app_key=None,
566
+ target_app_name_hint=None,
567
+ member_select_scope_type=None,
568
+ member_select_scope=None,
569
+ dept_select_scope_type=None,
570
+ dept_select_scope=None,
571
+ raw={"queId": field_id, "queTitle": str(field_id), "queType": CODE_BLOCK_QUE_TYPE},
572
+ )
573
+ return self._resolve_field_selector(selector, index, location="code_block_field")
574
+
418
575
  def _load_record_answers_for_code_block(
419
576
  self,
420
577
  context, # type: ignore[no-untyped-def]
421
578
  *,
579
+ profile: str,
422
580
  app_key: str,
423
581
  apply_id: int,
582
+ view_id: str | None,
424
583
  role: int,
425
584
  audit_node_id: int | None,
426
585
  ) -> list[JSONObject]:
427
586
  """执行内部辅助逻辑。"""
587
+ normalized_view_id = _normalize_optional_text(view_id)
588
+ if normalized_view_id:
589
+ try:
590
+ resolved_view, _warnings = self._resolve_accessible_view_route(
591
+ profile,
592
+ context,
593
+ app_key,
594
+ view_id=normalized_view_id,
595
+ list_type=None,
596
+ view_key=None,
597
+ view_name=None,
598
+ allow_default=False,
599
+ )
600
+ record, _used_list_type, _used_role = self._record_get_apply_detail(
601
+ context,
602
+ app_key=app_key,
603
+ record_id=apply_id,
604
+ resolved_view=resolved_view,
605
+ audit_node_id=audit_node_id,
606
+ )
607
+ answers = record.get("answers") if isinstance(record, dict) else None
608
+ return [item for item in answers if isinstance(item, dict)] if isinstance(answers, list) else []
609
+ except QingflowApiError as exc:
610
+ if not _is_optional_code_block_record_read_error(exc):
611
+ raise
612
+
428
613
  last_error: QingflowApiError | None = None
429
614
  for list_type in self._INTERNAL_GET_LIST_TYPE_FALLBACKS:
430
615
  params: JSONObject = {"role": role, "listType": list_type}
@@ -436,19 +621,28 @@ class CodeBlockTools(RecordTools):
436
621
  return [item for item in answers if isinstance(item, dict)] if isinstance(answers, list) else []
437
622
  except QingflowApiError as exc:
438
623
  last_error = exc
439
- if exc.backend_code == 40002:
624
+ if _is_code_block_permission_error(exc):
440
625
  continue
441
626
  raise
442
627
  if last_error is not None:
443
628
  raise last_error
444
629
  raise_tool_error(QingflowApiError.config_error("record answers could not be loaded for code-block execution"))
445
630
 
446
- def _answers_to_open_match_values(self, answers: list[JSONObject], index: FieldIndex) -> list[JSONObject]:
631
+ def _answers_to_open_match_values(
632
+ self,
633
+ answers: list[JSONObject],
634
+ index: FieldIndex,
635
+ *,
636
+ exclude_que_ids: set[int] | None = None,
637
+ ) -> list[JSONObject]:
447
638
  """执行内部辅助逻辑。"""
448
639
  values: list[JSONObject] = []
449
640
  for answer in answers:
450
641
  if not isinstance(answer, dict):
451
642
  continue
643
+ que_id = _coerce_count(answer.get("queId", answer.get("que_id")))
644
+ if que_id is not None and exclude_que_ids is not None and que_id in exclude_que_ids:
645
+ continue
452
646
  open_match = self._answer_to_open_match_value(answer, index)
453
647
  if open_match is None:
454
648
  continue
@@ -507,8 +701,10 @@ class CodeBlockTools(RecordTools):
507
701
  self,
508
702
  context, # type: ignore[no-untyped-def]
509
703
  *,
704
+ profile: str,
510
705
  app_key: str,
511
706
  apply_id: int,
707
+ view_id: str | None,
512
708
  expected_answers: list[JSONObject],
513
709
  index: FieldIndex,
514
710
  role: int,
@@ -526,8 +722,10 @@ class CodeBlockTools(RecordTools):
526
722
  )
527
723
  actual_answers = self._load_record_answers_for_code_block(
528
724
  context,
725
+ profile=profile,
529
726
  app_key=app_key,
530
727
  apply_id=apply_id,
728
+ view_id=view_id,
531
729
  role=role,
532
730
  audit_node_id=audit_node_id,
533
731
  )
@@ -619,6 +817,42 @@ def _normalize_code_block_value_item(value: JSONValue, field: FormField) -> str
619
817
  return text if text is not None else None
620
818
 
621
819
 
820
+ def _code_block_transport_error(error: QingflowApiError) -> JSONObject:
821
+ payload: JSONObject = {
822
+ "category": error.category,
823
+ "message": error.message,
824
+ }
825
+ if error.backend_code is not None:
826
+ payload["backend_code"] = error.backend_code
827
+ if error.http_status is not None:
828
+ payload["http_status"] = error.http_status
829
+ if error.request_id:
830
+ payload["request_id"] = error.request_id
831
+ if error.details:
832
+ payload["details"] = error.details
833
+ return payload
834
+
835
+
836
+ def _code_block_failure_fields(error: JSONObject | None, *, context: str) -> JSONObject:
837
+ default_code = "CODE_BLOCK_RELATION_FAILED" if context == "relation" else "CODE_BLOCK_WRITEBACK_FAILED"
838
+ permission_code = "CODE_BLOCK_RELATION_PERMISSION_DENIED" if context == "relation" else "CODE_BLOCK_WRITEBACK_PERMISSION_DENIED"
839
+ payload: JSONObject = {
840
+ "error_code": default_code,
841
+ }
842
+ if not isinstance(error, dict):
843
+ return payload
844
+ category = str(error.get("category") or "").strip().lower()
845
+ http_status = backend_code_value_int(error.get("http_status"))
846
+ if category == "auth" or http_status == 401 or message_looks_like_invalid_token(error.get("message")):
847
+ payload["error_code"] = "AUTH_REQUIRED"
848
+ elif backend_code_value_int(error.get("backend_code")) in {40002, 40027}:
849
+ payload["error_code"] = permission_code
850
+ for key in ("category", "backend_code", "http_status", "request_id"):
851
+ if key in error:
852
+ payload[key] = error.get(key)
853
+ return payload
854
+
855
+
622
856
  def _selector_numeric_or_text(value: JSONValue, keys: tuple[str, ...], *, allow_text: bool) -> str | None:
623
857
  numeric = _coerce_count(value)
624
858
  if numeric is not None:
@@ -775,3 +1009,26 @@ def _relation_result_errors(items: list[JSONObject]) -> list[JSONObject]:
775
1009
  }
776
1010
  )
777
1011
  return errors
1012
+
1013
+
1014
+ def _is_optional_code_block_record_read_error(error: QingflowApiError) -> bool:
1015
+ if is_auth_like_error(error):
1016
+ return False
1017
+ backend_code = _code_block_backend_code(error)
1018
+ return backend_code in {40002, 40027, 404} or error.http_status == 404
1019
+
1020
+
1021
+ def _is_optional_code_block_schema_error(error: QingflowApiError) -> bool:
1022
+ if is_auth_like_error(error):
1023
+ return False
1024
+ return _code_block_backend_code(error) in _CODE_BLOCK_SCHEMA_PERMISSION_CODES or error.http_status == 404
1025
+
1026
+
1027
+ def _is_code_block_permission_error(error: QingflowApiError) -> bool:
1028
+ if is_auth_like_error(error):
1029
+ return False
1030
+ return _code_block_backend_code(error) in {40002, 40027}
1031
+
1032
+
1033
+ def _code_block_backend_code(error: QingflowApiError) -> int | None:
1034
+ return backend_code_int(error)
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  from copy import deepcopy
4
4
 
5
5
  from ..config import DEFAULT_PROFILE
6
- from ..errors import QingflowApiError, raise_tool_error
6
+ from ..errors import QingflowApiError, backend_code_int, is_auth_like_error, raise_tool_error
7
7
  from ..json_types import JSONObject
8
8
  from .base import ToolBase, tool_cn_name
9
9
 
@@ -31,12 +31,26 @@ class CustomButtonTools(ToolBase):
31
31
  self._require_app_key(app_key)
32
32
 
33
33
  def runner(session_profile, context):
34
- result = self.backend.request(
35
- "GET",
36
- context,
37
- f"/app/{app_key}/customButton",
38
- params={"beingDraft": being_draft},
39
- )
34
+ effective_being_draft = being_draft
35
+ fallback_error: QingflowApiError | None = None
36
+ try:
37
+ result = self.backend.request(
38
+ "GET",
39
+ context,
40
+ f"/app/{app_key}/customButton",
41
+ params={"beingDraft": being_draft},
42
+ )
43
+ except QingflowApiError as exc:
44
+ if not being_draft or not _is_optional_draft_button_read_error(exc):
45
+ raise
46
+ fallback_error = exc
47
+ effective_being_draft = False
48
+ result = self.backend.request(
49
+ "GET",
50
+ context,
51
+ f"/app/{app_key}/customButton",
52
+ params={"beingDraft": False},
53
+ )
40
54
  items = []
41
55
  raw_items = result.get("result") if isinstance(result, dict) and isinstance(result.get("result"), list) else []
42
56
  for item in raw_items:
@@ -47,11 +61,23 @@ class CustomButtonTools(ToolBase):
47
61
  "profile": profile,
48
62
  "ws_id": session_profile.selected_ws_id,
49
63
  "app_key": app_key,
50
- "being_draft": being_draft,
64
+ "being_draft": effective_being_draft,
65
+ "requested_being_draft": being_draft,
51
66
  "items": result if include_raw else items,
52
67
  "count": len(items),
53
68
  "compact": not include_raw,
54
69
  }
70
+ if fallback_error is not None:
71
+ response["warnings"] = [
72
+ {
73
+ "code": "CUSTOM_BUTTON_DRAFT_LIST_UNAVAILABLE",
74
+ "message": "draft custom button list is unavailable; returned published custom buttons instead",
75
+ "backend_code": fallback_error.backend_code,
76
+ "http_status": fallback_error.http_status,
77
+ "request_id": fallback_error.request_id,
78
+ }
79
+ ]
80
+ response["verification"] = {"draft_readable": False, "published_fallback_used": True}
55
81
  if include_raw:
56
82
  response["summary"] = items
57
83
  return response
@@ -74,16 +100,37 @@ class CustomButtonTools(ToolBase):
74
100
 
75
101
  def runner(session_profile, context):
76
102
  params = {"beingDraft": being_draft}
77
- result = self.backend.request("GET", context, f"/app/{app_key}/customButton/{button_id}", params=params)
103
+ effective_being_draft = being_draft
104
+ fallback_error: QingflowApiError | None = None
105
+ try:
106
+ result = self.backend.request("GET", context, f"/app/{app_key}/customButton/{button_id}", params=params)
107
+ except QingflowApiError as exc:
108
+ if not being_draft or not _is_optional_draft_button_read_error(exc):
109
+ raise
110
+ fallback_error = exc
111
+ effective_being_draft = False
112
+ result = self.backend.request("GET", context, f"/app/{app_key}/customButton/{button_id}", params={"beingDraft": False})
78
113
  response = {
79
114
  "profile": profile,
80
115
  "ws_id": session_profile.selected_ws_id,
81
116
  "app_key": app_key,
82
117
  "button_id": button_id,
83
- "being_draft": being_draft,
118
+ "being_draft": effective_being_draft,
119
+ "requested_being_draft": being_draft,
84
120
  "result": result if include_raw else self._compact_button_detail(result if isinstance(result, dict) else {}),
85
121
  "compact": not include_raw,
86
122
  }
123
+ if fallback_error is not None:
124
+ response["warnings"] = [
125
+ {
126
+ "code": "CUSTOM_BUTTON_DRAFT_DETAIL_UNAVAILABLE",
127
+ "message": "draft custom button detail is unavailable; returned published custom button detail instead",
128
+ "backend_code": fallback_error.backend_code,
129
+ "http_status": fallback_error.http_status,
130
+ "request_id": fallback_error.request_id,
131
+ }
132
+ ]
133
+ response["verification"] = {"draft_readable": False, "published_fallback_used": True}
87
134
  if include_raw:
88
135
  response["summary"] = self._compact_button_detail(result if isinstance(result, dict) else {})
89
136
  return response
@@ -198,3 +245,10 @@ class CustomButtonTools(ToolBase):
198
245
  else None,
199
246
  "trigger_wings_config": deepcopy(item.get("triggerWingsConfig")) if isinstance(item.get("triggerWingsConfig"), dict) else None,
200
247
  }
248
+
249
+
250
+ def _is_optional_draft_button_read_error(error: QingflowApiError) -> bool:
251
+ if is_auth_like_error(error):
252
+ return False
253
+ backend_code = backend_code_int(error)
254
+ return backend_code in {40002, 40027, 404} or error.http_status == 404