@josephyan/qingflow-app-user-mcp 0.2.0-beta.981 → 0.2.0-beta.983
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/qingflow_mcp/__init__.py +1 -1
- package/src/qingflow_mcp/builder_facade/models.py +2 -2
- package/src/qingflow_mcp/builder_facade/service.py +184 -45
- package/src/qingflow_mcp/cli/commands/builder.py +3 -2
- package/src/qingflow_mcp/cli/formatters.py +2 -0
- package/src/qingflow_mcp/response_trim.py +50 -10
- package/src/qingflow_mcp/server_app_builder.py +1 -1
- package/src/qingflow_mcp/tools/ai_builder_tools.py +22 -18
- package/src/qingflow_mcp/tools/import_tools.py +36 -2
package/README.md
CHANGED
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
Install:
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
|
-
npm install @josephyan/qingflow-app-user-mcp@0.2.0-beta.
|
|
6
|
+
npm install @josephyan/qingflow-app-user-mcp@0.2.0-beta.983
|
|
7
7
|
```
|
|
8
8
|
|
|
9
9
|
Run:
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
|
-
npx -y -p @josephyan/qingflow-app-user-mcp@0.2.0-beta.
|
|
12
|
+
npx -y -p @josephyan/qingflow-app-user-mcp@0.2.0-beta.983 qingflow-app-user-mcp
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
Environment:
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
@@ -1536,8 +1536,8 @@ class PortalApplyRequest(StrictModel):
|
|
|
1536
1536
|
raise ValueError("package_tag_id is required when dash_key is empty")
|
|
1537
1537
|
if not self.dash_key and not self.dash_name:
|
|
1538
1538
|
raise ValueError("dash_name is required when creating a portal")
|
|
1539
|
-
if not self.sections:
|
|
1540
|
-
raise ValueError("portal apply requires a non-empty sections list")
|
|
1539
|
+
if not self.dash_key and not self.sections:
|
|
1540
|
+
raise ValueError("portal apply requires a non-empty sections list when creating a portal")
|
|
1541
1541
|
if self.visibility is not None and self.auth is not None:
|
|
1542
1542
|
raise ValueError("visibility and auth cannot be provided together")
|
|
1543
1543
|
return self
|
|
@@ -1413,6 +1413,8 @@ class AiBuilderFacade:
|
|
|
1413
1413
|
issues: list[dict[str, Any]] = []
|
|
1414
1414
|
resolved: list[dict[str, Any]] = []
|
|
1415
1415
|
seen_ids: set[int] = set()
|
|
1416
|
+
if not dept_ids and not dept_names:
|
|
1417
|
+
return {"department_entries": resolved, "issues": issues}
|
|
1416
1418
|
listed = self.directory.directory_list_all_departments(
|
|
1417
1419
|
profile=profile,
|
|
1418
1420
|
parent_dept_id=None,
|
|
@@ -2784,6 +2786,19 @@ class AiBuilderFacade:
|
|
|
2784
2786
|
"can_copy_app": _coerce_optional_bool(base.get("copyAppStatus")),
|
|
2785
2787
|
}
|
|
2786
2788
|
|
|
2789
|
+
def _derive_can_edit_app_base(self, *, profile: str, permission_summary: JSONObject) -> bool:
|
|
2790
|
+
if permission_summary.get("can_edit_app") is not True:
|
|
2791
|
+
return False
|
|
2792
|
+
tag_ids = _coerce_int_list(permission_summary.get("tag_ids"))
|
|
2793
|
+
for tag_id in tag_ids:
|
|
2794
|
+
try:
|
|
2795
|
+
package_permission = self._read_package_permission_summary(profile=profile, tag_id=tag_id)
|
|
2796
|
+
except (QingflowApiError, RuntimeError):
|
|
2797
|
+
return False
|
|
2798
|
+
if package_permission.get("can_edit_tag") is not True:
|
|
2799
|
+
return False
|
|
2800
|
+
return True
|
|
2801
|
+
|
|
2787
2802
|
def _read_portal_permission_summary(self, *, dash_key: str, portal_result: dict[str, Any]) -> JSONObject:
|
|
2788
2803
|
tag_ids = _coerce_int_list(portal_result.get("tagIds"))
|
|
2789
2804
|
if not tag_ids:
|
|
@@ -2997,7 +3012,7 @@ class AiBuilderFacade:
|
|
|
2997
3012
|
|
|
2998
3013
|
def app_read_summary(self, *, profile: str, app_key: str) -> JSONObject:
|
|
2999
3014
|
try:
|
|
3000
|
-
|
|
3015
|
+
base = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True)
|
|
3001
3016
|
except (QingflowApiError, RuntimeError) as error:
|
|
3002
3017
|
api_error = _coerce_api_error(error)
|
|
3003
3018
|
return _failed_from_api_error(
|
|
@@ -3007,26 +3022,55 @@ class AiBuilderFacade:
|
|
|
3007
3022
|
details={"app_key": app_key},
|
|
3008
3023
|
suggested_next_call={"tool_name": "app_resolve", "arguments": {"profile": profile, "app_key": app_key}},
|
|
3009
3024
|
)
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3025
|
+
base_result = base.get("result") if isinstance(base.get("result"), dict) else {}
|
|
3026
|
+
schema_unavailable = False
|
|
3027
|
+
try:
|
|
3028
|
+
schema_result, _schema_source = self._read_schema_with_fallback(profile=profile, app_key=app_key)
|
|
3029
|
+
parsed = _parse_schema(schema_result)
|
|
3030
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
3031
|
+
api_error = _coerce_api_error(error)
|
|
3032
|
+
if api_error.http_status == 404 or _is_permission_restricted_api_error(api_error):
|
|
3033
|
+
schema_unavailable = True
|
|
3034
|
+
parsed = {"fields": [], "layout": {"sections": []}}
|
|
3035
|
+
else:
|
|
3036
|
+
return _failed_from_api_error(
|
|
3037
|
+
"APP_READ_FAILED",
|
|
3038
|
+
api_error,
|
|
3039
|
+
normalized_args={"app_key": app_key},
|
|
3040
|
+
details={"app_key": app_key},
|
|
3041
|
+
suggested_next_call={"tool_name": "app_resolve", "arguments": {"profile": profile, "app_key": app_key}},
|
|
3042
|
+
)
|
|
3043
|
+
views, views_unavailable = self._load_views_result(
|
|
3044
|
+
profile=profile,
|
|
3045
|
+
app_key=app_key,
|
|
3046
|
+
tolerate_404=True,
|
|
3047
|
+
tolerate_permission_restricted=True,
|
|
3048
|
+
)
|
|
3049
|
+
workflow, workflow_unavailable = self._load_workflow_result(
|
|
3050
|
+
profile=profile,
|
|
3051
|
+
app_key=app_key,
|
|
3052
|
+
tolerate_404=True,
|
|
3053
|
+
tolerate_permission_restricted=True,
|
|
3054
|
+
)
|
|
3013
3055
|
verification_hints = _build_verification_hints(
|
|
3014
|
-
tag_ids=_coerce_int_list(
|
|
3056
|
+
tag_ids=_coerce_int_list(base_result.get("tagIds")),
|
|
3015
3057
|
fields=parsed["fields"],
|
|
3016
3058
|
layout=parsed["layout"],
|
|
3017
3059
|
views=_summarize_views(views),
|
|
3018
3060
|
)
|
|
3061
|
+
if schema_unavailable:
|
|
3062
|
+
verification_hints.append("schema_read_unavailable")
|
|
3019
3063
|
if views_unavailable:
|
|
3020
3064
|
verification_hints.append("views_read_unavailable")
|
|
3021
3065
|
if workflow_unavailable:
|
|
3022
3066
|
verification_hints.append("workflow_read_unavailable")
|
|
3023
3067
|
response = AppReadSummaryResponse(
|
|
3024
3068
|
app_key=app_key,
|
|
3025
|
-
title=
|
|
3026
|
-
app_icon=str(
|
|
3027
|
-
visibility=_public_visibility_from_member_auth(
|
|
3028
|
-
tag_ids=_coerce_int_list(
|
|
3029
|
-
publish_status=
|
|
3069
|
+
title=base_result.get("formTitle"),
|
|
3070
|
+
app_icon=str(base_result.get("appIcon") or "").strip() or None,
|
|
3071
|
+
visibility=_public_visibility_from_member_auth(base_result.get("auth")),
|
|
3072
|
+
tag_ids=_coerce_int_list(base_result.get("tagIds")),
|
|
3073
|
+
publish_status=base_result.get("appPublishStatus"),
|
|
3030
3074
|
field_count=len(parsed["fields"]),
|
|
3031
3075
|
layout_section_count=len(parsed["layout"].get("sections", [])),
|
|
3032
3076
|
view_count=len(_summarize_views(views)),
|
|
@@ -3048,10 +3092,11 @@ class AiBuilderFacade:
|
|
|
3048
3092
|
"warnings": _warnings_from_verification_hints(verification_hints),
|
|
3049
3093
|
"verification": {
|
|
3050
3094
|
"app_exists": True,
|
|
3095
|
+
"schema_read_unavailable": schema_unavailable,
|
|
3051
3096
|
"views_read_unavailable": views_unavailable,
|
|
3052
3097
|
"workflow_read_unavailable": workflow_unavailable,
|
|
3053
3098
|
},
|
|
3054
|
-
"verified": not views_unavailable and not workflow_unavailable,
|
|
3099
|
+
"verified": not schema_unavailable and not views_unavailable and not workflow_unavailable,
|
|
3055
3100
|
**response.model_dump(mode="json"),
|
|
3056
3101
|
}
|
|
3057
3102
|
|
|
@@ -3064,8 +3109,9 @@ class AiBuilderFacade:
|
|
|
3064
3109
|
permission_summary = self._read_app_permission_summary(profile=profile, app_key=app_key)
|
|
3065
3110
|
result["message"] = "read app config summary"
|
|
3066
3111
|
result["editability"] = {
|
|
3112
|
+
"can_edit_app_base": self._derive_can_edit_app_base(profile=profile, permission_summary=permission_summary),
|
|
3067
3113
|
"can_edit_form": permission_summary.get("can_edit_app"),
|
|
3068
|
-
"can_edit_flow": permission_summary.get("
|
|
3114
|
+
"can_edit_flow": permission_summary.get("can_manage_data"),
|
|
3069
3115
|
"can_edit_views": permission_summary.get("can_manage_data"),
|
|
3070
3116
|
"can_edit_charts": permission_summary.get("can_manage_data"),
|
|
3071
3117
|
}
|
|
@@ -6706,6 +6752,7 @@ class AiBuilderFacade:
|
|
|
6706
6752
|
|
|
6707
6753
|
for patch in request.upsert_charts:
|
|
6708
6754
|
try:
|
|
6755
|
+
config_update_requested = _chart_patch_updates_chart_config(patch)
|
|
6709
6756
|
chart_visible_auth = (
|
|
6710
6757
|
self._compile_visibility_to_chart_visible_auth(profile=profile, visibility=patch.visibility)
|
|
6711
6758
|
if patch.visibility is not None
|
|
@@ -6809,18 +6856,17 @@ class AiBuilderFacade:
|
|
|
6809
6856
|
existing_by_name.pop(old_name, None)
|
|
6810
6857
|
existing_by_name.setdefault(patch.name, []).append(deepcopy(updated_chart))
|
|
6811
6858
|
|
|
6812
|
-
|
|
6813
|
-
|
|
6814
|
-
|
|
6815
|
-
|
|
6816
|
-
|
|
6817
|
-
|
|
6818
|
-
|
|
6819
|
-
|
|
6820
|
-
|
|
6821
|
-
|
|
6822
|
-
|
|
6823
|
-
if existing is not None and chart_id not in updated_ids:
|
|
6859
|
+
config_updated = False
|
|
6860
|
+
if existing is None or config_update_requested:
|
|
6861
|
+
config_payload = _build_public_chart_config_payload(
|
|
6862
|
+
patch=patch,
|
|
6863
|
+
app_key=app_key,
|
|
6864
|
+
field_lookup=field_lookup,
|
|
6865
|
+
qingbi_fields_by_id=qingbi_fields_by_id,
|
|
6866
|
+
)
|
|
6867
|
+
self.charts.qingbi_report_update_config(profile=profile, chart_id=chart_id, payload=config_payload)
|
|
6868
|
+
config_updated = True
|
|
6869
|
+
if existing is not None and chart_id not in updated_ids and config_updated:
|
|
6824
6870
|
updated_ids.append(chart_id)
|
|
6825
6871
|
if patch.question_config:
|
|
6826
6872
|
self._request_backend(
|
|
@@ -6836,6 +6882,8 @@ class AiBuilderFacade:
|
|
|
6836
6882
|
path=f"/chart/{chart_id}/user/config",
|
|
6837
6883
|
json_body=patch.user_config,
|
|
6838
6884
|
)
|
|
6885
|
+
if existing is not None and chart_id not in updated_ids and (patch.question_config or patch.user_config):
|
|
6886
|
+
updated_ids.append(chart_id)
|
|
6839
6887
|
chart_results.append(
|
|
6840
6888
|
{
|
|
6841
6889
|
"chart_id": chart_id,
|
|
@@ -6992,11 +7040,12 @@ class AiBuilderFacade:
|
|
|
6992
7040
|
permission_outcomes: list[PermissionCheckOutcome] = []
|
|
6993
7041
|
dash_key = str(request.dash_key or "").strip()
|
|
6994
7042
|
creating = not dash_key
|
|
7043
|
+
sections_requested = creating or bool(request.sections)
|
|
6995
7044
|
verify_dash_name = creating or request.dash_name is not None
|
|
6996
7045
|
verify_dash_icon = bool(request.icon or request.color)
|
|
6997
7046
|
verify_auth = request.visibility is not None or request.auth is not None
|
|
6998
|
-
verify_hide_copyright = request.hide_copyright is not None
|
|
6999
|
-
verify_dash_global_config = request.dash_global_config is not None
|
|
7047
|
+
verify_hide_copyright = request.hide_copyright is not None and sections_requested
|
|
7048
|
+
verify_dash_global_config = request.dash_global_config is not None and sections_requested
|
|
7000
7049
|
verify_tags = creating or request.package_tag_id is not None
|
|
7001
7050
|
requested_visibility = request.visibility
|
|
7002
7051
|
if requested_visibility is None and isinstance(request.auth, dict) and request.auth:
|
|
@@ -7073,6 +7122,25 @@ class AiBuilderFacade:
|
|
|
7073
7122
|
if package_edit_outcome.block is not None:
|
|
7074
7123
|
return package_edit_outcome.block
|
|
7075
7124
|
permission_outcomes.append(package_edit_outcome)
|
|
7125
|
+
if not sections_requested:
|
|
7126
|
+
unsupported_base_only_keys: list[str] = []
|
|
7127
|
+
if request.hide_copyright is not None:
|
|
7128
|
+
unsupported_base_only_keys.append("hide_copyright")
|
|
7129
|
+
if request.dash_global_config is not None:
|
|
7130
|
+
unsupported_base_only_keys.append("dash_global_config")
|
|
7131
|
+
if request.config:
|
|
7132
|
+
unsupported_base_only_keys.append("config")
|
|
7133
|
+
if unsupported_base_only_keys:
|
|
7134
|
+
return _failed(
|
|
7135
|
+
"PORTAL_SECTIONS_REQUIRED",
|
|
7136
|
+
"editing a portal without sections only supports base-info updates",
|
|
7137
|
+
normalized_args=normalized_args,
|
|
7138
|
+
details={
|
|
7139
|
+
"unsupported_without_sections": unsupported_base_only_keys,
|
|
7140
|
+
"fix_hint": "Pass sections when changing layout or global portal config, or omit those keys for visibility/icon/package updates.",
|
|
7141
|
+
},
|
|
7142
|
+
suggested_next_call={"tool_name": "portal_apply", "arguments": {"profile": profile, **normalized_args}},
|
|
7143
|
+
)
|
|
7076
7144
|
try:
|
|
7077
7145
|
if creating:
|
|
7078
7146
|
create_payload = _build_public_portal_base_payload(
|
|
@@ -7098,7 +7166,6 @@ class AiBuilderFacade:
|
|
|
7098
7166
|
suggested_next_call={"tool_name": "portal_apply", "arguments": {"profile": profile, **normalized_args}},
|
|
7099
7167
|
)
|
|
7100
7168
|
base_payload = self.portals.portal_get(profile=profile, dash_key=dash_key, being_draft=True).get("result") or {}
|
|
7101
|
-
component_payload = self._build_portal_components_from_sections(profile=profile, sections=request.sections)
|
|
7102
7169
|
update_payload = _build_public_portal_base_payload(
|
|
7103
7170
|
dash_name=request.dash_name or str(base_payload.get("dashName") or "").strip() or "未命名门户",
|
|
7104
7171
|
package_tag_id=target_package_tag_id,
|
|
@@ -7110,8 +7177,10 @@ class AiBuilderFacade:
|
|
|
7110
7177
|
config=request.config,
|
|
7111
7178
|
base_payload=base_payload,
|
|
7112
7179
|
)
|
|
7113
|
-
|
|
7114
|
-
|
|
7180
|
+
if sections_requested:
|
|
7181
|
+
component_payload = self._build_portal_components_from_sections(profile=profile, sections=request.sections)
|
|
7182
|
+
update_payload["components"] = component_payload
|
|
7183
|
+
self.portals.portal_update(profile=profile, dash_key=dash_key, payload=update_payload)
|
|
7115
7184
|
self.portals.portal_update_base_info(
|
|
7116
7185
|
profile=profile,
|
|
7117
7186
|
dash_key=dash_key,
|
|
@@ -7148,11 +7217,14 @@ class AiBuilderFacade:
|
|
|
7148
7217
|
publish_failed = True
|
|
7149
7218
|
|
|
7150
7219
|
draft_components = draft_result.get("components") if isinstance(draft_result, dict) else None
|
|
7151
|
-
expected_count = len(request.sections)
|
|
7152
|
-
draft_verified = isinstance(
|
|
7220
|
+
expected_count = len(request.sections) if sections_requested else None
|
|
7221
|
+
draft_verified = isinstance(draft_result, dict) and (
|
|
7222
|
+
expected_count is None or (isinstance(draft_components, list) and len(draft_components) == expected_count)
|
|
7223
|
+
)
|
|
7153
7224
|
draft_meta_verified, draft_meta_mismatches = _verify_portal_readback(
|
|
7154
7225
|
actual=draft_result,
|
|
7155
7226
|
expected_payload=update_payload,
|
|
7227
|
+
expected_visibility=requested_visibility.model_dump(mode="json") if requested_visibility is not None else None,
|
|
7156
7228
|
expected_section_count=expected_count,
|
|
7157
7229
|
requested_config_keys=set((request.config or {}).keys()),
|
|
7158
7230
|
verify_dash_name=verify_dash_name,
|
|
@@ -7168,12 +7240,18 @@ class AiBuilderFacade:
|
|
|
7168
7240
|
if request.publish:
|
|
7169
7241
|
live_verified = (
|
|
7170
7242
|
isinstance(live_result, dict)
|
|
7171
|
-
and
|
|
7172
|
-
|
|
7243
|
+
and (
|
|
7244
|
+
expected_count is None
|
|
7245
|
+
or (
|
|
7246
|
+
isinstance(live_result.get("components"), list)
|
|
7247
|
+
and len(live_result.get("components")) == expected_count
|
|
7248
|
+
)
|
|
7249
|
+
)
|
|
7173
7250
|
)
|
|
7174
7251
|
live_meta_verified, live_meta_mismatches = _verify_portal_readback(
|
|
7175
7252
|
actual=live_result,
|
|
7176
7253
|
expected_payload=update_payload,
|
|
7254
|
+
expected_visibility=requested_visibility.model_dump(mode="json") if requested_visibility is not None else None,
|
|
7177
7255
|
expected_section_count=expected_count,
|
|
7178
7256
|
requested_config_keys=set((request.config or {}).keys()),
|
|
7179
7257
|
verify_dash_name=verify_dash_name,
|
|
@@ -7207,7 +7285,15 @@ class AiBuilderFacade:
|
|
|
7207
7285
|
"status": status,
|
|
7208
7286
|
"error_code": error_code,
|
|
7209
7287
|
"recoverable": not verified,
|
|
7210
|
-
"message":
|
|
7288
|
+
"message": (
|
|
7289
|
+
"updated portal base info"
|
|
7290
|
+
if verified and not sections_requested
|
|
7291
|
+
else "applied portal"
|
|
7292
|
+
if verified
|
|
7293
|
+
else "updated portal base info; draft/live verification pending"
|
|
7294
|
+
if not sections_requested
|
|
7295
|
+
else "applied portal; draft/live verification pending"
|
|
7296
|
+
),
|
|
7211
7297
|
"normalized_args": normalized_args,
|
|
7212
7298
|
"missing_fields": [],
|
|
7213
7299
|
"allowed_values": {"section.source_type": ["chart", "view", "grid", "filter", "text", "link"]},
|
|
@@ -7402,17 +7488,35 @@ class AiBuilderFacade:
|
|
|
7402
7488
|
sync_result = {**sync_result, "button_config_restored": True}
|
|
7403
7489
|
return sync_result
|
|
7404
7490
|
|
|
7405
|
-
def _load_views_result(
|
|
7491
|
+
def _load_views_result(
|
|
7492
|
+
self,
|
|
7493
|
+
*,
|
|
7494
|
+
profile: str,
|
|
7495
|
+
app_key: str,
|
|
7496
|
+
tolerate_404: bool,
|
|
7497
|
+
tolerate_permission_restricted: bool = False,
|
|
7498
|
+
) -> tuple[Any, bool]:
|
|
7406
7499
|
try:
|
|
7407
7500
|
views = self.views.view_list_flat(profile=profile, app_key=app_key)
|
|
7408
7501
|
except (QingflowApiError, RuntimeError) as error:
|
|
7409
7502
|
api_error = _coerce_api_error(error)
|
|
7410
|
-
if api_error.http_status == 404
|
|
7503
|
+
if api_error.http_status == 404 or (
|
|
7504
|
+
tolerate_permission_restricted and _is_permission_restricted_api_error(api_error)
|
|
7505
|
+
):
|
|
7411
7506
|
try:
|
|
7412
7507
|
legacy_views = self.views.view_list(profile=profile, app_key=app_key)
|
|
7413
7508
|
except (QingflowApiError, RuntimeError) as legacy_error:
|
|
7414
7509
|
legacy_api_error = _coerce_api_error(legacy_error)
|
|
7415
|
-
if
|
|
7510
|
+
if (
|
|
7511
|
+
tolerate_404
|
|
7512
|
+
and (
|
|
7513
|
+
legacy_api_error.http_status == 404
|
|
7514
|
+
or (
|
|
7515
|
+
tolerate_permission_restricted
|
|
7516
|
+
and _is_permission_restricted_api_error(legacy_api_error)
|
|
7517
|
+
)
|
|
7518
|
+
)
|
|
7519
|
+
):
|
|
7416
7520
|
return [], True
|
|
7417
7521
|
raise
|
|
7418
7522
|
legacy_result = legacy_views.get("result")
|
|
@@ -7429,19 +7533,38 @@ class AiBuilderFacade:
|
|
|
7429
7533
|
legacy_views = self.views.view_list(profile=profile, app_key=app_key)
|
|
7430
7534
|
except (QingflowApiError, RuntimeError) as legacy_error:
|
|
7431
7535
|
legacy_api_error = _coerce_api_error(legacy_error)
|
|
7432
|
-
if
|
|
7536
|
+
if (
|
|
7537
|
+
tolerate_404
|
|
7538
|
+
and (
|
|
7539
|
+
legacy_api_error.http_status == 404
|
|
7540
|
+
or (
|
|
7541
|
+
tolerate_permission_restricted
|
|
7542
|
+
and _is_permission_restricted_api_error(legacy_api_error)
|
|
7543
|
+
)
|
|
7544
|
+
)
|
|
7545
|
+
):
|
|
7433
7546
|
return normalized_views, False
|
|
7434
7547
|
raise
|
|
7435
7548
|
legacy_result = legacy_views.get("result")
|
|
7436
7549
|
legacy_normalized = _normalize_view_collection(legacy_result)
|
|
7437
7550
|
return legacy_normalized or normalized_views, False
|
|
7438
7551
|
|
|
7439
|
-
def _load_workflow_result(
|
|
7552
|
+
def _load_workflow_result(
|
|
7553
|
+
self,
|
|
7554
|
+
*,
|
|
7555
|
+
profile: str,
|
|
7556
|
+
app_key: str,
|
|
7557
|
+
tolerate_404: bool,
|
|
7558
|
+
tolerate_permission_restricted: bool = False,
|
|
7559
|
+
) -> tuple[Any, bool]:
|
|
7440
7560
|
try:
|
|
7441
7561
|
workflow = self.workflows.workflow_list_nodes(profile=profile, app_key=app_key)
|
|
7442
7562
|
except (QingflowApiError, RuntimeError) as error:
|
|
7443
7563
|
api_error = _coerce_api_error(error)
|
|
7444
|
-
if tolerate_404 and
|
|
7564
|
+
if tolerate_404 and (
|
|
7565
|
+
api_error.http_status == 404
|
|
7566
|
+
or (tolerate_permission_restricted and _is_permission_restricted_api_error(api_error))
|
|
7567
|
+
):
|
|
7445
7568
|
return [], True
|
|
7446
7569
|
raise
|
|
7447
7570
|
return workflow.get("result"), False
|
|
@@ -8733,6 +8856,11 @@ def _build_public_chart_config_payload(
|
|
|
8733
8856
|
return payload
|
|
8734
8857
|
|
|
8735
8858
|
|
|
8859
|
+
def _chart_patch_updates_chart_config(patch: ChartUpsertPatch) -> bool:
|
|
8860
|
+
explicit_fields = set(getattr(patch, "model_fields_set", set()) or set())
|
|
8861
|
+
return bool({"dimension_field_ids", "indicator_field_ids", "filters", "config"} & explicit_fields)
|
|
8862
|
+
|
|
8863
|
+
|
|
8736
8864
|
def _build_public_portal_base_payload(
|
|
8737
8865
|
*,
|
|
8738
8866
|
dash_name: str,
|
|
@@ -8962,7 +9090,8 @@ def _verify_portal_readback(
|
|
|
8962
9090
|
*,
|
|
8963
9091
|
actual: Any,
|
|
8964
9092
|
expected_payload: dict[str, Any],
|
|
8965
|
-
|
|
9093
|
+
expected_visibility: dict[str, Any] | None,
|
|
9094
|
+
expected_section_count: int | None,
|
|
8966
9095
|
requested_config_keys: set[str],
|
|
8967
9096
|
verify_dash_name: bool,
|
|
8968
9097
|
verify_dash_icon: bool,
|
|
@@ -8975,14 +9104,19 @@ def _verify_portal_readback(
|
|
|
8975
9104
|
if not isinstance(actual, dict):
|
|
8976
9105
|
return False, ["portal readback payload is unavailable"]
|
|
8977
9106
|
components = actual.get("components")
|
|
8978
|
-
if not isinstance(components, list) or len(components) != expected_section_count:
|
|
9107
|
+
if expected_section_count is not None and (not isinstance(components, list) or len(components) != expected_section_count):
|
|
8979
9108
|
mismatches.append(f"components expected {expected_section_count}, got {len(components) if isinstance(components, list) else 'unavailable'}")
|
|
8980
9109
|
if verify_dash_name and str(actual.get("dashName") or "").strip() != str(expected_payload.get("dashName") or "").strip():
|
|
8981
9110
|
mismatches.append("dash_name")
|
|
8982
9111
|
if verify_dash_icon and str(actual.get("dashIcon") or "") != str(expected_payload.get("dashIcon") or ""):
|
|
8983
9112
|
mismatches.append("dash_icon")
|
|
8984
|
-
if verify_auth
|
|
8985
|
-
|
|
9113
|
+
if verify_auth:
|
|
9114
|
+
if expected_visibility is not None:
|
|
9115
|
+
actual_visibility = _public_visibility_from_member_auth(actual.get("auth"))
|
|
9116
|
+
if not _visibility_matches_expected(actual_visibility, expected_visibility):
|
|
9117
|
+
mismatches.append("auth")
|
|
9118
|
+
elif not _mapping_contains(actual.get("auth"), expected_payload.get("auth")):
|
|
9119
|
+
mismatches.append("auth")
|
|
8986
9120
|
if verify_hide_copyright and bool(actual.get("hideCopyright", False)) != bool(expected_payload.get("hideCopyright", False)):
|
|
8987
9121
|
mismatches.append("hide_copyright")
|
|
8988
9122
|
if verify_dash_global_config and not _mapping_contains(actual.get("dashGlobalConfig") or {}, expected_payload.get("dashGlobalConfig") or {}):
|
|
@@ -9190,7 +9324,11 @@ def _visibility_matches_expected(actual: Any, expected: Any) -> bool:
|
|
|
9190
9324
|
if expected_text and sorted_values(actual_group, text_key) != expected_text:
|
|
9191
9325
|
return False
|
|
9192
9326
|
|
|
9193
|
-
if
|
|
9327
|
+
if (
|
|
9328
|
+
"include_sub_departs" in expected_selectors
|
|
9329
|
+
and expected_selectors.get("include_sub_departs") is not None
|
|
9330
|
+
and actual_selectors.get("include_sub_departs") != expected_selectors.get("include_sub_departs")
|
|
9331
|
+
):
|
|
9194
9332
|
return False
|
|
9195
9333
|
return True
|
|
9196
9334
|
|
|
@@ -12184,6 +12322,7 @@ def _warnings_from_verification_hints(hints: list[str]) -> list[dict[str, Any]]:
|
|
|
12184
12322
|
"package attachment not verified": _warning("PACKAGE_ATTACHMENT_UNVERIFIED", "package attachment is not verified"),
|
|
12185
12323
|
"layout has unplaced fields": _warning("LAYOUT_HAS_UNPLACED_FIELDS", "layout still contains unplaced fields"),
|
|
12186
12324
|
"no public views detected": _warning("NO_PUBLIC_VIEWS", "no public views were detected"),
|
|
12325
|
+
"schema_read_unavailable": _warning("SCHEMA_READ_UNAVAILABLE", "schema summary readback is unavailable"),
|
|
12187
12326
|
"views_read_unavailable": _warning("VIEWS_READ_UNAVAILABLE", "views summary readback is unavailable"),
|
|
12188
12327
|
"workflow_read_unavailable": _warning("WORKFLOW_READ_UNAVAILABLE", "workflow summary readback is unavailable"),
|
|
12189
12328
|
}
|
|
@@ -160,7 +160,7 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
|
|
|
160
160
|
portal_apply.add_argument("--dash-name", default="")
|
|
161
161
|
portal_apply.add_argument("--package-id", type=int)
|
|
162
162
|
portal_apply.add_argument("--publish", action=argparse.BooleanOptionalAction, default=True)
|
|
163
|
-
portal_apply.add_argument("--sections-file"
|
|
163
|
+
portal_apply.add_argument("--sections-file")
|
|
164
164
|
portal_apply.add_argument("--visibility-file")
|
|
165
165
|
portal_apply.add_argument("--auth-file")
|
|
166
166
|
portal_apply.add_argument("--icon")
|
|
@@ -513,13 +513,14 @@ def _handle_portal_apply(args: argparse.Namespace, context: CliContext) -> dict:
|
|
|
513
513
|
"portal apply requires either --dash-key, or --package-id together with --dash-name.",
|
|
514
514
|
fix_hint="Use `--dash-key` for an existing portal. For create mode, pass `--package-id --dash-name`.",
|
|
515
515
|
)
|
|
516
|
+
sections = [] if not args.sections_file else require_list_arg(args.sections_file, option_name="--sections-file")
|
|
516
517
|
return context.builder.portal_apply(
|
|
517
518
|
profile=args.profile,
|
|
518
519
|
dash_key=args.dash_key,
|
|
519
520
|
dash_name=args.dash_name,
|
|
520
521
|
package_id=args.package_id,
|
|
521
522
|
publish=bool(args.publish),
|
|
522
|
-
sections=
|
|
523
|
+
sections=sections,
|
|
523
524
|
visibility=load_object_arg(args.visibility_file, option_name="--visibility-file"),
|
|
524
525
|
auth=load_object_arg(args.auth_file, option_name="--auth-file"),
|
|
525
526
|
icon=args.icon,
|
|
@@ -118,6 +118,7 @@ def _format_app_get(result: dict[str, Any]) -> str:
|
|
|
118
118
|
if editability:
|
|
119
119
|
lines.append(
|
|
120
120
|
"Editability: "
|
|
121
|
+
f"app_base={editability.get('can_edit_app_base')} / "
|
|
121
122
|
f"form={editability.get('can_edit_form')} / "
|
|
122
123
|
f"flow={editability.get('can_edit_flow')} / "
|
|
123
124
|
f"views={editability.get('can_edit_views')} / "
|
|
@@ -268,6 +269,7 @@ def _format_import_status(result: dict[str, Any]) -> str:
|
|
|
268
269
|
f"Process ID: {result.get('process_id_str') or '-'}",
|
|
269
270
|
f"Total Rows: {result.get('total') or 0}",
|
|
270
271
|
f"Finished Rows: {result.get('finished') or 0}",
|
|
272
|
+
f"Succeeded Rows: {result.get('succeeded') or 0}",
|
|
271
273
|
f"Failed Rows: {result.get('failed') or 0}",
|
|
272
274
|
f"Progress: {result.get('progress') or '-'}",
|
|
273
275
|
]
|
|
@@ -343,7 +343,7 @@ def _trim_record_schema(payload: JSONObject) -> None:
|
|
|
343
343
|
payload["required_fields"] = required_fields
|
|
344
344
|
payload["optional_fields"] = optional_fields
|
|
345
345
|
|
|
346
|
-
for key in ("required_fields", "optional_fields", "runtime_linked_required_fields", "fields"):
|
|
346
|
+
for key in ("required_fields", "optional_fields", "runtime_linked_required_fields", "fields", "ambiguous_fields"):
|
|
347
347
|
if key in payload:
|
|
348
348
|
payload[key] = _compact_schema_fields(payload.get(key), template_map=template_map)
|
|
349
349
|
|
|
@@ -353,11 +353,12 @@ def _trim_record_schema(payload: JSONObject) -> None:
|
|
|
353
353
|
_pick(item, ("field_id", "title")) for item in payload.get(key) if isinstance(item, dict)
|
|
354
354
|
]
|
|
355
355
|
|
|
356
|
-
for key in ("workflow_node", "view_resolution", "field_count"
|
|
356
|
+
for key in ("workflow_node", "view_resolution", "field_count"):
|
|
357
357
|
payload.pop(key, None)
|
|
358
358
|
|
|
359
359
|
|
|
360
360
|
def _trim_record_write(payload: JSONObject) -> None:
|
|
361
|
+
payload.pop("verification", None)
|
|
361
362
|
data = payload.get("data")
|
|
362
363
|
if not isinstance(data, dict):
|
|
363
364
|
return
|
|
@@ -370,6 +371,24 @@ def _trim_record_write(payload: JSONObject) -> None:
|
|
|
370
371
|
data["resource"] = resource
|
|
371
372
|
else:
|
|
372
373
|
data.pop("resource", None)
|
|
374
|
+
verification = data.get("verification")
|
|
375
|
+
if isinstance(verification, dict):
|
|
376
|
+
compact_verification = _pick(
|
|
377
|
+
verification,
|
|
378
|
+
(
|
|
379
|
+
"verified",
|
|
380
|
+
"verification_mode",
|
|
381
|
+
"field_level_verified",
|
|
382
|
+
),
|
|
383
|
+
)
|
|
384
|
+
if compact_verification:
|
|
385
|
+
data["verification"] = compact_verification
|
|
386
|
+
else:
|
|
387
|
+
data.pop("verification", None)
|
|
388
|
+
for key in ("blockers", "field_errors", "confirmation_requests", "resolved_fields"):
|
|
389
|
+
value = data.get(key)
|
|
390
|
+
if value in (None, [], {}, ""):
|
|
391
|
+
data.pop(key, None)
|
|
373
392
|
|
|
374
393
|
|
|
375
394
|
def _trim_record_get(payload: JSONObject) -> None:
|
|
@@ -386,6 +405,12 @@ def _trim_record_get(payload: JSONObject) -> None:
|
|
|
386
405
|
record = data.get("record")
|
|
387
406
|
if isinstance(record, dict):
|
|
388
407
|
compact["record"] = record
|
|
408
|
+
normalized_record = data.get("normalized_record")
|
|
409
|
+
if isinstance(normalized_record, dict):
|
|
410
|
+
compact["normalized_record"] = normalized_record
|
|
411
|
+
normalized_ambiguous_fields = data.get("normalized_ambiguous_fields")
|
|
412
|
+
if isinstance(normalized_ambiguous_fields, dict):
|
|
413
|
+
compact["normalized_ambiguous_fields"] = normalized_ambiguous_fields
|
|
389
414
|
payload["data"] = compact
|
|
390
415
|
|
|
391
416
|
|
|
@@ -558,11 +583,14 @@ def _compact_schema_field(item: Any, *, template_map: dict[str, Any] | None) ->
|
|
|
558
583
|
|
|
559
584
|
|
|
560
585
|
def _compact_import_column(item: dict[str, Any]) -> dict[str, Any]:
|
|
561
|
-
compact: dict[str, Any] = {
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
"
|
|
565
|
-
|
|
586
|
+
compact: dict[str, Any] = {}
|
|
587
|
+
title = item.get("title")
|
|
588
|
+
if title not in (None, ""):
|
|
589
|
+
compact["title"] = title
|
|
590
|
+
kind = item.get("kind") or item.get("write_kind")
|
|
591
|
+
if kind not in (None, ""):
|
|
592
|
+
compact["kind"] = kind
|
|
593
|
+
compact["required"] = bool(item.get("required"))
|
|
566
594
|
options = item.get("options")
|
|
567
595
|
if isinstance(options, list) and options:
|
|
568
596
|
compact["options"] = options
|
|
@@ -590,18 +618,24 @@ def _trim_import_verify_payload(payload: JSONObject) -> None:
|
|
|
590
618
|
issues = payload.get("issues") if isinstance(payload.get("issues"), list) else []
|
|
591
619
|
issue_summary = _summarize_import_issues(issues)
|
|
592
620
|
payload["issue_summary"] = issue_summary
|
|
621
|
+
columns = payload.get("columns")
|
|
622
|
+
if "expected_columns" not in payload and isinstance(columns, list):
|
|
623
|
+
payload["expected_columns"] = columns
|
|
593
624
|
file_name = payload.get("file_name")
|
|
594
625
|
if not file_name:
|
|
595
626
|
file_path = payload.get("file_path")
|
|
596
627
|
if isinstance(file_path, str) and file_path:
|
|
597
628
|
payload["file_name"] = file_path.split("/")[-1]
|
|
598
|
-
for key in ("
|
|
629
|
+
for key in ("apply_rows", "schema_fingerprint", "import_capability", "file_sha256", "verified_file_sha256", "file_format", "local_precheck_limited"):
|
|
599
630
|
payload.pop(key, None)
|
|
600
631
|
|
|
601
632
|
|
|
602
633
|
def _trim_import_repair_payload(payload: JSONObject) -> None:
|
|
603
634
|
payload["verification_id"] = payload.get("new_verification_id") or payload.get("verification_id")
|
|
604
|
-
|
|
635
|
+
post_repair_issues = payload.get("post_repair_issues")
|
|
636
|
+
if isinstance(post_repair_issues, list):
|
|
637
|
+
payload["post_repair_issue_summary"] = _summarize_import_issues(post_repair_issues)
|
|
638
|
+
for key in ("new_verification_id", "verification"):
|
|
605
639
|
payload.pop(key, None)
|
|
606
640
|
|
|
607
641
|
|
|
@@ -615,7 +649,13 @@ def _trim_import_status_payload(payload: JSONObject) -> None:
|
|
|
615
649
|
success_rows = payload.get("success_rows")
|
|
616
650
|
failed_rows = payload.get("failed_rows")
|
|
617
651
|
payload["total"] = total_rows
|
|
618
|
-
|
|
652
|
+
if isinstance(success_rows, int) and isinstance(failed_rows, int):
|
|
653
|
+
payload["finished"] = success_rows + failed_rows
|
|
654
|
+
elif isinstance(success_rows, int):
|
|
655
|
+
payload["finished"] = success_rows
|
|
656
|
+
else:
|
|
657
|
+
payload["finished"] = None
|
|
658
|
+
payload["succeeded"] = success_rows
|
|
619
659
|
payload["failed"] = failed_rows
|
|
620
660
|
for key in (
|
|
621
661
|
"matched_by",
|
|
@@ -38,7 +38,7 @@ def build_builder_server() -> FastMCP:
|
|
|
38
38
|
"If creating or updating an app package may be appropriate, use package_apply with explicit user intent; otherwise use package_get and app_resolve to locate resources, "
|
|
39
39
|
"app_get/app_get_fields/app_repair_code_blocks/app_get_layout/app_get_views/app_get_flow/app_get_charts/portal_list/portal_get/view_get/chart_get for configuration reads, "
|
|
40
40
|
"member_search/role_search/role_create when workflow assignees must come from the directory or role catalog, preferring roles over explicit members unless the user explicitly names members, "
|
|
41
|
-
"then app_schema_apply/app_layout_apply/app_flow_apply/app_views_apply/app_charts_apply/portal_apply to execute normalized patches; these apply tools perform planning, normalization, and dependency checks internally where applicable. Schema/layout/views noop requests skip publish, charts are immediate-live without publish and resolve targets by chart_id first then exact unique chart name, portal updates
|
|
41
|
+
"then app_schema_apply/app_layout_apply/app_flow_apply/app_views_apply/app_charts_apply/portal_apply to execute normalized patches; these apply tools perform planning, normalization, and dependency checks internally where applicable. Schema/layout/views noop requests skip publish, charts are immediate-live without publish and resolve targets by chart_id first then exact unique chart name, portal updates use replace semantics only when sections are supplied and edit-mode base-info-only updates may omit sections, publish=false only guarantees draft/base-info updates, and flow should use publish=false whenever you only want draft/precheck behavior. "
|
|
42
42
|
"For code_block fields with output bindings, always use qf_output assignment rather than const/let qf_output, and use app_repair_code_blocks when an existing form hangs because output-bound fields stay loading. "
|
|
43
43
|
"Use package_apply to manage package metadata, visibility, grouping, and ordering, and app_publish_verify for explicit final publish verification. "
|
|
44
44
|
"For workflow edits, keep the public builder surface on stable linear flows only: start/approve/fill/copy/webhook/end. Branch and condition nodes are intentionally disabled because the backend workflow route is not front-end stable for those node types. Declare node assignees and editable fields explicitly. "
|
|
@@ -1763,7 +1763,7 @@ class AiBuilderTools(ToolBase):
|
|
|
1763
1763
|
dash_name: str = "",
|
|
1764
1764
|
package_id: int | None = None,
|
|
1765
1765
|
publish: bool = True,
|
|
1766
|
-
sections: list[JSONObject],
|
|
1766
|
+
sections: list[JSONObject] | None = None,
|
|
1767
1767
|
visibility: JSONObject | None = None,
|
|
1768
1768
|
auth: JSONObject | None = None,
|
|
1769
1769
|
icon: str | None = None,
|
|
@@ -3058,7 +3058,9 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
|
|
|
3058
3058
|
"execution_notes": [
|
|
3059
3059
|
"returns builder-side app configuration summary and editability",
|
|
3060
3060
|
"use this as the default builder discovery read before fields/layout/views/flow/charts detail reads",
|
|
3061
|
-
"editability
|
|
3061
|
+
"editability is route-aware builder capability summary, not end-user data visibility",
|
|
3062
|
+
"can_edit_app_base covers app base-info writes such as app_name, icon, and visibility",
|
|
3063
|
+
"can_edit_form covers form/schema routes only and does not imply app base-info writes",
|
|
3062
3064
|
"returns normalized app visibility when backend auth is readable",
|
|
3063
3065
|
],
|
|
3064
3066
|
"minimal_example": {
|
|
@@ -3179,14 +3181,15 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
|
|
|
3179
3181
|
"chart.filter.operator": [member.value for member in ViewFilterOperator],
|
|
3180
3182
|
**deepcopy(_VISIBILITY_ALLOWED_VALUES),
|
|
3181
3183
|
},
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3184
|
+
"execution_notes": [
|
|
3185
|
+
"app_charts_apply is immediate-live and does not publish",
|
|
3186
|
+
"chart matching precedence is chart_id first, then exact unique chart name",
|
|
3187
|
+
"when chart names are not unique, supply chart_id instead of guessing by name",
|
|
3188
|
+
"successful create results must return a real backend chart_id",
|
|
3189
|
+
"upsert_charts[].visibility compiles to QingBI base visibleAuth only",
|
|
3190
|
+
"visibility-only updates keep the existing chart config and do not rewrite rawDataConfigDTO.authInfo",
|
|
3191
|
+
*_VISIBILITY_EXECUTION_NOTES,
|
|
3192
|
+
],
|
|
3190
3193
|
"minimal_example": {
|
|
3191
3194
|
"profile": "default",
|
|
3192
3195
|
"app_key": "APP_KEY",
|
|
@@ -3242,14 +3245,15 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
|
|
|
3242
3245
|
"dashStyleConfigBO": "dash_style_config",
|
|
3243
3246
|
},
|
|
3244
3247
|
"allowed_values": {"section.source_type": ["chart", "view", "grid", "filter", "text", "link"], **deepcopy(_VISIBILITY_ALLOWED_VALUES)},
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
3248
|
+
"execution_notes": [
|
|
3249
|
+
"use exactly one resource mode",
|
|
3250
|
+
"update mode: dash_key",
|
|
3251
|
+
"create mode: package_id + dash_name",
|
|
3252
|
+
"portal_apply uses replace semantics for sections",
|
|
3253
|
+
"when editing an existing portal, sections may be omitted to update only base info such as visibility, icon, or package",
|
|
3254
|
+
"remove a section by omitting it from the new sections list",
|
|
3255
|
+
"package_id is required when creating a new portal",
|
|
3256
|
+
"publish=false only guarantees draft and base-info updates; it does not claim live has changed",
|
|
3253
3257
|
"chart_ref resolves by chart_id first, then exact unique chart_name",
|
|
3254
3258
|
"view_ref resolves by view_key first, then exact unique view_name",
|
|
3255
3259
|
"position.pc/mobile is the canonical portal layout shape",
|
|
@@ -760,6 +760,8 @@ class ImportTools(ToolBase):
|
|
|
760
760
|
error_code="CONFIG_ERROR",
|
|
761
761
|
message="record_import_status_get accepts import_id or process_id_str, but not both at the same time",
|
|
762
762
|
extra={
|
|
763
|
+
"import_id": normalized_import_id,
|
|
764
|
+
"process_id_str": normalized_process_id,
|
|
763
765
|
"details": {
|
|
764
766
|
"fix_hint": "Use only one of `import_id` or `process_id_str`. You may pass `app_key` as an optional routing hint for direct method compatibility.",
|
|
765
767
|
}
|
|
@@ -770,6 +772,8 @@ class ImportTools(ToolBase):
|
|
|
770
772
|
error_code="CONFIG_ERROR",
|
|
771
773
|
message="record_import_status_get requires at least one selector: process_id_str, import_id, or app_key",
|
|
772
774
|
extra={
|
|
775
|
+
"import_id": normalized_import_id,
|
|
776
|
+
"process_id_str": normalized_process_id,
|
|
773
777
|
"details": {
|
|
774
778
|
"fix_hint": "Use `process_id_str` or `import_id` for a known import, or use only `app_key` to inspect the latest import in that app.",
|
|
775
779
|
}
|
|
@@ -783,6 +787,9 @@ class ImportTools(ToolBase):
|
|
|
783
787
|
if local_job is None and normalized_process_id:
|
|
784
788
|
matches = [item for item in self._job_store.list() if _normalize_optional_text(item.get("process_id_str")) == normalized_process_id]
|
|
785
789
|
local_job = matches[0] if len(matches) == 1 else None
|
|
790
|
+
effective_process_id = normalized_process_id
|
|
791
|
+
if effective_process_id is None and isinstance(local_job, dict):
|
|
792
|
+
effective_process_id = _normalize_optional_text(local_job.get("process_id_str"))
|
|
786
793
|
resolved_app_key = normalized_app_key
|
|
787
794
|
if not resolved_app_key and isinstance(local_job, dict):
|
|
788
795
|
resolved_app_key = str(local_job.get("app_key") or "").strip()
|
|
@@ -791,6 +798,8 @@ class ImportTools(ToolBase):
|
|
|
791
798
|
error_code="CONFIG_ERROR",
|
|
792
799
|
message="record_import_status_get could not determine app_key from the provided selector",
|
|
793
800
|
extra={
|
|
801
|
+
"import_id": normalized_import_id,
|
|
802
|
+
"process_id_str": effective_process_id,
|
|
794
803
|
"details": {
|
|
795
804
|
"fix_hint": "Use the original `app_key`, or call import status with the latest-import mode: only `app_key`.",
|
|
796
805
|
}
|
|
@@ -809,13 +818,18 @@ class ImportTools(ToolBase):
|
|
|
809
818
|
matched_record, matched_by = _match_import_record(
|
|
810
819
|
records,
|
|
811
820
|
local_job=local_job,
|
|
812
|
-
|
|
821
|
+
import_id=normalized_import_id,
|
|
822
|
+
process_id_str=effective_process_id,
|
|
813
823
|
)
|
|
814
824
|
if matched_record is None:
|
|
815
825
|
return self._failed_status_result(
|
|
816
826
|
error_code="IMPORT_STATUS_AMBIGUOUS",
|
|
817
827
|
message="could not uniquely resolve an import record from the provided identifiers",
|
|
818
|
-
extra={
|
|
828
|
+
extra={
|
|
829
|
+
"import_id": normalized_import_id,
|
|
830
|
+
"process_id_str": effective_process_id,
|
|
831
|
+
"matched_by": matched_by,
|
|
832
|
+
},
|
|
819
833
|
)
|
|
820
834
|
normalized_process = _normalize_optional_text(
|
|
821
835
|
matched_record.get("processIdStr") or matched_record.get("processId") or matched_record.get("process_id_str")
|
|
@@ -2079,6 +2093,7 @@ def _match_import_record(
|
|
|
2079
2093
|
records: list[JSONObject],
|
|
2080
2094
|
*,
|
|
2081
2095
|
local_job: dict[str, Any] | None,
|
|
2096
|
+
import_id: str | None,
|
|
2082
2097
|
process_id_str: str | None,
|
|
2083
2098
|
) -> tuple[JSONObject | None, str | None]:
|
|
2084
2099
|
if process_id_str:
|
|
@@ -2091,6 +2106,16 @@ def _match_import_record(
|
|
|
2091
2106
|
return exact[0], "process_id_str"
|
|
2092
2107
|
if len(exact) > 1:
|
|
2093
2108
|
return None, "process_id_str"
|
|
2109
|
+
if import_id:
|
|
2110
|
+
exact = [
|
|
2111
|
+
item
|
|
2112
|
+
for item in records
|
|
2113
|
+
if import_id in _extract_import_record_ids(item)
|
|
2114
|
+
]
|
|
2115
|
+
if len(exact) == 1:
|
|
2116
|
+
return exact[0], "import_id"
|
|
2117
|
+
if len(exact) > 1:
|
|
2118
|
+
return None, "import_id"
|
|
2094
2119
|
if isinstance(local_job, dict):
|
|
2095
2120
|
source_file_name = _normalize_optional_text(local_job.get("source_file_name"))
|
|
2096
2121
|
started_at = _parse_utc(local_job.get("started_at"))
|
|
@@ -2121,6 +2146,15 @@ def _match_import_record(
|
|
|
2121
2146
|
return None, None
|
|
2122
2147
|
|
|
2123
2148
|
|
|
2149
|
+
def _extract_import_record_ids(record: JSONObject) -> set[str]:
|
|
2150
|
+
identifiers: set[str] = set()
|
|
2151
|
+
for key in ("importId", "import_id", "dataImportId", "data_import_id"):
|
|
2152
|
+
normalized = _normalize_optional_text(record.get(key))
|
|
2153
|
+
if normalized:
|
|
2154
|
+
identifiers.add(normalized)
|
|
2155
|
+
return identifiers
|
|
2156
|
+
|
|
2157
|
+
|
|
2124
2158
|
def _parse_utc(value: Any) -> datetime | None:
|
|
2125
2159
|
text = _normalize_optional_text(value)
|
|
2126
2160
|
if text is None:
|