@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.
Files changed (70) hide show
  1. package/README.md +5 -3
  2. package/docs/local-agent-install.md +21 -5
  3. package/npm/bin/qingflow-app-builder-mcp.mjs +1 -1
  4. package/npm/lib/runtime.mjs +168 -12
  5. package/package.json +1 -1
  6. package/pyproject.toml +4 -1
  7. package/skills/qingflow-app-builder/SKILL.md +155 -22
  8. package/skills/qingflow-app-builder/references/create-app.md +51 -21
  9. package/skills/qingflow-app-builder/references/environments.md +1 -1
  10. package/skills/qingflow-app-builder/references/flow-actors-and-permissions.md +123 -0
  11. package/skills/qingflow-app-builder/references/gotchas.md +28 -1
  12. package/skills/qingflow-app-builder/references/solution-playbooks.md +14 -12
  13. package/skills/qingflow-app-builder/references/tool-selection.md +47 -19
  14. package/skills/qingflow-app-builder/references/update-flow.md +112 -25
  15. package/skills/qingflow-app-builder/references/update-layout.md +11 -24
  16. package/skills/qingflow-app-builder/references/update-schema.md +1 -23
  17. package/skills/qingflow-app-builder/references/update-views.md +87 -21
  18. package/skills/qingflow-app-builder-code-integrations/SKILL.md +137 -0
  19. package/skills/qingflow-app-builder-code-integrations/agents/openai.yaml +4 -0
  20. package/skills/qingflow-app-builder-code-integrations/references/code-block.md +66 -0
  21. package/skills/qingflow-app-builder-code-integrations/references/q-linker.md +77 -0
  22. package/src/qingflow_mcp/__init__.py +1 -1
  23. package/src/qingflow_mcp/backend_client.py +210 -0
  24. package/src/qingflow_mcp/builder_facade/models.py +1252 -3
  25. package/src/qingflow_mcp/builder_facade/service.py +11367 -2389
  26. package/src/qingflow_mcp/cli/__init__.py +1 -0
  27. package/src/qingflow_mcp/cli/commands/__init__.py +15 -0
  28. package/src/qingflow_mcp/cli/commands/app.py +40 -0
  29. package/src/qingflow_mcp/cli/commands/auth.py +78 -0
  30. package/src/qingflow_mcp/cli/commands/builder.py +515 -0
  31. package/src/qingflow_mcp/cli/commands/common.py +62 -0
  32. package/src/qingflow_mcp/cli/commands/imports.py +96 -0
  33. package/src/qingflow_mcp/cli/commands/record.py +304 -0
  34. package/src/qingflow_mcp/cli/commands/task.py +89 -0
  35. package/src/qingflow_mcp/cli/commands/workspace.py +33 -0
  36. package/src/qingflow_mcp/cli/context.py +48 -0
  37. package/src/qingflow_mcp/cli/formatters.py +355 -0
  38. package/src/qingflow_mcp/cli/json_io.py +50 -0
  39. package/src/qingflow_mcp/cli/main.py +149 -0
  40. package/src/qingflow_mcp/config.py +39 -0
  41. package/src/qingflow_mcp/import_store.py +121 -0
  42. package/src/qingflow_mcp/list_type_labels.py +24 -0
  43. package/src/qingflow_mcp/response_trim.py +668 -0
  44. package/src/qingflow_mcp/server.py +160 -18
  45. package/src/qingflow_mcp/server_app_builder.py +275 -68
  46. package/src/qingflow_mcp/server_app_user.py +219 -191
  47. package/src/qingflow_mcp/session_store.py +41 -1
  48. package/src/qingflow_mcp/solution/compiler/form_compiler.py +43 -4
  49. package/src/qingflow_mcp/solution/compiler/icon_utils.py +119 -45
  50. package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +41 -2
  51. package/src/qingflow_mcp/solution/executor.py +107 -11
  52. package/src/qingflow_mcp/solution/spec_models.py +2 -0
  53. package/src/qingflow_mcp/tools/ai_builder_tools.py +2032 -127
  54. package/src/qingflow_mcp/tools/app_tools.py +419 -12
  55. package/src/qingflow_mcp/tools/approval_tools.py +571 -72
  56. package/src/qingflow_mcp/tools/auth_tools.py +398 -2
  57. package/src/qingflow_mcp/tools/code_block_tools.py +756 -0
  58. package/src/qingflow_mcp/tools/custom_button_tools.py +179 -0
  59. package/src/qingflow_mcp/tools/directory_tools.py +203 -31
  60. package/src/qingflow_mcp/tools/feedback_tools.py +230 -0
  61. package/src/qingflow_mcp/tools/file_tools.py +1 -0
  62. package/src/qingflow_mcp/tools/import_tools.py +2150 -0
  63. package/src/qingflow_mcp/tools/package_tools.py +18 -4
  64. package/src/qingflow_mcp/tools/portal_tools.py +31 -0
  65. package/src/qingflow_mcp/tools/qingbi_report_tools.py +109 -7
  66. package/src/qingflow_mcp/tools/record_tools.py +9894 -1104
  67. package/src/qingflow_mcp/tools/solution_tools.py +115 -3
  68. package/src/qingflow_mcp/tools/task_context_tools.py +2040 -0
  69. package/src/qingflow_mcp/tools/task_tools.py +376 -225
  70. 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
