@qingflow-tech/qingflow-app-builder-mcp 1.0.11 → 1.0.13

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 (59) 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 +11 -0
  21. package/src/qingflow_mcp/builder_facade/service.py +1488 -288
  22. package/src/qingflow_mcp/cli/commands/builder.py +2 -2
  23. package/src/qingflow_mcp/cli/commands/exports.py +2 -2
  24. package/src/qingflow_mcp/cli/commands/imports.py +1 -1
  25. package/src/qingflow_mcp/cli/commands/record.py +91 -19
  26. package/src/qingflow_mcp/cli/context.py +0 -3
  27. package/src/qingflow_mcp/cli/formatters.py +206 -7
  28. package/src/qingflow_mcp/cli/main.py +47 -3
  29. package/src/qingflow_mcp/errors.py +43 -2
  30. package/src/qingflow_mcp/public_surface.py +21 -15
  31. package/src/qingflow_mcp/response_trim.py +74 -13
  32. package/src/qingflow_mcp/server.py +11 -9
  33. package/src/qingflow_mcp/server_app_builder.py +3 -2
  34. package/src/qingflow_mcp/server_app_user.py +19 -13
  35. package/src/qingflow_mcp/session_store.py +11 -7
  36. package/src/qingflow_mcp/solution/executor.py +112 -15
  37. package/src/qingflow_mcp/tools/ai_builder_tools.py +36 -11
  38. package/src/qingflow_mcp/tools/app_tools.py +184 -43
  39. package/src/qingflow_mcp/tools/approval_tools.py +196 -34
  40. package/src/qingflow_mcp/tools/auth_tools.py +92 -16
  41. package/src/qingflow_mcp/tools/code_block_tools.py +298 -40
  42. package/src/qingflow_mcp/tools/custom_button_tools.py +64 -10
  43. package/src/qingflow_mcp/tools/directory_tools.py +236 -72
  44. package/src/qingflow_mcp/tools/export_tools.py +244 -34
  45. package/src/qingflow_mcp/tools/file_tools.py +7 -3
  46. package/src/qingflow_mcp/tools/import_tools.py +336 -49
  47. package/src/qingflow_mcp/tools/navigation_tools.py +91 -12
  48. package/src/qingflow_mcp/tools/package_tools.py +118 -6
  49. package/src/qingflow_mcp/tools/portal_tools.py +39 -3
  50. package/src/qingflow_mcp/tools/qingbi_report_tools.py +116 -7
  51. package/src/qingflow_mcp/tools/record_tools.py +1067 -349
  52. package/src/qingflow_mcp/tools/resource_read_tools.py +188 -39
  53. package/src/qingflow_mcp/tools/role_tools.py +80 -9
  54. package/src/qingflow_mcp/tools/solution_tools.py +57 -15
  55. package/src/qingflow_mcp/tools/task_context_tools.py +569 -119
  56. package/src/qingflow_mcp/tools/task_tools.py +113 -29
  57. package/src/qingflow_mcp/tools/view_tools.py +106 -3
  58. package/src/qingflow_mcp/tools/workflow_tools.py +17 -1
  59. package/src/qingflow_mcp/tools/workspace_tools.py +71 -3
@@ -33,6 +33,7 @@ def build_user_server() -> FastMCP:
33
33
 
34
34
  If `app_key` is unknown, use `app_list` first. Pass `query` to filter visible apps by keyword.
35
35
  If the app is known but the data range is not, use `app_get` first and choose from `accessible_views`.
36
+ Treat an explicit `view_id` as the exact frontend view context. If `system:all` fails, do not silently switch to `system:initiated`, `system:todo`, or another system view unless the user, frontend URL, or `app_get.accessible_views` explicitly selects that view.
36
37
  If an accessible view has `analysis_supported=false`, do not use it for `record_access` or `record_list`. `boardView` and `ganttView` are special UI views, not data-access targets.
37
38
  `view_get(view_id=...)` also returns `export_capability`; it only means there is a supported export route, not that export permission has been verified.
38
39
 
