@josephyan/qingflow-cli 0.2.0-beta.1015 → 0.2.0-beta.1017

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 CHANGED
@@ -3,13 +3,13 @@
3
3
  Install:
4
4
 
5
5
  ```bash
6
- npm install @josephyan/qingflow-cli@0.2.0-beta.1015
6
+ npm install @josephyan/qingflow-cli@0.2.0-beta.1017
7
7
  ```
8
8
 
9
9
  Run:
10
10
 
11
11
  ```bash
12
- npx -y -p @josephyan/qingflow-cli@0.2.0-beta.1015 qingflow
12
+ npx -y -p @josephyan/qingflow-cli@0.2.0-beta.1017 qingflow
13
13
  ```
14
14
 
15
15
  Environment:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@josephyan/qingflow-cli",
3
- "version": "0.2.0-beta.1015",
3
+ "version": "0.2.0-beta.1017",
4
4
  "description": "Human-friendly Qingflow command line interface for auth, record operations, import, tasks, and stable builder flows.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "qingflow-mcp"
7
- version = "0.2.0b1015"
7
+ version = "0.2.0b1017"
8
8
  description = "User-authenticated MCP server for Qingflow"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -5,7 +5,7 @@ from pathlib import Path
5
5
 
6
6
  __all__ = ["__version__"]
7
7
 
8
- _FALLBACK_VERSION = "0.2.0b1015"
8
+ _FALLBACK_VERSION = "0.2.0b1017"
9
9
 
10
10
 
11
11
  def _resolve_local_pyproject_version() -> str | None:
@@ -407,6 +407,13 @@ def _format_import_status(result: dict[str, Any]) -> str:
407
407
  f"Failed Rows: {result.get('failed') or 0}",
408
408
  f"Progress: {result.get('progress') or '-'}",
409
409
  ]
410
+ if result.get("process_status") not in (None, ""):
411
+ lines.append(f"Process Status: {result.get('process_status')}")
412
+ error_file_urls = result.get("error_file_urls") if isinstance(result.get("error_file_urls"), list) else []
413
+ if error_file_urls:
414
+ lines.append("Error Files:")
415
+ for url in error_file_urls:
416
+ lines.append(f"- {url}")
410
417
  _append_warnings(lines, result.get("warnings"))
411
418
  _append_verification(lines, result.get("verification"))
412
419
  return "\n".join(lines) + "\n"
@@ -666,7 +666,6 @@ def _trim_import_status_payload(payload: JSONObject) -> None:
666
666
  "total_rows",
667
667
  "success_rows",
668
668
  "failed_rows",
669
- "error_file_urls",
670
669
  "operate_time",
671
670
  "operate_user",
672
671
  "verification",
@@ -11,6 +11,7 @@ from uuid import uuid4
11
11
 
12
12
  from mcp.server.fastmcp import FastMCP
13
13
 
14
+ from ..backend_client import BackendRequestContext
14
15
  from ..config import DEFAULT_PROFILE, DEFAULT_RECORD_LIST_TYPE
15
16
  from ..errors import QingflowApiError
16
17
  from ..export_store import ExportJobStore
@@ -192,6 +193,8 @@ class ExportTools(ToolBase):
192
193
  filter_bean = self._build_export_filter_bean(
193
194
  resolved_view,
194
195
  selected_record_ids=effective_record_ids,
196
+ order_by=normalized_order_by,
197
+ row_scope=row_scope,
195
198
  include_workflow_log=include_workflow_log,
196
199
  )
197
200
  started_at = _utc_now().replace(microsecond=0).isoformat()
@@ -211,6 +214,10 @@ class ExportTools(ToolBase):
211
214
  {
212
215
  "created_at": started_at,
213
216
  "profile": profile,
217
+ "base_url": context.base_url,
218
+ "ws_id": context.ws_id,
219
+ "qf_version": context.qf_version,
220
+ "qf_version_source": context.qf_version_source,
214
221
  "app_key": normalized_app_key,
215
222
  "view_id": resolved_view.view_id,
216
223
  "backend_export_id": str(socket_result.get("backend_export_id") or ""),
@@ -278,7 +285,7 @@ class ExportTools(ToolBase):
278
285
  extra={"status": "failed"},
279
286
  )
280
287
 
281
- def runner(_session_profile, context):
288
+ def runner(session_profile, context):
282
289
  local_job = self._job_store.get(normalized_handle)
