@josephyan/qingflow-cli 0.2.0-beta.1000

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 (92) hide show
  1. package/README.md +31 -0
  2. package/docs/local-agent-install.md +309 -0
  3. package/entry_point.py +13 -0
  4. package/npm/bin/qingflow.mjs +5 -0
  5. package/npm/lib/runtime.mjs +346 -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 +37 -0
  11. package/src/qingflow_mcp/__main__.py +5 -0
  12. package/src/qingflow_mcp/backend_client.py +649 -0
  13. package/src/qingflow_mcp/builder_facade/__init__.py +3 -0
  14. package/src/qingflow_mcp/builder_facade/models.py +1846 -0
  15. package/src/qingflow_mcp/builder_facade/service.py +16502 -0
  16. package/src/qingflow_mcp/cli/__init__.py +1 -0
  17. package/src/qingflow_mcp/cli/commands/__init__.py +18 -0
  18. package/src/qingflow_mcp/cli/commands/app.py +40 -0
  19. package/src/qingflow_mcp/cli/commands/auth.py +112 -0
  20. package/src/qingflow_mcp/cli/commands/builder.py +539 -0
  21. package/src/qingflow_mcp/cli/commands/chart.py +18 -0
  22. package/src/qingflow_mcp/cli/commands/common.py +62 -0
  23. package/src/qingflow_mcp/cli/commands/imports.py +96 -0
  24. package/src/qingflow_mcp/cli/commands/portal.py +25 -0
  25. package/src/qingflow_mcp/cli/commands/record.py +331 -0
  26. package/src/qingflow_mcp/cli/commands/repo.py +80 -0
  27. package/src/qingflow_mcp/cli/commands/task.py +141 -0
  28. package/src/qingflow_mcp/cli/commands/view.py +18 -0
  29. package/src/qingflow_mcp/cli/commands/workspace.py +110 -0
  30. package/src/qingflow_mcp/cli/context.py +60 -0
  31. package/src/qingflow_mcp/cli/formatters.py +573 -0
  32. package/src/qingflow_mcp/cli/json_io.py +50 -0
  33. package/src/qingflow_mcp/cli/main.py +186 -0
  34. package/src/qingflow_mcp/cli/qingflow_login.py +116 -0
  35. package/src/qingflow_mcp/cli/terminal_ui.py +173 -0
  36. package/src/qingflow_mcp/config.py +407 -0
  37. package/src/qingflow_mcp/errors.py +66 -0
  38. package/src/qingflow_mcp/id_utils.py +49 -0
  39. package/src/qingflow_mcp/import_store.py +121 -0
  40. package/src/qingflow_mcp/json_types.py +18 -0
  41. package/src/qingflow_mcp/list_type_labels.py +76 -0
  42. package/src/qingflow_mcp/public_surface.py +243 -0
  43. package/src/qingflow_mcp/repository_store.py +71 -0
  44. package/src/qingflow_mcp/response_trim.py +841 -0
  45. package/src/qingflow_mcp/server.py +216 -0
  46. package/src/qingflow_mcp/server_app_builder.py +543 -0
  47. package/src/qingflow_mcp/server_app_user.py +386 -0
  48. package/src/qingflow_mcp/session_store.py +369 -0
  49. package/src/qingflow_mcp/solution/__init__.py +6 -0
  50. package/src/qingflow_mcp/solution/build_assembly_store.py +181 -0
  51. package/src/qingflow_mcp/solution/compiler/__init__.py +282 -0
  52. package/src/qingflow_mcp/solution/compiler/chart_compiler.py +96 -0
  53. package/src/qingflow_mcp/solution/compiler/form_compiler.py +495 -0
  54. package/src/qingflow_mcp/solution/compiler/icon_utils.py +187 -0
  55. package/src/qingflow_mcp/solution/compiler/navigation_compiler.py +57 -0
  56. package/src/qingflow_mcp/solution/compiler/package_compiler.py +19 -0
  57. package/src/qingflow_mcp/solution/compiler/portal_compiler.py +60 -0
  58. package/src/qingflow_mcp/solution/compiler/view_compiler.py +51 -0
  59. package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +173 -0
  60. package/src/qingflow_mcp/solution/design_session.py +222 -0
  61. package/src/qingflow_mcp/solution/design_store.py +100 -0
  62. package/src/qingflow_mcp/solution/executor.py +2398 -0
  63. package/src/qingflow_mcp/solution/normalizer.py +23 -0
  64. package/src/qingflow_mcp/solution/requirements_builder.py +536 -0
  65. package/src/qingflow_mcp/solution/run_store.py +244 -0
  66. package/src/qingflow_mcp/solution/spec_models.py +855 -0
  67. package/src/qingflow_mcp/tools/__init__.py +1 -0
  68. package/src/qingflow_mcp/tools/ai_builder_tools.py +3449 -0
  69. package/src/qingflow_mcp/tools/app_tools.py +926 -0
  70. package/src/qingflow_mcp/tools/approval_tools.py +1062 -0
  71. package/src/qingflow_mcp/tools/auth_tools.py +1133 -0
  72. package/src/qingflow_mcp/tools/base.py +281 -0
  73. package/src/qingflow_mcp/tools/code_block_tools.py +777 -0
  74. package/src/qingflow_mcp/tools/custom_button_tools.py +202 -0
  75. package/src/qingflow_mcp/tools/directory_tools.py +675 -0
  76. package/src/qingflow_mcp/tools/feedback_tools.py +238 -0
  77. package/src/qingflow_mcp/tools/file_tools.py +409 -0
  78. package/src/qingflow_mcp/tools/import_tools.py +2223 -0
  79. package/src/qingflow_mcp/tools/navigation_tools.py +210 -0
  80. package/src/qingflow_mcp/tools/package_tools.py +326 -0
  81. package/src/qingflow_mcp/tools/portal_tools.py +158 -0
  82. package/src/qingflow_mcp/tools/qingbi_report_tools.py +374 -0
  83. package/src/qingflow_mcp/tools/record_tools.py +14291 -0
  84. package/src/qingflow_mcp/tools/repository_dev_tools.py +552 -0
  85. package/src/qingflow_mcp/tools/resource_read_tools.py +503 -0
  86. package/src/qingflow_mcp/tools/role_tools.py +112 -0
  87. package/src/qingflow_mcp/tools/solution_tools.py +4054 -0
  88. package/src/qingflow_mcp/tools/task_context_tools.py +2986 -0
  89. package/src/qingflow_mcp/tools/task_tools.py +889 -0
  90. package/src/qingflow_mcp/tools/view_tools.py +335 -0
  91. package/src/qingflow_mcp/tools/workflow_tools.py +376 -0
  92. package/src/qingflow_mcp/tools/workspace_tools.py +266 -0
