@josephyan/qingflow-cli 1.0.11 → 1.1.2

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 (67) hide show
  1. package/README.md +3 -3
  2. package/npm/bin/qingflow.mjs +40 -2
  3. package/npm/lib/runtime.mjs +386 -15
  4. package/npm/scripts/postinstall.mjs +7 -2
  5. package/package.json +1 -1
  6. package/pyproject.toml +1 -1
  7. package/skills/qingflow-cli/SKILL.md +440 -0
  8. package/skills/qingflow-cli/manifest.yaml +10 -0
  9. package/skills/qingflow-cli/reference/QINGFLOW_CLI_ADMIN_CHEATSHEET.md +94 -0
  10. package/skills/qingflow-cli/reference/QINGFLOW_CLI_BUILDER_APP_DELIVERY_WORKFLOW.md +485 -0
  11. package/skills/qingflow-cli/reference/QINGFLOW_CLI_BUILDER_CHARTS_WORKFLOW.md +237 -0
  12. package/skills/qingflow-cli/reference/QINGFLOW_CLI_BUILDER_MATCH_RULES.md +137 -0
  13. package/skills/qingflow-cli/reference/QINGFLOW_CLI_BUILDER_PORTAL_WORKFLOW.md +263 -0
  14. package/skills/qingflow-cli/reference/QINGFLOW_CLI_BUILDER_VIEWS_WORKFLOW.md +304 -0
  15. package/skills/qingflow-cli/reference/QINGFLOW_CLI_BUILDER_WORKSPACE_ICONS.md +41 -0
  16. package/skills/qingflow-cli/reference/QINGFLOW_CLI_DATA_RETRIEVAL_WORKFLOW.md +139 -0
  17. package/skills/qingflow-cli/reference/QINGFLOW_CLI_EXPLORATION_REPORT.md +84 -0
  18. package/skills/qingflow-cli/reference/QINGFLOW_CLI_FIELD_DATA_TYPES.md +129 -0
  19. package/skills/qingflow-cli/reference/QINGFLOW_CLI_MEMBER_CHEATSHEET.md +195 -0
  20. package/skills/qingflow-cli/reference/QINGFLOW_CLI_ONE_SHOT_CHEATSHEET.md +159 -0
  21. package/skills/qingflow-cli/reference/QINGFLOW_CLI_RECORD_CREATE_WORKFLOW.md +20 -0
  22. package/skills/qingflow-cli/reference/QINGFLOW_CLI_RECORD_IMPORT_WORKFLOW.md +176 -0
  23. package/skills/qingflow-cli/reference/QINGFLOW_CLI_RECORD_UPDATE_WORKFLOW.md +163 -0
  24. package/skills/qingflow-cli/reference/QINGFLOW_CLI_SCHEMA_APPLY_FIELD_TYPES_AND_SCENARIOS.md +107 -0
  25. package/skills/qingflow-cli/reference/QINGFLOW_CLI_TASK_CONTEXT_WORKFLOW.md +151 -0
  26. package/skills/qingflow-cli/reference/_batch_schema_complex.json +18 -0
  27. package/skills/qingflow-cli/reference/_batch_schema_scalar.json +17 -0
  28. package/skills/qingflow-cli/reference/charts_remove.example.json +1 -0
  29. package/skills/qingflow-cli/reference/charts_reorder.example.json +1 -0
  30. package/skills/qingflow-cli/reference/charts_upsert_bar.example.json +8 -0
  31. package/skills/qingflow-cli/reference/charts_upsert_dashboard_starter.example.json +37 -0
  32. package/skills/qingflow-cli/reference/charts_upsert_minimal.example.json +13 -0
  33. package/skills/qingflow-cli/reference/portal_sections_all_types.example.json +131 -0
  34. package/skills/qingflow-cli/reference/portal_sections_five_types.example.json +126 -0
  35. package/skills/qingflow-cli/reference/portal_sections_standard_workbench.example.json +128 -0
  36. package/skills/qingflow-cli/reference/schema_add_fields_minimal.example.json +7 -0
  37. package/skills/qingflow-cli/reference/schema_apply_add_fields_all_types.json +78 -0
  38. package/skills/qingflow-cli/reference/views_upsert_table_minimal.example.json +7 -0
  39. package/skills/qingflow-cli/scripts/builder-package-from-app-list.py +140 -0
  40. package/skills/qingflow-cli/scripts/find-app-by-keyword.py +132 -0
  41. package/skills/qingflow-cli/scripts/validate_qingflow_output_files.py +87 -0
  42. package/src/qingflow_mcp/__init__.py +1 -1
  43. package/src/qingflow_mcp/builder_facade/models.py +532 -48
  44. package/src/qingflow_mcp/builder_facade/service.py +9194 -2384
  45. package/src/qingflow_mcp/builder_facade/workflow_spec.py +111 -0
  46. package/src/qingflow_mcp/cli/commands/app.py +3 -16
  47. package/src/qingflow_mcp/cli/commands/builder.py +354 -56
  48. package/src/qingflow_mcp/cli/commands/record.py +89 -2
  49. package/src/qingflow_mcp/cli/formatters.py +32 -1
  50. package/src/qingflow_mcp/cli/main.py +245 -3
  51. package/src/qingflow_mcp/public_surface.py +11 -8
  52. package/src/qingflow_mcp/response_trim.py +143 -14
  53. package/src/qingflow_mcp/server.py +15 -12
  54. package/src/qingflow_mcp/server_app_builder.py +108 -30
  55. package/src/qingflow_mcp/server_app_user.py +17 -18
  56. package/src/qingflow_mcp/solution/compiler/__init__.py +1 -3
  57. package/src/qingflow_mcp/solution/compiler/icon_utils.py +294 -0
  58. package/src/qingflow_mcp/solution/executor.py +3 -133
  59. package/src/qingflow_mcp/tools/ai_builder_tools.py +2617 -440
  60. package/src/qingflow_mcp/tools/app_tools.py +53 -8
  61. package/src/qingflow_mcp/tools/package_tools.py +16 -2
  62. package/src/qingflow_mcp/tools/record_tools.py +2095 -176
  63. package/src/qingflow_mcp/tools/resource_read_tools.py +3 -0
  64. package/src/qingflow_mcp/tools/solution_tools.py +30 -2
  65. package/src/qingflow_mcp/tools/workflow_tools.py +3 -31
  66. package/src/qingflow_mcp/version.py +110 -0
  67. package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +0 -173