283
290
  if local_job is None:
284
291
  return self._failed_export_result(
@@ -286,7 +293,13 @@ class ExportTools(ToolBase):
286
293
  message="export_handle is missing or expired",
287
294
  extra={"export_handle": normalized_handle, "status": "failed"},
288
295
  )
289
- snapshot = self._resolve_export_snapshot(context, local_job)
296
+ lookup_context = self._build_export_lookup_context(
297
+ profile=profile,
298
+ session_profile=session_profile,
299
+ current_context=context,
300
+ local_job=local_job,
301
+ )
302
+ snapshot = self._resolve_export_snapshot(lookup_context, local_job)
290
303
  return self._status_payload_from_snapshot(local_job, normalized_handle, snapshot)
291
304
 
292
305
  try:
@@ -314,7 +327,7 @@ class ExportTools(ToolBase):
314
327
  extra={"status": "failed"},
315
328
  )
316
329
 
317
- def runner(_session_profile, context):
330
+ def runner(session_profile, context):
318
331
  local_job = self._job_store.get(normalized_handle)
319
332
  if local_job is None:
320
333
  return self._failed_export_result(
@@ -322,7 +335,13 @@ class ExportTools(ToolBase):
322
335
  message="export_handle is missing or expired",
323
336
  extra={"export_handle": normalized_handle, "status": "failed"},
324
337
  )
325
- snapshot = self._resolve_export_snapshot(context, local_job)
338
+ lookup_context = self._build_export_lookup_context(
339
+ profile=profile,
340
+ session_profile=session_profile,
341
+ current_context=context,
342
+ local_job=local_job,
343
+ )
344
+ snapshot = self._resolve_export_snapshot(lookup_context, local_job)
326
345
  normalized_status = str(snapshot.get("status") or "unknown")
327
346
  if normalized_status not in {"succeeded", "failed"}:
328
347
  return self._failed_export_result(
@@ -412,7 +431,7 @@ class ExportTools(ToolBase):
412
431
  "downloaded_files": downloaded_files,
413
432
  "warnings": snapshot.get("warnings") or [],
414
433
  "verification": snapshot.get("verification") or {},
415
- "request_route": self.backend.describe_route(context),
434
+ "request_route": self.backend.describe_route(lookup_context),
416
435
  }
417
436
 
418
437
  try:
@@ -449,7 +468,7 @@ class ExportTools(ToolBase):
449
468
  )
450
469
  timeout_seconds = self._normalize_timeout_seconds(wait_timeout_seconds)
451
470
 
452
- def runner(_session_profile, context):
471
+ def runner(session_profile, context):
453
472
  start_result = self.record_export_start(
454
473
  profile=profile,
455
474
  app_key=normalized_app_key,
@@ -473,7 +492,13 @@ class ExportTools(ToolBase):
473
492
  message="export_handle is missing or expired",
474
493
  extra={"status": "failed", "export_handle": export_handle},
475
494
  )
476
- snapshot = self._resolve_export_snapshot(context, local_job)
495
+ lookup_context = self._build_export_lookup_context(
496
+ profile=profile,
497
+ session_profile=session_profile,
498
+ current_context=context,
499
+ local_job=local_job,
500
+ )
501
+ snapshot = self._resolve_export_snapshot(lookup_context, local_job)
477
502
  last_snapshot = snapshot
478
503
  normalized_status = str(snapshot.get("status") or "unknown")
479
504
  if normalized_status == "succeeded":
@@ -581,6 +606,49 @@ class ExportTools(ToolBase):
581
606
  extra={"app_key": normalized_app_key, "view_id": normalized_view_id},
582
607
  )
583
608
 
