@josephyan/qingflow-cli 0.2.0-beta.55

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 (79) hide show
  1. package/README.md +30 -0
  2. package/docs/local-agent-install.md +235 -0
  3. package/entry_point.py +13 -0
  4. package/npm/bin/qingflow.mjs +5 -0
  5. package/npm/lib/runtime.mjs +204 -0
  6. package/npm/scripts/postinstall.mjs +16 -0
  7. package/package.json +34 -0
  8. package/pyproject.toml +67 -0
  9. package/qingflow +15 -0
  10. package/src/qingflow_mcp/__init__.py +5 -0
  11. package/src/qingflow_mcp/__main__.py +5 -0
  12. package/src/qingflow_mcp/backend_client.py +547 -0
  13. package/src/qingflow_mcp/builder_facade/__init__.py +3 -0
  14. package/src/qingflow_mcp/builder_facade/models.py +985 -0
  15. package/src/qingflow_mcp/builder_facade/service.py +8243 -0
  16. package/src/qingflow_mcp/cli/__init__.py +1 -0
  17. package/src/qingflow_mcp/cli/commands/__init__.py +15 -0
  18. package/src/qingflow_mcp/cli/commands/app.py +40 -0
  19. package/src/qingflow_mcp/cli/commands/auth.py +78 -0
  20. package/src/qingflow_mcp/cli/commands/builder.py +184 -0
  21. package/src/qingflow_mcp/cli/commands/common.py +47 -0
  22. package/src/qingflow_mcp/cli/commands/imports.py +86 -0
  23. package/src/qingflow_mcp/cli/commands/record.py +202 -0
  24. package/src/qingflow_mcp/cli/commands/task.py +87 -0
  25. package/src/qingflow_mcp/cli/commands/workspace.py +33 -0
  26. package/src/qingflow_mcp/cli/context.py +48 -0
  27. package/src/qingflow_mcp/cli/formatters.py +269 -0
  28. package/src/qingflow_mcp/cli/json_io.py +50 -0
  29. package/src/qingflow_mcp/cli/main.py +147 -0
  30. package/src/qingflow_mcp/config.py +221 -0
  31. package/src/qingflow_mcp/errors.py +66 -0
  32. package/src/qingflow_mcp/import_store.py +121 -0
  33. package/src/qingflow_mcp/json_types.py +18 -0
  34. package/src/qingflow_mcp/list_type_labels.py +76 -0
  35. package/src/qingflow_mcp/server.py +211 -0
  36. package/src/qingflow_mcp/server_app_builder.py +387 -0
  37. package/src/qingflow_mcp/server_app_user.py +317 -0
  38. package/src/qingflow_mcp/session_store.py +289 -0
  39. package/src/qingflow_mcp/solution/__init__.py +6 -0
  40. package/src/qingflow_mcp/solution/build_assembly_store.py +181 -0
  41. package/src/qingflow_mcp/solution/compiler/__init__.py +282 -0
  42. package/src/qingflow_mcp/solution/compiler/chart_compiler.py +96 -0
  43. package/src/qingflow_mcp/solution/compiler/form_compiler.py +466 -0
  44. package/src/qingflow_mcp/solution/compiler/icon_utils.py +113 -0
  45. package/src/qingflow_mcp/solution/compiler/navigation_compiler.py +57 -0
  46. package/src/qingflow_mcp/solution/compiler/package_compiler.py +19 -0
  47. package/src/qingflow_mcp/solution/compiler/portal_compiler.py +60 -0
  48. package/src/qingflow_mcp/solution/compiler/view_compiler.py +51 -0
  49. package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +173 -0
  50. package/src/qingflow_mcp/solution/design_session.py +222 -0
  51. package/src/qingflow_mcp/solution/design_store.py +100 -0
  52. package/src/qingflow_mcp/solution/executor.py +2339 -0
  53. package/src/qingflow_mcp/solution/normalizer.py +23 -0
  54. package/src/qingflow_mcp/solution/requirements_builder.py +536 -0
  55. package/src/qingflow_mcp/solution/run_store.py +244 -0
  56. package/src/qingflow_mcp/solution/spec_models.py +853 -0
  57. package/src/qingflow_mcp/tools/__init__.py +1 -0
  58. package/src/qingflow_mcp/tools/ai_builder_tools.py +2063 -0
  59. package/src/qingflow_mcp/tools/app_tools.py +850 -0
  60. package/src/qingflow_mcp/tools/approval_tools.py +833 -0
  61. package/src/qingflow_mcp/tools/auth_tools.py +697 -0
  62. package/src/qingflow_mcp/tools/base.py +81 -0
  63. package/src/qingflow_mcp/tools/code_block_tools.py +679 -0
  64. package/src/qingflow_mcp/tools/directory_tools.py +648 -0
  65. package/src/qingflow_mcp/tools/feedback_tools.py +230 -0
  66. package/src/qingflow_mcp/tools/file_tools.py +385 -0
  67. package/src/qingflow_mcp/tools/import_tools.py +1971 -0
  68. package/src/qingflow_mcp/tools/navigation_tools.py +177 -0
  69. package/src/qingflow_mcp/tools/package_tools.py +240 -0
  70. package/src/qingflow_mcp/tools/portal_tools.py +131 -0
  71. package/src/qingflow_mcp/tools/qingbi_report_tools.py +269 -0
  72. package/src/qingflow_mcp/tools/record_tools.py +12739 -0
  73. package/src/qingflow_mcp/tools/role_tools.py +94 -0
  74. package/src/qingflow_mcp/tools/solution_tools.py +3887 -0
  75. package/src/qingflow_mcp/tools/task_context_tools.py +1423 -0
  76. package/src/qingflow_mcp/tools/task_tools.py +843 -0
  77. package/src/qingflow_mcp/tools/view_tools.py +280 -0
  78. package/src/qingflow_mcp/tools/workflow_tools.py +312 -0
  79. package/src/qingflow_mcp/tools/workspace_tools.py +219 -0
@@ -0,0 +1,850 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import Counter
4
+ from typing import Any
5
+
6
+ from mcp.server.fastmcp import FastMCP
7
+
8
+ from ..backend_client import BackendRequestContext
9
+ from ..config import DEFAULT_PROFILE
10
+ from ..errors import QingflowApiError, raise_tool_error
11
+ from ..json_types import JSONObject
12
+ from ..list_type_labels import SYSTEM_VIEW_DEFINITIONS, get_app_publish_status_label
13
+ from .base import ToolBase
14
+
15
+
16
+ class AppTools(ToolBase):
17
+ def register(self, mcp: FastMCP) -> None:
18
+ @mcp.tool()
19
+ def app_list(profile: str = DEFAULT_PROFILE, ship_auth: bool = False) -> JSONObject:
20
+ return self.app_list(profile=profile, ship_auth=ship_auth)
21
+
22
+ @mcp.tool()
23
+ def app_search(profile: str = DEFAULT_PROFILE, keyword: str = "", page_num: int = 1, page_size: int = 50) -> JSONObject:
24
+ return self.app_search(profile=profile, keyword=keyword, page_num=page_num, page_size=page_size)
25
+
26
+ @mcp.tool()
27
+ def app_get(profile: str = DEFAULT_PROFILE, app_key: str = "") -> JSONObject:
28
+ return self.app_get(profile=profile, app_key=app_key)
29
+
30
+ @mcp.tool()
31
+ def app_get_base(profile: str = DEFAULT_PROFILE, app_key: str = "", include_raw: bool = False) -> JSONObject:
32
+ return self.app_get_base(profile=profile, app_key=app_key, include_raw=include_raw)
33
+
34
+ @mcp.tool(description=self._high_risk_tool_description(operation="update", target="app base settings"))
35
+ def app_update_base(profile: str = DEFAULT_PROFILE, app_key: str = "", payload: JSONObject | None = None) -> JSONObject:
36
+ return self.app_update_base(profile=profile, app_key=app_key, payload=payload or {})
37
+
38
+ @mcp.tool()
39
+ def app_get_form_schema(
40
+ profile: str = DEFAULT_PROFILE,
41
+ app_key: str = "",
42
+ form_type: int | str = 1,
43
+ being_draft: bool | None = None,
44
+ being_apply: bool | None = None,
45
+ audit_node_id: int | None = None,
46
+ include_raw: bool = False,
47
+ ) -> JSONObject:
48
+ return self.app_get_form_schema(
49
+ profile=profile,
50
+ app_key=app_key,
51
+ form_type=form_type,
52
+ being_draft=being_draft,
53
+ being_apply=being_apply,
54
+ audit_node_id=audit_node_id,
55
+ include_raw=include_raw,
56
+ )
57
+
58
+ @mcp.tool()
59
+ def app_get_edit_version_no(profile: str = DEFAULT_PROFILE, app_key: str = "") -> JSONObject:
60
+ return self.app_get_edit_version_no(profile=profile, app_key=app_key)
61
+
62
+ @mcp.tool()
63
+ def app_edit_finished(profile: str = DEFAULT_PROFILE, app_key: str = "", payload: JSONObject | None = None) -> JSONObject:
64
+ return self.app_edit_finished(profile=profile, app_key=app_key, payload=payload or {})
65
+
66
+ @mcp.tool()
67
+ def app_create(profile: str = DEFAULT_PROFILE, payload: JSONObject | None = None) -> JSONObject:
68
+ return self.app_create(profile=profile, payload=payload or {})
69
+
70
+ @mcp.tool(description=self._high_risk_tool_description(operation="delete", target="app configuration"))
71
+ def app_delete(profile: str = DEFAULT_PROFILE, app_key: str = "") -> JSONObject:
72
+ return self.app_delete(profile=profile, app_key=app_key)
73
+
74
+ @mcp.tool()
75
+ def app_publish(
76
+ profile: str = DEFAULT_PROFILE,
77
+ app_key: str = "",
78
+ payload: JSONObject | None = None,
79
+ ) -> JSONObject:
80
+ return self.app_publish(profile=profile, app_key=app_key, payload=payload or {})
81
+
82
+ def app_list(self, *, profile: str, ship_auth: bool = False) -> JSONObject:
83
+ """List current-user visible apps in the selected workspace."""
84
+ def runner(session_profile, context):
85
+ result = self.backend.request("GET", context, "/tag/apps")
86
+ items, source_shape = self._extract_visible_apps(result)
87
+ response = {
88
+ "profile": profile,
89
+ "ws_id": session_profile.selected_ws_id,
90
+ "items": items,
91
+ "count": len(items),
92
+ "source_shape": source_shape,
93
+ }
94
+ if ship_auth:
95
+ response["raw"] = result
96
+ return response
97
+
98
+ return self._run(profile, runner)
99
+
100
+ def app_search(self, *, profile: str, keyword: str = "", page_num: int = 1, page_size: int = 50) -> JSONObject:
101
+ """Search apps by keyword in name/title using backend search API.
102
+ Useful for finding BUG-related apps across all packages."""
103
+ def runner(session_profile, context):
104
+ # Use GET /app/item which supports queryKey search across all apps
105
+ params: JSONObject = {"pageNum": page_num, "pageSize": page_size}
106
+ if keyword:
107
+ params["queryKey"] = keyword
108
+
109
+ result = self.backend.request("GET", context, "/app/item", params=params)
110
+
111
+ apps = []
112
+ if isinstance(result, dict):
113
+ items = result.get("list", [])
114
+ for item in items:
115
+ if isinstance(item, dict):
116
+ normalized = self._normalize_visible_app(
117
+ item,
118
+ package_tag_id=_coerce_positive_int(item.get("tagId")),
119
+ package_name=str(item.get("tagName") or "").strip() or None,
120
+ group_id=_coerce_positive_int(item.get("groupId")),
121
+ group_name=str(item.get("groupName") or "").strip() or None,
122
+ )
123
+ if normalized is not None:
124
+ apps.append(normalized)
125
+
126
+ return {
127
+ "profile": profile,
128
+ "ws_id": session_profile.selected_ws_id,
129
+ "keyword": keyword,
130
+ "page_num": page_num,
131
+ "page_size": page_size,
132
+ "total": result.get("total") if isinstance(result, dict) else len(apps),
133
+ "items": apps,
134
+ "apps": apps,
135
+ }
136
+
137
+ return self._run(profile, runner)
138
+
139
+ def app_get(self, *, profile: str, app_key: str) -> JSONObject:
140
+ self._require_app_key(app_key)
141
+
142
+ def runner(session_profile, context):
143
+ warnings: list[JSONObject] = []
144
+ app_name = app_key
145
+ base_info: JSONObject | None = None
146
+
147
+ try:
148
+ base_info = self.backend.request("GET", context, f"/app/{app_key}/baseInfo")
149
+ if isinstance(base_info, dict):
150
+ app_name = (
151
+ str(base_info.get("formTitle") or base_info.get("title") or base_info.get("appName") or app_key).strip()
152
+ or app_key
153
+ )
154
+ except QingflowApiError as exc:
155
+ if exc.backend_code not in {40002, 40027}:
156
+ raise
157
+ warnings.append(
158
+ {
159
+ "code": "APP_BASE_INFO_UNAVAILABLE",
160
+ "message": f"app_get could not load base info for {app_key}; using app_key as app_name.",
161
+ }
162
+ )
163
+
164
+ try:
165
+ can_create = self._probe_create_access(context, app_key)
166
+ except QingflowApiError as exc:
167
+ can_create = False
168
+ warnings.append(
169
+ {
170
+ "code": "APP_CREATE_PROBE_UNVERIFIED",
171
+ "message": (
172
+ f"app_get could not fully verify create access for {app_key}: "
173
+ f"{exc.message or 'probe failed'}"
174
+ ),
175
+ }
176
+ )
177
+ accessible_views, system_view_warnings = self._resolve_accessible_system_views(context, app_key)
178
+ warnings.extend(system_view_warnings)
179
+ accessible_views.extend(self._resolve_accessible_custom_views(context, app_key))
180
+ import_capability, import_warnings = _derive_import_capability(base_info)
181
+ warnings.extend(import_warnings)
182
+
183
+ return {
184
+ "profile": profile,
185
+ "ws_id": session_profile.selected_ws_id,
186
+ "ok": True,
187
+ "request_route": {"base_url": context.base_url, "qf_version": context.qf_version},
188
+ "warnings": warnings,
189
+ "data": {
190
+ "app_key": app_key,
191
+ "app_name": app_name,
192
+ "can_create": can_create,
193
+ "import_capability": import_capability,
194
+ "accessible_views": accessible_views,
195
+ },
196
+ }
197
+
198
+ return self._run(profile, runner)
199
+
200
+ def app_get_base(self, *, profile: str, app_key: str, include_raw: bool = False) -> JSONObject:
201
+ self._require_app_key(app_key)
202
+
203
+ def runner(session_profile, context):
204
+ result = self.backend.request("GET", context, f"/app/{app_key}/baseInfo")
205
+ publish_status = result.get("appPublishStatus") if isinstance(result, dict) else None
206
+ compact = self._compact_base_info(result if isinstance(result, dict) else {})
207
+ response = {
208
+ "profile": profile,
209
+ "ws_id": session_profile.selected_ws_id,
210
+ "app_key": app_key,
211
+ "result": result if include_raw else compact,
212
+ "app_publish_status": publish_status,
213
+ "app_publish_status_label": get_app_publish_status_label(publish_status if isinstance(publish_status, int) else None),
214
+ "compact": not include_raw,
215
+ }
216
+ if include_raw:
217
+ response["summary"] = compact
218
+ return response
219
+
220
+ return self._run(profile, runner)
221
+
222
+ def app_update_base(self, *, profile: str, app_key: str, payload: JSONObject) -> JSONObject:
223
+ self._require_app_key(app_key)
224
+ body = self._require_dict(payload)
225
+
226
+ def runner(session_profile, context):
227
+ result = self.backend.request("POST", context, f"/app/{app_key}/baseInfo", json_body=body)
228
+ return self._attach_human_review_notice(
229
+ {"profile": profile, "ws_id": session_profile.selected_ws_id, "app_key": app_key, "result": result},
230
+ operation="update",
231
+ target="app base settings",
232
+ )
233
+
234
+ return self._run(profile, runner)
235
+
236
+ def app_get_form_schema(
237
+ self,
238
+ *,
239
+ profile: str,
240
+ app_key: str,
241
+ form_type: int | str,
242
+ being_draft: bool | None,
243
+ being_apply: bool | None,
244
+ audit_node_id: int | None,
245
+ include_raw: bool = False,
246
+ ) -> JSONObject:
247
+ self._require_app_key(app_key)
248
+ resolved_form_type = _normalize_form_type(form_type)
249
+
250
+ def runner(session_profile, context):
251
+ params: JSONObject = {"type": resolved_form_type}
252
+ if being_draft is not None:
253
+ params["beingDraft"] = being_draft
254
+ if being_apply is not None:
255
+ params["beingApply"] = being_apply
256
+ if audit_node_id is not None:
257
+ params["auditNodeId"] = audit_node_id
258
+ result = self.backend.request("GET", context, f"/app/{app_key}/form", params=params)
259
+ compact = self._compact_form_schema(result if isinstance(result, dict) else {})
260
+ response = {
261
+ "profile": profile,
262
+ "ws_id": session_profile.selected_ws_id,
263
+ "app_key": app_key,
264
+ "result": result if include_raw else compact,
265
+ "compact": not include_raw,
266
+ "requested_form_type": form_type,
267
+ "resolved_form_type": resolved_form_type,
268
+ }
269
+ if include_raw:
270
+ response["summary"] = compact
271
+ return response
272
+
273
+ return self._run(profile, runner)
274
+
275
+ def app_get_edit_version_no(self, *, profile: str, app_key: str) -> JSONObject:
276
+ self._require_app_key(app_key)
277
+
278
+ def runner(session_profile, context):
279
+ result = self.backend.request("GET", context, f"/app/{app_key}/editVersionNo")
280
+ return {"profile": profile, "ws_id": session_profile.selected_ws_id, "app_key": app_key, "result": result}
281
+
282
+ return self._run(profile, runner)
283
+
284
+ def app_get_apply_base_info(self, *, profile: str, app_key: str, list_type: int) -> JSONObject:
285
+ self._require_app_key(app_key)
286
+ if not isinstance(list_type, int) or isinstance(list_type, bool) or list_type <= 0:
287
+ raise_tool_error(QingflowApiError.config_error("list_type must be a positive integer"))
288
+
289
+ def runner(session_profile, context):
290
+ result = self.backend.request("GET", context, f"/app/{app_key}/apply/baseInfo", params={"type": list_type})
291
+ return {
292
+ "profile": profile,
293
+ "ws_id": session_profile.selected_ws_id,
294
+ "app_key": app_key,
295
+ "list_type": list_type,
296
+ "result": result,
297
+ }
298
+
299
+ return self._run(profile, runner)
300
+
301
+ def app_update_apply_config(self, *, profile: str, app_key: str, payload: JSONObject) -> JSONObject:
302
+ self._require_app_key(app_key)
303
+ body = self._require_dict(payload)
304
+
305
+ def runner(session_profile, context):
306
+ result = self.backend.request("POST", context, f"/app/{app_key}/apply/config", json_body=body)
307
+ return self._attach_human_review_notice(
308
+ {
309
+ "profile": profile,
310
+ "ws_id": session_profile.selected_ws_id,
311
+ "app_key": app_key,
312
+ "result": result,
313
+ },
314
+ operation="update",
315
+ target="app apply list configuration",
316
+ )
317
+
318
+ return self._run(profile, runner)
319
+
320
+ def app_update_form_schema(self, *, profile: str, app_key: str, payload: JSONObject) -> JSONObject:
321
+ self._require_app_key(app_key)
322
+ body = self._require_dict(payload)
323
+
324
+ def runner(session_profile, context):
325
+ result = self.backend.request("POST", context, f"/app/{app_key}/form", json_body=body)
326
+ return self._attach_human_review_notice(
327
+ {"profile": profile, "ws_id": session_profile.selected_ws_id, "app_key": app_key, "result": result},
328
+ operation="update",
329
+ target="app form schema",
330
+ )
331
+
332
+ return self._run(profile, runner)
333
+
334
+ def app_edit_finished(self, *, profile: str, app_key: str, payload: JSONObject) -> JSONObject:
335
+ self._require_app_key(app_key)
336
+ body = self._require_dict(payload)
337
+
338
+ def runner(session_profile, context):
339
+ result = self.backend.request("POST", context, f"/app/{app_key}/editFinished", json_body=body)
340
+ return {"profile": profile, "ws_id": session_profile.selected_ws_id, "app_key": app_key, "result": result}
341
+
342
+ return self._run(profile, runner)
343
+
344
+ def app_create(self, *, profile: str, payload: JSONObject) -> JSONObject:
345
+ body = self._require_dict(payload)
346
+
347
+ def runner(session_profile, context):
348
+ attempted_contexts = [context]
349
+ if context.qf_version is not None:
350
+ attempted_contexts.append(
351
+ BackendRequestContext(
352
+ base_url=context.base_url,
353
+ token=context.token,
354
+ ws_id=context.ws_id,
355
+ qf_version=None,
356
+ qf_version_source="app_create_retry_without_qf_version",
357
+ )
358
+ )
359
+ last_error: QingflowApiError | None = None
360
+ request_with_meta = getattr(self.backend, "request_with_meta", None)
361
+ for call_context in attempted_contexts:
362
+ try:
363
+ if callable(request_with_meta):
364
+ response = request_with_meta("POST", call_context, "/app", json_body=body)
365
+ result = response.data
366
+ request_id = response.request_id
367
+ else:
368
+ result = self.backend.request("POST", call_context, "/app", json_body=body)
369
+ request_id = None
370
+ return {
371
+ "profile": profile,
372
+ "ws_id": session_profile.selected_ws_id,
373
+ "result": result,
374
+ "request_route": self.backend.describe_route(call_context),
375
+ "request_id": request_id,
376
+ }
377
+ except QingflowApiError as error:
378
+ last_error = error
379
+ is_retryable_404 = (
380
+ error.http_status == 404
381
+ and call_context.qf_version is not None
382
+ )
383
+ if not is_retryable_404:
384
+ raise
385
+ assert last_error is not None
386
+ raise last_error
387
+
388
+ return self._run(profile, runner)
389
+
390
+ def app_delete(self, *, profile: str, app_key: str) -> JSONObject:
391
+ self._require_app_key(app_key)
392
+
393
+ def runner(session_profile, context):
394
+ result = self.backend.request("DELETE", context, f"/app/{app_key}")
395
+ return self._attach_human_review_notice(
396
+ {"profile": profile, "ws_id": session_profile.selected_ws_id, "app_key": app_key, "result": result},
397
+ operation="delete",
398
+ target="app configuration",
399
+ )
400
+
401
+ return self._run(profile, runner)
402
+
403
+ def app_publish(self, *, profile: str, app_key: str, payload: JSONObject) -> JSONObject:
404
+ self._require_app_key(app_key)
405
+ body = self._require_dict(payload)
406
+
407
+ def runner(session_profile, context):
408
+ result = self.backend.request("POST", context, f"/app/{app_key}/publish", json_body=body)
409
+ return {"profile": profile, "ws_id": session_profile.selected_ws_id, "app_key": app_key, "result": result}
410
+
411
+ return self._run(profile, runner)
412
+
413
+ def _require_app_key(self, app_key: str) -> None:
414
+ if not app_key:
415
+ raise_tool_error(QingflowApiError.config_error("app_key is required"))
416
+
417
+ def _probe_create_access(self, context: BackendRequestContext, app_key: str) -> bool:
418
+ try:
419
+ self.backend.request(
420
+ "GET",
421
+ context,
422
+ f"/app/{app_key}/form",
423
+ params={"type": 2, "beingApply": True},
424
+ )
425
+ return True
426
+ except QingflowApiError as exc:
427
+ if exc.backend_code in {40002, 40027}:
428
+ return False
429
+ raise
430
+
431
+ def _probe_list_type_access(self, context: BackendRequestContext, app_key: str, list_type: int) -> bool:
432
+ try:
433
+ self.backend.request(
434
+ "POST",
435
+ context,
436
+ f"/app/{app_key}/apply/filter",
437
+ json_body={"type": list_type, "pageNum": 1, "pageSize": 1},
438
+ )
439
+ return True
440
+ except QingflowApiError as exc:
441
+ if exc.backend_code in {40002, 40027}:
442
+ return False
443
+ raise
444
+
445
+ def _resolve_accessible_system_views(self, context: BackendRequestContext, app_key: str) -> tuple[list[JSONObject], list[JSONObject]]:
446
+ items: list[JSONObject] = []
447
+ warnings: list[JSONObject] = []
448
+ for view_id, list_type, name in SYSTEM_VIEW_DEFINITIONS:
449
+ try:
450
+ can_access = self._probe_list_type_access(context, app_key, list_type)
451
+ except QingflowApiError as exc:
452
+ warnings.append(
453
+ {
454
+ "code": "SYSTEM_VIEW_PROBE_UNVERIFIED",
455
+ "message": (
456
+ f"app_get skipped system view '{name}' because access probing failed: "
457
+ f"{exc.message or 'probe failed'}"
458
+ ),
459
+ "view_id": view_id,
460
+ "list_type": list_type,
461
+ }
462
+ )
463
+ continue
464
+ if not can_access:
465
+ continue
466
+ items.append({"view_id": view_id, "name": name, "kind": "system", "analysis_supported": True})
467
+ return items, warnings
468
+
469
+ def _resolve_accessible_custom_views(self, context: BackendRequestContext, app_key: str) -> list[JSONObject]:
470
+ try:
471
+ payload = self.backend.request("GET", context, f"/app/{app_key}/view/viewList")
472
+ except QingflowApiError as exc:
473
+ if exc.backend_code in {40002, 40027}:
474
+ return []
475
+ raise
476
+
477
+ items: list[JSONObject] = []
478
+ for item in _normalize_view_list(payload):
479
+ view_key = str(item.get("viewKey") or "").strip()
480
+ if not view_key:
481
+ continue
482
+ normalized: JSONObject = {
483
+ "view_id": f"custom:{view_key}",
484
+ "name": str(item.get("viewName") or view_key).strip() or view_key,
485
+ "kind": "custom",
486
+ }
487
+ view_type = str(item.get("viewType") or item.get("viewgraphType") or "").strip()
488
+ if view_type:
489
+ normalized["view_type"] = view_type
490
+ normalized["analysis_supported"] = _analysis_supported_for_view_type(view_type or None)
491
+ items.append(normalized)
492
+ return items
493
+
494
+ def _compact_base_info(self, result: dict[str, Any]) -> JSONObject:
495
+ publish_status = result.get("appPublishStatus")
496
+ auth = result.get("auth") if isinstance(result.get("auth"), dict) else {}
497
+ contact_auth = auth.get("contactAuth") if isinstance(auth, dict) and isinstance(auth.get("contactAuth"), dict) else {}
498
+ external_auth = auth.get("externalMemberAuth") if isinstance(auth, dict) and isinstance(auth.get("externalMemberAuth"), dict) else {}
499
+ tag_items = result.get("tagItems") if isinstance(result.get("tagItems"), list) else []
500
+ tag_ids = result.get("tagIds") if isinstance(result.get("tagIds"), list) else []
501
+ return {
502
+ "appKey": result.get("appKey"),
503
+ "formId": result.get("formId"),
504
+ "formTitle": result.get("formTitle"),
505
+ "appIcon": result.get("appIcon"),
506
+ "appPublishStatus": publish_status,
507
+ "appPublishStatusLabel": get_app_publish_status_label(publish_status if isinstance(publish_status, int) else None),
508
+ "appOpenStatus": result.get("appOpenStatus"),
509
+ "applyOpenStatus": result.get("applyOpenStatus"),
510
+ "dataManageStatus": result.get("dataManageStatus"),
511
+ "editItemStatus": result.get("editItemStatus"),
512
+ "deleteItemStatus": result.get("deleteItemStatus"),
513
+ "copyAppStatus": result.get("copyAppStatus"),
514
+ "flowStatus": result.get("flowStatus"),
515
+ "createTime": result.get("createTime"),
516
+ "creator": self._compact_user(result.get("creator")),
517
+ "tagIds": tag_ids,
518
+ "tagCount": len(tag_ids),
519
+ "tagItemCount": len(tag_items),
520
+ "tagItemsPreview": [self._compact_tag_item(item) for item in tag_items[:10] if isinstance(item, dict)],
521
+ "authSummary": {
522
+ "type": auth.get("type") if isinstance(auth, dict) else None,
523
+ "contactAuthType": contact_auth.get("type") if isinstance(contact_auth, dict) else None,
524
+ "contactDepartments": self._count_auth_members(contact_auth, "depart"),
525
+ "contactMembers": self._count_auth_members(contact_auth, "member"),
526
+ "contactRoles": self._count_auth_members(contact_auth, "role"),
527
+ "contactIncludeSubDeparts": self._extract_include_sub_departs(contact_auth),
528
+ "externalAuthType": external_auth.get("type") if isinstance(external_auth, dict) else None,
529
+ "externalDepartments": self._count_auth_members(external_auth, "depart"),
530
+ "externalMembers": self._count_auth_members(external_auth, "member"),
531
+ "externalRoles": self._count_auth_members(external_auth, "role"),
532
+ },
533
+ }
534
+
535
+ def _compact_form_schema(self, result: dict[str, Any]) -> JSONObject:
536
+ base_questions_raw = result.get("baseQues") if isinstance(result.get("baseQues"), list) else []
537
+ form_questions_raw = result.get("formQues") if isinstance(result.get("formQues"), list) else []
538
+ base_questions = [self._compact_question(question) for question in base_questions_raw if isinstance(question, dict)]
539
+ form_questions = [self._compact_question(question) for question in form_questions_raw if isinstance(question, dict)]
540
+ all_questions = base_questions + form_questions
541
+ type_counts = Counter(
542
+ question["queType"]
543
+ for question in all_questions
544
+ if isinstance(question.get("queType"), int)
545
+ )
546
+ required_count = sum(1 for question in all_questions if question.get("required") is True)
547
+ return {
548
+ "appKey": result.get("appKey"),
549
+ "formId": result.get("formId"),
550
+ "formTitle": result.get("formTitle"),
551
+ "editVersionNo": result.get("editVersionNo"),
552
+ "serialNumType": result.get("serialNumType"),
553
+ "hideCopyright": result.get("hideCopyright"),
554
+ "questionCounts": {
555
+ "baseQuestions": len(base_questions),
556
+ "formQuestions": len(form_questions),
557
+ "totalQuestions": len(all_questions),
558
+ "requiredQuestions": required_count,
559
+ "questionRelations": len(result.get("questionRelations") or []),
560
+ "selectScopeRelations": len(result.get("selectScopeRelations") or []),
561
+ "serialNumConfigs": len(result.get("serialNumConfig") or []),
562
+ },
563
+ "questionTypeCounts": [
564
+ {"queType": que_type, "count": count}
565
+ for que_type, count in sorted(type_counts.items())
566
+ ],
567
+ "baseQuestions": base_questions,
568
+ "formQuestions": form_questions,
569
+ }
570
+
571
+ def _compact_user(self, user: Any) -> JSONObject | None:
572
+ if not isinstance(user, dict):
573
+ return None
574
+ return {
575
+ "uid": user.get("uid"),
576
+ "nickName": user.get("nickName"),
577
+ "email": user.get("email"),
578
+ "remark": user.get("remark"),
579
+ }
580
+
581
+ def _compact_tag_item(self, item: dict[str, Any]) -> JSONObject:
582
+ return {
583
+ "itemType": item.get("itemType"),
584
+ "title": item.get("title"),
585
+ "appKey": item.get("appKey"),
586
+ "formId": item.get("formId"),
587
+ "dashKey": item.get("dashKey"),
588
+ "pageKey": item.get("pageKey"),
589
+ }
590
+
591
+ def _compact_question(self, question: dict[str, Any]) -> JSONObject:
592
+ options = question.get("options")
593
+ inner_questions = question.get("innerQuestions")
594
+ sub_questions = question.get("subQuestions")
595
+ compact = {
596
+ "queId": question.get("queId"),
597
+ "queTitle": question.get("queTitle"),
598
+ "queType": question.get("queType"),
599
+ "required": question.get("required") is True,
600
+ "ordinal": question.get("ordinal"),
601
+ "inlineOrdinal": question.get("inlineOrdinal"),
602
+ "queWidth": question.get("queWidth"),
603
+ "sectionId": question.get("sectionId"),
604
+ "supId": question.get("supId"),
605
+ "relatedQueId": question.get("relatedQueId"),
606
+ "pluginStatus": question.get("pluginStatus"),
607
+ "optionCount": len(options) if isinstance(options, list) else 0,
608
+ "innerQuestionCount": len(inner_questions) if isinstance(inner_questions, list) else 0,
609
+ "subQuestionCount": len(sub_questions) if isinstance(sub_questions, list) else 0,
610
+ }
611
+ return {key: value for key, value in compact.items() if value is not None}
612
+
613
+ def _extract_visible_apps(self, result: Any) -> tuple[list[JSONObject], str]:
614
+ apps: list[JSONObject] = []
615
+ seen: set[str] = set()
616
+
617
+ def walk(
618
+ node: Any,
619
+ *,
620
+ package_tag_id: int | None = None,
621
+ package_name: str | None = None,
622
+ group_id: int | None = None,
623
+ group_name: str | None = None,
624
+ ) -> None:
625
+ if isinstance(node, list):
626
+ for item in node:
627
+ walk(
628
+ item,
629
+ package_tag_id=package_tag_id,
630
+ package_name=package_name,
631
+ group_id=group_id,
632
+ group_name=group_name,
633
+ )
634
+ return
635
+ if not isinstance(node, dict):
636
+ return
637
+
638
+ next_package_tag_id = _coerce_positive_int(node.get("tagId")) or package_tag_id
639
+ next_package_name = str(node.get("tagName") or "").strip() or package_name
640
+ next_group_id = _coerce_positive_int(node.get("groupId")) or group_id
641
+ next_group_name = str(node.get("groupName") or node.get("groupTitle") or "").strip() or group_name
642
+
643
+ normalized = self._normalize_visible_app(
644
+ node,
645
+ package_tag_id=next_package_tag_id,
646
+ package_name=next_package_name,
647
+ group_id=next_group_id,
648
+ group_name=next_group_name,
649
+ )
650
+ if normalized is not None:
651
+ app_key = str(normalized.get("app_key") or "").strip()
652
+ if app_key and app_key not in seen:
653
+ seen.add(app_key)
654
+ apps.append(normalized)
655
+
656
+ for value in node.values():
657
+ if isinstance(value, (list, dict)):
658
+ walk(
659
+ value,
660
+ package_tag_id=next_package_tag_id,
661
+ package_name=next_package_name,
662
+ group_id=next_group_id,
663
+ group_name=next_group_name,
664
+ )
665
+
666
+ walk(result)
667
+ return apps, type(result).__name__
668
+
669
+ def _normalize_visible_app(
670
+ self,
671
+ item: dict[str, Any],
672
+ *,
673
+ package_tag_id: int | None,
674
+ package_name: str | None,
675
+ group_id: int | None,
676
+ group_name: str | None,
677
+ ) -> JSONObject | None:
678
+ app_key = str(item.get("appKey") or item.get("app_key") or "").strip()
679
+ if not app_key:
680
+ return None
681
+ title = str(item.get("title") or item.get("formTitle") or item.get("appName") or item.get("name") or app_key).strip() or app_key
682
+ tag_ids = item.get("tagIds") if isinstance(item.get("tagIds"), list) else []
683
+ compact = {
684
+ "app_key": app_key,
685
+ "title": title,
686
+ "form_id": item.get("formId"),
687
+ "tag_id": package_tag_id,
688
+ "package_name": package_name,
689
+ "group_id": group_id,
690
+ "group_name": group_name,
691
+ "tag_ids": [value for value in (_coerce_positive_int(tag_id) for tag_id in tag_ids) if value is not None],
692
+ }
693
+ return {key: value for key, value in compact.items() if value not in (None, [], "", {})}
694
+
695
+ def _count_auth_members(self, auth_payload: Any, member_key: str) -> int:
696
+ if not isinstance(auth_payload, dict):
697
+ return 0
698
+ auth_members = auth_payload.get("authMembers")
699
+ if not isinstance(auth_members, dict):
700
+ return 0
701
+ members = auth_members.get(member_key)
702
+ return len(members) if isinstance(members, list) else 0
703
+
704
+ def _extract_include_sub_departs(self, auth_payload: Any) -> bool | None:
705
+ if not isinstance(auth_payload, dict):
706
+ return None
707
+ auth_members = auth_payload.get("authMembers")
708
+ if isinstance(auth_members, dict):
709
+ include_sub_departs = auth_members.get("includeSubDeparts")
710
+ if isinstance(include_sub_departs, bool):
711
+ return include_sub_departs
712
+ include_sub_departs = auth_payload.get("includeSubDeparts")
713
+ if isinstance(include_sub_departs, bool):
714
+ return include_sub_departs
715
+ return None
716
+
717
+
718
+ FORM_TYPE_ALIASES = {
719
+ "default": 1,
720
+ "form": 1,
721
+ "schema": 1,
722
+ "new": 1,
723
+ "draft": 1,
724
+ "edit": 1,
725
+ }
726
+
727
+
728
+ def _normalize_form_type(value: int | str) -> int:
729
+ if isinstance(value, bool):
730
+ raise_tool_error(QingflowApiError.config_error("form_type must be a positive integer or supported alias"))
731
+ if isinstance(value, int):
732
+ return value
733
+ text = str(value or "").strip().lower()
734
+ if not text:
735
+ raise_tool_error(QingflowApiError.config_error("form_type is required"))
736
+ if text.isdigit():
737
+ return int(text)
738
+ if text in FORM_TYPE_ALIASES:
739
+ return FORM_TYPE_ALIASES[text]
740
+ raise_tool_error(QingflowApiError.config_error("form_type must be a positive integer or one of: default, form, schema, new, draft, edit"))
741
+
742
+
743
+ def _normalize_view_list(payload: Any) -> list[JSONObject]:
744
+ if not isinstance(payload, list):
745
+ return []
746
+ flattened: list[JSONObject] = []
747
+ for group in payload:
748
+ if not isinstance(group, dict):
749
+ continue
750
+ view_list = group.get("viewList")
751
+ if not isinstance(view_list, list):
752
+ continue
753
+ for item in view_list:
754
+ if isinstance(item, dict) and item.get("viewKey"):
755
+ flattened.append(item)
756
+ return flattened
757
+
758
+
759
+ def _analysis_supported_for_view_type(view_type: str | None) -> bool:
760
+ normalized = str(view_type or "").strip().lower()
761
+ if not normalized:
762
+ return True
763
+ return normalized not in {"boardview", "ganttview"}
764
+
765
+
766
+ def _derive_import_capability(base_info: Any) -> tuple[JSONObject, list[JSONObject]]:
767
+ warnings: list[JSONObject] = []
768
+ if not isinstance(base_info, dict):
769
+ warnings.append(
770
+ {
771
+ "code": "IMPORT_CAPABILITY_UNAVAILABLE",
772
+ "message": "app_get could not determine import capability because baseInfo was unavailable.",
773
+ }
774
+ )
775
+ return _unknown_import_capability(), warnings
776
+
777
+ has_data_import_status = "dataImportStatus" in base_info
778
+ has_data_manage_status = "dataManageStatus" in base_info
779
+ applicant_import_enabled = _coerce_optional_bool(base_info.get("dataImportStatus")) if has_data_import_status else None
780
+ data_manage_status = _coerce_optional_bool(base_info.get("dataManageStatus")) if has_data_manage_status else None
781
+
782
+ if applicant_import_enabled is True:
783
+ return {
784
+ "can_import": True,
785
+ "auth_source": "apply_auth",
786
+ "applicant_import_enabled": True,
787
+ "data_manage_status": data_manage_status,
788
+ "runtime_checks_required": ["user_disabled", "function_demoted"],
789
+ "confidence": "preflight",
790
+ }, warnings
791
+
792
+ if data_manage_status is True:
793
+ return {
794
+ "can_import": True,
795
+ "auth_source": "data_manage_auth",
796
+ "applicant_import_enabled": applicant_import_enabled,
797
+ "data_manage_status": True,
798
+ "runtime_checks_required": ["user_disabled", "function_demoted"],
799
+ "confidence": "preflight",
800
+ }, warnings
801
+
802
+ if applicant_import_enabled is False and data_manage_status is False:
803
+ return {
804
+ "can_import": False,
805
+ "auth_source": "none",
806
+ "applicant_import_enabled": False,
807
+ "data_manage_status": False,
808
+ "runtime_checks_required": [],
809
+ "confidence": "preflight",
810
+ }, warnings
811
+
812
+ warnings.append(
813
+ {
814
+ "code": "IMPORT_CAPABILITY_UNAVAILABLE",
815
+ "message": "app_get could not fully determine import capability because baseInfo did not include a complete import permission summary.",
816
+ }
817
+ )
818
+ return _unknown_import_capability(
819
+ applicant_import_enabled=applicant_import_enabled,
820
+ data_manage_status=data_manage_status,
821
+ ), warnings
822
+
823
+
824
+ def _unknown_import_capability(
825
+ *,
826
+ applicant_import_enabled: bool | None = None,
827
+ data_manage_status: bool | None = None,
828
+ ) -> JSONObject:
829
+ return {
830
+ "can_import": None,
831
+ "auth_source": "unknown",
832
+ "applicant_import_enabled": applicant_import_enabled,
833
+ "data_manage_status": data_manage_status,
834
+ "runtime_checks_required": [],
835
+ "confidence": "unknown",
836
+ }
837
+
838
+
839
+ def _coerce_positive_int(value: Any) -> int | None:
840
+ try:
841
+ number = int(value)
842
+ except (TypeError, ValueError):
843
+ return None
844
+ return number if number > 0 else None
845
+
846
+
847
+ def _coerce_optional_bool(value: Any) -> bool | None:
848
+ if isinstance(value, bool):
849
+ return value
850
+ return None