@@ -0,0 +1,369 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from dataclasses import asdict, dataclass
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+
9
+ try:
10
+ import keyring
11
+ except ImportError:
12
+ keyring = None
13
+
14
+ from .config import get_profiles_path, normalize_base_url
15
+ from .json_types import JSONObject, KeyringBackend
16
+
17
+
18
+ KEYRING_SERVICE_NAME = "qingflow-mcp"
19
+ _UNSET = object()
20
+
21
+
22
+ def _utcnow() -> str:
23
+ return datetime.now(timezone.utc).isoformat()
24
+
25
+
26
+ @dataclass(slots=True)
27
+ class SessionProfile:
28
+ profile: str
29
+ base_url: str
30
+ qf_version: str | None
31
+ qf_version_source: str | None
32
+ token: str | None
33
+ login_token: str | None
34
+ credential: str | None
35
+ uid: int
36
+ email: str | None
37
+ nick_name: str | None
38
+ selected_ws_id: int | None
39
+ selected_ws_name: str | None
40
+ persisted: bool
41
+ created_at: str
42
+ updated_at: str
43
+
44
+ @classmethod
45
+ def from_dict(cls, value: JSONObject) -> "SessionProfile":
46
+ return cls(
47
+ profile=value["profile"],
48
+ base_url=value["base_url"],
49
+ qf_version=value.get("qf_version"),
50
+ qf_version_source=value.get("qf_version_source"),
51
+ token=value.get("token"),
52
+ login_token=value.get("login_token"),
53
+ credential=value.get("credential"),
54
+ uid=value["uid"],
55
+ email=value.get("email"),
56
+ nick_name=value.get("nick_name"),
57
+ selected_ws_id=value.get("selected_ws_id"),
58
+ selected_ws_name=value.get("selected_ws_name"),
59
+ persisted=bool(value.get("persisted", False)),
60
+ created_at=value.get("created_at", _utcnow()),
61
+ updated_at=value.get("updated_at", _utcnow()),
62
+ )
63
+
64
+
65
+ @dataclass(slots=True)
66
+ class BackendSession:
67
+ token: str
68
+ login_token: str | None
69
+ credential: str | None
70
+ profile: str
71
+ base_url: str
72
+ qf_version: str | None
73
+ qf_version_source: str | None = None
74
+
75
+
76
+ class SessionStore:
77
+ def __init__(self, base_dir: Path | None = None, keyring_backend: KeyringBackend | None = None) -> None:
78
+ profiles_path = get_profiles_path() if base_dir is None else Path(base_dir) / "profiles.json"
79
+ self._profiles_path = profiles_path
80
+ self._profiles_path.parent.mkdir(parents=True, exist_ok=True)
81
+ self._secrets_path = self._profiles_path.parent / "secrets.json"
82
+ self._keyring = keyring_backend if keyring_backend is not None else keyring
83
+ self._memory_sessions: dict[str, BackendSession] = {}
84
+ self._logged_out_profiles: set[str] = set()
85
+
86
+ def save_session(
87
+ self,
88
+ *,
89
+ profile: str,
90
+ base_url: str,
91
+ qf_version: str | None,
92
+ qf_version_source: str | None = None,
93
+ token: str,
94
+ login_token: str | None,
95
+ credential: str | None = None,
96
+ uid: int,
97
+ email: str | None,
98
+ nick_name: str | None,
99
+ persist: bool,
100
+ ) -> SessionProfile:
101
+ now = _utcnow()
102
+ previous = self.get_profile(profile)
103
+ persisted = False
104
+ if persist:
105
+ persisted = self._set_secret(self._token_key(profile), token)
106
+ if login_token:
107
+ self._set_secret(self._login_token_key(profile), login_token)
108
+ else:
109
+ self._delete_secret(self._login_token_key(profile))
110
+ if credential:
111
+ self._set_secret(self._credential_key(profile), credential)
112
+ else:
113
+ self._delete_secret(self._credential_key(profile))
114
+ else:
115
+ self._delete_secret(self._token_key(profile))
116
+ self._delete_secret(self._login_token_key(profile))
117
+ self._delete_secret(self._credential_key(profile))
118
+ session_profile = SessionProfile(
119
+ profile=profile,
120
+ base_url=normalize_base_url(base_url) or base_url,
121
+ qf_version=(str(qf_version).strip() or None) if qf_version is not None else None,
122
+ qf_version_source=(str(qf_version_source).strip() or None) if qf_version_source is not None else None,
123
+ token=str(token).strip() or None,
124
+ login_token=(str(login_token).strip() or None) if login_token is not None else None,
125
+ credential=(str(credential).strip() or None) if credential is not None else None,
126
+ uid=uid,
127
+ email=email,
128
+ nick_name=nick_name,
129
+ selected_ws_id=None,
130
+ selected_ws_name=None,
131
+ persisted=persisted,
132
+ created_at=previous.created_at if previous else now,
133
+ updated_at=now,
134
+ )
135
+ self._memory_sessions[profile] = BackendSession(
136
+ token=session_profile.token or token,
137
+ login_token=session_profile.login_token,
138
+ credential=session_profile.credential,
139
+ profile=profile,
140
+ base_url=session_profile.base_url,
141
+ qf_version=session_profile.qf_version,
142
+ qf_version_source=session_profile.qf_version_source,
143
+ )
144
+ self._logged_out_profiles.discard(profile)
145
+ self._upsert_profile(session_profile)
146
+ return session_profile
147
+
148
+ def get_profile(self, profile: str) -> SessionProfile | None:
149
+ payload = self._load_profiles()
150
+ raw_profile = payload.get("profiles", {}).get(profile)
151
+ if not raw_profile:
152
+ return None
153
+ return SessionProfile.from_dict(raw_profile)
154
+
155
+ def get_backend_session(self, profile: str) -> BackendSession | None:
156
+ if profile in self._logged_out_profiles:
157
+ return None
158
+ memory_session = self._memory_sessions.get(profile)
159
+ session_profile = self.get_profile(profile)
160
+ if memory_session:
161
+ if session_profile is not None:
162
+ memory_session.base_url = session_profile.base_url
163
+ memory_session.qf_version = session_profile.qf_version
164
+ memory_session.qf_version_source = session_profile.qf_version_source
165
+ return memory_session
166
+ if not session_profile:
167
+ return None
168
+ token = self._get_secret(self._token_key(profile)) if session_profile.persisted else None
169
+ if not token:
170
+ token = session_profile.token
171
+ if not token:
172
+ return None
173
+ login_token = self._get_secret(self._login_token_key(profile)) if session_profile.persisted else None
174
+ credential = self._get_secret(self._credential_key(profile)) if session_profile.persisted else None
175
+ backend_session = BackendSession(
176
+ token=token,
177
+ login_token=login_token or session_profile.login_token,
178
+ credential=credential or session_profile.credential,
179
+ profile=profile,
180
+ base_url=session_profile.base_url,
181
+ qf_version=session_profile.qf_version,
182
+ qf_version_source=session_profile.qf_version_source,
183
+ )
184
+ self._memory_sessions[profile] = backend_session
185
+ return backend_session
186
+
187
+ def select_workspace(self, profile: str, ws_id: int, ws_name: str | None) -> SessionProfile:
188
+ session_profile = self.get_profile(profile)
189
+ if session_profile is None:
190
+ raise KeyError(profile)
191
+ session_profile.selected_ws_id = ws_id
192
+ session_profile.selected_ws_name = ws_name
193
+ session_profile.updated_at = _utcnow()
194
+ self._upsert_profile(session_profile)
195
+ return session_profile
196
+
197
+ def update_route(self, profile: str, *, qf_version: str | None, qf_version_source: str | None) -> SessionProfile:
198
+ session_profile = self.get_profile(profile)
199
+ if session_profile is None:
200
+ raise KeyError(profile)
201
+ session_profile.qf_version = (str(qf_version).strip() or None) if qf_version is not None else None
202
+ session_profile.qf_version_source = (str(qf_version_source).strip() or None) if qf_version_source is not None else None
203
+ session_profile.updated_at = _utcnow()
204
+ self._upsert_profile(session_profile)
205
+ backend_session = self._memory_sessions.get(profile)
206
+ if backend_session is not None:
207
+ backend_session.qf_version = session_profile.qf_version
208
+ backend_session.qf_version_source = session_profile.qf_version_source
209
+ return session_profile
210
+
211
+ def update_profile_metadata(
212
+ self,
213
+ profile: str,
214
+ *,
215
+ uid: int | object = _UNSET,
216
+ email: str | None | object = _UNSET,
217
+ nick_name: str | None | object = _UNSET,
218
+ selected_ws_id: int | None | object = _UNSET,
219
+ selected_ws_name: str | None | object = _UNSET,
220
+ ) -> SessionProfile:
221
+ session_profile = self.get_profile(profile)
222
+ if session_profile is None:
223
+ raise KeyError(profile)
224
+ if uid is not _UNSET:
225
+ session_profile.uid = int(uid)
226
+ if email is not _UNSET:
227
+ session_profile.email = email if isinstance(email, str) or email is None else session_profile.email
228
+ if nick_name is not _UNSET:
229
+ session_profile.nick_name = nick_name if isinstance(nick_name, str) or nick_name is None else session_profile.nick_name
230
+ if selected_ws_id is not _UNSET:
231
+ session_profile.selected_ws_id = (
232
+ int(selected_ws_id)
233
+ if isinstance(selected_ws_id, int) and not isinstance(selected_ws_id, bool)
234
+ else None
235
+ )
236
+ if selected_ws_name is not _UNSET:
237
+ session_profile.selected_ws_name = (
238
+ selected_ws_name
239
+ if isinstance(selected_ws_name, str) or selected_ws_name is None
240
+ else session_profile.selected_ws_name
241
+ )
242
+ session_profile.updated_at = _utcnow()
243
+ self._upsert_profile(session_profile)
244
+ return session_profile
245
+
246
+ def logout(self, profile: str, forget_persisted: bool = False) -> None:
247
+ self._memory_sessions.pop(profile, None)
248
+ if forget_persisted:
249
+ self.invalidate(profile)
250
+ return
251
+ if self.get_profile(profile):
252
+ self._logged_out_profiles.add(profile)
253
+
254
+ def invalidate(self, profile: str) -> None:
255
+ self._memory_sessions.pop(profile, None)
256
+ self._logged_out_profiles.discard(profile)
257
+ self._delete_secret(self._token_key(profile))
258
+ self._delete_secret(self._login_token_key(profile))
259
+ self._delete_secret(self._credential_key(profile))
260
+ payload = self._load_profiles()
261
+ profiles = payload.get("profiles", {})
262
+ if profile in profiles:
263
+ profiles.pop(profile)
264
+ self._save_profiles(payload)
265
+
266
+ def has_profile(self, profile: str) -> bool:
267
+ return self.get_profile(profile) is not None
268
+
269
+ def _token_key(self, profile: str) -> str:
270
+ return f"{profile}:token"
271
+
272
+ def _login_token_key(self, profile: str) -> str:
273
+ return f"{profile}:login-token"
274
+
275
+ def _credential_key(self, profile: str) -> str:
276
+ return f"{profile}:credential"
277
+
278
+ def _upsert_profile(self, profile: SessionProfile) -> None:
279
+ payload = self._load_profiles()
280
+ payload.setdefault("profiles", {})[profile.profile] = asdict(profile)
281
+ self._save_profiles(payload)
282
+
283
+ def _load_profiles(self) -> JSONObject:
284
+ if not self._profiles_path.exists():
285
+ return {"profiles": {}}
286
+ with self._profiles_path.open("r", encoding="utf-8") as handle:
287
+ return json.load(handle)
288
+
289
+ def _save_profiles(self, payload: JSONObject) -> None:
290
+ self._profiles_path.parent.mkdir(parents=True, exist_ok=True)
291
+ with self._profiles_path.open("w", encoding="utf-8") as handle:
292
+ json.dump(payload, handle, ensure_ascii=False, indent=2)
293
+
294
+ def _set_secret(self, key: str, value: str) -> bool:
295
+ if self._keyring is not None:
296
+ try:
297
+ self._keyring.set_password(KEYRING_SERVICE_NAME, key, value)
298
+ self._delete_file_secret(key)
299
+ return True
300
+ except Exception:
301
+ pass
302
+ return self._set_file_secret(key, value)
303
+
304
+ def _get_secret(self, key: str) -> str | None:
305
+ if self._keyring is not None:
306
+ try:
307
+ value = self._keyring.get_password(KEYRING_SERVICE_NAME, key)
308
+ except Exception:
309
+ value = None
310
+ if value:
311
+ return value
312
+ return self._get_file_secret(key)
313
+
314
+ def _delete_secret(self, key: str) -> None:
315
+ if self._keyring is not None:
316
+ try:
317
+ self._keyring.delete_password(KEYRING_SERVICE_NAME, key)
318
+ except Exception:
319
+ pass
320
+ self._delete_file_secret(key)
321
+
322
+ def _load_file_secrets(self) -> dict[str, str]:
323
+ if not self._secrets_path.exists():
324
+ return {}
325
+ try:
326
+ with self._secrets_path.open("r", encoding="utf-8") as handle:
327
+ payload = json.load(handle)
328
+ except (OSError, json.JSONDecodeError):
329
+ return {}
330
+ if not isinstance(payload, dict):
331
+ return {}
332
+ return {str(key): str(value) for key, value in payload.items() if isinstance(value, str)}
333
+
334
+ def _save_file_secrets(self, payload: dict[str, str]) -> bool:
335
+ self._secrets_path.parent.mkdir(parents=True, exist_ok=True)
336
+ try:
337
+ fd = os.open(self._secrets_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
338
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
339
+ json.dump(payload, handle, ensure_ascii=False, indent=2)
340
+ try:
341
+ os.chmod(self._secrets_path, 0o600)
342
+ except OSError:
343
+ pass
344
+ return True
345
+ except OSError:
346
+ return False
347
+
348
+ def _set_file_secret(self, key: str, value: str) -> bool:
349
+ payload = self._load_file_secrets()
350
+ payload[key] = value
351
+ return self._save_file_secrets(payload)
352
+
353
+ def _get_file_secret(self, key: str) -> str | None:
354
+ return self._load_file_secrets().get(key)
355
+
356
+ def _delete_file_secret(self, key: str) -> None:
357
+ payload = self._load_file_secrets()
358
+ if key not in payload:
359
+ return
360
+ payload.pop(key, None)
361
+ if payload:
362
+ self._save_file_secrets(payload)
363
+ return
364
+ try:
365
+ self._secrets_path.unlink()
366
+ except FileNotFoundError:
367
+ return
368
+ except OSError:
369
+ return
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ from .normalizer import normalize_solution_spec
4
+ from .spec_models import SolutionSpec
5
+
6
+ __all__ = ["SolutionSpec", "normalize_solution_spec"]
@@ -0,0 +1,181 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from copy import deepcopy
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ import tempfile
9
+ from typing import Any
10
+
11
+ from ..config import get_mcp_home
12
+ from .run_store import resolve_storage_path, utc_now
13
+
14
+
15
+ def get_build_assemblies_path() -> Path:
16
+ custom_home = os.getenv("QINGFLOW_MCP_BUILD_HOME")
17
+ if custom_home:
18
+ return Path(custom_home).expanduser()
19
+ return get_mcp_home() / "build-assemblies"
20
+
21
+
22
+ def default_manifest() -> dict[str, Any]:
23
+ return {
24
+ "solution_name": "",
25
+ "summary": None,
26
+ "business_context": {},
27
+ "package": {"enabled": True, "ordinal": 1},
28
+ "entities": [],
29
+ "roles": [],
30
+ "requirements": [],
31
+ "success_metrics": [],
32
+ "portal": {"enabled": False, "sections": []},
33
+ "navigation": {"enabled": False, "items": []},
34
+ "publish_policy": {"apps": True, "portal": True, "navigation": True},
35
+ "preferences": {
36
+ "multi_app": None,
37
+ "create_package": True,
38
+ "create_portal": False,
39
+ "create_navigation": False,
40
+ "naming_style": "title",
41
+ },
42
+ "assumptions": [],
43
+ "constraints": [],
44
+ "metadata": {},
45
+ }
46
+
47
+
48
+ def default_artifacts() -> dict[str, Any]:
49
+ return {
50
+ "package": {},
51
+ "roles": {},
52
+ "apps": {},
53
+ "views": {},
54
+ "charts": {},
55
+ "records": {},
56
+ "portal": {},
57
+ "navigation": {},
58
+ "field_maps": {},
59
+ }
60
+
61
+
62
+ def default_builder_state() -> dict[str, Any]:
63
+ return {
64
+ "generated_specs": {},
65
+ "failure_signatures": {},
66
+ "last_failure_signature": None,
67
+ "last_failure_stage": None,
68
+ "last_failure_count": 0,
69
+ }
70
+
71
+
72
+ @dataclass(slots=True)
73
+ class BuildAssemblyStore:
74
+ path: Path
75
+ data: dict[str, Any]
76
+
77
+ @classmethod
78
+ def open(cls, *, build_id: str, create: bool = True) -> "BuildAssemblyStore":
79
+ base_dir = get_build_assemblies_path()
80
+ try:
81
+ base_dir.mkdir(parents=True, exist_ok=True)
82
+ except PermissionError:
83
+ base_dir = Path(tempfile.gettempdir()) / "qingflow-mcp-build-assemblies"
84
+ base_dir.mkdir(parents=True, exist_ok=True)
85
+ path = resolve_storage_path(base_dir, key=build_id, id_field="build_id")
86
+ if path.exists():
87
+ data = json.loads(path.read_text(encoding="utf-8"))
88
+ stored_build_id = data.get("build_id")
89
+ if stored_build_id != build_id:
90
+ raise ValueError(f"existing build assembly at '{path}' belongs to '{stored_build_id}', not '{build_id}'")
91
+ elif not create:
92
+ raise FileNotFoundError(build_id)
93
+ else:
94
+ data = {
95
+ "build_id": build_id,
96
+ "status": "draft",
97
+ "manifest": default_manifest(),
98
+ "stage_specs": {
99
+ "app_flow": {},
100
+ "views": {},
101
+ "analytics_portal": {},
102
+ "navigation": {},
103
+ },
104
+ "artifacts": default_artifacts(),
105
+ "builder_state": default_builder_state(),
106
+ "stage_history": [],
107
+ "created_at": utc_now(),
108
+ "updated_at": utc_now(),
109
+ }
110
+ try:
111
+ path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
112
+ except PermissionError:
113
+ base_dir = Path(tempfile.gettempdir()) / "qingflow-mcp-build-assemblies"
114
+ base_dir.mkdir(parents=True, exist_ok=True)
115
+ path = resolve_storage_path(base_dir, key=build_id, id_field="build_id")
116
+ path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
117
+ return cls(path=path, data=data)
118
+
119
+ def get_manifest(self) -> dict[str, Any]:
120
+ return deepcopy(self.data.get("manifest", default_manifest()))
121
+
122
+ def set_manifest(self, manifest: dict[str, Any]) -> None:
123
+ self.data["manifest"] = deepcopy(manifest)
124
+ self._flush()
125
+
126
+ def get_artifacts(self) -> dict[str, Any]:
127
+ return deepcopy(self.data.get("artifacts", default_artifacts()))
128
+
129
+ def set_artifacts(self, artifacts: dict[str, Any]) -> None:
130
+ self.data["artifacts"] = deepcopy(artifacts)
131
+ self._flush()
132
+
133
+ def set_stage_spec(self, stage_name: str, spec: dict[str, Any]) -> None:
134
+ self.data.setdefault("stage_specs", {})
135
+ self.data["stage_specs"][stage_name] = deepcopy(spec)
136
+ self._flush()
137
+
138
+ def add_stage_history(self, entry: dict[str, Any]) -> None:
139
+ self.data.setdefault("stage_history", []).append(deepcopy(entry))
140
+ self._flush()
141
+
142
+ def mark_status(self, status: str) -> None:
143
+ self.data["status"] = status
144
+ self._flush()
145
+
146
+ def get_builder_state(self) -> dict[str, Any]:
147
+ return deepcopy(self.data.get("builder_state", default_builder_state()))
148
+
149
+ def set_builder_state(self, builder_state: dict[str, Any]) -> None:
150
+ self.data["builder_state"] = deepcopy(builder_state)
151
+ self._flush()
152
+
153
+ def get_builder_value(self, key: str, default: Any = None) -> Any:
154
+ return deepcopy(self.data.get("builder_state", {}).get(key, default))
155
+
156
+ def set_builder_value(self, key: str, value: Any) -> None:
157
+ self.data.setdefault("builder_state", default_builder_state())
158
+ self.data["builder_state"][key] = deepcopy(value)
159
+ self._flush()
160
+
161
+ def summary(self) -> dict[str, Any]:
162
+ return {
163
+ "build_id": self.data["build_id"],
164
+ "status": self.data["status"],
165
+ "manifest": deepcopy(self.data.get("manifest", {})),
166
+ "artifacts": deepcopy(self.data.get("artifacts", {})),
167
+ "builder_state": deepcopy(self.data.get("builder_state", {})),
168
+ "stage_specs": deepcopy(self.data.get("stage_specs", {})),
169
+ "stage_history": deepcopy(self.data.get("stage_history", [])),
170
+ "build_path": str(self.path),
171
+ }
172
+
173
+ def _flush(self) -> None:
174
+ self.data["updated_at"] = utc_now()
175
+ try:
176
+ self.path.write_text(json.dumps(self.data, ensure_ascii=False, indent=2), encoding="utf-8")
177
+ except PermissionError:
178
+ fallback_dir = Path(tempfile.gettempdir()) / "qingflow-mcp-build-assemblies"
179
+ fallback_dir.mkdir(parents=True, exist_ok=True)
180
+ self.path = resolve_storage_path(fallback_dir, key=str(self.data.get("build_id") or "build"), id_field="build_id")
181
+ self.path.write_text(json.dumps(self.data, ensure_ascii=False, indent=2), encoding="utf-8")