@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.
- package/README.md +30 -0
- package/docs/local-agent-install.md +235 -0
- package/entry_point.py +13 -0
- package/npm/bin/qingflow.mjs +5 -0
- package/npm/lib/runtime.mjs +204 -0
- package/npm/scripts/postinstall.mjs +16 -0
- package/package.json +34 -0
- package/pyproject.toml +67 -0
- package/qingflow +15 -0
- package/src/qingflow_mcp/__init__.py +5 -0
- package/src/qingflow_mcp/__main__.py +5 -0
- package/src/qingflow_mcp/backend_client.py +547 -0
- package/src/qingflow_mcp/builder_facade/__init__.py +3 -0
- package/src/qingflow_mcp/builder_facade/models.py +985 -0
- package/src/qingflow_mcp/builder_facade/service.py +8243 -0
- package/src/qingflow_mcp/cli/__init__.py +1 -0
- package/src/qingflow_mcp/cli/commands/__init__.py +15 -0
- package/src/qingflow_mcp/cli/commands/app.py +40 -0
- package/src/qingflow_mcp/cli/commands/auth.py +78 -0
- package/src/qingflow_mcp/cli/commands/builder.py +184 -0
- package/src/qingflow_mcp/cli/commands/common.py +47 -0
- package/src/qingflow_mcp/cli/commands/imports.py +86 -0
- package/src/qingflow_mcp/cli/commands/record.py +202 -0
- package/src/qingflow_mcp/cli/commands/task.py +87 -0
- package/src/qingflow_mcp/cli/commands/workspace.py +33 -0
- package/src/qingflow_mcp/cli/context.py +48 -0
- package/src/qingflow_mcp/cli/formatters.py +269 -0
- package/src/qingflow_mcp/cli/json_io.py +50 -0
- package/src/qingflow_mcp/cli/main.py +147 -0
- package/src/qingflow_mcp/config.py +221 -0
- package/src/qingflow_mcp/errors.py +66 -0
- package/src/qingflow_mcp/import_store.py +121 -0
- package/src/qingflow_mcp/json_types.py +18 -0
- package/src/qingflow_mcp/list_type_labels.py +76 -0
- package/src/qingflow_mcp/server.py +211 -0
- package/src/qingflow_mcp/server_app_builder.py +387 -0
- package/src/qingflow_mcp/server_app_user.py +317 -0
- package/src/qingflow_mcp/session_store.py +289 -0
- package/src/qingflow_mcp/solution/__init__.py +6 -0
- package/src/qingflow_mcp/solution/build_assembly_store.py +181 -0
- package/src/qingflow_mcp/solution/compiler/__init__.py +282 -0
- package/src/qingflow_mcp/solution/compiler/chart_compiler.py +96 -0
- package/src/qingflow_mcp/solution/compiler/form_compiler.py +466 -0
- package/src/qingflow_mcp/solution/compiler/icon_utils.py +113 -0
- package/src/qingflow_mcp/solution/compiler/navigation_compiler.py +57 -0
- package/src/qingflow_mcp/solution/compiler/package_compiler.py +19 -0
- package/src/qingflow_mcp/solution/compiler/portal_compiler.py +60 -0
- package/src/qingflow_mcp/solution/compiler/view_compiler.py +51 -0
- package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +173 -0
- package/src/qingflow_mcp/solution/design_session.py +222 -0
- package/src/qingflow_mcp/solution/design_store.py +100 -0
- package/src/qingflow_mcp/solution/executor.py +2339 -0
- package/src/qingflow_mcp/solution/normalizer.py +23 -0
- package/src/qingflow_mcp/solution/requirements_builder.py +536 -0
- package/src/qingflow_mcp/solution/run_store.py +244 -0
- package/src/qingflow_mcp/solution/spec_models.py +853 -0
- package/src/qingflow_mcp/tools/__init__.py +1 -0
- package/src/qingflow_mcp/tools/ai_builder_tools.py +2063 -0
- package/src/qingflow_mcp/tools/app_tools.py +850 -0
- package/src/qingflow_mcp/tools/approval_tools.py +833 -0
- package/src/qingflow_mcp/tools/auth_tools.py +697 -0
- package/src/qingflow_mcp/tools/base.py +81 -0
- package/src/qingflow_mcp/tools/code_block_tools.py +679 -0
- package/src/qingflow_mcp/tools/directory_tools.py +648 -0
- package/src/qingflow_mcp/tools/feedback_tools.py +230 -0
- package/src/qingflow_mcp/tools/file_tools.py +385 -0
- package/src/qingflow_mcp/tools/import_tools.py +1971 -0
- package/src/qingflow_mcp/tools/navigation_tools.py +177 -0
- package/src/qingflow_mcp/tools/package_tools.py +240 -0
- package/src/qingflow_mcp/tools/portal_tools.py +131 -0
- package/src/qingflow_mcp/tools/qingbi_report_tools.py +269 -0
- package/src/qingflow_mcp/tools/record_tools.py +12739 -0
- package/src/qingflow_mcp/tools/role_tools.py +94 -0
- package/src/qingflow_mcp/tools/solution_tools.py +3887 -0
- package/src/qingflow_mcp/tools/task_context_tools.py +1423 -0
- package/src/qingflow_mcp/tools/task_tools.py +843 -0
- package/src/qingflow_mcp/tools/view_tools.py +280 -0
- package/src/qingflow_mcp/tools/workflow_tools.py +312 -0
- 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")
|