@@ -6,6 +6,7 @@ from typing import Any
6
6
  from ..errors import QingflowApiError, raise_tool_error
7
7
  from ..json_types import JSONObject
8
8
  from ..list_type_labels import SYSTEM_VIEW_DEFINITIONS
9
+ from ..solution.compiler.icon_utils import workspace_icon_config
9
10
  from .app_tools import _analysis_supported_for_view_type
10
11
  from .base import ToolBase, tool_cn_name
11
12
  from .qingbi_report_tools import QingbiReportTools
@@ -75,6 +76,7 @@ class ResourceReadTools(ToolBase):
75
76
  "dash_key": dash_key,
76
77
  "dash_name": dash_name,
77
78
  "dash_icon": dash_icon,
79
+ "icon_config": workspace_icon_config(dash_icon),
78
80
  "package_tag_ids": package_tag_ids,
79
81
  "component_count": len(components),
80
82
  "components": components,
@@ -368,6 +370,7 @@ def _normalize_portal_list_items(raw_items: Any) -> list[dict[str, Any]]:
368
370
  "dash_key": dash_key or None,
369
371
  "dash_name": dash_name or None,
370
372
  "dash_icon": dash_icon,
373
+ "icon_config": workspace_icon_config(dash_icon),
371
374
  "package_tag_ids": package_tag_ids,
372
375
  }
373
376
  )
@@ -38,7 +38,6 @@ from .qingbi_report_tools import QingbiReportTools
38
38
  from .record_tools import RecordTools
39
39
  from .role_tools import RoleTools
40
40
  from .view_tools import ViewTools
41
- from .workflow_tools import WorkflowTools
42
41
  from .workspace_tools import WorkspaceTools
43
42
 
44
43
  STAGED_BUILD_MODES = {"preflight", "plan", "apply", "repair"}
@@ -737,6 +736,36 @@ class SolutionTools(ToolBase):
737
736
  ) -> dict[str, Any]:
738
737
  """执行方案相关逻辑。"""
739
738
  mode = _normalize_staged_build_mode(mode)
