@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,60 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from ..spec_models import PortalSourceType, SolutionSpec
6
+ from .form_compiler import default_member_auth
7
+ from .icon_utils import encode_workspace_icon
8
+
9
+
10
+ def compile_portal(spec: SolutionSpec) -> dict[str, Any] | None:
11
+ if not spec.preferences.create_portal or not spec.portal.enabled or not spec.portal.sections:
12
+ return None
13
+ components: list[dict[str, Any]] = []
14
+ for ordinal, section in enumerate(spec.portal.sections, start=1):
15
+ component: dict[str, Any] = {
16
+ "sectionId": section.section_id,
17
+ "title": section.title,
18
+ "sourceType": section.source_type.value,
19
+ "ordinal": ordinal,
20
+ **section.config,
21
+ }
22
+ if section.source_type == PortalSourceType.chart:
23
+ component["chartRef"] = {"entity_id": section.entity_id, "chart_id": section.chart_id}
24
+ elif section.source_type == PortalSourceType.view:
25
+ component["viewRef"] = {"entity_id": section.entity_id, "view_id": section.view_id}
26
+ elif section.source_type == PortalSourceType.text:
27
+ component["text"] = section.text or ""
28
+ elif section.source_type == PortalSourceType.link:
29
+ component["url"] = section.url or ""
30
+ components.append(component)
31
+ return {
32
+ "create_payload": {
33
+ "dashName": spec.portal.name or f"{spec.solution_name} 首页",
34
+ "dashIcon": encode_workspace_icon(
35
+ icon=spec.portal.icon,
36
+ color=spec.portal.color,
37
+ title=spec.portal.name or f"{spec.solution_name} 首页",
38
+ fallback_icon_name="view-grid",
39
+ ),
40
+ "auth": default_member_auth(),
41
+ "hideCopyright": False,
42
+ "tags": [{"tagId": "__PACKAGE_TAG_ID__", "ordinal": 1}],
43
+ "dashGlobalConfig": {"layout": "default"},
44
+ },
45
+ "update_payload": {
46
+ "dashName": spec.portal.name or f"{spec.solution_name} 首页",
47
+ "dashIcon": encode_workspace_icon(
48
+ icon=spec.portal.icon,
49
+ color=spec.portal.color,
50
+ title=spec.portal.name or f"{spec.solution_name} 首页",
51
+ fallback_icon_name="view-grid",
52
+ ),
53
+ "auth": default_member_auth(),
54
+ "hideCopyright": False,
55
+ "tags": [{"tagId": "__PACKAGE_TAG_ID__", "ordinal": 1}],
56
+ "dashGlobalConfig": {"layout": "default"},
57
+ "components": components,
58
+ **spec.portal.config,
59
+ },
60
+ }
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from ..spec_models import EntitySpec, ViewType
6
+ from .form_compiler import default_member_auth
7
+
8
+
9
+ VIEW_TYPE_MAP = {
10
+ ViewType.table: "tableView",
11
+ ViewType.card: "cardView",
12
+ ViewType.board: "boardView",
13
+ ViewType.gantt: "ganttView",
14
+ ViewType.hierarchy: "hierarchyView",
15
+ }
16
+
17
+
18
+ def compile_views(entity: EntitySpec) -> list[dict[str, Any]]:
19
+ views: list[dict[str, Any]] = []
20
+ for ordinal, view in enumerate(entity.views, start=1):
21
+ views.append(
22
+ {
23
+ "view_id": view.view_id,
24
+ "name": view.name,
25
+ "type": view.type.value,
26
+ "create_payload": {
27
+ "appKey": "__APP_KEY__",
28
+ "viewgraphName": view.name,
29
+ "viewgraphType": VIEW_TYPE_MAP[view.type],
30
+ "auth": default_member_auth(),
31
+ "beingPinNavigate": True,
32
+ "viewgraphQueIds": list(view.field_ids),
33
+ "viewgraphQuestions": [],
34
+ "ordinal": ordinal,
35
+ "beingShowTitleQue": view.type in (ViewType.card, ViewType.board, ViewType.gantt, ViewType.hierarchy),
36
+ "beingShowCover": False,
37
+ "viewgraphLimitType": 1,
38
+ "viewgraphLimit": view.filters,
39
+ "sortType": "defaultSort",
40
+ "viewgraphSorts": view.sort,
41
+ **view.config,
42
+ },
43
+ "column_widths": view.column_widths,
44
+ "member_config": view.member_config,
45
+ "apply_config": view.apply_config,
46
+ "group_by_field_id": view.group_by_field_id,
47
+ "config": view.config,
48
+ "being_default": view.being_default,
49
+ }
50
+ )
51
+ return views
@@ -0,0 +1,173 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from ..spec_models import EntitySpec, WorkflowNodeType
6
+
7
+
8
+ WORKFLOW_TYPE_MAP = {
9
+ WorkflowNodeType.start: {"type": 0, "dealType": 3},
10
+ WorkflowNodeType.branch: {"type": 1, "dealType": None},
11
+ WorkflowNodeType.audit: {"type": 0, "dealType": 0},
12
+ WorkflowNodeType.fill: {"type": 0, "dealType": 1},
13
+ WorkflowNodeType.copy: {"type": 0, "dealType": 2},
14
+ WorkflowNodeType.webhook: {"type": 3, "dealType": 10},
15
+ WorkflowNodeType.condition: {"type": 2, "dealType": None},
16
+ }
17
+
18
+
19
+ def _default_audit_user_infos() -> dict[str, Any]:
20
+ return {
21
+ "member": [],
22
+ "depart": [],
23
+ "role": [],
24
+ "dynamic": [],
25
+ "includeSubDeparts": None,
26
+ "externalMemberList": [],
27
+ "externalDepartList": [],
28
+ "role_refs": [],
29
+ }
30
+
31
+
32
+ def compile_workflow(entity: EntitySpec) -> dict[str, Any] | None:
33
+ workflow = entity.workflow
34
+ if workflow is None or not workflow.enabled:
35
+ return None
36
+ actions: list[dict[str, Any]] = []
37
+ seen_node_ids: set[str] = set()
38
+ created_extra_branch_lanes: set[str] = set()
39
+ start_node_ids = {
40
+ node.node_id
41
+ for node in workflow.nodes
42
+ if node.node_type == WorkflowNodeType.start
43
+ }
44
+ for node in workflow.nodes:
45
+ if node.node_type == WorkflowNodeType.start:
46
+ seen_node_ids.add(node.node_id)
47
+ continue
48
+ if node.parent_node_id and node.parent_node_id not in seen_node_ids:
49
+ raise ValueError(f"workflow node '{node.node_id}' must appear after parent node '{node.parent_node_id}'")
50
+ if node.branch_parent_id and node.branch_parent_id not in seen_node_ids:
51
+ raise ValueError(f"workflow node '{node.node_id}' must appear after branch node '{node.branch_parent_id}'")
52
+ branch_index = _branch_index(node)
53
+ branch_lane_ref = _branch_lane_ref(node.branch_parent_id, branch_index) if node.branch_parent_id else None
54
+ if branch_lane_ref and branch_index > 2 and branch_lane_ref not in created_extra_branch_lanes:
55
+ actions.append(
56
+ {
57
+ "action": "create_sub_branch",
58
+ "node_id": branch_lane_ref,
59
+ "payload": {
60
+ "editVersionNo": 1,
61
+ "auditNodeRef": node.branch_parent_id,
62
+ },
63
+ }
64
+ )
65
+ created_extra_branch_lanes.add(branch_lane_ref)
66
+ lane_only = bool((node.config or {}).get("__lane_only__")) and node.node_type == WorkflowNodeType.condition and branch_lane_ref
67
+ if lane_only:
68
+ lane_payload = {key: value for key, value in node.config.items() if key != "__lane_only__"}
69
+ actions.append(
70
+ {
71
+ "action": "update_node",
72
+ "node_id": branch_lane_ref,
73
+ "node_name": node.name,
74
+ "node_type": node.node_type.value,
75
+ "payload": {
76
+ "editVersionNo": 1,
77
+ "auditNodeName": node.name,
78
+ "type": WORKFLOW_TYPE_MAP[node.node_type]["type"],
79
+ "dealType": WORKFLOW_TYPE_MAP[node.node_type]["dealType"],
80
+ **lane_payload,
81
+ },
82
+ }
83
+ )
84
+ seen_node_ids.add(node.node_id)
85
+ continue
86
+ actions.append(
87
+ {
88
+ "action": "add_node",
89
+ "node_id": node.node_id,
90
+ "node_name": node.name,
91
+ "node_type": node.node_type.value,
92
+ "payload": {
93
+ "editVersionNo": 1,
94
+ "auditNodeName": node.name,
95
+ "type": WORKFLOW_TYPE_MAP[node.node_type]["type"],
96
+ "dealType": WORKFLOW_TYPE_MAP[node.node_type]["dealType"],
97
+ "prevNodeRef": _prev_node_ref(node, branch_lane_ref, start_node_ids),
98
+ "auditUserInfos": _build_audit_user_infos(node)
99
+ if node.node_type in {WorkflowNodeType.audit, WorkflowNodeType.fill, WorkflowNodeType.copy}
100
+ else None,
101
+ **node.config,
102
+ },
103
+ }
104
+ )
105
+ seen_node_ids.add(node.node_id)
106
+ return {
107
+ "global_settings": {
108
+ "editVersionNo": 1,
109
+ **workflow.global_settings,
110
+ },
111
+ "actions": actions,
112
+ }
113
+
114
+
115
+ def _build_audit_user_infos(node) -> dict[str, Any]:
116
+ audit_user_infos = _default_audit_user_infos()
117
+ assignees = node.assignees or {}
118
+ member_uids = assignees.get("member_uids") or []
119
+ if member_uids:
120
+ audit_user_infos["member"] = [
121
+ {"uid": uid, "beingFrontendConfig": True}
122
+ for uid in member_uids
123
+ if isinstance(uid, int) and uid > 0
124
+ ]
125
+ role_refs = assignees.get("role_refs") or []
126
+ if role_refs:
127
+ audit_user_infos["role_refs"] = [role_ref for role_ref in role_refs if role_ref]
128
+ role_entries = assignees.get("role_entries") or []
129
+ if role_entries:
130
+ audit_user_infos["role"] = [
131
+ {
132
+ "roleId": int(entry.get("roleId") or entry.get("role_id")),
133
+ "roleName": entry.get("roleName") or entry.get("role_name") or str(entry.get("roleId") or entry.get("role_id")),
134
+ "roleIcon": entry.get("roleIcon") or entry.get("role_icon") or "ex-user-outlined",
135
+ "beingFrontendConfig": True,
136
+ }
137
+ for entry in role_entries
138
+ if isinstance(entry, dict) and isinstance(entry.get("roleId") or entry.get("role_id"), int) and int(entry.get("roleId") or entry.get("role_id")) > 0
139
+ ]
140
+ include_sub_departs = assignees.get("include_sub_departs")
141
+ if include_sub_departs is not None:
142
+ audit_user_infos["includeSubDeparts"] = bool(include_sub_departs)
143
+ return audit_user_infos
144
+
145
+
146
+ def _prev_node_ref(node, branch_lane_ref: str | None, start_node_ids: set[str]) -> str:
147
+ if branch_lane_ref:
148
+ if node.parent_node_id and node.parent_node_id != node.branch_parent_id:
149
+ return node.parent_node_id
150
+ return branch_lane_ref
151
+ if node.parent_node_id in start_node_ids:
152
+ return "__applicant__"
153
+ return node.parent_node_id or "__applicant__"
154
+
155
+
156
+ def _branch_index(node) -> int:
157
+ config = node.config or {}
158
+ raw_value = getattr(node, "branch_index", None)
159
+ if raw_value is None:
160
+ raw_value = config.get("branch_index", config.get("branchIndex", config.get("lane_index", config.get("laneIndex", 1))))
161
+ try:
162
+ branch_index = int(raw_value)
163
+ except (TypeError, ValueError) as exc:
164
+ raise ValueError(f"workflow node '{node.node_id}' has invalid branch index '{raw_value}'") from exc
165
+ if branch_index <= 0:
166
+ raise ValueError(f"workflow node '{node.node_id}' branch index must be positive")
167
+ return branch_index
168
+
169
+
170
+ def _branch_lane_ref(branch_parent_id: str | None, branch_index: int) -> str:
171
+ if not branch_parent_id:
172
+ raise ValueError("branch_parent_id is required for branch lane references")
173
+ return f"__branch_lane__{branch_parent_id}__{branch_index}"
@@ -0,0 +1,222 @@
1
+ from __future__ import annotations
2
+
3
+ from copy import deepcopy
4
+ from enum import Enum
5
+ from typing import Any
6
+
7
+ from .compiler import compile_solution
8
+ from .normalizer import normalize_solution_spec
9
+ from .spec_models import SolutionSpec
10
+
11
+
12
+ class DesignStage(str, Enum):
13
+ discover = "discover"
14
+ design = "design"
15
+ experience = "experience"
16
+ finalize = "finalize"
17
+
18
+
19
+ STAGE_ORDER = [DesignStage.discover, DesignStage.design, DesignStage.experience]
20
+
21
+ LIST_MERGE_KEYS = {
22
+ "entities": "entity_id",
23
+ "roles": "role_id",
24
+ "requirements": "requirement_id",
25
+ "success_metrics": "metric_id",
26
+ "fields": "field_id",
27
+ "subfields": "field_id",
28
+ "relations": "relation_id",
29
+ "lifecycle_stages": "stage_id",
30
+ "views": "view_id",
31
+ "charts": "chart_id",
32
+ "nodes": "node_id",
33
+ "sections": "section_id",
34
+ "items": "item_id",
35
+ "children": "item_id",
36
+ "entity_scopes": "entity_id",
37
+ }
38
+
39
+
40
+ def merge_design_payload(base: Any, patch: Any, *, key_hint: str | None = None) -> Any:
41
+ if patch is None:
42
+ return deepcopy(base)
43
+ if base is None:
44
+ return deepcopy(patch)
45
+ if isinstance(base, dict) and isinstance(patch, dict):
46
+ merged = deepcopy(base)
47
+ for key, value in patch.items():
48
+ merged[key] = merge_design_payload(merged.get(key), value, key_hint=key)
49
+ return merged
50
+ if isinstance(base, list) and isinstance(patch, list):
51
+ merge_key = LIST_MERGE_KEYS.get(key_hint or "")
52
+ if merge_key and _can_merge_named_list(base, patch, merge_key):
53
+ return _merge_named_list(base, patch, merge_key)
54
+ return deepcopy(patch)
55
+ return deepcopy(patch)
56
+
57
+
58
+ def evaluate_design_session(stage_payloads: dict[str, dict[str, Any]]) -> dict[str, Any]:
59
+ discover_spec = merge_design_payload({}, stage_payloads.get(DesignStage.discover.value, {}))
60
+ design_spec = merge_design_payload(discover_spec, stage_payloads.get(DesignStage.design.value, {}))
61
+ experience_spec = merge_design_payload(design_spec, stage_payloads.get(DesignStage.experience.value, {}))
62
+
63
+ discover_missing = _validate_discover_stage(discover_spec)
64
+ design_missing = _blocked("discover", discover_missing) if discover_missing else _validate_design_stage(design_spec)
65
+ experience_missing = _blocked("design", design_missing) if design_missing else _validate_experience_stage(experience_spec)
66
+
67
+ stage_results = {
68
+ DesignStage.discover.value: _stage_result(discover_missing),
69
+ DesignStage.design.value: _stage_result(design_missing, blocked=bool(discover_missing)),
70
+ DesignStage.experience.value: _stage_result(experience_missing, blocked=bool(design_missing)),
71
+ }
72
+ current_stage = _current_stage(discover_missing, design_missing, experience_missing)
73
+ status = "ready" if current_stage == DesignStage.finalize.value else "active"
74
+ return {
75
+ "status": status,
76
+ "current_stage": current_stage,
77
+ "next_stage": None if current_stage == DesignStage.finalize.value else current_stage,
78
+ "stage_results": stage_results,
79
+ "merged_design_spec": experience_spec,
80
+ }
81
+
82
+
83
+ def finalize_design_session(stage_payloads: dict[str, dict[str, Any]]) -> dict[str, Any]:
84
+ evaluation = evaluate_design_session(stage_payloads)
85
+ if evaluation["current_stage"] != DesignStage.finalize.value:
86
+ raise ValueError("design session is not ready to finalize")
87
+ parsed = SolutionSpec.model_validate(evaluation["merged_design_spec"])
88
+ normalized = normalize_solution_spec(parsed)
89
+ compiled = compile_solution(normalized)
90
+ return {
91
+ **evaluation,
92
+ "normalized_solution_spec": normalized.model_dump(mode="json"),
93
+ "execution_plan": compiled.execution_plan.as_dict(),
94
+ }
95
+
96
+
97
+ def _stage_result(missing_requirements: list[str], *, blocked: bool = False) -> dict[str, Any]:
98
+ if blocked and missing_requirements:
99
+ status = "blocked"
100
+ elif missing_requirements:
101
+ status = "pending"
102
+ else:
103
+ status = "completed"
104
+ return {
105
+ "status": status,
106
+ "missing_requirements": missing_requirements,
107
+ }
108
+
109
+
110
+ def _current_stage(discover_missing: list[str], design_missing: list[str], experience_missing: list[str]) -> str:
111
+ if discover_missing:
112
+ return DesignStage.discover.value
113
+ if design_missing:
114
+ return DesignStage.design.value
115
+ if experience_missing:
116
+ return DesignStage.experience.value
117
+ return DesignStage.finalize.value
118
+
119
+
120
+ def _blocked(previous_stage: str, previous_missing: list[str]) -> list[str]:
121
+ return [f"{previous_stage} stage is incomplete"] if previous_missing else []
122
+
123
+
124
+ def _validate_discover_stage(spec: dict[str, Any]) -> list[str]:
125
+ missing: list[str] = []
126
+ solution_name = spec.get("solution_name")
127
+ if not isinstance(solution_name, str) or not solution_name.strip():
128
+ missing.append("solution_name is required")
129
+
130
+ entities = spec.get("entities")
131
+ if not isinstance(entities, list) or not entities:
132
+ missing.append("entities must be a non-empty list")
133
+ return missing
134
+
135
+ seen_entity_ids: set[str] = set()
136
+ for entity in entities:
137
+ entity_id = entity.get("entity_id")
138
+ display_name = entity.get("display_name")
139
+ kind = entity.get("kind")
140
+ if not entity_id:
141
+ missing.append("each entity must declare entity_id")
142
+ continue
143
+ if entity_id in seen_entity_ids:
144
+ missing.append(f"entity '{entity_id}' is duplicated")
145
+ continue
146
+ seen_entity_ids.add(entity_id)
147
+ if not display_name:
148
+ missing.append(f"entity '{entity_id}' must declare display_name")
149
+ if not kind:
150
+ missing.append(f"entity '{entity_id}' must declare kind")
151
+ fields = entity.get("fields")
152
+ if not isinstance(fields, list) or not fields:
153
+ missing.append(f"entity '{entity_id}' must declare fields")
154
+ continue
155
+ field_ids = {field.get("field_id") for field in fields if isinstance(field, dict)}
156
+ if kind in {"master", "transaction"}:
157
+ title_field_id = entity.get("title_field_id")
158
+ if not title_field_id:
159
+ missing.append(f"entity '{entity_id}' must explicitly declare title_field_id")
160
+ elif title_field_id not in field_ids:
161
+ missing.append(f"entity '{entity_id}' title_field_id '{title_field_id}' is missing from fields")
162
+ if entity.get("workflow") or entity.get("lifecycle_stages"):
163
+ status_field_id = entity.get("status_field_id")
164
+ if not status_field_id:
165
+ missing.append(f"entity '{entity_id}' must explicitly declare status_field_id")
166
+ elif status_field_id not in field_ids:
167
+ missing.append(f"entity '{entity_id}' status_field_id '{status_field_id}' is missing from fields")
168
+ return missing
169
+
170
+
171
+ def _validate_design_stage(spec: dict[str, Any]) -> list[str]:
172
+ missing: list[str] = []
173
+ for entity in spec.get("entities", []):
174
+ entity_id = entity.get("entity_id", "<unknown>")
175
+ if "form_layout" not in entity:
176
+ missing.append(f"entity '{entity_id}' must explicitly declare form_layout")
177
+ if "workflow" not in entity:
178
+ missing.append(f"entity '{entity_id}' must explicitly declare workflow")
179
+ if "views" not in entity:
180
+ missing.append(f"entity '{entity_id}' must explicitly declare views")
181
+ if "charts" not in entity:
182
+ missing.append(f"entity '{entity_id}' must explicitly declare charts")
183
+ if "sample_records" not in entity:
184
+ missing.append(f"entity '{entity_id}' must explicitly declare sample_records")
185
+ return missing
186
+
187
+
188
+ def _validate_experience_stage(spec: dict[str, Any]) -> list[str]:
189
+ missing: list[str] = []
190
+ if "portal" not in spec:
191
+ missing.append("portal must be explicitly declared")
192
+ else:
193
+ portal = spec.get("portal") or {}
194
+ if portal.get("enabled", True) and not portal.get("sections"):
195
+ missing.append("portal.sections must be provided when portal.enabled is true")
196
+ if "navigation" not in spec:
197
+ missing.append("navigation must be explicitly declared")
198
+ else:
199
+ navigation = spec.get("navigation") or {}
200
+ if navigation.get("enabled", True) and not navigation.get("items"):
201
+ missing.append("navigation.items must be provided when navigation.enabled is true")
202
+ return missing
203
+
204
+
205
+ def _can_merge_named_list(base: list[Any], patch: list[Any], merge_key: str) -> bool:
206
+ items = [*base, *patch]
207
+ if not items:
208
+ return False
209
+ return all(isinstance(item, dict) and merge_key in item for item in items)
210
+
211
+
212
+ def _merge_named_list(base: list[dict[str, Any]], patch: list[dict[str, Any]], merge_key: str) -> list[dict[str, Any]]:
213
+ merged = [deepcopy(item) for item in base]
214
+ index = {item[merge_key]: position for position, item in enumerate(merged)}
215
+ for item in patch:
216
+ item_key = item[merge_key]
217
+ if item_key in index:
218
+ merged[index[item_key]] = merge_design_payload(merged[index[item_key]], item)
219
+ else:
220
+ index[item_key] = len(merged)
221
+ merged.append(deepcopy(item))
222
+ return merged
@@ -0,0 +1,100 @@
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
+ from typing import Any
9
+
10
+ from ..config import get_mcp_home
11
+ from .design_session import DesignStage
12
+ from .run_store import resolve_storage_path, utc_now
13
+
14
+
15
+ def get_design_sessions_path() -> Path:
16
+ custom_home = os.getenv("QINGFLOW_MCP_DESIGN_HOME")
17
+ if custom_home:
18
+ return Path(custom_home).expanduser()
19
+ return get_mcp_home() / "design-sessions"
20
+
21
+
22
+ @dataclass(slots=True)
23
+ class DesignSessionStore:
24
+ path: Path
25
+ data: dict[str, Any]
26
+
27
+ @classmethod
28
+ def open(cls, *, session_id: str, metadata: dict[str, Any] | None = None) -> "DesignSessionStore":
29
+ base_dir = get_design_sessions_path()
30
+ base_dir.mkdir(parents=True, exist_ok=True)
31
+ path = resolve_storage_path(base_dir, key=session_id, id_field="session_id")
32
+ if path.exists():
33
+ data = json.loads(path.read_text(encoding="utf-8"))
34
+ stored_session_id = data.get("session_id")
35
+ if stored_session_id != session_id:
36
+ raise ValueError(f"existing design session at '{path}' belongs to '{stored_session_id}', not '{session_id}'")
37
+ else:
38
+ data = {
39
+ "session_id": session_id,
40
+ "status": "active",
41
+ "current_stage": DesignStage.discover.value,
42
+ "metadata": metadata or {},
43
+ "stage_payloads": {
44
+ DesignStage.discover.value: {},
45
+ DesignStage.design.value: {},
46
+ DesignStage.experience.value: {},
47
+ },
48
+ "stage_results": {},
49
+ "merged_design_spec": {},
50
+ "normalized_solution_spec": None,
51
+ "execution_plan": None,
52
+ "created_at": utc_now(),
53
+ "updated_at": utc_now(),
54
+ }
55
+ path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
56
+ return cls(path=path, data=data)
57
+
58
+ def set_metadata(self, metadata: dict[str, Any]) -> None:
59
+ if metadata:
60
+ self.data["metadata"] = deepcopy(metadata)
61
+ self._flush()
62
+
63
+ def set_stage_payload(self, stage: str, payload: dict[str, Any]) -> None:
64
+ self.data.setdefault("stage_payloads", {})
65
+ self.data["stage_payloads"][stage] = deepcopy(payload)
66
+ self._flush()
67
+
68
+ def get_stage_payload(self, stage: str) -> dict[str, Any]:
69
+ return deepcopy(self.data.get("stage_payloads", {}).get(stage, {}))
70
+
71
+ def update_progress(self, *, status: str, current_stage: str, stage_results: dict[str, Any], merged_design_spec: dict[str, Any]) -> None:
72
+ self.data["status"] = status
73
+ self.data["current_stage"] = current_stage
74
+ self.data["stage_results"] = deepcopy(stage_results)
75
+ self.data["merged_design_spec"] = deepcopy(merged_design_spec)
76
+ self._flush()
77
+
78
+ def mark_finalized(self, *, normalized_solution_spec: dict[str, Any], execution_plan: dict[str, Any]) -> None:
79
+ self.data["status"] = "finalized"
80
+ self.data["current_stage"] = DesignStage.finalize.value
81
+ self.data["normalized_solution_spec"] = deepcopy(normalized_solution_spec)
82
+ self.data["execution_plan"] = deepcopy(execution_plan)
83
+ self._flush()
84
+
85
+ def summary(self) -> dict[str, Any]:
86
+ return {
87
+ "session_id": self.data["session_id"],
88
+ "status": self.data["status"],
89
+ "current_stage": self.data["current_stage"],
90
+ "metadata": deepcopy(self.data.get("metadata", {})),
91
+ "stage_results": deepcopy(self.data.get("stage_results", {})),
92
+ "merged_design_spec": deepcopy(self.data.get("merged_design_spec", {})),
93
+ "normalized_solution_spec": deepcopy(self.data.get("normalized_solution_spec")),
94
+ "execution_plan": deepcopy(self.data.get("execution_plan")),
95
+ "session_path": str(self.path),
96
+ }
97
+
98
+ def _flush(self) -> None:
99
+ self.data["updated_at"] = utc_now()
100
+ self.path.write_text(json.dumps(self.data, ensure_ascii=False, indent=2), encoding="utf-8")