@@ -48,7 +49,7 @@ If an accessible view has `analysis_supported=false`, do not use it for `record_
48
49
 
49
50
  Call `record_insert_schema_get` before `record_insert`.
50
51
  For simple field changes after the target record is clear, call `record_update` directly. Use `record_update_schema_get` for diagnostics, ambiguous fields, or complex writable-scope inspection.
51
- Call `record_code_block_schema_get` before `record_code_block_run`.
52
+ Prefer `record_code_block_schema_get` before `record_code_block_run` when field selection or binding diagnostics are unclear; if the exact code-block field id is already known from record/task detail, run directly.
52
53
  Call `app_get` first when the data range is unclear, then use `record_browse_schema_get(view_id=...)` before `record_access`, `record_list`, `record_get`, or `record_logs_get`.
53
54
  Call `record_import_schema_get` when the import field mapping is unclear before template download or verify.
54
55
 
@@ -59,11 +60,11 @@ Call `record_import_schema_get` when the import field mapping is unclear before
59
60
 
60
61
  `record_insert_schema_get` returns the current user's insert-ready applicant schema; read `required_fields`, `optional_fields`, `runtime_linked_required_fields`, and `payload_template`.
61
62
  Inside `optional_fields`, any field with `may_become_required=true` is still writable, but may become required when linked visibility or option-driven runtime rules activate.
62
- `record_update_schema_get` returns the current record's overall update-ready writable field set and route diagnostics across matched accessible views; read `writable_fields`, `payload_template`, `available_update_routes`, and `recommended_update_route`.
63
+ `record_update_schema_get` returns the current record's update-ready writable field set and route diagnostics. When `view_id` is explicit, it is the exact frontend view context and must not be silently replaced by another view.
63
64
  `record_browse_schema_get(view_id=...)` returns the same readable fields shown in the selected Qingflow table view header.
64
65
  `record_access.fields` / CSV columns and `record_list.columns / where / order_by / query_fields` use that exact same view schema; a missing field means it is not readable in that view.
65
66
  `searchQueIds` is a backend full-text search scope, not an output-column/projection mechanism.
66
- `record_code_block_schema_get` returns code-block-ready schema for exact code block field selection.
67
+ `record_code_block_schema_get` returns code-block-ready schema for exact code block field selection when the applicant schema is readable.
67
68
  `record_import_schema_get` returns import-ready column metadata.
68
69
 
69
70
  - Hidden fields are omitted.
@@ -105,8 +106,8 @@ Analysis answers must include concrete numbers. When applicable, include percent
105
106
  `app_get -> record_browse_schema_get(view_id=...) -> record_list / record_get / record_logs_get`
106
107
  `record_insert_schema_get -> record_insert(items)`
107
108
  `record_update` for simple updates; `record_update_schema_get -> record_update` when the writable field scope is unclear.
108
- `record_list / record_get -> record_delete`
109
- `record_code_block_schema_get -> record_code_block_run`
109
+ `record_list / record_get -> record_delete(system view_id)`
110
+ `record_code_block_run` directly when the exact code-block field is known; otherwise `record_code_block_schema_get -> record_code_block_run`
110
111
  `portal_list -> portal_get -> chart_get / view_get`
111
112
  `portal_get -> view_get -> record_list`
112
113
 
@@ -117,14 +118,14 @@ Analysis answers must include concrete numbers. When applicable, include percent
117
118
  - Legacy forms such as bare integer `field_id`, `fieldId`, `operator`, `values`, or `order` may still parse, but they are compatibility-only and not the canonical DSL
118
119
 
119
120
  - `record_insert` defaults to an applicant-node `items` array; each item contains a field-title keyed `fields` map. A single insert is one item.
120
- - `record_update` uses a field-title keyed `fields` map. It first tries the data-manager direct update route, then falls back to the frontend custom-view detail edit route when the selected view can cover the payload; if a unique current-user todo task for the same record exposes editable fields, it can finally use the workflow save-only route. Read `update_route` and `tried_routes` after execution.
121
+ - `record_update` uses a field-title keyed `fields` map. It first tries the data-manager direct update route, then falls back to the frontend custom-view detail edit route when the selected view can cover the payload; if a unique current-user todo task for the same record exposes editable fields, it can finally use the workflow save-only route. On success, read `status`, `update_route`, and `verification_status`; on failure, read the failure reason and route diagnostics.
121
122
  - For insert, `runtime_linked_required_fields` means required-but-not-directly-writable fields that are usually supplied by runtime linkage or upstream context.
122
123
  - For insert, fields marked `may_become_required=true` stay in `optional_fields`; they are still directly writable, but linked visibility or option-driven rules can make them required at runtime.
123
124
  - Read field-level `linkage` whenever present on `record_insert_schema_get` or `record_update_schema_get`; it is the static hint for linked visibility, reference-driven auto fill, and formula/default auto-fill behavior.
124
125
  - `linkage.sources` lists upstream field titles that influence the current field; `linkage.affects_fields` lists downstream fields that may change when the current field changes.
125
126
  - `linkage.kind=logic_visibility` means linked visibility or option-driven rules are involved; `linkage.kind=reference_fill` means reference/default matching logic is involved; `linkage.kind=formula_fill` means formula/default auto-fill logic is involved.
126
- - `record_update_schema_get` exposes the overall writable field set and update route candidates for the record, but not every field combination is guaranteed; `record_update` still needs data-manager permission, one single matched custom view that can cover the payload, or one unique editable current-user todo task.
127
- - `record_delete` deletes by `record_id` or `record_ids`.
127
+ - `record_update_schema_get` exposes the writable field set and update route candidates for the record; with an explicit `view_id`, diagnostics stay scoped to that selected frontend view. Not every field combination is guaranteed; `record_update` still needs data-manager permission, one single matched custom view that can cover the payload, or one unique editable current-user todo task.
128
+ - `record_delete` deletes by `record_id` or `record_ids`, and requires an accessible system `view_id`; use custom views only to locate records, not as the delete route.
128
129
  - `record_get` is the single-record frontend detail context tool. It returns detail-page visible fields, one-level relation targets, first-page data/workflow logs, associated views/reports, local readable image assets, local downloadable file assets, unavailable context, and `semantic_context`.
129
130
  - Use `record_logs_get` only when the user needs the full visible data/workflow log history for a specific record. It writes JSONL files locally and returns file paths plus completeness metadata; do not expect full log arrays in the response.
130
131
  - Read record images from `record_get.media_assets.items[].local_path` when `readable_by_agent=true`; read attachments/documents/tables from `record_get.file_assets.items[].local_path` and `extraction.text_path` when present. `record_get` follows the frontend storage cookie redirect path for Qingflow attachments, and remote file URLs should not be treated as directly readable.
@@ -142,7 +143,8 @@ Analysis answers must include concrete numbers. When applicable, include percent
142
143
 
143
144
  Use `record_code_block_run` when the user wants to execute a form code-block field against an existing record.
144
145
 
145
- - Always resolve the exact code-block field from `record_code_block_schema_get` first.
146
+ - Prefer resolving the exact code-block field from `record_code_block_schema_get`, but do not treat applicant-schema 40002 as final denial when record/task detail already exposes the code-block field id.
147
+ - `record_code_block_run` uses the record/task detail answers for the execution context; when applicant schema is unavailable it can still execute with a numeric code-block field id, but schema-bound relation writeback may be skipped.
146
148
  - Treat code-block execution as write-capable, not read-only.
147
149
  - If the code block is bound to relation outputs, Qingflow may calculate target answers and write them back automatically.
148
150
  - For safe debugging, pass `apply_writeback=false` and inspect the parsed alias results plus `relation.calculated_answers_preview` before allowing any writeback.
@@ -191,7 +193,7 @@ Use export only when the user explicitly asks to export/download/generate an Exc
191
193
  - `task_action_execute(task_id=..., action=...)` is also supported; MCP resolves the current todo locator internally before calling the real action route.
192
194
  - `task_workflow_log_get(task_id=...)` and `task_associated_report_detail_get(task_id=...)` are also supported for the current todo context.
193
195
  - Use `task_associated_report_detail_get` for associated view or report details.
194
- - Use `task_workflow_log_get` for the current task context workflow log page. For full record-level data/workflow logs, use `record_logs_get(app_key, record_id, view_id?)`.
196
+ - Use `task_workflow_log_get` for the current task context workflow log page. For full record-level data/workflow logs, first choose an accessible view with `app_get`, then call `record_logs_get(app_key, record_id, view_id)` with that same explicit `view_id`.
195
197
  - Task actions operate on `app_key + record_id + workflow_node_id`, not `task_id`.
196
198
  - Treat `task_action_execute` as the tool-level action enum surface; the current task's real actions are only the ones listed in `task_get.capabilities.available_actions`.
197
199
  - Use `task_action_execute(action="save_only", fields=...)` when the user wants to save editable field changes on the current node without advancing the workflow.
@@ -263,10 +265,11 @@ If the current MCP capability is unsupported, the workflow is awkward, or the us
263
265
  def record_export_start(
264
266
  profile: str = DEFAULT_PROFILE,
265
267
  app_key: str = "",
266
- view_id: str = "system:all",
268
+ view_id: str = "",
267
269
  columns: list[dict | int] | None = None,
268
270
  where: list[dict] | None = None,
269
271
  order_by: list[dict] | None = None,
272
+ record_id: str | int | None = None,
270
273
  record_ids: list[str | int] | None = None,
271
274
  include_workflow_log: bool = False,
272
275
  ) -> dict:
@@ -277,6 +280,7 @@ If the current MCP capability is unsupported, the workflow is awkward, or the us
277
280
  columns=columns or [],
278
281
  where=where or [],
279
282
  order_by=order_by or [],
283
+ record_id=record_id,
280
284
  record_ids=record_ids or [],
281
285
  include_workflow_log=include_workflow_log,
282
286
  )
@@ -304,10 +308,11 @@ If the current MCP capability is unsupported, the workflow is awkward, or the us
304
308
  def record_export_direct(
305
309
  profile: str = DEFAULT_PROFILE,
306
310
  app_key: str = "",
307
- view_id: str = "system:all",
311
+ view_id: str = "",
308
312
  columns: list[dict | int] | None = None,
309
313
  where: list[dict] | None = None,
310
314
  order_by: list[dict] | None = None,
315
+ record_id: str | int | None = None,
311
316
  record_ids: list[str | int] | None = None,
312
317
  include_workflow_log: bool = False,
313
318
  download_to_path: str | None = None,
@@ -320,6 +325,7 @@ If the current MCP capability is unsupported, the workflow is awkward, or the us
320
325
  columns=columns or [],
321
326
  where=where or [],
322
327
  order_by=order_by or [],
328
+ record_id=record_id,
323
329
  record_ids=record_ids or [],
324
330
  include_workflow_log=include_workflow_log,
325
331
  download_to_path=download_to_path,
@@ -474,7 +480,7 @@ If the current MCP capability is unsupported, the workflow is awkward, or the us
474
480
 
475
481
  code_block_tools.register(server)
476
482
  task_context_tools.register(server)
477
- directory_tools.register(server)
483
+ directory_tools.register_frontend_search(server)
478
484
 
479
485
  return server
480
486
 
@@ -176,17 +176,21 @@ class SessionStore:
176
176
  return memory_session
177
177
  if not session_profile:
178
178
  return None
179
- token = self._get_secret(self._token_key(profile)) if session_profile.persisted else None
180
- if not token:
181
- token = session_profile.token
179
+ token = session_profile.token
180
+ if not token and session_profile.persisted:
181
+ token = self._get_secret(self._token_key(profile))
182
182
  if not token:
183
183
  return None
184
- login_token = self._get_secret(self._login_token_key(profile)) if session_profile.persisted else None
185
- credential = self._get_secret(self._credential_key(profile)) if session_profile.persisted else None
184
+ login_token = session_profile.login_token
185
+ if not login_token and session_profile.persisted:
186
+ login_token = self._get_secret(self._login_token_key(profile))
187
+ credential = session_profile.credential
188
+ if not credential and session_profile.persisted:
189
+ credential = self._get_secret(self._credential_key(profile))
186
190
  backend_session = BackendSession(
187
191
  token=token,
188
- login_token=login_token or session_profile.login_token,
189
- credential=credential or session_profile.credential,
192
+ login_token=login_token,
193
+ credential=credential,
190
194
  profile=profile,
191
195
  base_url=session_profile.base_url,
192
196
  qf_version=session_profile.qf_version,
@@ -5,7 +5,7 @@ from copy import deepcopy
5
5
  from typing import Any
6
6
  from uuid import uuid4
7
7
 
8
- from ..errors import QingflowApiError
8
+ from ..errors import QingflowApiError, backend_code_int, is_auth_like_error
9
9
  from ..tools.app_tools import AppTools
10
10
  from ..tools.navigation_tools import NavigationTools
11
11
  from ..tools.package_tools import PackageTools
@@ -76,7 +76,8 @@ class SolutionExecutor:
76
76
  except Exception as exc: # noqa: BLE001
77
77
  store.record_step_failed(step.step_name, str(exc), debug_context=debug_context)
78
78
  return store.summary()
79
- store.mark_finished(status="success")
79
+ final_status = "partial_success" if _artifacts_have_post_write_readback_pending(store.data.get("artifacts", {})) else "success"
80
+ store.mark_finished(status=final_status)
80
81
  return store.summary()
81
82
 
82
83
  def _repair_start_index(self, compiled: CompiledSolution, store: RunArtifactStore) -> int:
@@ -203,6 +204,11 @@ class SolutionExecutor:
203
204
  dash_key = store.get_artifact("portal", "dash_key")
204
205
  if dash_key:
205
206
  self.portal_tools.portal_publish(profile=profile, dash_key=dash_key)
207
+ store.set_artifact(
208
+ "portal",
209
+ "publish",
210
+ {"published": True, "write_executed": True, "safe_to_retry": False},
211
+ )
206
212
  self._refresh_portal_artifact(profile=profile, store=store, being_draft=False, artifact_key="published_result")
207
213
  return
208
214
  if step_name == "publish.navigation" and publish and compiled.normalized_spec.publish_policy.navigation:
@@ -347,7 +353,32 @@ class SolutionExecutor:
347
353
  updated_items.insert(insert_at, item)
348
354
  self.package_tools.package_sort_items(profile=profile, tag_id=tag_id, tag_items=updated_items)
349
355
 
350
- verified_detail = _verify_package_attachment(self.package_tools, profile=profile, tag_id=tag_id, app_key=app_key)
356
+ try:
357
+ verified_detail = _verify_package_attachment(self.package_tools, profile=profile, tag_id=tag_id, app_key=app_key)
358
+ except Exception as exc: # noqa: BLE001
359
+ api_error = _coerce_qingflow_error(exc)
360
+ if api_error is None or not _is_permission_restricted_error(api_error):
361
+ raise
362
+ store.set_artifact(
363
+ "package",
364
+ "attachment_readback",
365
+ _post_write_readback_artifact(
366
+ resource="package_attach",
367
+ target={"tag_id": tag_id, "app_key": app_key},
368
+ error=api_error,
369
+ ),
370
+ )
371
+ self._record_package_attachment(
372
+ store,
373
+ entity.entity_id,
374
+ app_artifact,
375
+ tag_id=tag_id,
376
+ attached=True,
377
+ reused=False,
378
+ readback_status="unavailable",
379
+ readback_verified=False,
380
+ )
381
+ return
351
382
  verified_result = verified_detail.get("result") if isinstance(verified_detail.get("result"), dict) else {}
352
383
  verified_items = [deepcopy(existing) for existing in verified_result.get("tagItems", []) if isinstance(existing, dict)]
353
384
  if not any(_package_item_app_key(existing) == app_key for existing in verified_items):
@@ -369,12 +400,18 @@ class SolutionExecutor:
369
400
  tag_id: int,
370
401
  attached: bool,
371
402
  reused: bool,
403
+ readback_status: str = "verified",
404
+ readback_verified: bool = True,
372
405
  ) -> None:
373
406
  next_artifact = deepcopy(app_artifact)
374
407
  next_artifact["package_attachment"] = {
375
408
  "tag_id": tag_id,
376
409
  "attached": attached,
377
410
  "reused": reused,
411
+ "readback_status": readback_status,
412
+ "readback_verified": readback_verified,
413
+ "write_executed": not reused,
414
+ "safe_to_retry": reused or not attached,
378
415
  }
379
416
  store.set_artifact("apps", entity_id, next_artifact)
380
417
 
@@ -698,12 +735,23 @@ class SolutionExecutor:
698
735
  api_error = _coerce_qingflow_error(exc)
699
736
  if api_error is None or not _is_permission_restricted_error(api_error):
700
737
  raise
701
- raise _required_state_read_blocked_error(
702
- resource="portal",
703
- message=f"portal update requires readable draft state for dash '{dash_key}'",
704
- error=api_error,
705
- details={"dash_key": dash_key},
706
- ) from exc
738
+ if not result:
739
+ raise _required_state_read_blocked_error(
740
+ resource="portal",
741
+ message=f"portal update requires readable draft state for dash '{dash_key}'",
742
+ error=api_error,
743
+ details={"dash_key": dash_key},
744
+ ) from exc
745
+ base_payload = {}
746
+ store.set_artifact(
747
+ "portal",
748
+ "draft_readback_before_update",
749
+ _post_write_readback_artifact(
750
+ resource="portal",
751
+ target={"dash_key": dash_key, "phase": "created_portal_draft_readback"},
752
+ error=api_error,
753
+ ),
754
+ )
707
755
  update_payload = self._resolve_portal_payload(compiled.portal_plan["update_payload"], store, base_payload=base_payload)
708
756
  self.portal_tools.portal_update(profile=profile, dash_key=dash_key, payload=update_payload)
709
757
  self._refresh_portal_artifact(profile=profile, store=store, being_draft=True, artifact_key="draft_result")
@@ -744,7 +792,19 @@ class SolutionExecutor:
744
792
  return
745
793
  try:
746
794
  result = self.portal_tools.portal_get(profile=profile, dash_key=dash_key, being_draft=being_draft)
747
- except Exception: # noqa: BLE001
795
+ except Exception as exc: # noqa: BLE001
796
+ api_error = _coerce_qingflow_error(exc)
797
+ if api_error is None:
798
+ raise
799
+ store.set_artifact(
800
+ "portal",
801
+ f"{artifact_key}_readback",
802
+ _post_write_readback_artifact(
803
+ resource="portal",
804
+ target={"dash_key": dash_key, "being_draft": being_draft, "artifact_key": artifact_key},
805
+ error=api_error,
806
+ ),
807
+ )
748
808
  return
749
809
  store.set_artifact("portal", artifact_key, result)
750
810
  store.set_artifact("portal", "result", result)
@@ -2112,10 +2172,9 @@ def _has_explicit_workflow_global_settings(global_settings: dict[str, Any] | Non
2112
2172
 
2113
2173
 
2114
2174
  def _is_navigation_plugin_unavailable(error: QingflowApiError) -> bool:
2115
- try:
2116
- backend_code = int(error.backend_code)
2117
- except (TypeError, ValueError):
2118
- backend_code = None
2175
+ if is_auth_like_error(error):
2176
+ return False
2177
+ backend_code = backend_code_int(error)
2119
2178
  if backend_code != 50004:
2120
2179
  return False
2121
2180
  message = error.message or ""
@@ -2152,7 +2211,9 @@ def _coerce_qingflow_error(error: Exception) -> QingflowApiError | None:
2152
2211
 
2153
2212
 
2154
2213
  def _is_permission_restricted_error(error: QingflowApiError) -> bool:
2155
- return error.backend_code in {40002, 40027}
2214
+ if is_auth_like_error(error):
2215
+ return False
2216
+ return backend_code_int(error) in {40002, 40027}
2156
2217
 
2157
2218
 
2158
2219
  def _required_state_read_blocked_error(
@@ -2182,6 +2243,42 @@ def _required_state_read_blocked_error(
2182
2243
  )
2183
2244
 
2184
2245
 
2246
+ def _post_write_readback_artifact(
2247
+ *,
2248
+ resource: str,
2249
+ target: dict[str, Any],
2250
+ error: QingflowApiError,
2251
+ ) -> dict[str, Any]:
2252
+ return {
2253
+ "resource": resource,
2254
+ "target": deepcopy(target),
2255
+ "readback_status": "unavailable",
2256
+ "readback_verified": False,
2257
+ "write_executed": True,
2258
+ "safe_to_retry": False,
2259
+ "transport_error": {
2260
+ "http_status": error.http_status,
2261
+ "backend_code": error.backend_code,
2262
+ "category": error.category,
2263
+ "request_id": error.request_id,
2264
+ },
2265
+ }
2266
+
2267
+
2268
+ def _artifacts_have_post_write_readback_pending(value: Any) -> bool:
2269
+ if isinstance(value, dict):
2270
+ if (
2271
+ value.get("write_executed") is True
2272
+ and value.get("safe_to_retry") is False
2273
+ and value.get("readback_status") == "unavailable"
2274
+ ):
2275
+ return True
2276
+ return any(_artifacts_have_post_write_readback_pending(item) for item in value.values())
2277
+ if isinstance(value, list):
2278
+ return any(_artifacts_have_post_write_readback_pending(item) for item in value)
2279
+ return False
2280
+
2281
+
2185
2282
  def _portal_component_position(
2186
2283
  source_type: Any,
2187
2284
  *,
@@ -15,7 +15,7 @@ from ..builder_facade.button_style_catalog import (
15
15
  )
16
16
  from ..public_surface import public_builder_contract_tool_names
17
17
  from ..config import DEFAULT_PROFILE
18
- from ..errors import QingflowApiError
18
+ from ..errors import QingflowApiError, backend_code_int
19
19
  from ..json_types import JSONObject
20
20
  from ..builder_facade.models import (
21
21
  AssociatedResourcesApplyRequest,
@@ -854,16 +854,23 @@ class AiBuilderTools(ToolBase):
854
854
  contain_disable: bool = False,
855
855
  ) -> JSONObject:
856
856
  """执行工具方法逻辑。"""
857
+ normalized_query = str(query or "").strip()
857
858
  normalized_args = {
858
- "query": query,
859
+ "query": normalized_query,
859
860
  "page_num": page_num,
860
861
  "page_size": page_size,
861
862
  "contain_disable": contain_disable,
862
863
  }
864
+ if not normalized_query:
865
+ return _config_failure(
866
+ tool_name="member_search",
867
+ message="query is required for member_search; builder member lookup is a contact-directory path, not a record candidate fallback.",
868
+ fix_hint="For record member/department field ambiguity, use record member-candidates / department-candidates instead.",
869
+ )
863
870
  return _safe_tool_call(
864
871
  lambda: self._facade.member_search(
865
872
  profile=profile,
866
- query=query,
873
+ query=normalized_query,
867
874
  page_num=page_num,
868
875
  page_size=page_size,
869
876
  contain_disable=contain_disable,
@@ -876,9 +883,16 @@ class AiBuilderTools(ToolBase):
876
883
  @tool_cn_name("角色检索")
877
884
  def role_search(self, *, profile: str, keyword: str, page_num: int = 1, page_size: int = 20) -> JSONObject:
878
885
  """执行角色相关逻辑。"""
879
- normalized_args = {"keyword": keyword, "page_num": page_num, "page_size": page_size}
886
+ normalized_keyword = str(keyword or "").strip()
887
+ normalized_args = {"keyword": normalized_keyword, "page_num": page_num, "page_size": page_size}
888
+ if not normalized_keyword:
889
+ return _config_failure(
890
+ tool_name="role_search",
891
+ message="keyword is required for role_search; builder role lookup is a contact-management path, not a record candidate fallback.",
892
+ fix_hint="For record member/department field ambiguity, use record member-candidates / department-candidates instead.",
893
+ )
880
894
  return _safe_tool_call(
881
- lambda: self._facade.role_search(profile=profile, keyword=keyword, page_num=page_num, page_size=page_size),
895
+ lambda: self._facade.role_search(profile=profile, keyword=normalized_keyword, page_num=page_num, page_size=page_size),
882
896
  error_code="ROLE_SEARCH_FAILED",
883
897
  normalized_args=normalized_args,
884
898
  suggested_next_call={"tool_name": "role_search", "arguments": {"profile": profile, **normalized_args}},
@@ -1820,6 +1834,9 @@ class AiBuilderTools(ToolBase):
1820
1834
  )
1821
1835
  public_shell = _publicize_package_fields(shell)
1822
1836
  resolved_key = str(public_shell.get("app_key") or "").strip()
1837
+ shell_write_executed = _schema_apply_result_has_write(public_shell)
1838
+ if shell_write_executed:
1839
+ any_write_executed = True
1823
1840
  if public_shell.get("status") not in {"success", "partial_success"} or not resolved_key:
1824
1841
  results.append({
1825
1842
  "index": index,
@@ -1831,13 +1848,12 @@ class AiBuilderTools(ToolBase):
1831
1848
  "stage": "resolve_or_create_shell",
1832
1849
  "error_code": public_shell.get("error_code") or "APP_SHELL_APPLY_FAILED",
1833
1850
  "message": public_shell.get("message") or "app shell resolve/create failed",
1834
- "safe_to_retry": not any_write_executed,
1851
+ "write_executed": shell_write_executed,
1852
+ "safe_to_retry": not shell_write_executed and not any_write_executed,
1835
1853
  })
1836
1854
  continue
1837
1855
  if bool(public_shell.get("created")):
1838
1856
  created_app_keys.append(resolved_key)
1839
- if _schema_apply_result_has_write(public_shell):
1840
- any_write_executed = True
1841
1857
  if client_key:
1842
1858
  client_key_to_app_key[client_key] = resolved_key
1843
1859
  results.append({
@@ -2842,6 +2858,8 @@ def _merge_schema_field_diffs(*diffs: object) -> JSONObject:
2842
2858
 
2843
2859
 
2844
2860
  def _schema_apply_result_has_write(result: JSONObject) -> bool:
2861
+ if "write_executed" in result:
2862
+ return bool(result.get("write_executed"))
2845
2863
  if bool(result.get("created")) or bool(result.get("published")) or bool(result.get("app_base_updated")):
2846
2864
  return True
2847
2865
  field_diff = result.get("field_diff")
@@ -3831,7 +3849,7 @@ def _coerce_api_error(error: Exception) -> QingflowApiError:
3831
3849
 
3832
3850
 
3833
3851
  def _public_error_message(error_code: str, error: QingflowApiError) -> str:
3834
- if error.backend_code == 40074 or error_code == "APP_EDIT_LOCKED":
3852
+ if backend_code_int(error) == 40074 or error_code == "APP_EDIT_LOCKED":
3835
3853
  return "app is currently locked by another active editor session"
3836
3854
  if error.http_status != 404:
3837
3855
  return error.message
@@ -4030,6 +4048,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
4030
4048
  "package_id maps internally to backend tagId; do not use tag_id in public calls",
4031
4049
  "items is a full package layout tree; omitting existing app/portal items is blocked unless allow_detach=true",
4032
4050
  "item shapes: {type:'app', app_key}, {type:'portal', dash_key}, or {type:'group', group_id?, name, items:[...]}",
4051
+ "layout apply calls backend package ordering (MoveGroupAuth), so it requires package edit_app permission even when items=[] only clears/deletes existing groups",
4033
4052
  *_VISIBILITY_EXECUTION_NOTES,
4034
4053
  ],
4035
4054
  "minimal_example": {
@@ -4256,6 +4275,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
4256
4275
  "field_mappings.source_field accepts source schema fields and supported system fields: 数据ID/row_record_id/apply_id/_id maps to current record id (-17), 编号/record_number maps to visible record number (0)",
4257
4276
  "to fill a target relation field with the current source record, map source_field='数据ID' to the target relation field; default_values is for static constants, not dynamic current-record values",
4258
4277
  "do not write raw que_relation unless maintaining a legacy config; field_mappings/default_values and que_relation are mutually exclusive",
4278
+ "permission split follows backend routes: upsert_buttons/patch_buttons/remove_buttons require EditAppAuth; view_configs also requires ViewManagementAuth (beingViewManageStatus), which falls back to DataManageAuth when advanced app permissions are not enabled",
4259
4279
  "view_configs binds custom buttons into views in the same apply call; button_ref may be a same-call client_key, a button_id, or an exact unique existing button_text",
4260
4280
  "view_configs[].view_key is the raw builder view key from app_get.views[].view_key; do not pass record-data view_id values like custom:VIEW_KEY",
4261
4281
  "view_configs[].buttons is required in merge mode; omitting buttons is blocked to avoid no-op writes and accidental publish",
@@ -4399,6 +4419,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
4399
4419
  "this tool manages Qingflow in-app associated report/view display; it does not create or edit QingBI report bodies/configs",
4400
4420
  "create or edit app-source BI report bodies first with app_charts_apply, then attach the resulting chart_id here with graph_type=chart; dataset BI reports can only be attached when they already exist",
4401
4421
  "this is the default associated report/view path; it manages both the app-level associated resource pool and per-view display config",
4422
+ "permission split follows backend routes: upsert_resources/patch_resources/remove/reorder require EditAppAuth; view_configs require ViewManagementAuth (beingViewManageStatus), which falls back to DataManageAuth when advanced app permissions are not enabled",
4402
4423
  "use patch_resources for partial parameter replacement on existing associated resources; the tool reads the current resource including backend-required raw fields, merges patch_resources[].set/unset, then submits the backend full-save payload internally",
4403
4424
  "associated_item_id is form_asos_chart.id from app_get.associated_resources[].associated_item_id; view_configs/remove/reorder may also pass an existing associated resource's chart_id/chart_key/view_key and the tool resolves it to the internal id",
4404
4425
  "before creating an associated resource, read app_get.associated_resources and reuse an existing item with patch_resources when target_app_key + view_key/chart_key already matches; repeated upsert_resources without associated_item_id can create duplicate associated items",
@@ -4587,6 +4608,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
4587
4608
  "use exactly one resource mode",
4588
4609
  "edit mode: app_key, optional app_name to rename the existing app",
4589
4610
  "create mode: package_id + app_name + create_if_missing=true",
4611
+ "create mode follows backend CreateAppBean: package add_app permission is checked on the target package; package edit_app is not required for the create precheck",
4590
4612
  "multi-app mode: pass package_id + create_if_missing + apps[]; do not mix apps with top-level app_key/app_name/add_fields/update_fields/remove_fields",
4591
4613
  "multi-app relation fields may use target_app_ref to point at another apps[].client_key; the tool creates/resolves app shells first and compiles it to target_app_key",
4592
4614
  "multi-app mode is not transactional; read created_app_keys and apps[].status before retrying, and retry only failed app items",
@@ -4987,6 +5009,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
4987
5009
  **deepcopy(_VISIBILITY_ALLOWED_VALUES),
4988
5010
  },
4989
5011
  "execution_notes": [
5012
+ "creating a new view follows backend createViewgraphConfig and requires both ViewManagementAuth and DataManageAuth; updating/deleting existing views only requires ViewManagementAuth",
4990
5013
  "upsert_views[].visibility may set per-view visibility; omit it to preserve an existing view's auth or default a new view to workspace/not",
4991
5014
  "filters are saved fixed filters that apply when the view opens; query_conditions configure the frontend query panel and only apply after a user enters query values",
4992
5015
  "upsert_views[].query_conditions.rows is a layout matrix of field names; it is compiled to backend queryCondition queIds",
@@ -5111,6 +5134,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
5111
5134
  },
5112
5135
  "execution_notes": [
5113
5136
  "apply may return partial_success when some views land and others fail",
5137
+ "creating a new view follows backend createViewgraphConfig and requires both ViewManagementAuth and DataManageAuth; updating/deleting existing views only requires ViewManagementAuth",
5114
5138
  "when duplicate view names exist, supply view_key to target the exact view",
5115
5139
  "read back app_get after any failed or partial view apply",
5116
5140
  "view existence verification and saved-filter verification are separate; treat filters as unverified until verification.view_filters_verified is true",
@@ -5196,8 +5220,8 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
5196
5220
  "returns builder-side app map: base summary, editability, field/view/chart/button counts, compact views, compact charts, custom_buttons, and app-level associated_resources",
5197
5221
  "use this as the default builder discovery read before view_get/chart_get/apply detail work",
5198
5222
  "editability is route-aware builder capability summary, not end-user data visibility",
5199
- "can_edit_app_base covers app base-info writes such as app_name, icon, and visibility",
5200
- "can_edit_form covers form/schema routes only and does not imply app base-info writes",
5223
+ "can_edit_app_base covers app base-info writes such as app_name, icon, and visibility; it follows backend EditAppAuth and does not require package edit_tag",
5224
+ "can_edit_form covers form/schema routes and also follows backend EditAppAuth",
5201
5225
  "returns normalized app visibility when backend auth is readable",
5202
5226
  "custom_buttons[].button_id is the id required by app_custom_buttons_apply view_configs[].buttons[].button_ref",
5203
5227
  "associated_resources[].associated_item_id is the internal id; app_associated_resources_apply.view_configs/remove/reorder may also pass an existing resource's chart_id/chart_key/view_key",
@@ -5413,6 +5437,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
5413
5437
  "use exactly one resource mode",
5414
5438
  "update mode: dash_key",
5415
5439
  "create mode: package_id + dash_name",
5440
+ "create mode follows backend DashCtrl.createDash: package add_app permission is checked on the target package; package edit_app is not required for the create precheck",
5416
5441
  "create mode requires explicit icon + color; icon=template is blocked because it is too generic",
5417
5442
  "edit mode preserves existing icon/color when omitted; explicit icon/color values are still validated",
5418
5443
  "call workspace_icon_catalog_get or `qingflow --json builder icon catalog` for supported icon/color candidates",