739
+ app_key = str(flow_spec.get("app_key") or "").strip()
740
+ spec_payload = flow_spec.get("spec")
741
+ if app_key and isinstance(spec_payload, dict) and spec_payload:
742
+ from .ai_builder_tools import AiBuilderTools
743
+
744
+ builder = AiBuilderTools(self.sessions, self.backend)
745
+ if mode in {"preflight", "plan"}:
746
+ return {
747
+ "build_id": build_id or _generate_build_id(run_label=run_label, stage_name="flow"),
748
+ "mode": mode,
749
+ "stage": "flow",
750
+ "status": "planned" if mode == "plan" else "preflighted",
751
+ "normalized_args": {"app_key": app_key, "spec": spec_payload},
752
+ "tool_name": "solution_build_flow",
753
+ }
754
+ result = builder.app_flow_apply(
755
+ profile=profile,
756
+ app_key=app_key,
757
+ spec=spec_payload,
758
+ publish=publish,
759
+ schema_version=flow_spec.get("schema_version") or flow_spec.get("schemaVersion"),
760
+ )
761
+ return {
762
+ "build_id": build_id,
763
+ "mode": mode,
764
+ "stage": "flow",
765
+ "status": result.get("status"),
766
+ "result": result,
767
+ "tool_name": "solution_build_flow",
768
+ }
740
769
  return self._stage_build(
741
770
  profile=profile,
742
771
  mode=mode,
@@ -1409,7 +1438,6 @@ class SolutionTools(ToolBase):
1409
1438
  role_tools=RoleTools(self.sessions, self.backend),
1410
1439
  app_tools=AppTools(self.sessions, self.backend),
1411
1440
  record_tools=RecordTools(self.sessions, self.backend),
1412
- workflow_tools=WorkflowTools(self.sessions, self.backend),
1413
1441
  view_tools=ViewTools(self.sessions, self.backend),
1414
1442
  chart_tools=QingbiReportTools(self.sessions, self.backend),
1415
1443
  portal_tools=PortalTools(self.sessions, self.backend),
@@ -14,25 +14,13 @@ class WorkflowTools(ToolBase):
14
14
 
15
15
  类型:流程配置工具。
16
16
  主要职责:
17
- 1. 查询流程节点与流程图配置;
18
- 2. 读取与更新流程规则;
19
- 3. 支持流程发布与流程调试辅助能力。
17
+ 1. 支持流程运行时校验与调试辅助能力;
18
+ 2. 保留 legacy 节点写路径供内部/测试直接调用(非 MCP 公开)。
19
+ 设计期流程配置读取统一走 WorkflowSpec(app_flow_get / GET /api/workflow/spec)。
20
20
  """
21
21
 
22
22
  def register(self, mcp: FastMCP) -> None:
23
23
  """注册当前工具到 MCP 服务。"""
24
- @mcp.tool()
25
- def workflow_list_nodes(profile: str = DEFAULT_PROFILE, app_key: str = "") -> JSONObject:
26
- return self.workflow_list_nodes(profile=profile, app_key=app_key)
27
-
28
- @mcp.tool()
29
- def workflow_get_node_detail(profile: str = DEFAULT_PROFILE, app_key: str = "", audit_node_id: int = 0) -> JSONObject:
30
- return self.workflow_get_node_detail(profile=profile, app_key=app_key, audit_node_id=audit_node_id)
31
-
32
- @mcp.tool()
33
- def workflow_get_global_settings(profile: str = DEFAULT_PROFILE, app_key: str = "") -> JSONObject:
34
- return self.workflow_get_global_settings(profile=profile, app_key=app_key)
35
-
36
24
  @mcp.tool()
37
25
  def workflow_get_future_nodes(profile: str = DEFAULT_PROFILE, app_key: str = "", apply_id: int = 0) -> JSONObject:
38
26
  return self.workflow_get_future_nodes(profile=profile, app_key=app_key, apply_id=apply_id)
@@ -53,22 +41,6 @@ class WorkflowTools(ToolBase):
53
41
  audit_node_id=audit_node_id,
54
42
  )
55
43
 
56
- @mcp.tool()
57
- def workflow_get_qsource_active(profile: str = DEFAULT_PROFILE, app_key: str = "", qsource_id: int = 0) -> JSONObject:
58
- return self.workflow_get_qsource_active(profile=profile, app_key=app_key, qsource_id=qsource_id)
59
-
60
- @mcp.tool()
61
- def workflow_get_qsource_passive(profile: str = DEFAULT_PROFILE, app_key: str = "", qsource_id: int = 0) -> JSONObject:
62
- return self.workflow_get_qsource_passive(profile=profile, app_key=app_key, qsource_id=qsource_id)
63
-
64
- @mcp.tool()
65
- def workflow_get_editable_question_ids(profile: str = DEFAULT_PROFILE, app_key: str = "", audit_node_id: int = 0) -> JSONObject:
66
- return self.workflow_get_editable_question_ids(profile=profile, app_key=app_key, audit_node_id=audit_node_id)
67
-
68
- @mcp.tool()
69
- def workflow_get_print_nodes(profile: str = DEFAULT_PROFILE, app_key: str = "") -> JSONObject:
70
- return self.workflow_get_print_nodes(profile=profile, app_key=app_key)
71
-
72
44
  @tool_cn_name("流程节点列表")
73
45
  def workflow_list_nodes(self, *, profile: str, app_key: str) -> JSONObject:
74
46
  """执行流程相关逻辑。"""
@@ -0,0 +1,110 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ import shutil
6
+ import sys
7
+ from importlib import metadata
8
+ from pathlib import Path
9
+
10
+
11
+ def get_cli_version() -> str:
12
+ package_json_version = _find_package_json_version()
13
+ if package_json_version:
14
+ return package_json_version
15
+ try:
16
+ return metadata.version("qingflow-mcp")
17
+ except metadata.PackageNotFoundError:
18
+ return "0+local"
19
+
20
+
21
+ def get_cli_version_info() -> dict[str, str | None]:
22
+ package_root = _find_package_root()
23
+ return {
24
+ "version": get_cli_version(),
25
+ "package": _find_package_name(package_root) or "@qingflow-tech/qingflow-cli",
26
+ "executable_path": _resolve_executable_path(),
27
+ "command_path": shutil.which("qingflow"),
28
+ "package_root": str(package_root) if package_root is not None else None,
29
+ "skill_version": _find_skill_version(package_root),
30
+ }
31
+
32
+
33
+ def _find_package_json_version() -> str | None:
34
+ package_root = _find_package_root()
35
+ if package_root is None:
36
+ return None
37
+ try:
38
+ payload = json.loads((package_root / "package.json").read_text(encoding="utf-8"))
39
+ except (OSError, json.JSONDecodeError):
40
+ return None
41
+ version = str(payload.get("version") or "")
42
+ return version or None
43
+
44
+
45
+ def _find_package_root() -> Path | None:
46
+ current = Path(__file__).resolve()
47
+ for parent in current.parents:
48
+ package_json = parent / "package.json"
49
+ if not package_json.exists():
50
+ continue
51
+ try:
52
+ payload = json.loads(package_json.read_text(encoding="utf-8"))
53
+ except (OSError, json.JSONDecodeError):
54
+ continue
55
+ name = str(payload.get("name") or "")
56
+ version = str(payload.get("version") or "")
57
+ if version and name in {
58
+ "qingflow-mcp-workspace",
59
+ "@qingflow-tech/qingflow-cli",
60
+ "@qingflow-tech/qingflow-app-user-mcp",
61
+ "@qingflow-tech/qingflow-app-builder-mcp",
62
+ "@josephyan/qingflow-cli",
63
+ "@josephyan/qingflow-app-user-mcp",
64
+ "@josephyan/qingflow-app-builder-mcp",
65
+ }:
66
+ return parent
67
+ return None
68
+
69
+
70
+ def _find_package_name(package_root: Path | None) -> str | None:
71
+ if package_root is None:
72
+ return None
73
+ try:
74
+ payload = json.loads((package_root / "package.json").read_text(encoding="utf-8"))
75
+ except (OSError, json.JSONDecodeError):
76
+ return None
77
+ name = str(payload.get("name") or "")
78
+ return name or None
79
+
80
+
81
+ def _resolve_executable_path() -> str | None:
82
+ if not sys.argv:
83
+ return None
84
+ raw = str(sys.argv[0] or "").strip()
85
+ if not raw:
86
+ return None
87
+ try:
88
+ return str(Path(raw).resolve())
89
+ except OSError:
90
+ return raw
91
+
92
+
93
+ def _find_skill_version(package_root: Path | None) -> str | None:
94
+ if package_root is None:
95
+ return None
96
+ candidates = [
97
+ package_root / "skills" / "qingflow-cli" / "SKILL.md",
98
+ package_root / "skill" / "qingflow-cli" / "SKILL.md",
99
+ ]
100
+ for skill_file in candidates:
101
+ if not skill_file.exists():
102
+ continue
103
+ try:
104
+ text = skill_file.read_text(encoding="utf-8")
105
+ except OSError:
106
+ continue
107
+ match = re.search(r"Skill\s*版本\**[::]\s*`?([^`\s))]+)", text)
108
+ if match:
109
+ return match.group(1).strip()
110
+ return None
@@ -1,173 +0,0 @@
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}"