@josephyan/qingflow-app-builder-mcp 0.2.0-beta.7 → 0.2.0-beta.71
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 +5 -3
- package/docs/local-agent-install.md +21 -5
- package/npm/bin/qingflow-app-builder-mcp.mjs +1 -1
- package/npm/lib/runtime.mjs +168 -12
- package/package.json +1 -1
- package/pyproject.toml +4 -1
- package/skills/qingflow-app-builder/SKILL.md +155 -22
- package/skills/qingflow-app-builder/references/create-app.md +51 -21
- package/skills/qingflow-app-builder/references/environments.md +1 -1
- package/skills/qingflow-app-builder/references/flow-actors-and-permissions.md +123 -0
- package/skills/qingflow-app-builder/references/gotchas.md +28 -1
- package/skills/qingflow-app-builder/references/solution-playbooks.md +14 -12
- package/skills/qingflow-app-builder/references/tool-selection.md +47 -19
- package/skills/qingflow-app-builder/references/update-flow.md +112 -25
- package/skills/qingflow-app-builder/references/update-layout.md +11 -24
- package/skills/qingflow-app-builder/references/update-schema.md +1 -23
- package/skills/qingflow-app-builder/references/update-views.md +87 -21
- package/skills/qingflow-app-builder-code-integrations/SKILL.md +137 -0
- package/skills/qingflow-app-builder-code-integrations/agents/openai.yaml +4 -0
- package/skills/qingflow-app-builder-code-integrations/references/code-block.md +66 -0
- package/skills/qingflow-app-builder-code-integrations/references/q-linker.md +77 -0
- package/src/qingflow_mcp/__init__.py +1 -1
- package/src/qingflow_mcp/backend_client.py +210 -0
- package/src/qingflow_mcp/builder_facade/models.py +1252 -3
- package/src/qingflow_mcp/builder_facade/service.py +11367 -2389
- 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 +515 -0
- package/src/qingflow_mcp/cli/commands/common.py +62 -0
- package/src/qingflow_mcp/cli/commands/imports.py +96 -0
- package/src/qingflow_mcp/cli/commands/record.py +304 -0
- package/src/qingflow_mcp/cli/commands/task.py +89 -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 +355 -0
- package/src/qingflow_mcp/cli/json_io.py +50 -0
- package/src/qingflow_mcp/cli/main.py +149 -0
- package/src/qingflow_mcp/config.py +39 -0
- package/src/qingflow_mcp/import_store.py +121 -0
- package/src/qingflow_mcp/list_type_labels.py +24 -0
- package/src/qingflow_mcp/response_trim.py +668 -0
- package/src/qingflow_mcp/server.py +160 -18
- package/src/qingflow_mcp/server_app_builder.py +275 -68
- package/src/qingflow_mcp/server_app_user.py +219 -191
- package/src/qingflow_mcp/session_store.py +41 -1
- package/src/qingflow_mcp/solution/compiler/form_compiler.py +43 -4
- package/src/qingflow_mcp/solution/compiler/icon_utils.py +119 -45
- package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +41 -2
- package/src/qingflow_mcp/solution/executor.py +107 -11
- package/src/qingflow_mcp/solution/spec_models.py +2 -0
- package/src/qingflow_mcp/tools/ai_builder_tools.py +2032 -127
- package/src/qingflow_mcp/tools/app_tools.py +419 -12
- package/src/qingflow_mcp/tools/approval_tools.py +571 -72
- package/src/qingflow_mcp/tools/auth_tools.py +398 -2
- package/src/qingflow_mcp/tools/code_block_tools.py +756 -0
- package/src/qingflow_mcp/tools/custom_button_tools.py +179 -0
- package/src/qingflow_mcp/tools/directory_tools.py +203 -31
- package/src/qingflow_mcp/tools/feedback_tools.py +230 -0
- package/src/qingflow_mcp/tools/file_tools.py +1 -0
- package/src/qingflow_mcp/tools/import_tools.py +2150 -0
- package/src/qingflow_mcp/tools/package_tools.py +18 -4
- package/src/qingflow_mcp/tools/portal_tools.py +31 -0
- package/src/qingflow_mcp/tools/qingbi_report_tools.py +109 -7
- package/src/qingflow_mcp/tools/record_tools.py +9894 -1104
- package/src/qingflow_mcp/tools/solution_tools.py +115 -3
- package/src/qingflow_mcp/tools/task_context_tools.py +2040 -0
- package/src/qingflow_mcp/tools/task_tools.py +376 -225
- package/src/qingflow_mcp/tools/workspace_tools.py +163 -19
|
@@ -1,44 +1,57 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
import re
|
|
4
5
|
|
|
5
6
|
|
|
6
7
|
DEFAULT_ICON_COLOR = "qing-orange"
|
|
7
|
-
|
|
8
|
-
"
|
|
9
|
-
"
|
|
10
|
-
"
|
|
11
|
-
"
|
|
12
|
-
"
|
|
13
|
-
"
|
|
8
|
+
DEFAULT_ICON_STYLE_POOL: tuple[tuple[str, str], ...] = (
|
|
9
|
+
("briefcase", "qing-orange"),
|
|
10
|
+
("calendar", "emerald"),
|
|
11
|
+
("files-folder", "azure"),
|
|
12
|
+
("shield-check", "indigo"),
|
|
13
|
+
("user-group", "qing-purple"),
|
|
14
|
+
("chart-square-bar", "blue"),
|
|
15
|
+
("clock", "pink"),
|
|
16
|
+
("document-text", "green"),
|
|
17
|
+
("home", "orange"),
|
|
18
|
+
("globe", "red"),
|
|
19
|
+
)
|
|
20
|
+
LEGACY_EX_ICON_MAP = {
|
|
21
|
+
"ex-alert-outlined": "exclamation-circle",
|
|
22
|
+
"ex-clock-outlined": "clock",
|
|
23
|
+
"ex-folder-outlined": "folder",
|
|
24
|
+
"ex-form-outlined": "template",
|
|
25
|
+
"ex-home-outlined": "home",
|
|
26
|
+
"ex-user-outlined": "user",
|
|
14
27
|
}
|
|
15
28
|
|
|
16
29
|
ICON_ALIAS_MAP = {
|
|
17
|
-
"archive-box": "
|
|
18
|
-
"beaker": "
|
|
19
|
-
"briefcase": "
|
|
20
|
-
"building-office": "
|
|
21
|
-
"calendar": "
|
|
22
|
-
"chat": "
|
|
23
|
-
"chat-bubble-left-right": "
|
|
24
|
-
"chart-bar": "
|
|
25
|
-
"chart-square-bar": "
|
|
26
|
-
"check-badge": "
|
|
27
|
-
"check-circle": "
|
|
28
|
-
"clipboard-check": "
|
|
29
|
-
"clock": "
|
|
30
|
-
"cube": "
|
|
31
|
-
"document-text": "
|
|
32
|
-
"folder": "
|
|
33
|
-
"home": "
|
|
34
|
-
"office-building": "
|
|
35
|
-
"shield-check": "
|
|
36
|
-
"template": "
|
|
37
|
-
"user": "
|
|
38
|
-
"user-group": "
|
|
39
|
-
"users": "
|
|
40
|
-
"view-grid": "
|
|
41
|
-
"warning": "
|
|
30
|
+
"archive-box": "template",
|
|
31
|
+
"beaker": "beaker",
|
|
32
|
+
"briefcase": "briefcase",
|
|
33
|
+
"building-office": "office-building",
|
|
34
|
+
"calendar": "calendar",
|
|
35
|
+
"chat": "chat",
|
|
36
|
+
"chat-bubble-left-right": "chat",
|
|
37
|
+
"chart-bar": "chart-bar",
|
|
38
|
+
"chart-square-bar": "chart-square-bar",
|
|
39
|
+
"check-badge": "badge-check",
|
|
40
|
+
"check-circle": "check-circle",
|
|
41
|
+
"clipboard-check": "clipboard-check",
|
|
42
|
+
"clock": "clock",
|
|
43
|
+
"cube": "cube-transparent",
|
|
44
|
+
"document-text": "document-text",
|
|
45
|
+
"folder": "files-folder",
|
|
46
|
+
"home": "home",
|
|
47
|
+
"office-building": "office-building",
|
|
48
|
+
"shield-check": "shield-check",
|
|
49
|
+
"template": "template",
|
|
50
|
+
"user": "user",
|
|
51
|
+
"user-group": "user-group",
|
|
52
|
+
"users": "user-group",
|
|
53
|
+
"view-grid": "view-grid",
|
|
54
|
+
"warning": "exclamation-circle",
|
|
42
55
|
}
|
|
43
56
|
|
|
44
57
|
|
|
@@ -52,8 +65,6 @@ def encode_workspace_icon(
|
|
|
52
65
|
if icon and _looks_like_icon_json(icon):
|
|
53
66
|
return icon
|
|
54
67
|
normalized_icon_name = normalize_workspace_icon_name(icon or fallback_icon_name)
|
|
55
|
-
if normalized_icon_name and normalized_icon_name.startswith("ex-") and not color:
|
|
56
|
-
return normalized_icon_name
|
|
57
68
|
icon_name = normalized_icon_name
|
|
58
69
|
icon_color = color or DEFAULT_ICON_COLOR
|
|
59
70
|
if icon_name:
|
|
@@ -78,6 +89,65 @@ def encode_workspace_icon(
|
|
|
78
89
|
)
|
|
79
90
|
|
|
80
91
|
|
|
92
|
+
def choose_default_workspace_icon_style(*, title: str) -> tuple[str, str]:
|
|
93
|
+
seed_text = (title or "未命名").strip() or "未命名"
|
|
94
|
+
digest = 0
|
|
95
|
+
for index, char in enumerate(seed_text):
|
|
96
|
+
digest = (digest * 131 + ord(char) + index) % 1_000_003
|
|
97
|
+
return DEFAULT_ICON_STYLE_POOL[digest % len(DEFAULT_ICON_STYLE_POOL)]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def parse_workspace_icon(value: str | None) -> tuple[str | None, str | None, str | None]:
|
|
101
|
+
if not value:
|
|
102
|
+
return None, None, None
|
|
103
|
+
stripped = str(value).strip()
|
|
104
|
+
if not stripped:
|
|
105
|
+
return None, None, None
|
|
106
|
+
if _looks_like_icon_json(stripped):
|
|
107
|
+
try:
|
|
108
|
+
payload = json.loads(stripped)
|
|
109
|
+
except Exception:
|
|
110
|
+
return normalize_workspace_icon_name(stripped), None, None
|
|
111
|
+
icon_name = normalize_workspace_icon_name(payload.get("iconName"))
|
|
112
|
+
icon_color = str(payload.get("iconColor") or "").strip() or None
|
|
113
|
+
icon_text = str(payload.get("iconText") or "").strip() or None
|
|
114
|
+
return icon_name, icon_color, icon_text
|
|
115
|
+
return normalize_workspace_icon_name(stripped), None, None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def encode_workspace_icon_with_defaults(
|
|
119
|
+
*,
|
|
120
|
+
icon: str | None,
|
|
121
|
+
color: str | None,
|
|
122
|
+
title: str,
|
|
123
|
+
fallback_icon_name: str | None = None,
|
|
124
|
+
existing_icon: str | None = None,
|
|
125
|
+
) -> str:
|
|
126
|
+
if icon and _looks_like_icon_json(icon):
|
|
127
|
+
return icon
|
|
128
|
+
if not icon and not color and existing_icon:
|
|
129
|
+
existing_payload = str(existing_icon).strip()
|
|
130
|
+
if existing_payload:
|
|
131
|
+
return existing_payload
|
|
132
|
+
existing_icon_name, existing_icon_color, _ = parse_workspace_icon(existing_icon)
|
|
133
|
+
default_icon, default_color = choose_default_workspace_icon_style(title=title)
|
|
134
|
+
resolved_icon = icon or existing_icon_name
|
|
135
|
+
resolved_color = color or existing_icon_color
|
|
136
|
+
if not resolved_icon:
|
|
137
|
+
if icon is None and color is None:
|
|
138
|
+
resolved_icon = default_icon
|
|
139
|
+
else:
|
|
140
|
+
resolved_icon = fallback_icon_name or default_icon
|
|
141
|
+
if not resolved_color:
|
|
142
|
+
resolved_color = default_color
|
|
143
|
+
return encode_workspace_icon(
|
|
144
|
+
icon=resolved_icon,
|
|
145
|
+
color=resolved_color,
|
|
146
|
+
title=title,
|
|
147
|
+
fallback_icon_name=fallback_icon_name,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
81
151
|
def normalize_workspace_icon_name(icon: str | None) -> str | None:
|
|
82
152
|
if not icon:
|
|
83
153
|
return None
|
|
@@ -85,27 +155,31 @@ def normalize_workspace_icon_name(icon: str | None) -> str | None:
|
|
|
85
155
|
try:
|
|
86
156
|
payload = json.loads(icon)
|
|
87
157
|
except Exception:
|
|
88
|
-
return "
|
|
89
|
-
return normalize_workspace_icon_name(payload.get("iconName")) or "
|
|
90
|
-
if icon in SUPPORTED_EX_ICONS:
|
|
91
|
-
return icon
|
|
158
|
+
return "template"
|
|
159
|
+
return normalize_workspace_icon_name(payload.get("iconName")) or "template"
|
|
92
160
|
normalized = icon.strip().lower()
|
|
161
|
+
if normalized in LEGACY_EX_ICON_MAP:
|
|
162
|
+
return LEGACY_EX_ICON_MAP[normalized]
|
|
93
163
|
if normalized in ICON_ALIAS_MAP:
|
|
94
164
|
return ICON_ALIAS_MAP[normalized]
|
|
95
165
|
for token in normalized.replace("_", "-").split("-"):
|
|
96
166
|
if token in ICON_ALIAS_MAP:
|
|
97
167
|
return ICON_ALIAS_MAP[token]
|
|
98
168
|
if "folder" in normalized or "package" in normalized:
|
|
99
|
-
return "
|
|
100
|
-
if "home" in normalized
|
|
101
|
-
return "
|
|
169
|
+
return "files-folder"
|
|
170
|
+
if "home" in normalized:
|
|
171
|
+
return "home"
|
|
172
|
+
if "portal" in normalized or "dashboard" in normalized:
|
|
173
|
+
return "view-grid"
|
|
102
174
|
if "user" in normalized or "member" in normalized or "contact" in normalized:
|
|
103
|
-
return "
|
|
175
|
+
return "user"
|
|
104
176
|
if "clock" in normalized or "time" in normalized or "schedule" in normalized or "log" in normalized:
|
|
105
|
-
return "
|
|
177
|
+
return "clock"
|
|
106
178
|
if "risk" in normalized or "alert" in normalized or "warn" in normalized or "safety" in normalized:
|
|
107
|
-
return "
|
|
108
|
-
|
|
179
|
+
return "exclamation-circle"
|
|
180
|
+
if re.fullmatch(r"[a-z0-9-]+", normalized):
|
|
181
|
+
return normalized
|
|
182
|
+
return "template"
|
|
109
183
|
|
|
110
184
|
|
|
111
185
|
def _looks_like_icon_json(value: str) -> bool:
|
|
@@ -36,6 +36,11 @@ def compile_workflow(entity: EntitySpec) -> dict[str, Any] | None:
|
|
|
36
36
|
actions: list[dict[str, Any]] = []
|
|
37
37
|
seen_node_ids: set[str] = set()
|
|
38
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
|
+
}
|
|
39
44
|
for node in workflow.nodes:
|
|
40
45
|
if node.node_type == WorkflowNodeType.start:
|
|
41
46
|
seen_node_ids.add(node.node_id)
|
|
@@ -58,6 +63,26 @@ def compile_workflow(entity: EntitySpec) -> dict[str, Any] | None:
|
|
|
58
63
|
}
|
|
59
64
|
)
|
|
60
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
|
|
61
86
|
actions.append(
|
|
62
87
|
{
|
|
63
88
|
"action": "add_node",
|
|
@@ -69,7 +94,7 @@ def compile_workflow(entity: EntitySpec) -> dict[str, Any] | None:
|
|
|
69
94
|
"auditNodeName": node.name,
|
|
70
95
|
"type": WORKFLOW_TYPE_MAP[node.node_type]["type"],
|
|
71
96
|
"dealType": WORKFLOW_TYPE_MAP[node.node_type]["dealType"],
|
|
72
|
-
"prevNodeRef": _prev_node_ref(node, branch_lane_ref),
|
|
97
|
+
"prevNodeRef": _prev_node_ref(node, branch_lane_ref, start_node_ids),
|
|
73
98
|
"auditUserInfos": _build_audit_user_infos(node)
|
|
74
99
|
if node.node_type in {WorkflowNodeType.audit, WorkflowNodeType.fill, WorkflowNodeType.copy}
|
|
75
100
|
else None,
|
|
@@ -100,17 +125,31 @@ def _build_audit_user_infos(node) -> dict[str, Any]:
|
|
|
100
125
|
role_refs = assignees.get("role_refs") or []
|
|
101
126
|
if role_refs:
|
|
102
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
|
+
]
|
|
103
140
|
include_sub_departs = assignees.get("include_sub_departs")
|
|
104
141
|
if include_sub_departs is not None:
|
|
105
142
|
audit_user_infos["includeSubDeparts"] = bool(include_sub_departs)
|
|
106
143
|
return audit_user_infos
|
|
107
144
|
|
|
108
145
|
|
|
109
|
-
def _prev_node_ref(node, branch_lane_ref: str | None) -> str:
|
|
146
|
+
def _prev_node_ref(node, branch_lane_ref: str | None, start_node_ids: set[str]) -> str:
|
|
110
147
|
if branch_lane_ref:
|
|
111
148
|
if node.parent_node_id and node.parent_node_id != node.branch_parent_id:
|
|
112
149
|
return node.parent_node_id
|
|
113
150
|
return branch_lane_ref
|
|
151
|
+
if node.parent_node_id in start_node_ids:
|
|
152
|
+
return "__applicant__"
|
|
114
153
|
return node.parent_node_id or "__applicant__"
|
|
115
154
|
|
|
116
155
|
|
|
@@ -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
|
-
|
|
240
|
-
|
|
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
|
-
|
|
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):
|
|
@@ -415,13 +432,13 @@ class SolutionExecutor:
|
|
|
415
432
|
current_nodes = _coerce_workflow_nodes(existing_nodes)
|
|
416
433
|
existing_nodes_by_name = {
|
|
417
434
|
node.get("auditNodeName"): int(node_id)
|
|
418
|
-
for node_id, node in
|
|
435
|
+
for node_id, node in current_nodes.items()
|
|
419
436
|
if isinstance(node, dict) and node.get("auditNodeName")
|
|
420
437
|
}
|
|
421
438
|
applicant_node_id = next(
|
|
422
439
|
(
|
|
423
440
|
int(node_id)
|
|
424
|
-
for node_id, node in
|
|
441
|
+
for node_id, node in current_nodes.items()
|
|
425
442
|
if isinstance(node, dict) and node.get("type") == 0 and node.get("dealType") == 3
|
|
426
443
|
),
|
|
427
444
|
None,
|
|
@@ -429,11 +446,24 @@ class SolutionExecutor:
|
|
|
429
446
|
if applicant_node_id is not None:
|
|
430
447
|
node_artifacts.setdefault("__applicant__", applicant_node_id)
|
|
431
448
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
449
|
+
desired_global_settings = deepcopy(entity.workflow_plan["global_settings"])
|
|
450
|
+
explicit_global_settings = _has_explicit_workflow_global_settings(desired_global_settings)
|
|
451
|
+
current_global_settings: dict[str, Any] = {}
|
|
452
|
+
if explicit_global_settings:
|
|
453
|
+
current_global_settings = self.workflow_tools.workflow_get_global_settings(profile=profile, app_key=app_key).get("result") or {}
|
|
454
|
+
else:
|
|
455
|
+
try:
|
|
456
|
+
current_global_settings = self.workflow_tools.workflow_get_global_settings(profile=profile, app_key=app_key).get("result") or {}
|
|
457
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
458
|
+
api_error = QingflowApiError(**_coerce_nested_error_payload(error))
|
|
459
|
+
if api_error.http_status != 404:
|
|
460
|
+
raise
|
|
461
|
+
current_global_settings = {}
|
|
462
|
+
if explicit_global_settings:
|
|
463
|
+
global_settings = deepcopy(current_global_settings if isinstance(current_global_settings, dict) else {})
|
|
464
|
+
global_settings.update(desired_global_settings)
|
|
465
|
+
global_settings["editVersionNo"] = workflow_edit_version_no or global_settings.get("editVersionNo") or 1
|
|
466
|
+
self.workflow_tools.workflow_update_global_settings(profile=profile, app_key=app_key, payload=global_settings)
|
|
437
467
|
for action in entity.workflow_plan["actions"]:
|
|
438
468
|
if action["action"] == "create_sub_branch" and node_artifacts.get(action["node_id"]) is not None:
|
|
439
469
|
continue
|
|
@@ -462,6 +492,16 @@ class SolutionExecutor:
|
|
|
462
492
|
payload["editVersionNo"] = int(workflow_edit_version_no)
|
|
463
493
|
if action["action"] == "create_sub_branch":
|
|
464
494
|
result = self.workflow_tools.workflow_create_sub_branch(profile=profile, app_key=app_key, payload=payload)
|
|
495
|
+
elif action["action"] == "update_node":
|
|
496
|
+
target_node_id = node_artifacts.get(action["node_id"])
|
|
497
|
+
if target_node_id is None:
|
|
498
|
+
raise RuntimeError(f"workflow lane '{action['node_id']}' could not be resolved before update")
|
|
499
|
+
result = self.workflow_tools.workflow_update_node(
|
|
500
|
+
profile=profile,
|
|
501
|
+
app_key=app_key,
|
|
502
|
+
audit_node_id=target_node_id,
|
|
503
|
+
payload=payload,
|
|
504
|
+
)
|
|
465
505
|
else:
|
|
466
506
|
result = self.workflow_tools.workflow_add_node(profile=profile, app_key=app_key, payload=payload)
|
|
467
507
|
expected_type = 1 if action.get("node_type") == "branch" else None
|
|
@@ -652,7 +692,18 @@ class SolutionExecutor:
|
|
|
652
692
|
if dash_key:
|
|
653
693
|
store.set_artifact("portal", "dash_key", dash_key)
|
|
654
694
|
if dash_key:
|
|
655
|
-
|
|
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
|
|
656
707
|
update_payload = self._resolve_portal_payload(compiled.portal_plan["update_payload"], store, base_payload=base_payload)
|
|
657
708
|
self.portal_tools.portal_update(profile=profile, dash_key=dash_key, payload=update_payload)
|
|
658
709
|
self._refresh_portal_artifact(profile=profile, store=store, being_draft=True, artifact_key="draft_result")
|
|
@@ -2046,6 +2097,20 @@ def _find_created_sub_branch_lane_id(
|
|
|
2046
2097
|
return candidates[0] if candidates else None
|
|
2047
2098
|
|
|
2048
2099
|
|
|
2100
|
+
def _has_explicit_workflow_global_settings(global_settings: dict[str, Any] | None) -> bool:
|
|
2101
|
+
if not isinstance(global_settings, dict):
|
|
2102
|
+
return False
|
|
2103
|
+
for key, value in global_settings.items():
|
|
2104
|
+
if key == "editVersionNo":
|
|
2105
|
+
continue
|
|
2106
|
+
if value is None:
|
|
2107
|
+
continue
|
|
2108
|
+
if isinstance(value, (list, dict)) and not value:
|
|
2109
|
+
continue
|
|
2110
|
+
return True
|
|
2111
|
+
return False
|
|
2112
|
+
|
|
2113
|
+
|
|
2049
2114
|
def _is_navigation_plugin_unavailable(error: QingflowApiError) -> bool:
|
|
2050
2115
|
try:
|
|
2051
2116
|
backend_code = int(error.backend_code)
|
|
@@ -2086,6 +2151,37 @@ def _coerce_qingflow_error(error: Exception) -> QingflowApiError | None:
|
|
|
2086
2151
|
)
|
|
2087
2152
|
|
|
2088
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
|
+
|
|
2089
2185
|
def _portal_component_position(
|
|
2090
2186
|
source_type: Any,
|
|
2091
2187
|
*,
|