609
+ def _build_export_lookup_context(
610
+ self,
611
+ *,
612
+ profile: str,
613
+ session_profile,
614
+ current_context: BackendRequestContext,
615
+ local_job: dict[str, Any],
616
+ ) -> BackendRequestContext:
617
+ stored_profile = str(local_job.get("profile") or "").strip()
618
+ if stored_profile and stored_profile != profile:
619
+ raise QingflowApiError.config_error(
620
+ "export_handle was created under a different profile",
621
+ details={
622
+ "error_code": "EXPORT_HANDLE_PROFILE_MISMATCH",
623
+ "expected_profile": stored_profile,
624
+ "received_profile": profile,
625
+ },
626
+ )
627
+ stored_uid = _coerce_positive_int(local_job.get("uid"))
628
+ if stored_uid is not None and stored_uid != session_profile.uid:
629
+ raise QingflowApiError.config_error(
630
+ "export_handle belongs to a different authenticated user",
631
+ details={
632
+ "error_code": "EXPORT_HANDLE_OWNER_MISMATCH",
633
+ "expected_uid": stored_uid,
634
+ "current_uid": session_profile.uid,
635
+ },
636
+ )
637
+ stored_base_url = str(local_job.get("base_url") or "").strip() or current_context.base_url
638
+ stored_ws_id = _coerce_positive_int(local_job.get("ws_id"))
639
+ stored_qf_version = str(local_job.get("qf_version") or "").strip() or current_context.qf_version
640
+ stored_qf_version_source = (
641
+ str(local_job.get("qf_version_source") or "").strip() or current_context.qf_version_source
642
+ )
643
+ return BackendRequestContext(
644
+ base_url=stored_base_url,
645
+ token=current_context.token,
646
+ ws_id=stored_ws_id if stored_ws_id is not None else current_context.ws_id,
647
+ qf_request_id=current_context.qf_request_id,
648
+ qf_version=stored_qf_version,
649
+ qf_version_source=stored_qf_version_source,
650
+ )
651
+
584
652
  def _resolve_export_record_scope(
585
653
  self,
586
654
  *,
@@ -732,6 +800,8 @@ class ExportTools(ToolBase):
732
800
  resolved_view: AccessibleViewRoute,
733
801
  *,
734
802
  selected_record_ids: list[int],
803
+ order_by: list[JSONObject],
804
+ row_scope: str,
735
805
  include_workflow_log: bool,
736
806
  ) -> JSONObject:
737
807
  filter_payload: JSONObject = {}
@@ -744,6 +814,17 @@ class ExportTools(ToolBase):
744
814
  filter_payload["type"] = DEFAULT_RECORD_LIST_TYPE
745
815
  if selected_record_ids:
746
816
  filter_payload["applyIds"] = selected_record_ids
817
+ if row_scope == "queried" and order_by:
818
+ normalized_sorts = [
819
+ {
820
+ "queId": field_id,
821
+ "isAscend": str(item.get("direction") or "asc").strip().lower() != "desc",
822
+ }
823
+ for item in order_by
824
+ if isinstance(item, dict) and (field_id := _coerce_int(item.get("field_id"))) is not None
825
+ ]
826
+ if normalized_sorts:
827
+ filter_payload["sorts"] = normalized_sorts
747
828
  return {
748
829
  "filter": filter_payload,
749
830
  # Backend export code later auto-unboxes this field to primitive boolean.
@@ -37,6 +37,13 @@ SAFE_REPAIRS = {
37
37
  "normalize_url_cells",
38
38
  }
39
39
  EMAIL_PATTERN = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
40
+ IMPORT_STATUS_BY_PROCESS_STATUS = {
41
+ 1: "queued",
42
+ 2: "running",
43
+ 3: "succeeded",
44
+ 4: "failed",
45
+ 5: "partially_failed",
46
+ }
40
47
 
41
48
 
42
49
  class ImportTools(ToolBase):
@@ -866,13 +873,26 @@ class ImportTools(ToolBase):
866
873
  "process_id_str": normalized_process,
867
874
  },
868
875
  )
876
+ raw_process_status = matched_record.get("processStatus")
869
877
  total_rows = _coerce_int(matched_record.get("totalNumber") or matched_record.get("total_rows"))
870
878
  success_rows = _coerce_int(matched_record.get("successNum") or matched_record.get("success_rows"))
871
879
  failed_rows = _coerce_int(matched_record.get("errorNum") or matched_record.get("failed_rows"))
872
880
  progress = _coerce_int(matched_record.get("importPercentage") or matched_record.get("progress"))