- SUPPORTED_EX_ICONS = {
8
- "ex-alert-outlined",
9
- "ex-clock-outlined",
10
- "ex-folder-outlined",
11
- "ex-form-outlined",
12
- "ex-home-outlined",
13
- "ex-user-outlined",
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": "ex-form-outlined",
18
- "beaker": "ex-form-outlined",
19
- "briefcase": "ex-folder-outlined",
20
- "building-office": "ex-form-outlined",
21
- "calendar": "ex-clock-outlined",
22
- "chat": "ex-clock-outlined",
23
- "chat-bubble-left-right": "ex-clock-outlined",
24
- "chart-bar": "ex-form-outlined",
25
- "chart-square-bar": "ex-form-outlined",
26
- "check-badge": "ex-alert-outlined",
27
- "check-circle": "ex-alert-outlined",
28
- "clipboard-check": "ex-form-outlined",
29
- "clock": "ex-clock-outlined",
30
- "cube": "ex-form-outlined",
31
- "document-text": "ex-form-outlined",
32
- "folder": "ex-folder-outlined",
33
- "home": "ex-home-outlined",
34
- "office-building": "ex-form-outlined",
35
- "shield-check": "ex-alert-outlined",
36
- "template": "ex-form-outlined",
37
- "user": "ex-user-outlined",
38
- "user-group": "ex-user-outlined",
39
- "users": "ex-user-outlined",
40
- "view-grid": "ex-home-outlined",
41
- "warning": "ex-alert-outlined",
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 "ex-form-outlined"
89
- return normalize_workspace_icon_name(payload.get("iconName")) or "ex-form-outlined"
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 "ex-folder-outlined"
100
- if "home" in normalized or "portal" in normalized or "dashboard" in normalized:
101
- return "ex-home-outlined"
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 "ex-user-outlined"
175
+ return "user"
104
176
  if "clock" in normalized or "time" in normalized or "schedule" in normalized or "log" in normalized:
105
- return "ex-clock-outlined"
177
+ return "clock"
106
178
  if "risk" in normalized or "alert" in normalized or "warn" in normalized or "safety" in normalized:
107
- return "ex-alert-outlined"
108
- return "ex-form-outlined"
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
- page = self.role_tools.role_search(profile=profile, keyword=role.name, page_num=1, page_size=50).get("page") or {}
240
- role_list = page.get("list") if isinstance(page, dict) else []
239
+ try:
240
+ page = self.role_tools.role_search(profile=profile, keyword=role.name, page_num=1, page_size=50).get("page") or {}
241
+ role_list = page.get("list") if isinstance(page, dict) else []
242
+ except Exception as exc: # noqa: BLE001
243
+ api_error = _coerce_qingflow_error(exc)
244
+ if api_error is None or not _is_permission_restricted_error(api_error):
245
+ raise
246
+ role_list = []
241
247
  matched_role = next(
242
248
  (
243
249
  item
@@ -312,7 +318,18 @@ class SolutionExecutor:
312
318
  if not isinstance(app_key, str) or not app_key:
313
319
  raise ValueError(f"missing app_key for package attach on entity '{entity.entity_id}'")
314
320
 
315
- package_detail = self.package_tools.package_get(profile=profile, tag_id=tag_id, include_raw=True)
321
+ try:
322
+ package_detail = self.package_tools.package_get(profile=profile, tag_id=tag_id, include_raw=True)
323
+ except Exception as exc: # noqa: BLE001
324
+ api_error = _coerce_qingflow_error(exc)
325
+ if api_error is None or not _is_permission_restricted_error(api_error):
326
+ raise
327
+ raise _required_state_read_blocked_error(
328
+ resource="package_attach",
329
+ message=f"package attach requires readable package state before sorting items for tag '{tag_id}'",
330
+ error=api_error,
331
+ details={"tag_id": tag_id, "app_key": app_key},
332
+ ) from exc
316
333
  package_result = package_detail.get("result") if isinstance(package_detail.get("result"), dict) else {}
317
334
  tag_items = [deepcopy(item) for item in package_result.get("tagItems", []) if isinstance(item, dict)]
318
335
  if any(_package_item_app_key(item) == app_key for item in tag_items):
@@ -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 existing_nodes.items()
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 existing_nodes.items()
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
- current_global_settings = self.workflow_tools.workflow_get_global_settings(profile=profile, app_key=app_key).get("result") or {}
433
- global_settings = deepcopy(current_global_settings if isinstance(current_global_settings, dict) else {})
434
- global_settings.update(entity.workflow_plan["global_settings"])
435
- global_settings["editVersionNo"] = workflow_edit_version_no or global_settings.get("editVersionNo") or 1
436
- self.workflow_tools.workflow_update_global_settings(profile=profile, app_key=app_key, payload=global_settings)
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
- base_payload = self.portal_tools.portal_get(profile=profile, dash_key=dash_key, being_draft=True).get("result") or {}
695
+ try:
696
+ base_payload = self.portal_tools.portal_get(profile=profile, dash_key=dash_key, being_draft=True).get("result") or {}
697
+ except Exception as exc: # noqa: BLE001
698
+ api_error = _coerce_qingflow_error(exc)
699
+ if api_error is None or not _is_permission_restricted_error(api_error):
700
+ raise
701
+ raise _required_state_read_blocked_error(
702
+ resource="portal",
703
+ message=f"portal update requires readable draft state for dash '{dash_key}'",
704
+ error=api_error,
705
+ details={"dash_key": dash_key},
706
+ ) from exc
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
  *,
@@ -33,6 +33,8 @@ class FieldType(str, Enum):
33
33
  address = "address"
34
34
  attachment = "attachment"
35
35
  boolean = "boolean"
36
+ q_linker = "q_linker"
37
+ code_block = "code_block"
36
38
  relation = "relation"
37
39
  subtable = "subtable"
38
40