@josephyan/qingflow-cli 0.2.0-beta.58 → 0.2.0-beta.60

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 (35) hide show
  1. package/README.md +3 -2
  2. package/docs/local-agent-install.md +9 -0
  3. package/npm/bin/qingflow.mjs +1 -1
  4. package/npm/lib/runtime.mjs +156 -21
  5. package/package.json +1 -1
  6. package/pyproject.toml +1 -1
  7. package/src/qingflow_mcp/builder_facade/service.py +670 -191
  8. package/src/qingflow_mcp/cli/commands/app.py +16 -16
  9. package/src/qingflow_mcp/cli/commands/auth.py +19 -16
  10. package/src/qingflow_mcp/cli/commands/builder.py +124 -162
  11. package/src/qingflow_mcp/cli/commands/common.py +21 -95
  12. package/src/qingflow_mcp/cli/commands/imports.py +42 -34
  13. package/src/qingflow_mcp/cli/commands/record.py +131 -133
  14. package/src/qingflow_mcp/cli/commands/task.py +43 -44
  15. package/src/qingflow_mcp/cli/commands/workspace.py +10 -10
  16. package/src/qingflow_mcp/cli/context.py +35 -32
  17. package/src/qingflow_mcp/cli/formatters.py +124 -121
  18. package/src/qingflow_mcp/cli/main.py +52 -17
  19. package/src/qingflow_mcp/server_app_builder.py +122 -190
  20. package/src/qingflow_mcp/server_app_user.py +63 -662
  21. package/src/qingflow_mcp/solution/executor.py +63 -4
  22. package/src/qingflow_mcp/tools/solution_tools.py +115 -3
  23. package/src/qingflow_mcp/ops/__init__.py +0 -3
  24. package/src/qingflow_mcp/ops/apps.py +0 -64
  25. package/src/qingflow_mcp/ops/auth.py +0 -121
  26. package/src/qingflow_mcp/ops/base.py +0 -290
  27. package/src/qingflow_mcp/ops/builder.py +0 -357
  28. package/src/qingflow_mcp/ops/context.py +0 -120
  29. package/src/qingflow_mcp/ops/directory.py +0 -171
  30. package/src/qingflow_mcp/ops/feedback.py +0 -49
  31. package/src/qingflow_mcp/ops/files.py +0 -78
  32. package/src/qingflow_mcp/ops/imports.py +0 -140
  33. package/src/qingflow_mcp/ops/records.py +0 -415
  34. package/src/qingflow_mcp/ops/tasks.py +0 -171
  35. package/src/qingflow_mcp/ops/workspace.py +0 -76
@@ -236,8 +236,14 @@ class SolutionExecutor:
236
236
  existing_role_id = existing.get("role_id")
237
237
  if existing_role_id:
238
238
  return
239
- page = self.role_tools.role_search(profile=profile, keyword=role.name, page_num=1, page_size=50).get("page") or {}
240
- role_list = page.get("list") if isinstance(page, dict) else []
239
+ try:
240
+ page = self.role_tools.role_search(profile=profile, keyword=role.name, page_num=1, page_size=50).get("page") or {}
241
+ role_list = page.get("list") if isinstance(page, dict) else []
242
+ except Exception as exc: # noqa: BLE001
243
+ api_error = _coerce_qingflow_error(exc)
244
+ if api_error is None or not _is_permission_restricted_error(api_error):
245
+ raise
246
+ role_list = []
241
247
  matched_role = next(
242
248
  (
243
249
  item
@@ -312,7 +318,18 @@ class SolutionExecutor:
312
318
  if not isinstance(app_key, str) or not app_key:
313
319
  raise ValueError(f"missing app_key for package attach on entity '{entity.entity_id}'")
314
320
 
315
- package_detail = self.package_tools.package_get(profile=profile, tag_id=tag_id, include_raw=True)
321
+ try:
322
+ package_detail = self.package_tools.package_get(profile=profile, tag_id=tag_id, include_raw=True)
323
+ except Exception as exc: # noqa: BLE001
324
+ api_error = _coerce_qingflow_error(exc)
325
+ if api_error is None or not _is_permission_restricted_error(api_error):
326
+ raise
327
+ raise _required_state_read_blocked_error(
328
+ resource="package_attach",
329
+ message=f"package attach requires readable package state before sorting items for tag '{tag_id}'",
330
+ error=api_error,
331
+ details={"tag_id": tag_id, "app_key": app_key},
332
+ ) from exc
316
333
  package_result = package_detail.get("result") if isinstance(package_detail.get("result"), dict) else {}
317
334
  tag_items = [deepcopy(item) for item in package_result.get("tagItems", []) if isinstance(item, dict)]
318
335
  if any(_package_item_app_key(item) == app_key for item in tag_items):
@@ -675,7 +692,18 @@ class SolutionExecutor:
675
692
  if dash_key:
676
693
  store.set_artifact("portal", "dash_key", dash_key)
677
694
  if dash_key:
678
- base_payload = self.portal_tools.portal_get(profile=profile, dash_key=dash_key, being_draft=True).get("result") or {}
695
+ try:
696
+ base_payload = self.portal_tools.portal_get(profile=profile, dash_key=dash_key, being_draft=True).get("result") or {}
697
+ except Exception as exc: # noqa: BLE001
698
+ api_error = _coerce_qingflow_error(exc)
699
+ if api_error is None or not _is_permission_restricted_error(api_error):
700
+ raise
701
+ raise _required_state_read_blocked_error(
702
+ resource="portal",
703
+ message=f"portal update requires readable draft state for dash '{dash_key}'",
704
+ error=api_error,
705
+ details={"dash_key": dash_key},
706
+ ) from exc
679
707
  update_payload = self._resolve_portal_payload(compiled.portal_plan["update_payload"], store, base_payload=base_payload)
680
708
  self.portal_tools.portal_update(profile=profile, dash_key=dash_key, payload=update_payload)
681
709
  self._refresh_portal_artifact(profile=profile, store=store, being_draft=True, artifact_key="draft_result")
@@ -2123,6 +2151,37 @@ def _coerce_qingflow_error(error: Exception) -> QingflowApiError | None:
2123
2151
  )
2124
2152
 
2125
2153
 
2154
+ def _is_permission_restricted_error(error: QingflowApiError) -> bool:
2155
+ return error.backend_code in {40002, 40027}
2156
+
2157
+
2158
+ def _required_state_read_blocked_error(
2159
+ *,
2160
+ resource: str,
2161
+ message: str,
2162
+ error: QingflowApiError,
2163
+ details: dict[str, Any] | None = None,
2164
+ ) -> QingflowApiError:
2165
+ merged_details = deepcopy(details) if isinstance(details, dict) else {}
2166
+ merged_details["state_read_blocked"] = {
2167
+ "resource": resource,
2168
+ "transport_error": {
2169
+ "http_status": error.http_status,
2170
+ "backend_code": error.backend_code,
2171
+ "category": error.category,
2172
+ "request_id": error.request_id,
2173
+ },
2174
+ }
2175
+ return QingflowApiError(
2176
+ category=error.category,
2177
+ message=message,
2178
+ backend_code=error.backend_code,
2179
+ request_id=error.request_id,
2180
+ http_status=error.http_status,
2181
+ details=merged_details,
2182
+ )
2183
+
2184
+
2126
2185
  def _portal_component_position(
2127
2186
  source_type: Any,
2128
2187
  *,
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from copy import deepcopy
4
+ import json
4
5
  from typing import Any
5
6
  from uuid import uuid4
6
7
 
@@ -8,6 +9,7 @@ from mcp.server.fastmcp import FastMCP
8
9
  from pydantic import BaseModel, ValidationError
9
10
 
10
11
  from ..config import DEFAULT_PROFILE
12
+ from ..errors import QingflowApiError
11
13
  from ..list_type_labels import get_record_list_type_label
12
14
  from ..solution.build_assembly_store import BuildAssemblyStore, default_manifest
13
15
  from ..solution.compiler import CompiledSolution, ExecutionPlan, ExecutionStep, build_execution_plan, compile_solution
@@ -1850,7 +1852,35 @@ class SolutionTools(ToolBase):
1850
1852
  packages = PackageTools(self.sessions, self.backend)
1851
1853
  normalized_name = package_name.strip()
1852
1854
  if package_tag_id > 0:
1853
- result = packages.package_get(profile=profile, tag_id=package_tag_id, include_raw=False)
1855
+ try:
1856
+ result = packages.package_get(profile=profile, tag_id=package_tag_id, include_raw=False)
1857
+ except (QingflowApiError, RuntimeError) as exc:
1858
+ error = _coerce_solution_api_error(exc)
1859
+ if error.backend_code in {40002, 40027}:
1860
+ return {
1861
+ "status": "resolved",
1862
+ "matched_via": "tag_id",
1863
+ "tag_id": package_tag_id,
1864
+ "tag_name": None,
1865
+ "candidates": [],
1866
+ "metadata_unverified": True,
1867
+ "lookup_permission_blocked": {
1868
+ "scope": "package",
1869
+ "target": {"tag_id": package_tag_id},
1870
+ "transport_error": {
1871
+ "http_status": error.http_status,
1872
+ "backend_code": error.backend_code,
1873
+ "category": error.category,
1874
+ "request_id": error.request_id,
1875
+ },
1876
+ },
1877
+ }
1878
+ return _builder_package_resolution_failed(
1879
+ package_name=normalized_name,
1880
+ package_tag_id=package_tag_id,
1881
+ error=error,
1882
+ retried=False,
1883
+ )
1854
1884
  summary = result.get("result") if isinstance(result.get("result"), dict) else {}
1855
1885
  return {
1856
1886
  "status": "resolved",
@@ -1869,12 +1899,28 @@ class SolutionTools(ToolBase):
1869
1899
  "source_shape": None,
1870
1900
  "retried": False,
1871
1901
  }
1872
- result = packages.package_list(profile=profile, trial_status="all", include_raw=False)
1902
+ try:
1903
+ result = packages.package_list(profile=profile, trial_status="all", include_raw=False)
1904
+ except (QingflowApiError, RuntimeError) as exc:
1905
+ return _builder_package_resolution_failed(
1906
+ package_name=normalized_name,
1907
+ package_tag_id=package_tag_id,
1908
+ error=_coerce_solution_api_error(exc),
1909
+ retried=False,
1910
+ )
1873
1911
  items = result.get("items") if isinstance(result.get("items"), list) else []
1874
1912
  source_shape = result.get("source_shape")
1875
1913
  retried = False
1876
1914
  if not items:
1877
- retry_result = packages.package_list(profile=profile, trial_status="all", include_raw=False)
1915
+ try:
1916
+ retry_result = packages.package_list(profile=profile, trial_status="all", include_raw=False)
1917
+ except (QingflowApiError, RuntimeError) as exc:
1918
+ return _builder_package_resolution_failed(
1919
+ package_name=normalized_name,
1920
+ package_tag_id=package_tag_id,
1921
+ error=_coerce_solution_api_error(exc),
1922
+ retried=True,
1923
+ )
1878
1924
  retry_items = retry_result.get("items") if isinstance(retry_result.get("items"), list) else []
1879
1925
  if retry_items:
1880
1926
  items = retry_items
@@ -2982,6 +3028,72 @@ def _solution_error_fields(
2982
3028
  }
2983
3029
 
2984
3030
 
3031
+ def _builder_package_resolution_failed(
3032
+ *,
3033
+ package_name: str,
3034
+ package_tag_id: int,
3035
+ error: QingflowApiError,
3036
+ retried: bool,
3037
+ ) -> dict[str, Any]:
3038
+ if package_tag_id > 0:
3039
+ detail = f"failed to resolve package_tag_id '{package_tag_id}': {error.message}"
3040
+ elif error.backend_code in {40002, 40027}:
3041
+ detail = (
3042
+ f"failed to resolve package '{package_name}' because package listing is permission-restricted; "
3043
+ "provide package_tag_id explicitly or use an account that can list packages"
3044
+ )
3045
+ else:
3046
+ detail = f"failed to resolve package '{package_name}': {error.message}"
3047
+ error_fields = _solution_error_fields(category="config", detail=detail, suggested_next_call=None, stage="app")
3048
+ error_fields["error_code"] = "PACKAGE_RESOLVE_FAILED"
3049
+ return {
3050
+ "status": "failed",
3051
+ "response": {
3052
+ "status": "failed",
3053
+ "mode": "plan",
3054
+ "stage": "app",
3055
+ "errors": [{"category": "config", "detail": detail}],
3056
+ "package_resolution": {
3057
+ "status": "failed",
3058
+ "requested_name": package_name or None,
3059
+ "requested_tag_id": package_tag_id or None,
3060
+ "resolution_policy": "exact_name_only" if package_name else "tag_id_only",
3061
+ "candidates": [],
3062
+ "source_shape": None,
3063
+ "retried": retried,
3064
+ "transport_error": {
3065
+ "http_status": error.http_status,
3066
+ "backend_code": error.backend_code,
3067
+ "category": error.category,
3068
+ "request_id": error.request_id,
3069
+ },
3070
+ },
3071
+ **error_fields,
3072
+ },
3073
+ }
3074
+
3075
+
3076
+ def _coerce_solution_api_error(error: Exception) -> QingflowApiError:
3077
+ if isinstance(error, QingflowApiError):
3078
+ return error
3079
+ if isinstance(error, RuntimeError):
3080
+ try:
3081
+ payload = json.loads(str(error))
3082
+ except json.JSONDecodeError:
3083
+ payload = None
3084
+ if isinstance(payload, dict) and payload.get("category") and payload.get("message"):
3085
+ details = payload.get("details")
3086
+ return QingflowApiError(
3087
+ category=str(payload.get("category")),
3088
+ message=str(payload.get("message")),
3089
+ backend_code=payload.get("backend_code"),
3090
+ request_id=payload.get("request_id"),
3091
+ http_status=payload.get("http_status"),
3092
+ details=details if isinstance(details, dict) else None,
3093
+ )
3094
+ return QingflowApiError(category="runtime", message=str(error))
3095
+
3096
+
2985
3097
  def _coerce_count(value: Any) -> int | None:
2986
3098
  if isinstance(value, bool) or value is None:
2987
3099
  return None
@@ -1,3 +0,0 @@
1
- from .context import OperationsRuntime, build_operations_runtime
2
-
3
- __all__ = ["OperationsRuntime", "build_operations_runtime"]
@@ -1,64 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from typing import Any
4
-
5
- from .base import normalize_exception, success_result
6
-
7
-
8
- class AppOperations:
9
- def __init__(self, tools: Any) -> None:
10
- self._tools = tools
11
-
12
- def list(self, *, profile: str) -> dict:
13
- try:
14
- raw = self._tools.app.app_list(profile=profile)
15
- except Exception as error: # noqa: BLE001
16
- return normalize_exception(error)
17
- items = raw.get("items") if isinstance(raw.get("items"), list) else []
18
- return success_result(
19
- "APPS_LISTED",
20
- "已读取应用列表",
21
- data={"items": items, "count": raw.get("count", len(items))},
22
- meta={"profile": profile, "workspace_id": raw.get("ws_id")},
23
- legacy=raw,
24
- )
25
-
26
- def find(self, *, profile: str, keyword: str, page_num: int, page_size: int) -> dict:
27
- try:
28
- raw = self._tools.app.app_search(profile=profile, keyword=keyword, page_num=page_num, page_size=page_size)
29
- except Exception as error: # noqa: BLE001
30
- return normalize_exception(error)
31
- return success_result(
32
- "APPS_FOUND",
33
- "已完成应用搜索",
34
- data={
35
- "items": raw.get("items") if isinstance(raw.get("items"), list) else [],
36
- "total": raw.get("total"),
37
- "keyword": keyword,
38
- "page_num": page_num,
39
- "page_size": page_size,
40
- },
41
- meta={"profile": profile, "workspace_id": raw.get("ws_id")},
42
- legacy=raw,
43
- )
44
-
45
- def show(self, *, profile: str, app_key: str) -> dict:
46
- try:
47
- raw = self._tools.app.app_get(profile=profile, app_key=app_key)
48
- except Exception as error: # noqa: BLE001
49
- return normalize_exception(error)
50
- data = raw.get("data") if isinstance(raw.get("data"), dict) else {}
51
- return success_result(
52
- "APP_SHOWN",
53
- "已读取应用信息",
54
- data={
55
- "app_key": data.get("app_key", app_key),
56
- "app_name": data.get("app_name"),
57
- "can_create": data.get("can_create"),
58
- "import_capability": data.get("import_capability"),
59
- "accessible_views": data.get("accessible_views") if isinstance(data.get("accessible_views"), list) else [],
60
- },
61
- warnings=raw.get("warnings") if isinstance(raw.get("warnings"), list) else [],
62
- meta={"profile": profile, "workspace_id": raw.get("ws_id"), "request_route": raw.get("request_route")},
63
- legacy=raw,
64
- )
@@ -1,121 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from typing import Any
4
-
5
- from .base import normalize_exception, success_result
6
-
7
-
8
- class AuthOperations:
9
- def __init__(self, tools: Any) -> None:
10
- self._tools = tools
11
-
12
- def login(
13
- self,
14
- *,
15
- profile: str,
16
- base_url: str | None,
17
- qf_version: str | None,
18
- email: str,
19
- password: str,
20
- persist: bool,
21
- ) -> dict:
22
- try:
23
- raw = self._tools.auth.auth_login(
24
- profile=profile,
25
- base_url=base_url,
26
- qf_version=qf_version,
27
- email=email,
28
- password=password,
29
- persist=persist,
30
- )
31
- except Exception as error: # noqa: BLE001
32
- return normalize_exception(error)
33
- return success_result(
34
- "AUTHENTICATED",
35
- "登录成功",
36
- data=_auth_payload(raw),
37
- meta={"profile": profile},
38
- legacy=raw,
39
- )
40
-
41
- def use_token(
42
- self,
43
- *,
44
- profile: str,
45
- base_url: str | None,
46
- qf_version: str | None,
47
- token: str,
48
- ws_id: int | None,
49
- persist: bool,
50
- ) -> dict:
51
- try:
52
- raw = self._tools.auth.auth_use_token(
53
- profile=profile,
54
- base_url=base_url,
55
- qf_version=qf_version,
56
- token=token,
57
- ws_id=ws_id,
58
- persist=persist,
59
- )
60
- except Exception as error: # noqa: BLE001
61
- return normalize_exception(error)
62
- return success_result(
63
- "TOKEN_ACCEPTED",
64
- "已接入会话令牌",
65
- data=_auth_payload(raw),
66
- meta={"profile": profile},
67
- legacy=raw,
68
- )
69
-
70
- def me(self, *, profile: str) -> dict:
71
- try:
72
- raw = self._tools.auth.auth_whoami(profile=profile)
73
- except Exception as error: # noqa: BLE001
74
- return normalize_exception(error)
75
- return success_result(
76
- "SESSION_READY",
77
- "当前会话已就绪",
78
- data=_auth_payload(raw),
79
- meta={"profile": profile},
80
- legacy=raw,
81
- )
82
-
83
- def logout(self, *, profile: str, forget_persisted: bool) -> dict:
84
- try:
85
- raw = self._tools.auth.auth_logout(profile=profile, forget_persisted=forget_persisted)
86
- except Exception as error: # noqa: BLE001
87
- return normalize_exception(error)
88
- return success_result(
89
- "SESSION_CLEARED",
90
- "已退出当前会话",
91
- data={
92
- "profile": raw.get("profile", profile),
93
- "logged_out": bool(raw.get("logged_out", True)),
94
- "forgot_persisted": bool(raw.get("forgot_persisted", forget_persisted)),
95
- },
96
- meta={"profile": profile},
97
- legacy=raw,
98
- )
99
-
100
-
101
- def _auth_payload(raw: dict) -> dict:
102
- return {
103
- "profile": raw.get("profile"),
104
- "base_url": raw.get("base_url"),
105
- "qf_version": raw.get("qf_version"),
106
- "qf_version_source": raw.get("qf_version_source"),
107
- "user": {
108
- "uid": raw.get("uid"),
109
- "email": raw.get("email"),
110
- "nick_name": raw.get("nick_name"),
111
- },
112
- "workspace": {
113
- "ws_id": raw.get("selected_ws_id"),
114
- "name": raw.get("selected_ws_name"),
115
- },
116
- "suggested_workspace": {
117
- "ws_id": raw.get("suggested_ws_id"),
118
- "name": raw.get("suggested_ws_name"),
119
- },
120
- "persisted": bool(raw.get("persisted")),
121
- }