881
+ normalized_status = _normalize_import_status(raw_process_status)
882
+ warnings: list[dict[str, str]] = []
883
+ if normalized_status in {"succeeded", "failed", "partially_failed"} and all(
884
+ value is None for value in (total_rows, success_rows, failed_rows)
885
+ ):
886
+ warnings.append(
887
+ {
888
+ "code": "IMPORT_STATUS_COUNTERS_MISSING",
889
+ "message": "backend import history returned a terminal process status without row counters",
890
+ }
891
+ )
873
892
  return {
874
893
  "ok": True,
875
- "status": _normalize_optional_text(matched_record.get("processStatus")) or "unknown",
894
+ "status": normalized_status,
895
+ "process_status": _coerce_int(raw_process_status),
876
896
  "app_key": resolved_app_key,
877
897
  "import_id": normalized_import_id or (local_job.get("import_id") if isinstance(local_job, dict) else None),
878
898
  "process_id_str": normalized_process,
@@ -885,7 +905,7 @@ class ImportTools(ToolBase):
885
905
  "error_file_urls": _normalize_error_file_urls(matched_record.get("errorFileUrls")),
886
906
  "operate_time": matched_record.get("operateTime"),
887
907
  "operate_user": matched_record.get("operateUser"),
888
- "warnings": [],
908
+ "warnings": warnings,
889
909
  "verification": {
890
910
  "status_lookup_completed": True,
891
911
  "matched_by": matched_by,
@@ -2217,6 +2237,26 @@ def _coerce_int(value: Any) -> int | None:
2217
2237
  return None
2218
2238
 
2219
2239
 
2240
+ def _normalize_import_status(value: Any) -> str:
2241
+ status_code = _coerce_int(value)
2242
+ if status_code is not None:
2243
+ return IMPORT_STATUS_BY_PROCESS_STATUS.get(status_code, "unknown")
2244
+ text = str(value or "").strip().lower()
2245
+ if text in {"queued", "running", "succeeded", "failed", "partially_failed", "unknown"}:
2246
+ return text
2247
+ if text in {"line_up", "lineup"}:
2248
+ return "queued"
2249
+ if text in {"execute", "executing", "processing"}:
2250
+ return "running"
2251
+ if text in {"success", "completed"}:
2252
+ return "succeeded"
2253
+ if text in {"partly_fail", "partial_fail", "partially_fail", "partial_failed"}:
2254
+ return "partially_failed"
2255
+ if text in {"fail", "error"}:
2256
+ return "failed"
2257
+ return "unknown"
2258
+
2259
+
2220
2260
  def _normalize_error_file_urls(value: Any) -> list[str]:
2221
2261
  if isinstance(value, list):
2222
2262
  return [str(item).strip() for item in value if str(item).strip()]
@@ -2920,12 +2920,14 @@ class RecordTools(ToolBase):
2920
2920
  payload["category"] = error.category
2921
2921
  if error.backend_code is not None:
2922
2922
  payload["backend_code"] = error.backend_code
2923
+ if error.request_id is not None:
2924
+ payload["request_id"] = error.request_id
2923
2925
  if error.http_status is not None:
2924
2926
  payload["http_status"] = error.http_status
2925
2927
  return payload
2926
2928
 
2927
2929
  def _is_record_context_route_miss(self, error: QingflowApiError) -> bool:
2928
- if error.backend_code in {40002, 40027, 40038, 404}:
2930
+ if error.backend_code in {40002, 40023, 40027, 40038, 404}:
2929
2931
  return True
2930
2932
  if error.http_status == 404:
2931
2933
  return True
@@ -3059,6 +3061,107 @@ class RecordTools(ToolBase):
3059
3061
  f"({get_record_list_type_label(used_list_type)})."
3060
3062
  ]
3061
3063
 
3064
+ def _looks_like_generic_record_update_backend_failure(self, exc: QingflowApiError) -> bool:
3065
+ if exc.backend_code == 500:
3066
+ return True
3067
+ if exc.http_status is not None and exc.http_status >= 500:
3068
+ return True
3069
+ normalized_message = exc.message.strip().lower()
3070
+ return normalized_message in {"unknown error", "internal server error"}
3071
+
3072
+ def _remap_record_update_target_context_error(
3073
+ self,
3074
+ profile: str,
3075
+ context, # type: ignore[no-untyped-def]
3076
+ *,
3077
+ app_key: str,
3078
+ apply_id: int,
3079
+ exc: QingflowApiError,
3080
+ ) -> None:
3081
+ if not self._looks_like_generic_record_update_backend_failure(exc):
3082
+ return
3083
+ try:
3084
+ candidate_routes = self._candidate_update_views(profile, context, app_key)
3085
+ probes = self._probe_candidate_record_contexts(
3086
+ context,
3087
+ app_key=app_key,
3088
+ apply_id=apply_id,
3089
+ candidate_routes=candidate_routes,
3090
+ )
3091
+ except (QingflowApiError, RuntimeError):
3092
+ return
3093
+ if not probes or any(probe.readable for probe in probes):
3094
+ return
3095
+
3096
+ blocker = (
3097
+ "CURRENT_RECORD_CONTEXT_UNAVAILABLE"
3098
+ if all(probe.transport_error for probe in probes)
3099
+ else "NO_MATCHING_ACCESSIBLE_VIEW_FOR_RECORD"
3100
+ )
3101
+ recommended_next_actions = (
3102
+ [
3103
+ "Retry after the record becomes readable in the current workspace/profile context.",
3104
+ "If the issue persists, verify that the current profile still has read access to this record.",
3105
+ ]
3106
+ if blocker == "CURRENT_RECORD_CONTEXT_UNAVAILABLE"
3107
+ else [
3108
+ "Use record_get or record_list to confirm the record still exists in the current workspace.",
3109
+ "Call record_update_schema_get to inspect whether any accessible view still matches this record.",
3110
+ ]
3111
+ )
3112
+ first_error_payload = next(
3113
+ (
3114
+ cast(JSONObject, probe.error_payload)
3115
+ for probe in probes
3116
+ if isinstance(probe.error_payload, dict)
3117
+ ),
3118
+ None,
3119
+ )
3120
+ backend_code = (
3121
+ cast(int, first_error_payload.get("backend_code"))
3122
+ if isinstance(first_error_payload, dict) and isinstance(first_error_payload.get("backend_code"), int)
3123
+ else exc.backend_code
3124
+ )
3125
+ request_id = (
3126
+ _normalize_optional_text(first_error_payload.get("request_id"))
3127
+ if isinstance(first_error_payload, dict)
3128
+ else None
3129
+ ) or exc.request_id
3130
+ http_status = (
3131
+ cast(int, first_error_payload.get("http_status"))
3132
+ if isinstance(first_error_payload, dict) and isinstance(first_error_payload.get("http_status"), int)
3133
+ else exc.http_status
3134
+ )
3135
+ raise_tool_error(
3136
+ QingflowApiError(
3137
+ category="backend",
3138
+ message=(
3139
+ "Direct record edit was blocked because the current record context could not be loaded from any candidate route."
3140
+ if blocker == "CURRENT_RECORD_CONTEXT_UNAVAILABLE"
3141
+ else "Direct record edit was blocked because the target record is no longer accessible in any readable view for the current profile."
3142
+ ),
3143
+ backend_code=backend_code,
3144
+ request_id=request_id,
3145
+ http_status=http_status,
3146
+ details={
3147
+ "error_code": blocker,
3148
+ "operation": "update",
3149
+ "app_key": app_key,
3150
+ "record_id": apply_id,
3151
+ "blockers": [blocker],
3152
+ "request_route": self._request_route_payload(context),
3153
+ "view_probe_summary": [
3154
+ self._record_context_probe_summary_payload(probe)
3155
+ for probe in probes
3156
+ ],
3157
+ "recommended_next_actions": recommended_next_actions,
3158
+ "fix_hint": (
3159
+ "Confirm the target record still exists and remains visible in at least one accessible view before retrying the update."
3160
+ ),
3161
+ },
3162
+ )
3163
+ )
3164
+
3062
3165
  def _record_matches_accessible_view(
3063
3166
  self,
3064
3167
  context, # type: ignore[no-untyped-def]
@@ -6355,12 +6458,22 @@ class RecordTools(ToolBase):
6355
6458
  force_refresh_form=force_refresh_form,
6356
6459
  )
6357
6460
  self._validate_record_write(app_key, normalized_answers, apply_id=normalized_apply_id)
6358
- result = self.backend.request(
6359
- "POST",
6360
- context,
6361
- f"/app/{app_key}/apply/{normalized_apply_id}",
6362
- json_body={"role": role, "answers": normalized_answers},
6363
- )
6461
+ try:
6462
+ result = self.backend.request(
6463
+ "POST",
6464
+ context,
6465
+ f"/app/{app_key}/apply/{normalized_apply_id}",
6466
+ json_body={"role": role, "answers": normalized_answers},
6467
+ )
6468
+ except QingflowApiError as exc:
6469
+ self._remap_record_update_target_context_error(
6470
+ profile,
6471
+ context,
6472
+ app_key=app_key,
6473
+ apply_id=normalized_apply_id,
6474
+ exc=exc,
6475
+ )
6476
+ raise
6364
6477
  verification = self._verify_record_write_result(
6365
6478
  context,
6366
6479
  app_key=app_key,
@@ -8810,6 +8923,8 @@ class RecordTools(ToolBase):
8810
8923
  error_payload["request_id"] = parsed.get("request_id")
8811
8924
  if isinstance(parsed.get("request_route"), dict):
8812
8925
  request_route = cast(JSONObject, parsed.get("request_route"))
8926
+ elif details_payload is not None and isinstance(details_payload.get("request_route"), dict):
8927
+ request_route = cast(JSONObject, details_payload.get("request_route"))
8813
8928
  response: JSONObject = {
8814
8929
  "profile": profile,
8815
8930
  "ws_id": None,
@@ -96,7 +96,9 @@ class ResourceReadTools(ToolBase):
96
96
  )
97
97
  )
98
98
  if system_view is not None:
99
- export_capability = _view_export_capability_payload(supported=True)
99
+ export_capability = _view_export_capability_payload(
100
+ supported=_export_supported_for_view_type(system_view["view_type"])
101
+ )
100
102
  return self._run(
101
103
  profile,
102
104
  lambda session_profile, _context: {
@@ -133,13 +135,11 @@ class ResourceReadTools(ToolBase):
133
135
  )
134
136
 
135
137
  def runner(session_profile, context):
136
- export_capability = _view_export_capability_payload(supported=True)
138
+ raw_view_type = None
137
139
  warnings: list[JSONObject] = []
138
140
  verification = {
139
141
  "view_exists": True,
140
142
  "questions_verified": True,
141
- "export_route_supported": export_capability["supported"],
142
- "export_permission_verified": export_capability["permission_verified"],
143
143
  }
144
144
  config = self.backend.request("GET", context, f"/view/{view_key}/viewConfig")
145
145
  base_info = self.backend.request("GET", context, f"/view/{view_key}/viewConfig/baseInfo")
@@ -161,6 +161,11 @@ class ResourceReadTools(ToolBase):
161
161
  str(base_info.get("viewgraphType") or "").strip()
162
162
  or str(config.get("viewgraphType") or config.get("viewType") or "").strip()
163
163
  )
164
+ export_capability = _view_export_capability_payload(
165
+ supported=_export_supported_for_view_type(raw_view_type or None)
166
+ )
167
+ verification["export_route_supported"] = export_capability["supported"]
168
+ verification["export_permission_verified"] = export_capability["permission_verified"]
164
169
  resolved_app_key = str(base_info.get("appKey") or config.get("appKey") or "").strip() or None
165
170
  if not resolved_app_key:
166
171
  resolved_app_key = self._resolve_app_key_from_view_form(context=context, view_key=view_key)
@@ -176,12 +181,13 @@ class ResourceReadTools(ToolBase):
176
181
  "message": f"view_get could not resolve app_key for `{view_id}` from view metadata; keep using the app_key from the parent app or portal context.",
177
182
  }
178
183
  )
179
- warnings.append(
180
- {
181
- "code": "VIEW_EXPORT_APP_CONTEXT_REQUIRED",
182
- "message": f"view_get supports exporting `{view_id}`, but the export call still needs an explicit `app_key` from the parent app context.",
183
- }
184
- )
184
+ if export_capability["supported"]:
185
+ warnings.append(
186
+ {
187
+ "code": "VIEW_EXPORT_APP_CONTEXT_REQUIRED",
188
+ "message": f"view_get supports exporting `{view_id}`, but the export call still needs an explicit `app_key` from the parent app context.",
189
+ }
190
+ )
185
191
  return {
186
192
  "profile": profile,
187
193
  "ws_id": session_profile.selected_ws_id,
@@ -493,6 +499,10 @@ def _view_export_capability_payload(*, supported: bool) -> JSONObject:
493
499
  }
494
500
 
495
501
 
502
+ def _export_supported_for_view_type(view_type: str | None) -> bool:
503
+ return _analysis_supported_for_view_type(view_type)
504
+
505
+
496
506
  def _normalize_view_type(view_type: Any) -> str | None:
497
507
  value = str(view_type or "").strip()
498
508
  if not value: