flowent 0.0.4 → 0.0.5

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 (311) hide show
  1. package/README.md +1 -1
  2. package/backend/README.md +74 -0
  3. package/backend/pyproject.toml +2 -1
  4. package/backend/src/flowent/__pycache__/__init__.cpython-313.pyc +0 -0
  5. package/backend/src/flowent/__pycache__/_version.cpython-313.pyc +0 -0
  6. package/backend/src/flowent/__pycache__/access.cpython-313.pyc +0 -0
  7. package/backend/src/flowent/__pycache__/agent.cpython-313.pyc +0 -0
  8. package/backend/src/flowent/__pycache__/assistant_commands.cpython-313.pyc +0 -0
  9. package/backend/src/flowent/__pycache__/cli.cpython-313.pyc +0 -0
  10. package/backend/src/flowent/__pycache__/config.cpython-313.pyc +0 -0
  11. package/backend/src/flowent/__pycache__/events.cpython-313.pyc +0 -0
  12. package/backend/src/flowent/__pycache__/graph_runtime.cpython-313.pyc +0 -0
  13. package/backend/src/flowent/__pycache__/graph_service.cpython-313.pyc +0 -0
  14. package/backend/src/flowent/__pycache__/image_assets.cpython-313.pyc +0 -0
  15. package/backend/src/flowent/__pycache__/logging.cpython-313.pyc +0 -0
  16. package/backend/src/flowent/__pycache__/main.cpython-313.pyc +0 -0
  17. package/backend/src/flowent/__pycache__/mcp_service.cpython-313.pyc +0 -0
  18. package/backend/src/flowent/__pycache__/model_metadata.cpython-313.pyc +0 -0
  19. package/backend/src/flowent/__pycache__/network.cpython-313.pyc +0 -0
  20. package/backend/src/flowent/__pycache__/{stats_service.cpython-313.pyc → observability_service.cpython-313.pyc} +0 -0
  21. package/backend/src/flowent/__pycache__/registry.cpython-313.pyc +0 -0
  22. package/backend/src/flowent/__pycache__/role_management.cpython-313.pyc +0 -0
  23. package/backend/src/flowent/__pycache__/runtime.cpython-313.pyc +0 -0
  24. package/backend/src/flowent/__pycache__/sandbox.cpython-313.pyc +0 -0
  25. package/backend/src/flowent/__pycache__/security.cpython-313.pyc +0 -0
  26. package/backend/src/flowent/__pycache__/settings.cpython-313.pyc +0 -0
  27. package/backend/src/flowent/__pycache__/settings_management.cpython-313.pyc +0 -0
  28. package/backend/src/flowent/__pycache__/state_db.cpython-313.pyc +0 -0
  29. package/backend/src/flowent/__pycache__/workspace_store.cpython-313.pyc +0 -0
  30. package/backend/src/flowent/agent.py +364 -52
  31. package/backend/src/flowent/assistant_commands.py +31 -22
  32. package/backend/src/flowent/channels/__pycache__/__init__.cpython-313.pyc +0 -0
  33. package/backend/src/flowent/channels/__pycache__/telegram.cpython-313.pyc +0 -0
  34. package/backend/src/flowent/channels/telegram.py +4 -4
  35. package/backend/src/flowent/graph_service.py +1307 -145
  36. package/backend/src/flowent/mcp_service.py +21 -7
  37. package/backend/src/flowent/model_metadata.py +4 -0
  38. package/backend/src/flowent/models/__init__.py +6 -2
  39. package/backend/src/flowent/models/__pycache__/__init__.cpython-313.pyc +0 -0
  40. package/backend/src/flowent/models/__pycache__/agent.cpython-313.pyc +0 -0
  41. package/backend/src/flowent/models/__pycache__/base.cpython-313.pyc +0 -0
  42. package/backend/src/flowent/models/__pycache__/blueprint.cpython-313.pyc +0 -0
  43. package/backend/src/flowent/models/__pycache__/content.cpython-313.pyc +0 -0
  44. package/backend/src/flowent/models/__pycache__/delta.cpython-313.pyc +0 -0
  45. package/backend/src/flowent/models/__pycache__/event.cpython-313.pyc +0 -0
  46. package/backend/src/flowent/models/__pycache__/graph.cpython-313.pyc +0 -0
  47. package/backend/src/flowent/models/__pycache__/history.cpython-313.pyc +0 -0
  48. package/backend/src/flowent/models/__pycache__/llm.cpython-313.pyc +0 -0
  49. package/backend/src/flowent/models/__pycache__/message.cpython-313.pyc +0 -0
  50. package/backend/src/flowent/models/__pycache__/tab.cpython-313.pyc +0 -0
  51. package/backend/src/flowent/models/__pycache__/todo.cpython-313.pyc +0 -0
  52. package/backend/src/flowent/models/agent.py +1 -0
  53. package/backend/src/flowent/models/graph.py +44 -9
  54. package/backend/src/flowent/models/history.py +73 -15
  55. package/backend/src/flowent/models/llm.py +1 -0
  56. package/backend/src/flowent/models/message.py +6 -0
  57. package/backend/src/flowent/models/tab.py +38 -1
  58. package/backend/src/flowent/{stats_service.py → observability_service.py} +4 -4
  59. package/backend/src/flowent/prompts/__pycache__/__init__.cpython-313.pyc +0 -0
  60. package/backend/src/flowent/prompts/__pycache__/common.cpython-313.pyc +0 -0
  61. package/backend/src/flowent/prompts/__pycache__/steward.cpython-313.pyc +0 -0
  62. package/backend/src/flowent/prompts/common.py +2 -2
  63. package/backend/src/flowent/prompts/steward.py +2 -2
  64. package/backend/src/flowent/providers/__pycache__/__init__.cpython-313.pyc +0 -0
  65. package/backend/src/flowent/providers/__pycache__/anthropic.cpython-313.pyc +0 -0
  66. package/backend/src/flowent/providers/__pycache__/base_url.cpython-313.pyc +0 -0
  67. package/backend/src/flowent/providers/__pycache__/configuration.cpython-313.pyc +0 -0
  68. package/backend/src/flowent/providers/__pycache__/content.cpython-313.pyc +0 -0
  69. package/backend/src/flowent/providers/__pycache__/errors.cpython-313.pyc +0 -0
  70. package/backend/src/flowent/providers/__pycache__/gateway.cpython-313.pyc +0 -0
  71. package/backend/src/flowent/providers/__pycache__/headers.cpython-313.pyc +0 -0
  72. package/backend/src/flowent/providers/__pycache__/management.cpython-313.pyc +0 -0
  73. package/backend/src/flowent/providers/__pycache__/openai.cpython-313.pyc +0 -0
  74. package/backend/src/flowent/providers/__pycache__/openai_responses.cpython-313.pyc +0 -0
  75. package/backend/src/flowent/providers/__pycache__/registry.cpython-313.pyc +0 -0
  76. package/backend/src/flowent/providers/__pycache__/sse.cpython-313.pyc +0 -0
  77. package/backend/src/flowent/providers/__pycache__/thinking.cpython-313.pyc +0 -0
  78. package/backend/src/flowent/providers/configuration.py +7 -0
  79. package/backend/src/flowent/role_management.py +12 -0
  80. package/backend/src/flowent/routes/__init__.py +0 -2
  81. package/backend/src/flowent/routes/__pycache__/__init__.cpython-313.pyc +0 -0
  82. package/backend/src/flowent/routes/__pycache__/access.cpython-313.pyc +0 -0
  83. package/backend/src/flowent/routes/__pycache__/assistant.cpython-313.pyc +0 -0
  84. package/backend/src/flowent/routes/__pycache__/image_assets.cpython-313.pyc +0 -0
  85. package/backend/src/flowent/routes/__pycache__/mcp.cpython-313.pyc +0 -0
  86. package/backend/src/flowent/routes/__pycache__/meta.cpython-313.pyc +0 -0
  87. package/backend/src/flowent/routes/__pycache__/nodes.cpython-313.pyc +0 -0
  88. package/backend/src/flowent/routes/__pycache__/prompts.cpython-313.pyc +0 -0
  89. package/backend/src/flowent/routes/__pycache__/providers_route.cpython-313.pyc +0 -0
  90. package/backend/src/flowent/routes/__pycache__/roles.cpython-313.pyc +0 -0
  91. package/backend/src/flowent/routes/__pycache__/settings.cpython-313.pyc +0 -0
  92. package/backend/src/flowent/routes/__pycache__/tabs.cpython-313.pyc +0 -0
  93. package/backend/src/flowent/routes/__pycache__/ws.cpython-313.pyc +0 -0
  94. package/backend/src/flowent/routes/assistant.py +4 -4
  95. package/backend/src/flowent/routes/nodes.py +54 -6
  96. package/backend/src/flowent/routes/providers_route.py +1 -0
  97. package/backend/src/flowent/routes/roles.py +1 -1
  98. package/backend/src/flowent/routes/settings.py +4 -0
  99. package/backend/src/flowent/routes/tabs.py +29 -11
  100. package/backend/src/flowent/runtime.py +7 -30
  101. package/backend/src/flowent/security.py +23 -8
  102. package/backend/src/flowent/settings.py +56 -5
  103. package/backend/src/flowent/settings_management.py +12 -0
  104. package/backend/src/flowent/static/assets/AssistantPage-VBohhz4d.js +1 -0
  105. package/backend/src/flowent/static/assets/ChannelsPage-CIydPZA_.js +1 -0
  106. package/backend/src/flowent/static/assets/McpPage-CHPm2TPY.js +7 -0
  107. package/backend/src/flowent/static/assets/PageScaffold-DteOA8V7.js +1 -0
  108. package/backend/src/flowent/static/assets/PromptsPage-CSmJ3sZg.js +1 -0
  109. package/backend/src/flowent/static/assets/ProvidersPage-sl2jeG4e.js +3 -0
  110. package/backend/src/flowent/static/assets/RolesPage-DCe7W6Km.js +1 -0
  111. package/backend/src/flowent/static/assets/SettingsPage-Bix9e63E.js +3 -0
  112. package/backend/src/flowent/static/assets/ToolsPage-favNkj5C.js +1 -0
  113. package/backend/src/flowent/static/assets/WorkspaceCommandDialog-DRS6wiD6.js +1 -0
  114. package/backend/src/flowent/static/assets/WorkspacePage-KuaDjt_D.js +3 -0
  115. package/backend/src/flowent/static/assets/WorkspacePanels-BZxBw8M5.js +1 -0
  116. package/backend/src/flowent/static/assets/alert-dialog-DIBUCmqM.js +1 -0
  117. package/backend/src/flowent/static/assets/{dialog-BeGSweF6.js → dialog-BOvHIBrg.js} +1 -1
  118. package/backend/src/flowent/static/assets/index-Biio-CoI.js +10 -0
  119. package/backend/src/flowent/static/assets/index-CmQvO7sl.css +1 -0
  120. package/backend/src/flowent/static/assets/modelParams-DcEhGnu0.js +1 -0
  121. package/backend/src/flowent/static/assets/roles-BbIEIMeG.js +1 -0
  122. package/backend/src/flowent/static/assets/select-D9SwnlXF.js +1 -0
  123. package/backend/src/flowent/static/assets/surface-Bzr1FRG4.js +1 -0
  124. package/backend/src/flowent/static/assets/{ui-vendor-Dg9NNnWX.js → ui-vendor-UazN8rcv.js} +15 -15
  125. package/backend/src/flowent/static/index.html +3 -4
  126. package/backend/src/flowent/tools/__init__.py +76 -2
  127. package/backend/src/flowent/tools/__pycache__/__init__.cpython-313.pyc +0 -0
  128. package/backend/src/flowent/tools/__pycache__/connect.cpython-313.pyc +0 -0
  129. package/backend/src/flowent/tools/__pycache__/contacts.cpython-313.pyc +0 -0
  130. package/backend/src/flowent/tools/__pycache__/create_agent.cpython-313.pyc +0 -0
  131. package/backend/src/flowent/tools/__pycache__/create_tab.cpython-313.pyc +0 -0
  132. package/backend/src/flowent/tools/__pycache__/delete_tab.cpython-313.pyc +0 -0
  133. package/backend/src/flowent/tools/__pycache__/edit.cpython-313.pyc +0 -0
  134. package/backend/src/flowent/tools/__pycache__/exec.cpython-313.pyc +0 -0
  135. package/backend/src/flowent/tools/__pycache__/fetch.cpython-313.pyc +0 -0
  136. package/backend/src/flowent/tools/__pycache__/idle.cpython-313.pyc +0 -0
  137. package/backend/src/flowent/tools/__pycache__/list_roles.cpython-313.pyc +0 -0
  138. package/backend/src/flowent/tools/__pycache__/list_tabs.cpython-313.pyc +0 -0
  139. package/backend/src/flowent/tools/__pycache__/list_tools.cpython-313.pyc +0 -0
  140. package/backend/src/flowent/tools/__pycache__/manage_prompts.cpython-313.pyc +0 -0
  141. package/backend/src/flowent/tools/__pycache__/manage_providers.cpython-313.pyc +0 -0
  142. package/backend/src/flowent/tools/__pycache__/manage_roles.cpython-313.pyc +0 -0
  143. package/backend/src/flowent/tools/__pycache__/manage_settings.cpython-313.pyc +0 -0
  144. package/backend/src/flowent/tools/__pycache__/mcp.cpython-313.pyc +0 -0
  145. package/backend/src/flowent/tools/__pycache__/read.cpython-313.pyc +0 -0
  146. package/backend/src/flowent/tools/__pycache__/send.cpython-313.pyc +0 -0
  147. package/backend/src/flowent/tools/__pycache__/set_permissions.cpython-313.pyc +0 -0
  148. package/backend/src/flowent/tools/__pycache__/sleep.cpython-313.pyc +0 -0
  149. package/backend/src/flowent/tools/__pycache__/todo.cpython-313.pyc +0 -0
  150. package/backend/src/flowent/tools/connect.py +10 -66
  151. package/backend/src/flowent/tools/contacts.py +1 -1
  152. package/backend/src/flowent/tools/create_agent.py +9 -88
  153. package/backend/src/flowent/tools/create_tab.py +7 -5
  154. package/backend/src/flowent/tools/exec.py +3 -2
  155. package/backend/src/flowent/tools/list_roles.py +29 -4
  156. package/backend/src/flowent/tools/list_tabs.py +4 -0
  157. package/backend/src/flowent/tools/list_tools.py +5 -1
  158. package/backend/src/flowent/tools/manage_settings.py +18 -0
  159. package/backend/src/flowent/tools/send.py +21 -3
  160. package/backend/src/flowent/tools/set_permissions.py +21 -6
  161. package/backend/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  162. package/backend/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  163. package/backend/tests/integration/api/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  164. package/backend/tests/integration/api/__pycache__/test_access_api.cpython-313-pytest-9.0.3.pyc +0 -0
  165. package/backend/tests/integration/api/__pycache__/test_assistant_api.cpython-313-pytest-9.0.3.pyc +0 -0
  166. package/backend/tests/integration/api/__pycache__/test_frontend_mounting.cpython-313-pytest-9.0.3.pyc +0 -0
  167. package/backend/tests/integration/api/__pycache__/test_mcp_api.cpython-313-pytest-9.0.3.pyc +0 -0
  168. package/backend/tests/integration/api/__pycache__/test_meta_api.cpython-313-pytest-9.0.3.pyc +0 -0
  169. package/backend/tests/integration/api/__pycache__/test_nodes_api.cpython-313-pytest-9.0.3.pyc +0 -0
  170. package/backend/tests/integration/api/__pycache__/test_prompts_api.cpython-313-pytest-9.0.3.pyc +0 -0
  171. package/backend/tests/integration/api/__pycache__/test_roles_api.cpython-313-pytest-9.0.3.pyc +0 -0
  172. package/backend/tests/integration/api/__pycache__/test_tabs_api.cpython-313-pytest-9.0.3.pyc +0 -0
  173. package/backend/tests/integration/api/test_assistant_api.py +1 -1
  174. package/backend/tests/integration/api/test_nodes_api.py +257 -21
  175. package/backend/tests/integration/api/test_roles_api.py +3 -2
  176. package/backend/tests/integration/api/test_tabs_api.py +312 -11
  177. package/backend/tests/unit/__pycache__/test_access.cpython-313-pytest-9.0.3.pyc +0 -0
  178. package/backend/tests/unit/__pycache__/test_cli.cpython-313-pytest-9.0.3.pyc +0 -0
  179. package/backend/tests/unit/__pycache__/test_graph_runtime.cpython-313-pytest-9.0.3.pyc +0 -0
  180. package/backend/tests/unit/__pycache__/test_network.cpython-313-pytest-9.0.3.pyc +0 -0
  181. package/backend/tests/unit/__pycache__/test_state_sqlite_storage.cpython-313-pytest-9.0.3.pyc +0 -0
  182. package/backend/tests/unit/__pycache__/test_workspace_store.cpython-313-pytest-9.0.3.pyc +0 -0
  183. package/backend/tests/unit/agent/__pycache__/test_agent_public_api.cpython-313-pytest-9.0.3.pyc +0 -0
  184. package/backend/tests/unit/agent/__pycache__/test_agent_runtime.cpython-313-pytest-9.0.3.pyc +0 -0
  185. package/backend/tests/unit/agent/test_agent_public_api.py +162 -71
  186. package/backend/tests/unit/agent/test_agent_runtime.py +285 -69
  187. package/backend/tests/unit/channels/__pycache__/test_telegram_channel.cpython-313-pytest-9.0.3.pyc +0 -0
  188. package/backend/tests/unit/logging/__pycache__/test_logging.cpython-313-pytest-9.0.3.pyc +0 -0
  189. package/backend/tests/unit/prompts/__pycache__/test_prompts.cpython-313-pytest-9.0.3.pyc +0 -0
  190. package/backend/tests/unit/prompts/test_prompts.py +3 -2
  191. package/backend/tests/unit/providers/__pycache__/test_anthropic_provider.cpython-313-pytest-9.0.3.pyc +0 -0
  192. package/backend/tests/unit/providers/__pycache__/test_errors.cpython-313-pytest-9.0.3.pyc +0 -0
  193. package/backend/tests/unit/providers/__pycache__/test_extract_delta_parts.cpython-313-pytest-9.0.3.pyc +0 -0
  194. package/backend/tests/unit/providers/__pycache__/test_openai_provider.cpython-313-pytest-9.0.3.pyc +0 -0
  195. package/backend/tests/unit/providers/__pycache__/test_openai_responses.cpython-313-pytest-9.0.3.pyc +0 -0
  196. package/backend/tests/unit/providers/__pycache__/test_provider_gateway.cpython-313-pytest-9.0.3.pyc +0 -0
  197. package/backend/tests/unit/providers/__pycache__/test_think_tag_parser.cpython-313-pytest-9.0.3.pyc +0 -0
  198. package/backend/tests/unit/routes/__pycache__/test_prompts_routes.cpython-313-pytest-9.0.3.pyc +0 -0
  199. package/backend/tests/unit/routes/__pycache__/test_providers_route.cpython-313-pytest-9.0.3.pyc +0 -0
  200. package/backend/tests/unit/routes/__pycache__/test_roles_routes.cpython-313-pytest-9.0.3.pyc +0 -0
  201. package/backend/tests/unit/routes/__pycache__/test_settings_routes.cpython-313-pytest-9.0.3.pyc +0 -0
  202. package/backend/tests/unit/routes/test_providers_route.py +2 -0
  203. package/backend/tests/unit/routes/test_roles_routes.py +109 -0
  204. package/backend/tests/unit/routes/test_settings_routes.py +4 -0
  205. package/backend/tests/unit/runtime/__pycache__/test_bootstrap_runtime.cpython-313-pytest-9.0.3.pyc +0 -0
  206. package/backend/tests/unit/runtime/test_bootstrap_runtime.py +8 -18
  207. package/backend/tests/unit/sandbox/__pycache__/test_sandbox_tools.cpython-313-pytest-9.0.3.pyc +0 -0
  208. package/backend/tests/unit/security/__pycache__/test_security.cpython-313-pytest-9.0.3.pyc +0 -0
  209. package/backend/tests/unit/security/test_security.py +16 -2
  210. package/backend/tests/unit/settings/__pycache__/test_settings_roles.cpython-313-pytest-9.0.3.pyc +0 -0
  211. package/backend/tests/unit/settings/test_settings_roles.py +40 -0
  212. package/backend/tests/unit/test_state_sqlite_storage.py +67 -1
  213. package/backend/tests/unit/tools/__pycache__/test_connect_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  214. package/backend/tests/unit/tools/__pycache__/test_create_agent_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  215. package/backend/tests/unit/tools/__pycache__/test_delete_tab_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  216. package/backend/tests/unit/tools/__pycache__/test_edit_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  217. package/backend/tests/unit/tools/__pycache__/test_exec_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  218. package/backend/tests/unit/tools/__pycache__/test_fetch_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  219. package/backend/tests/unit/tools/__pycache__/test_manage_prompts_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  220. package/backend/tests/unit/tools/__pycache__/test_manage_providers_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  221. package/backend/tests/unit/tools/__pycache__/test_manage_roles_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  222. package/backend/tests/unit/tools/__pycache__/test_manage_settings_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  223. package/backend/tests/unit/tools/__pycache__/test_read_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  224. package/backend/tests/unit/tools/__pycache__/test_set_permissions_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  225. package/backend/tests/unit/tools/__pycache__/test_todo_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  226. package/backend/tests/unit/tools/__pycache__/test_tool_registry.cpython-313-pytest-9.0.3.pyc +0 -0
  227. package/backend/tests/unit/tools/test_connect_tool.py +2 -3
  228. package/backend/tests/unit/tools/test_create_agent_tool.py +9 -97
  229. package/backend/tests/unit/tools/test_delete_tab_tool.py +33 -0
  230. package/backend/tests/unit/tools/test_manage_providers_tool.py +2 -0
  231. package/backend/tests/unit/tools/test_manage_settings_tool.py +3 -0
  232. package/backend/tests/unit/tools/test_set_permissions_tool.py +216 -12
  233. package/backend/tests/unit/tools/test_tool_registry.py +103 -0
  234. package/backend/uv.lock +1 -1
  235. package/dist/frontend/assets/AssistantPage-VBohhz4d.js +1 -0
  236. package/dist/frontend/assets/ChannelsPage-CIydPZA_.js +1 -0
  237. package/dist/frontend/assets/McpPage-CHPm2TPY.js +7 -0
  238. package/dist/frontend/assets/PageScaffold-DteOA8V7.js +1 -0
  239. package/dist/frontend/assets/PromptsPage-CSmJ3sZg.js +1 -0
  240. package/dist/frontend/assets/ProvidersPage-sl2jeG4e.js +3 -0
  241. package/dist/frontend/assets/RolesPage-DCe7W6Km.js +1 -0
  242. package/dist/frontend/assets/SettingsPage-Bix9e63E.js +3 -0
  243. package/dist/frontend/assets/ToolsPage-favNkj5C.js +1 -0
  244. package/dist/frontend/assets/WorkspaceCommandDialog-DRS6wiD6.js +1 -0
  245. package/dist/frontend/assets/WorkspacePage-KuaDjt_D.js +3 -0
  246. package/dist/frontend/assets/WorkspacePanels-BZxBw8M5.js +1 -0
  247. package/dist/frontend/assets/alert-dialog-DIBUCmqM.js +1 -0
  248. package/dist/frontend/assets/{dialog-BeGSweF6.js → dialog-BOvHIBrg.js} +1 -1
  249. package/dist/frontend/assets/index-Biio-CoI.js +10 -0
  250. package/dist/frontend/assets/index-CmQvO7sl.css +1 -0
  251. package/dist/frontend/assets/modelParams-DcEhGnu0.js +1 -0
  252. package/dist/frontend/assets/roles-BbIEIMeG.js +1 -0
  253. package/dist/frontend/assets/select-D9SwnlXF.js +1 -0
  254. package/dist/frontend/assets/surface-Bzr1FRG4.js +1 -0
  255. package/dist/frontend/assets/{ui-vendor-Dg9NNnWX.js → ui-vendor-UazN8rcv.js} +15 -15
  256. package/dist/frontend/index.html +3 -4
  257. package/package.json +3 -3
  258. package/backend/src/flowent/routes/__pycache__/stats.cpython-313.pyc +0 -0
  259. package/backend/src/flowent/routes/stats.py +0 -229
  260. package/backend/src/flowent/static/assets/AssistantPage-B3Xc08AS.js +0 -1
  261. package/backend/src/flowent/static/assets/ChannelsPage-ByLd28xk.js +0 -1
  262. package/backend/src/flowent/static/assets/HomePage-C0hAx9_l.js +0 -3
  263. package/backend/src/flowent/static/assets/McpPage-DkrYLvBv.js +0 -7
  264. package/backend/src/flowent/static/assets/PageScaffold-D4jO9ooX.js +0 -1
  265. package/backend/src/flowent/static/assets/PromptsPage-DWA7rRJd.js +0 -1
  266. package/backend/src/flowent/static/assets/ProvidersPage-PUWT8seJ.js +0 -3
  267. package/backend/src/flowent/static/assets/RolesPage-CqcclGRw.js +0 -1
  268. package/backend/src/flowent/static/assets/SettingsPage-8tS2cJgX.js +0 -3
  269. package/backend/src/flowent/static/assets/StatsPage-BX9khYzu.js +0 -1
  270. package/backend/src/flowent/static/assets/ToolsPage-9Tl9FdeD.js +0 -1
  271. package/backend/src/flowent/static/assets/WorkspaceCommandDialog-CCXxjDL8.js +0 -1
  272. package/backend/src/flowent/static/assets/WorkspacePanels-aMdJ7ZH7.js +0 -1
  273. package/backend/src/flowent/static/assets/alert-dialog-kFYVQ7oX.js +0 -1
  274. package/backend/src/flowent/static/assets/badge-74-3jsCg.js +0 -1
  275. package/backend/src/flowent/static/assets/constants-XUzFf6i1.js +0 -1
  276. package/backend/src/flowent/static/assets/index-BHC1Vhy8.css +0 -1
  277. package/backend/src/flowent/static/assets/index-CL1ALZ3r.js +0 -10
  278. package/backend/src/flowent/static/assets/modelParams-CaHd0903.js +0 -1
  279. package/backend/src/flowent/static/assets/roles-2OLDeTc5.js +0 -1
  280. package/backend/src/flowent/static/assets/select-DL_LPeDj.js +0 -1
  281. package/backend/src/flowent/static/assets/shared-CMxbpLeQ.js +0 -1
  282. package/backend/tests/unit/routes/__pycache__/test_stats_routes.cpython-313-pytest-9.0.3.pyc +0 -0
  283. package/backend/tests/unit/routes/test_stats_routes.py +0 -149
  284. package/dist/frontend/assets/AssistantPage-B3Xc08AS.js +0 -1
  285. package/dist/frontend/assets/ChannelsPage-ByLd28xk.js +0 -1
  286. package/dist/frontend/assets/HomePage-C0hAx9_l.js +0 -3
  287. package/dist/frontend/assets/McpPage-DkrYLvBv.js +0 -7
  288. package/dist/frontend/assets/PageScaffold-D4jO9ooX.js +0 -1
  289. package/dist/frontend/assets/PromptsPage-DWA7rRJd.js +0 -1
  290. package/dist/frontend/assets/ProvidersPage-PUWT8seJ.js +0 -3
  291. package/dist/frontend/assets/RolesPage-CqcclGRw.js +0 -1
  292. package/dist/frontend/assets/SettingsPage-8tS2cJgX.js +0 -3
  293. package/dist/frontend/assets/StatsPage-BX9khYzu.js +0 -1
  294. package/dist/frontend/assets/ToolsPage-9Tl9FdeD.js +0 -1
  295. package/dist/frontend/assets/WorkspaceCommandDialog-CCXxjDL8.js +0 -1
  296. package/dist/frontend/assets/WorkspacePanels-aMdJ7ZH7.js +0 -1
  297. package/dist/frontend/assets/alert-dialog-kFYVQ7oX.js +0 -1
  298. package/dist/frontend/assets/badge-74-3jsCg.js +0 -1
  299. package/dist/frontend/assets/constants-XUzFf6i1.js +0 -1
  300. package/dist/frontend/assets/index-BHC1Vhy8.css +0 -1
  301. package/dist/frontend/assets/index-CL1ALZ3r.js +0 -10
  302. package/dist/frontend/assets/modelParams-CaHd0903.js +0 -1
  303. package/dist/frontend/assets/roles-2OLDeTc5.js +0 -1
  304. package/dist/frontend/assets/select-DL_LPeDj.js +0 -1
  305. package/dist/frontend/assets/shared-CMxbpLeQ.js +0 -1
  306. /package/backend/src/flowent/static/assets/{datetime-m6_O_Ci9.js → datetime-eJqd0V2S.js} +0 -0
  307. /package/backend/src/flowent/static/assets/{markdown-vendor-DVdy_w12.js → markdown-vendor-C9RtvaJh.js} +0 -0
  308. /package/backend/src/flowent/static/assets/{triState-DEr3NkXV.js → triState-DgLlKdRR.js} +0 -0
  309. /package/dist/frontend/assets/{datetime-m6_O_Ci9.js → datetime-eJqd0V2S.js} +0 -0
  310. /package/dist/frontend/assets/{markdown-vendor-DVdy_w12.js → markdown-vendor-C9RtvaJh.js} +0 -0
  311. /package/dist/frontend/assets/{triState-DEr3NkXV.js → triState-DgLlKdRR.js} +0 -0
@@ -1,7 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import ast
4
+ import json
5
+ import shutil
6
+ import subprocess
3
7
  import uuid
4
8
  from copy import deepcopy
9
+ from dataclasses import dataclass
10
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
5
11
 
6
12
  from flowent import settings as settings_module
7
13
  from flowent.events import event_bus
@@ -16,12 +22,17 @@ from flowent.models import (
16
22
  NodeConfig,
17
23
  NodeType,
18
24
  PortDirection,
25
+ PortInboundEntry,
26
+ PortType,
19
27
  ReceivedMessage,
20
28
  Tab,
29
+ WorkflowActivationState,
21
30
  WorkflowDefinition,
22
31
  WorkflowNodeDefinition,
23
32
  WorkflowNodeKind,
24
33
  WorkflowPort,
34
+ content_parts_to_text,
35
+ deserialize_content_parts,
25
36
  )
26
37
  from flowent.registry import registry
27
38
  from flowent.runtime import SYSTEM_NODE_TIMEOUT
@@ -33,20 +44,56 @@ from flowent.settings import (
33
44
  STEWARD_ROLE_INCLUDED_TOOLS,
34
45
  STEWARD_ROLE_NAME,
35
46
  build_assistant_write_dirs,
47
+ find_provider,
36
48
  find_role,
49
+ resolve_model_info,
37
50
  resolve_path,
38
51
  )
39
- from flowent.tools import MINIMUM_TOOLS
52
+ from flowent.tools import (
53
+ MINIMUM_TOOLS,
54
+ is_assistant_only_mcp_tool_name,
55
+ is_assistant_only_tool_name,
56
+ )
40
57
  from flowent.workspace_store import workspace_store
41
58
 
42
59
  LEADER_NODE_NAME = "Leader"
43
60
 
44
61
 
62
+ @dataclass(frozen=True)
63
+ class ContactPath:
64
+ target_id: str
65
+ target_node_type: str
66
+ target_role_name: str | None
67
+ target_name: str | None
68
+ target_state: str | None
69
+ is_leader: bool
70
+ from_output_port_key: str
71
+ to_input_port_key: str
72
+ port_type: str
73
+ edge_id: str
74
+
75
+ def serialize(self) -> dict[str, object]:
76
+ return {
77
+ "id": self.target_id,
78
+ "target_id": self.target_id,
79
+ "node_type": self.target_node_type,
80
+ "role_name": self.target_role_name,
81
+ "name": self.target_name,
82
+ "state": self.target_state,
83
+ "is_leader": self.is_leader,
84
+ "from_output_port_key": self.from_output_port_key,
85
+ "to_input_port_key": self.to_input_port_key,
86
+ "port_type": self.port_type,
87
+ "edge_id": self.edge_id,
88
+ }
89
+
90
+
45
91
  def build_tools_for_role(
46
92
  role_name: str,
47
93
  *,
48
94
  requested_tools: list[str] | None = None,
49
95
  settings=None,
96
+ assistant_boundary: bool = False,
50
97
  ) -> list[str]:
51
98
  current_settings = settings or settings_module.get_settings()
52
99
  normalized_role_name = role_name.strip()
@@ -70,6 +117,11 @@ def build_tools_for_role(
70
117
  for tool_name in [*MINIMUM_TOOLS, *included_tools, *(requested_tools or [])]:
71
118
  if tool_name in seen_tools:
72
119
  continue
120
+ if not assistant_boundary and (
121
+ is_assistant_only_tool_name(tool_name)
122
+ or is_assistant_only_mcp_tool_name(tool_name)
123
+ ):
124
+ continue
73
125
  if tool_name in excluded_tools and tool_name not in MINIMUM_TOOLS:
74
126
  continue
75
127
  final_tools.append(tool_name)
@@ -82,6 +134,7 @@ def build_assistant_tools(*, settings=None) -> list[str]:
82
134
  assistant_tools = build_tools_for_role(
83
135
  current_settings.assistant.role_name,
84
136
  settings=current_settings,
137
+ assistant_boundary=True,
85
138
  )
86
139
  final_tools: list[str] = []
87
140
  seen_tools: set[str] = set()
@@ -126,87 +179,160 @@ def is_tab_leader(*, node_id: str, tab_id: str | None = None) -> bool:
126
179
  return get_tab_leader_id(resolved_tab_id) == node_id
127
180
 
128
181
 
182
+ def _coerce_port_type(raw_value: object, default: PortType) -> PortType:
183
+ try:
184
+ return PortType(str(raw_value))
185
+ except ValueError:
186
+ return default
187
+
188
+
189
+ def _port_from_code_config(
190
+ raw_port: object,
191
+ *,
192
+ direction: PortDirection,
193
+ ) -> WorkflowPort | None:
194
+ if not isinstance(raw_port, dict):
195
+ return None
196
+ key = raw_port.get("key")
197
+ if not isinstance(key, str) or not key.strip():
198
+ return None
199
+ try:
200
+ port_type = PortType(str(raw_port.get("type")))
201
+ except ValueError:
202
+ return None
203
+ return WorkflowPort(
204
+ key=key.strip(),
205
+ direction=direction,
206
+ type=port_type,
207
+ required=bool(raw_port.get("required", direction == PortDirection.INPUT)),
208
+ multiple=bool(raw_port.get("multiple", False)),
209
+ )
210
+
211
+
212
+ def _build_code_ports(
213
+ config: dict[str, object],
214
+ ) -> tuple[list[WorkflowPort], list[WorkflowPort]]:
215
+ raw_inputs = config.get("inputs")
216
+ raw_outputs = config.get("outputs")
217
+ inputs = [
218
+ port
219
+ for port in (
220
+ _port_from_code_config(item, direction=PortDirection.INPUT)
221
+ for item in (raw_inputs if isinstance(raw_inputs, list) else [])
222
+ )
223
+ if port is not None
224
+ ]
225
+ outputs = [
226
+ port
227
+ for port in (
228
+ _port_from_code_config(item, direction=PortDirection.OUTPUT)
229
+ for item in (raw_outputs if isinstance(raw_outputs, list) else [])
230
+ )
231
+ if port is not None
232
+ ]
233
+ if not inputs:
234
+ inputs = [
235
+ WorkflowPort(
236
+ key="in",
237
+ direction=PortDirection.INPUT,
238
+ type=PortType.PARTS,
239
+ required=True,
240
+ )
241
+ ]
242
+ if not outputs:
243
+ outputs = [
244
+ WorkflowPort(
245
+ key="out",
246
+ direction=PortDirection.OUTPUT,
247
+ type=PortType.PARTS,
248
+ multiple=True,
249
+ )
250
+ ]
251
+ return inputs, outputs
252
+
253
+
129
254
  def _default_ports(
130
255
  node_kind: WorkflowNodeKind,
256
+ config: dict[str, object] | None = None,
131
257
  ) -> tuple[list[WorkflowPort], list[WorkflowPort]]:
258
+ node_config = config or {}
132
259
  if node_kind == WorkflowNodeKind.TRIGGER:
260
+ output_type = _coerce_port_type(node_config.get("output_type"), PortType.PARTS)
133
261
  return (
134
262
  [],
135
263
  [
136
264
  WorkflowPort(
137
265
  key="out",
138
266
  direction=PortDirection.OUTPUT,
139
- kind=EdgeKind.CONTROL,
267
+ type=output_type,
140
268
  multiple=True,
141
269
  )
142
270
  ],
143
271
  )
144
- if node_kind == WorkflowNodeKind.CODE:
272
+ if node_kind == WorkflowNodeKind.LLM:
273
+ input_type = _coerce_port_type(node_config.get("input_type"), PortType.PARTS)
274
+ output_type = _coerce_port_type(node_config.get("output_type"), PortType.PARTS)
145
275
  return (
146
276
  [
147
277
  WorkflowPort(
148
278
  key="in",
149
279
  direction=PortDirection.INPUT,
150
- kind=EdgeKind.CONTROL,
151
- ),
152
- WorkflowPort(
153
- key="input",
154
- direction=PortDirection.INPUT,
155
- kind=EdgeKind.DATA,
156
- multiple=True,
157
- ),
280
+ type=input_type,
281
+ required=True,
282
+ )
158
283
  ],
159
284
  [
160
285
  WorkflowPort(
161
286
  key="out",
162
287
  direction=PortDirection.OUTPUT,
163
- kind=EdgeKind.CONTROL,
288
+ type=output_type,
164
289
  multiple=True,
165
- ),
166
- WorkflowPort(
167
- key="output",
168
- direction=PortDirection.OUTPUT,
169
- kind=EdgeKind.DATA,
170
- multiple=True,
171
- ),
290
+ )
172
291
  ],
173
292
  )
293
+ if node_kind == WorkflowNodeKind.CODE:
294
+ return _build_code_ports(node_config)
174
295
  if node_kind == WorkflowNodeKind.IF:
296
+ input_type = _coerce_port_type(node_config.get("input_type"), PortType.PARTS)
175
297
  return (
176
298
  [
177
299
  WorkflowPort(
178
300
  key="in",
179
301
  direction=PortDirection.INPUT,
180
- kind=EdgeKind.CONTROL,
181
- ),
182
- WorkflowPort(
183
- key="condition",
184
- direction=PortDirection.INPUT,
185
- kind=EdgeKind.DATA,
302
+ type=input_type,
303
+ required=True,
186
304
  ),
187
305
  ],
188
306
  [
189
307
  WorkflowPort(
190
- key="true",
308
+ key="then",
191
309
  direction=PortDirection.OUTPUT,
192
- kind=EdgeKind.CONTROL,
310
+ type=input_type,
193
311
  multiple=True,
194
312
  ),
195
313
  WorkflowPort(
196
- key="false",
314
+ key="else",
197
315
  direction=PortDirection.OUTPUT,
198
- kind=EdgeKind.CONTROL,
316
+ type=input_type,
199
317
  multiple=True,
200
318
  ),
201
319
  ],
202
320
  )
203
321
  if node_kind == WorkflowNodeKind.MERGE:
322
+ input_type = _coerce_port_type(node_config.get("input_type"), PortType.PARTS)
323
+ strategy = node_config.get("strategy")
324
+ output_type = (
325
+ input_type
326
+ if strategy == "first_completed"
327
+ else _coerce_port_type(node_config.get("output_type"), PortType.JSON)
328
+ )
204
329
  return (
205
330
  [
206
331
  WorkflowPort(
207
332
  key="in",
208
333
  direction=PortDirection.INPUT,
209
- kind=EdgeKind.CONTROL,
334
+ type=input_type,
335
+ required=True,
210
336
  multiple=True,
211
337
  )
212
338
  ],
@@ -214,7 +340,26 @@ def _default_ports(
214
340
  WorkflowPort(
215
341
  key="out",
216
342
  direction=PortDirection.OUTPUT,
217
- kind=EdgeKind.CONTROL,
343
+ type=output_type,
344
+ multiple=True,
345
+ )
346
+ ],
347
+ )
348
+ if node_kind == WorkflowNodeKind.AGENT:
349
+ return (
350
+ [
351
+ WorkflowPort(
352
+ key="in",
353
+ direction=PortDirection.INPUT,
354
+ type=PortType.PARTS,
355
+ required=False,
356
+ )
357
+ ],
358
+ [
359
+ WorkflowPort(
360
+ key="out",
361
+ direction=PortDirection.OUTPUT,
362
+ type=PortType.PARTS,
218
363
  multiple=True,
219
364
  )
220
365
  ],
@@ -224,14 +369,15 @@ def _default_ports(
224
369
  WorkflowPort(
225
370
  key="in",
226
371
  direction=PortDirection.INPUT,
227
- kind=EdgeKind.CONTROL,
372
+ type=PortType.PARTS,
373
+ required=True,
228
374
  )
229
375
  ],
230
376
  [
231
377
  WorkflowPort(
232
378
  key="out",
233
379
  direction=PortDirection.OUTPUT,
234
- kind=EdgeKind.CONTROL,
380
+ type=PortType.PARTS,
235
381
  multiple=True,
236
382
  )
237
383
  ],
@@ -244,7 +390,7 @@ def build_workflow_node_definition(
244
390
  node_kind: WorkflowNodeKind,
245
391
  config: dict[str, object] | None = None,
246
392
  ) -> WorkflowNodeDefinition:
247
- inputs, outputs = _default_ports(node_kind)
393
+ inputs, outputs = _default_ports(node_kind, config)
248
394
  return WorkflowNodeDefinition(
249
395
  id=node_id,
250
396
  type=node_kind,
@@ -268,6 +414,376 @@ def get_workflow_node(tab_id: str, node_id: str) -> WorkflowNodeDefinition | Non
268
414
  return tab.definition.get_node(node_id)
269
415
 
270
416
 
417
+ def get_workflow_node_definition(node_id: str) -> WorkflowNodeDefinition | None:
418
+ record = workspace_store.get_node_record(node_id)
419
+ if record is not None and record.config.tab_id:
420
+ return get_workflow_node(record.config.tab_id, node_id)
421
+ live_node = registry.get(node_id)
422
+ if live_node is not None and live_node.config.tab_id:
423
+ return get_workflow_node(live_node.config.tab_id, node_id)
424
+ for tab in workspace_store.list_tabs():
425
+ node = tab.definition.get_node(node_id)
426
+ if node is not None:
427
+ return node
428
+ return None
429
+
430
+
431
+ def resolve_workflow_node_ref(*, tab_id: str, node_ref: str) -> str | None:
432
+ target = registry.get(node_ref)
433
+ if target is not None and target.config.tab_id == tab_id:
434
+ return target.uuid
435
+
436
+ tab = workspace_store.get_tab(tab_id)
437
+ if tab is None:
438
+ return None
439
+
440
+ definition_nodes = list(tab.definition.nodes)
441
+ exact_match = next((node for node in definition_nodes if node.id == node_ref), None)
442
+ if exact_match is not None:
443
+ return exact_match.id
444
+
445
+ named_matches = [
446
+ node
447
+ for node in definition_nodes
448
+ if isinstance(node.config.get("name"), str) and node.config["name"] == node_ref
449
+ ]
450
+ if len(named_matches) == 1:
451
+ return named_matches[0].id
452
+
453
+ role_matches = [
454
+ node
455
+ for node in definition_nodes
456
+ if node.type == WorkflowNodeKind.AGENT
457
+ and isinstance(node.config.get("role_name"), str)
458
+ and node.config["role_name"] == node_ref
459
+ ]
460
+ if len(role_matches) == 1:
461
+ return role_matches[0].id
462
+
463
+ if 4 <= len(node_ref) < 36:
464
+ prefix_matches = [
465
+ node for node in definition_nodes if node.id.startswith(node_ref)
466
+ ]
467
+ if len(prefix_matches) == 1:
468
+ return prefix_matches[0].id
469
+ return None
470
+
471
+
472
+ def _resolve_contact_target_metadata(
473
+ *,
474
+ tab_id: str,
475
+ node_id: str,
476
+ ) -> tuple[str, str | None, str | None, str | None, bool]:
477
+ live_node = registry.get(node_id)
478
+ if live_node is not None:
479
+ return (
480
+ live_node.config.node_type.value,
481
+ live_node.config.role_name,
482
+ live_node.config.name,
483
+ live_node.state.value,
484
+ is_tab_leader(node_id=node_id, tab_id=tab_id),
485
+ )
486
+
487
+ record = workspace_store.get_node_record(node_id)
488
+ if record is not None:
489
+ return (
490
+ record.config.node_type.value,
491
+ record.config.role_name,
492
+ record.config.name,
493
+ record.state.value,
494
+ is_tab_leader(node_id=node_id, tab_id=tab_id),
495
+ )
496
+
497
+ definition = get_workflow_node(tab_id, node_id)
498
+ if definition is None:
499
+ return ("agent", None, None, None, False)
500
+ return (
501
+ definition.type.value,
502
+ str(definition.config["role_name"])
503
+ if isinstance(definition.config.get("role_name"), str)
504
+ else None,
505
+ str(definition.config["name"])
506
+ if isinstance(definition.config.get("name"), str)
507
+ else None,
508
+ None,
509
+ False,
510
+ )
511
+
512
+
513
+ def list_agent_contact_paths(*, tab_id: str, node_id: str) -> list[ContactPath]:
514
+ if is_tab_leader(node_id=node_id, tab_id=tab_id):
515
+ return []
516
+ tab = workspace_store.get_tab(tab_id)
517
+ if tab is None:
518
+ return []
519
+
520
+ paths: list[ContactPath] = []
521
+ source_node = tab.definition.get_node(node_id)
522
+ if source_node is None or source_node.type != WorkflowNodeKind.AGENT:
523
+ return paths
524
+
525
+ for edge in sorted(
526
+ tab.definition.edges, key=lambda item: (item.created_at, item.id)
527
+ ):
528
+ if edge.from_node_id != node_id:
529
+ continue
530
+ source_port = _port_matches(
531
+ source_node.outputs,
532
+ port_key=edge.from_port_key,
533
+ direction=PortDirection.OUTPUT,
534
+ )
535
+ target_node = tab.definition.get_node(edge.to_node_id)
536
+ if source_port is None or target_node is None:
537
+ continue
538
+ target_port = _port_matches(
539
+ target_node.inputs,
540
+ port_key=edge.to_port_key,
541
+ direction=PortDirection.INPUT,
542
+ )
543
+ if target_port is None or source_port.type != target_port.type:
544
+ continue
545
+ node_type, role_name, name, state, is_leader_contact = (
546
+ _resolve_contact_target_metadata(tab_id=tab_id, node_id=edge.to_node_id)
547
+ )
548
+ paths.append(
549
+ ContactPath(
550
+ target_id=edge.to_node_id,
551
+ target_node_type=node_type,
552
+ target_role_name=role_name,
553
+ target_name=name,
554
+ target_state=state,
555
+ is_leader=is_leader_contact,
556
+ from_output_port_key=edge.from_port_key,
557
+ to_input_port_key=edge.to_port_key,
558
+ port_type=source_port.type.value,
559
+ edge_id=edge.id,
560
+ )
561
+ )
562
+ return paths
563
+
564
+
565
+ def resolve_agent_contact_path(
566
+ *,
567
+ tab_id: str,
568
+ source_node_id: str,
569
+ target_node_id: str,
570
+ from_output_port_key: str,
571
+ to_input_port_key: str,
572
+ ) -> ContactPath | None:
573
+ return next(
574
+ (
575
+ path
576
+ for path in list_agent_contact_paths(tab_id=tab_id, node_id=source_node_id)
577
+ if path.target_id == target_node_id
578
+ and path.from_output_port_key == from_output_port_key
579
+ and path.to_input_port_key == to_input_port_key
580
+ ),
581
+ None,
582
+ )
583
+
584
+
585
+ def _summarize_port_value(
586
+ value: object, *, port_type: PortType, limit: int = 240
587
+ ) -> str:
588
+ if port_type == PortType.PARTS:
589
+ text = content_parts_to_text(
590
+ deserialize_content_parts(value if isinstance(value, list) else None)
591
+ )
592
+ elif isinstance(value, str):
593
+ text = value
594
+ else:
595
+ try:
596
+ text = json.dumps(value, ensure_ascii=False, sort_keys=True)
597
+ except TypeError:
598
+ text = str(value)
599
+ text = " ".join(text.split())
600
+ if len(text) <= limit:
601
+ return text
602
+ return text[: limit - 3].rstrip() + "..."
603
+
604
+
605
+ def _node_label_snapshot(tab_id: str, node_id: str) -> str | None:
606
+ _, role_name, name, _, is_leader_contact = _resolve_contact_target_metadata(
607
+ tab_id=tab_id,
608
+ node_id=node_id,
609
+ )
610
+ if name:
611
+ return name
612
+ if is_leader_contact:
613
+ return LEADER_NODE_NAME
614
+ if role_name:
615
+ return role_name
616
+ return node_id[:8]
617
+
618
+
619
+ def dispatch_port_value(
620
+ *,
621
+ tab_id: str,
622
+ source_node_id: str,
623
+ source_output_port_key: str,
624
+ target_node_id: str,
625
+ target_input_port_key: str,
626
+ value: object,
627
+ source_is_agent_send: bool = False,
628
+ message_id: str | None = None,
629
+ ) -> tuple[dict[str, object] | None, str | None]:
630
+ tab = workspace_store.get_tab(tab_id)
631
+ if tab is None:
632
+ return None, f"Tab '{tab_id}' not found"
633
+ source_node = tab.definition.get_node(source_node_id)
634
+ target_node = tab.definition.get_node(target_node_id)
635
+ if source_node is None:
636
+ return None, f"Source node '{source_node_id}' not found"
637
+ if target_node is None:
638
+ return None, f"Target node '{target_node_id}' not found"
639
+ source_port = _port_matches(
640
+ source_node.outputs,
641
+ port_key=source_output_port_key,
642
+ direction=PortDirection.OUTPUT,
643
+ )
644
+ if source_port is None:
645
+ return None, f"Output port '{source_output_port_key}' is invalid"
646
+ target_port = _port_matches(
647
+ target_node.inputs,
648
+ port_key=target_input_port_key,
649
+ direction=PortDirection.INPUT,
650
+ )
651
+ if target_port is None:
652
+ return None, f"Input port '{target_input_port_key}' is invalid"
653
+ if source_port.type != target_port.type:
654
+ return (
655
+ None,
656
+ f"Port type mismatch: '{source_node.id}.{source_port.key}' is {source_port.type.value} "
657
+ f"but '{target_node.id}.{target_port.key}' is {target_port.type.value}",
658
+ )
659
+ if not _validate_typed_value(value, source_port.type):
660
+ return None, f"Value must match {source_port.type.value} port type"
661
+
662
+ edge = next(
663
+ (
664
+ item
665
+ for item in tab.definition.edges
666
+ if item.from_node_id == source_node_id
667
+ and item.to_node_id == target_node_id
668
+ and item.from_port_key == source_output_port_key
669
+ and item.to_port_key == target_input_port_key
670
+ ),
671
+ None,
672
+ )
673
+ if edge is None:
674
+ return None, "Send path is not connected"
675
+
676
+ value_summary = _summarize_port_value(value, port_type=source_port.type)
677
+ payload: dict[str, object] = {
678
+ "status": "sent",
679
+ "target_id": target_node_id,
680
+ "from_output_port_key": source_output_port_key,
681
+ "to_input_port_key": target_input_port_key,
682
+ "port_type": source_port.type.value,
683
+ "value_summary": value_summary,
684
+ }
685
+
686
+ target = registry.get(target_node_id)
687
+ if target is not None and target_node.type == WorkflowNodeKind.AGENT:
688
+ if source_port.type == PortType.PARTS:
689
+ parts = deserialize_content_parts(value)
690
+ if source_is_agent_send:
691
+ target._append_history(
692
+ ReceivedMessage(
693
+ from_id=source_node_id,
694
+ parts=parts,
695
+ message_id=message_id,
696
+ from_output_port_key=source_output_port_key,
697
+ to_input_port_key=target_input_port_key,
698
+ value_summary=value_summary,
699
+ )
700
+ )
701
+ target.enqueue_message(
702
+ Message(
703
+ from_id=source_node_id,
704
+ to_id=target_node_id,
705
+ parts=parts,
706
+ message_id=message_id,
707
+ history_recorded=True,
708
+ from_output_port_key=source_output_port_key,
709
+ to_input_port_key=target_input_port_key,
710
+ port_type=source_port.type.value,
711
+ value=value,
712
+ value_summary=value_summary,
713
+ )
714
+ )
715
+ else:
716
+ target._append_history(
717
+ PortInboundEntry(
718
+ from_id=source_node_id,
719
+ from_output_port_key=source_output_port_key,
720
+ to_input_port_key=target_input_port_key,
721
+ port_type=source_port.type.value,
722
+ value=value,
723
+ source_label=_node_label_snapshot(tab_id, source_node_id),
724
+ value_summary=value_summary,
725
+ )
726
+ )
727
+ target.enqueue_message(
728
+ Message(
729
+ from_id=source_node_id,
730
+ to_id=target_node_id,
731
+ parts=parts,
732
+ message_id=message_id,
733
+ history_recorded=True,
734
+ from_output_port_key=source_output_port_key,
735
+ to_input_port_key=target_input_port_key,
736
+ port_type=source_port.type.value,
737
+ value=value,
738
+ value_summary=value_summary,
739
+ port_inbound_recorded=True,
740
+ )
741
+ )
742
+ else:
743
+ target._append_history(
744
+ PortInboundEntry(
745
+ from_id=source_node_id,
746
+ from_output_port_key=source_output_port_key,
747
+ to_input_port_key=target_input_port_key,
748
+ port_type=source_port.type.value,
749
+ value=value,
750
+ source_label=_node_label_snapshot(tab_id, source_node_id),
751
+ value_summary=value_summary,
752
+ )
753
+ )
754
+ target.enqueue_message(
755
+ Message(
756
+ from_id=source_node_id,
757
+ to_id=target_node_id,
758
+ content=value_summary,
759
+ message_id=message_id,
760
+ history_recorded=True,
761
+ from_output_port_key=source_output_port_key,
762
+ to_input_port_key=target_input_port_key,
763
+ port_type=source_port.type.value,
764
+ value=value,
765
+ value_summary=value_summary,
766
+ port_inbound_recorded=True,
767
+ )
768
+ )
769
+
770
+ event_bus.emit(
771
+ Event(
772
+ type=EventType.NODE_MESSAGE,
773
+ agent_id=source_node_id,
774
+ data={
775
+ "to_id": target_node_id,
776
+ "content": value_summary,
777
+ "message_id": message_id,
778
+ "from_output_port_key": source_output_port_key,
779
+ "to_input_port_key": target_input_port_key,
780
+ "port_type": source_port.type.value,
781
+ },
782
+ ),
783
+ )
784
+ return payload, None
785
+
786
+
271
787
  def _sync_runtime_positions_into_definition(tab: Tab) -> bool:
272
788
  changed = False
273
789
  for record in workspace_store.list_node_records(tab.id):
@@ -290,6 +806,9 @@ def serialize_tab_summary(tab: Tab) -> dict[str, object]:
290
806
  "id": tab.id,
291
807
  "title": tab.title,
292
808
  "leader_id": tab.leader_id,
809
+ "activation_state": tab.activation_state.value,
810
+ "allow_network": tab.allow_network,
811
+ "write_dirs": list(tab.write_dirs),
293
812
  "created_at": tab.created_at,
294
813
  "updated_at": tab.updated_at,
295
814
  "definition": tab.definition.serialize(),
@@ -303,14 +822,8 @@ def _build_leader_record(
303
822
  tab_id: str,
304
823
  leader_id: str,
305
824
  settings,
306
- allow_network: bool = False,
307
- write_dirs: list[str] | None = None,
308
825
  ) -> GraphNodeRecord:
309
826
  role_name = resolve_leader_role_name(settings=settings)
310
- normalized_write_dirs = build_assistant_write_dirs(
311
- write_dirs or [],
312
- field_name="write_dirs",
313
- )
314
827
  return GraphNodeRecord(
315
828
  id=leader_id,
316
829
  config=NodeConfig(
@@ -319,13 +832,28 @@ def _build_leader_record(
319
832
  tab_id=tab_id,
320
833
  name=LEADER_NODE_NAME,
321
834
  tools=build_tools_for_role(role_name, settings=settings),
322
- write_dirs=normalized_write_dirs,
323
- allow_network=allow_network,
324
835
  ),
325
836
  state=AgentState.INITIALIZING,
326
837
  )
327
838
 
328
839
 
840
+ def _sync_tab_permissions_from_legacy_leader(tab: Tab) -> bool:
841
+ if tab.permissions_initialized:
842
+ return False
843
+ tab.permissions_initialized = True
844
+ if not tab.leader_id:
845
+ workspace_store.upsert_tab(tab)
846
+ return False
847
+ record = workspace_store.get_node_record(tab.leader_id)
848
+ if record is None:
849
+ workspace_store.upsert_tab(tab)
850
+ return False
851
+ tab.allow_network = record.config.allow_network
852
+ tab.write_dirs = list(record.config.write_dirs)
853
+ workspace_store.upsert_tab(tab)
854
+ return True
855
+
856
+
329
857
  def _sync_leader_record(
330
858
  *,
331
859
  tab_id: str,
@@ -359,7 +887,19 @@ def _start_persisted_agent(
359
887
  ) -> tuple[GraphNodeRecord | None, str | None]:
360
888
  from flowent.agent import Agent
361
889
 
362
- node = Agent(record.config, uuid=record.id)
890
+ allow_network, write_dirs = resolve_effective_permissions_for_node_record(record)
891
+ node = Agent(
892
+ NodeConfig(
893
+ node_type=record.config.node_type,
894
+ role_name=record.config.role_name,
895
+ tab_id=record.config.tab_id,
896
+ name=record.config.name,
897
+ tools=list(record.config.tools),
898
+ write_dirs=write_dirs,
899
+ allow_network=allow_network,
900
+ ),
901
+ uuid=record.id,
902
+ )
363
903
  registry.register(node)
364
904
  node.start()
365
905
  return workspace_store.get_node_record(record.id), None
@@ -416,6 +956,9 @@ def ensure_tab_leaders(*, start_nodes: bool = False) -> bool:
416
956
  workspace_store.upsert_tab(tab)
417
957
  changed = True
418
958
 
959
+ if _sync_tab_permissions_from_legacy_leader(tab):
960
+ changed = True
961
+
419
962
  if _sync_leader_record(tab_id=tab.id, record=leader_record, settings=settings):
420
963
  workspace_store.upsert_node_record(leader_record)
421
964
  changed = True
@@ -460,8 +1003,6 @@ def sync_tab_leaders(*, reason: str) -> None:
460
1003
  live_node.config.role_name = record.config.role_name
461
1004
  live_node.config.name = record.config.name
462
1005
  live_node.config.tools = list(record.config.tools)
463
- live_node.config.write_dirs = list(record.config.write_dirs)
464
- live_node.config.allow_network = record.config.allow_network
465
1006
  live_node._sync_system_prompt_entry()
466
1007
  live_node.set_state(
467
1008
  live_node.state,
@@ -514,14 +1055,18 @@ def create_tab(
514
1055
  title=title.strip(),
515
1056
  leader_id=leader_id,
516
1057
  definition=WorkflowDefinition(),
1058
+ allow_network=allow_network,
1059
+ write_dirs=build_assistant_write_dirs(
1060
+ write_dirs or [],
1061
+ field_name="write_dirs",
1062
+ ),
1063
+ permissions_initialized=True,
517
1064
  )
518
1065
  workspace_store.upsert_tab(tab)
519
1066
  leader_record = _build_leader_record(
520
1067
  tab_id=tab.id,
521
1068
  leader_id=leader_id,
522
1069
  settings=settings,
523
- allow_network=allow_network,
524
- write_dirs=write_dirs,
525
1070
  )
526
1071
  workspace_store.upsert_node_record(leader_record)
527
1072
  if registry.get_all():
@@ -544,13 +1089,7 @@ def duplicate_tab(
544
1089
  if source_tab is None:
545
1090
  return None, f"Tab '{tab_id}' not found"
546
1091
 
547
- leader_record = (
548
- workspace_store.get_node_record(source_tab.leader_id)
549
- if source_tab.leader_id
550
- else None
551
- )
552
- allow_network = leader_record.config.allow_network if leader_record else False
553
- write_dirs = list(leader_record.config.write_dirs) if leader_record else []
1092
+ _sync_tab_permissions_from_legacy_leader(source_tab)
554
1093
  duplicated_definition = WorkflowDefinition.from_mapping(
555
1094
  source_tab.definition.serialize()
556
1095
  )
@@ -589,6 +1128,9 @@ def duplicate_tab(
589
1128
  id=str(uuid.uuid4()),
590
1129
  title=f"{source_tab.title} Copy",
591
1130
  leader_id=str(uuid.uuid4()),
1131
+ allow_network=source_tab.allow_network,
1132
+ write_dirs=list(source_tab.write_dirs),
1133
+ permissions_initialized=True,
592
1134
  definition=WorkflowDefinition(
593
1135
  version=duplicated_definition.version,
594
1136
  nodes=duplicated_nodes,
@@ -605,8 +1147,6 @@ def duplicate_tab(
605
1147
  tab_id=new_tab.id,
606
1148
  leader_id=new_tab.leader_id,
607
1149
  settings=settings,
608
- allow_network=allow_network,
609
- write_dirs=write_dirs,
610
1150
  )
611
1151
  )
612
1152
 
@@ -654,15 +1194,30 @@ def _is_path_within_boundary(path: str, boundary_dirs: list[str]) -> bool:
654
1194
  )
655
1195
 
656
1196
 
657
- def _clamp_write_dirs_to_boundary(
658
- write_dirs: list[str],
659
- boundary_dirs: list[str],
660
- ) -> list[str]:
661
- if not boundary_dirs:
662
- return []
663
- return [
664
- path for path in write_dirs if _is_path_within_boundary(path, boundary_dirs)
665
- ]
1197
+ def resolve_effective_permissions_for_agent(agent) -> tuple[bool, list[str]]:
1198
+ if agent.config.node_type == NodeType.ASSISTANT:
1199
+ settings = settings_module.get_settings()
1200
+ return settings.assistant.allow_network, list(settings.assistant.write_dirs)
1201
+ if agent.config.tab_id:
1202
+ tab = workspace_store.get_tab(agent.config.tab_id)
1203
+ if tab is not None:
1204
+ _sync_tab_permissions_from_legacy_leader(tab)
1205
+ return tab.allow_network, list(tab.write_dirs)
1206
+ return agent.config.allow_network, list(agent.config.write_dirs)
1207
+
1208
+
1209
+ def resolve_effective_permissions_for_node_record(
1210
+ record: GraphNodeRecord,
1211
+ ) -> tuple[bool, list[str]]:
1212
+ if record.config.node_type == NodeType.ASSISTANT:
1213
+ settings = settings_module.get_settings()
1214
+ return settings.assistant.allow_network, list(settings.assistant.write_dirs)
1215
+ if record.config.tab_id:
1216
+ tab = workspace_store.get_tab(record.config.tab_id)
1217
+ if tab is not None:
1218
+ _sync_tab_permissions_from_legacy_leader(tab)
1219
+ return tab.allow_network, list(tab.write_dirs)
1220
+ return record.config.allow_network, list(record.config.write_dirs)
666
1221
 
667
1222
 
668
1223
  def set_tab_permissions(
@@ -677,6 +1232,9 @@ def set_tab_permissions(
677
1232
  tab = workspace_store.get_tab(tab_id)
678
1233
  if tab is None:
679
1234
  return None, f"Tab '{tab_id}' not found"
1235
+ _sync_tab_permissions_from_legacy_leader(tab)
1236
+ if _is_active(tab):
1237
+ return None, _active_edit_error("permissions")
680
1238
 
681
1239
  leader_id = get_tab_leader_id(tab_id)
682
1240
  if not leader_id:
@@ -703,64 +1261,29 @@ def set_tab_permissions(
703
1261
  "write_dirs boundary exceeded: " + ", ".join(invalid_write_dirs),
704
1262
  )
705
1263
 
706
- next_allow_network = (
707
- leader_record.config.allow_network if allow_network is None else allow_network
708
- )
709
- next_write_dirs = (
710
- list(leader_record.config.write_dirs)
711
- if write_dirs is None
712
- else list(write_dirs)
713
- )
1264
+ next_allow_network = tab.allow_network if allow_network is None else allow_network
1265
+ next_write_dirs = list(tab.write_dirs) if write_dirs is None else list(write_dirs)
714
1266
 
715
1267
  changed_node_ids: list[str] = []
716
-
717
- if (
718
- leader_record.config.allow_network != next_allow_network
719
- or leader_record.config.write_dirs != next_write_dirs
720
- ):
721
- leader_record.config.allow_network = next_allow_network
722
- leader_record.config.write_dirs = list(next_write_dirs)
723
- workspace_store.upsert_node_record(leader_record)
724
- changed_node_ids.append(leader_record.id)
725
-
726
- for record in list_tab_nodes(tab_id):
727
- if record.id == leader_id:
728
- continue
729
- next_node_allow_network = record.config.allow_network and next_allow_network
730
- next_node_write_dirs = _clamp_write_dirs_to_boundary(
731
- record.config.write_dirs,
732
- next_write_dirs,
733
- )
734
- if (
735
- record.config.allow_network == next_node_allow_network
736
- and record.config.write_dirs == next_node_write_dirs
737
- ):
738
- continue
739
- record.config.allow_network = next_node_allow_network
740
- record.config.write_dirs = list(next_node_write_dirs)
741
- workspace_store.upsert_node_record(record)
742
- changed_node_ids.append(record.id)
743
-
744
- live_node = registry.get(record.id)
1268
+ if tab.allow_network != next_allow_network or tab.write_dirs != next_write_dirs:
1269
+ tab.allow_network = next_allow_network
1270
+ tab.write_dirs = list(next_write_dirs)
1271
+ workspace_store.upsert_tab(tab)
1272
+ changed_node_ids = [
1273
+ record.id for record in list_tab_nodes(tab_id) if record.id != leader_id
1274
+ ]
1275
+ if leader_record.id:
1276
+ changed_node_ids.insert(0, leader_record.id)
1277
+
1278
+ for node_id in changed_node_ids:
1279
+ live_node = registry.get(node_id)
745
1280
  if live_node is not None:
746
- live_node.config.allow_network = next_node_allow_network
747
- live_node.config.write_dirs = list(next_node_write_dirs)
748
1281
  live_node.set_state(
749
1282
  live_node.state,
750
1283
  "tab_permissions_updated",
751
1284
  force_emit=True,
752
1285
  )
753
1286
 
754
- live_leader = registry.get(leader_id)
755
- if live_leader is not None:
756
- live_leader.config.allow_network = next_allow_network
757
- live_leader.config.write_dirs = list(next_write_dirs)
758
- live_leader.set_state(
759
- live_leader.state,
760
- "tab_permissions_updated",
761
- force_emit=True,
762
- )
763
-
764
1287
  updated_tab = workspace_store.get_tab(tab_id)
765
1288
  if updated_tab is not None:
766
1289
  event_bus.emit(
@@ -791,6 +1314,17 @@ def delete_tab(
791
1314
  tab = workspace_store.get_tab(tab_id)
792
1315
  if tab is None:
793
1316
  return None, f"Tab '{tab_id}' not found"
1317
+ if _is_active(tab):
1318
+ _, deactivate_error = deactivate_tab(
1319
+ tab_id=tab_id,
1320
+ actor_id="assistant",
1321
+ timeout=timeout,
1322
+ )
1323
+ if deactivate_error is not None:
1324
+ return None, deactivate_error
1325
+ tab = workspace_store.get_tab(tab_id)
1326
+ if tab is None:
1327
+ return None, f"Tab '{tab_id}' not found"
794
1328
 
795
1329
  stored_nodes = list_tab_nodes(tab_id)
796
1330
  live_nodes = [node for node in registry.get_all() if node.config.tab_id == tab_id]
@@ -843,8 +1377,6 @@ def build_node_config(
843
1377
  tab_id: str,
844
1378
  name: str | None = None,
845
1379
  tools: list[str] | None = None,
846
- write_dirs: list[str] | None = None,
847
- allow_network: bool = False,
848
1380
  ) -> tuple[NodeConfig | None, str | None]:
849
1381
  settings = settings_module.get_settings()
850
1382
  role = find_role(settings, role_name.strip())
@@ -854,16 +1386,6 @@ def build_node_config(
854
1386
  requested_tools = tools or []
855
1387
  if not all(isinstance(item, str) for item in requested_tools):
856
1388
  return None, "tools must be an array of strings"
857
- requested_write_dirs = write_dirs or []
858
- if not all(isinstance(item, str) for item in requested_write_dirs):
859
- return None, "write_dirs must be an array of strings"
860
- try:
861
- normalized_write_dirs = build_assistant_write_dirs(
862
- requested_write_dirs,
863
- field_name="write_dirs",
864
- )
865
- except ValueError as exc:
866
- return None, str(exc)
867
1389
 
868
1390
  return (
869
1391
  NodeConfig(
@@ -876,8 +1398,6 @@ def build_node_config(
876
1398
  requested_tools=requested_tools,
877
1399
  settings=settings,
878
1400
  ),
879
- write_dirs=normalized_write_dirs,
880
- allow_network=allow_network,
881
1401
  ),
882
1402
  None,
883
1403
  )
@@ -889,6 +1409,14 @@ def _persist_tab(tab: Tab, *, actor_id: str) -> Tab:
889
1409
  return tab
890
1410
 
891
1411
 
1412
+ def _is_active(tab: Tab) -> bool:
1413
+ return tab.activation_state == WorkflowActivationState.ACTIVE
1414
+
1415
+
1416
+ def _active_edit_error(noun: str) -> str:
1417
+ return f"Workflow is active; deactivate it before changing {noun}"
1418
+
1419
+
892
1420
  def create_graph_node(
893
1421
  *,
894
1422
  tab_id: str,
@@ -899,6 +1427,8 @@ def create_graph_node(
899
1427
  tab = workspace_store.get_tab(tab_id)
900
1428
  if tab is None:
901
1429
  return None, f"Tab '{tab_id}' not found"
1430
+ if _is_active(tab):
1431
+ return None, _active_edit_error("nodes")
902
1432
  node_id = str(uuid.uuid4())
903
1433
  node = build_workflow_node_definition(
904
1434
  node_id=node_id,
@@ -916,8 +1446,6 @@ def create_agent_node(
916
1446
  tab_id: str,
917
1447
  name: str | None = None,
918
1448
  tools: list[str] | None = None,
919
- write_dirs: list[str] | None = None,
920
- allow_network: bool = False,
921
1449
  creator_node_id: str | None = None,
922
1450
  connect_to_creator: bool | None = None,
923
1451
  ) -> tuple[GraphNodeRecord | None, str | None]:
@@ -925,14 +1453,14 @@ def create_agent_node(
925
1453
  tab = workspace_store.get_tab(tab_id)
926
1454
  if tab is None:
927
1455
  return None, f"Tab '{tab_id}' not found"
1456
+ if _is_active(tab):
1457
+ return None, _active_edit_error("nodes")
928
1458
 
929
1459
  config, error = build_node_config(
930
1460
  role_name=role_name,
931
1461
  tab_id=tab_id,
932
1462
  name=name,
933
1463
  tools=tools,
934
- write_dirs=write_dirs,
935
- allow_network=allow_network,
936
1464
  )
937
1465
  if error is not None or config is None:
938
1466
  return None, error
@@ -980,6 +1508,10 @@ def update_tab_definition(
980
1508
  edge_ids = [edge.id for edge in next_definition.edges]
981
1509
  if len(edge_ids) != len(set(edge_ids)):
982
1510
  return None, "Workflow definition contains duplicate edge ids"
1511
+ if _is_active(tab) and _semantic_definition(tab.definition) != _semantic_definition(
1512
+ next_definition
1513
+ ):
1514
+ return None, _active_edit_error("workflow structure")
983
1515
 
984
1516
  current_agent_ids = {
985
1517
  node.id for node in tab.definition.nodes if node.type == WorkflowNodeKind.AGENT
@@ -1010,8 +1542,6 @@ def update_tab_definition(
1010
1542
  name=str(node.config["name"])
1011
1543
  if isinstance(node.config.get("name"), str)
1012
1544
  else None,
1013
- write_dirs=list(record.config.write_dirs),
1014
- allow_network=record.config.allow_network,
1015
1545
  )
1016
1546
  if error is not None or config is None:
1017
1547
  return None, error or f"Failed to validate agent node '{node.id}'"
@@ -1043,7 +1573,6 @@ def update_tab_definition(
1043
1573
  source_node.outputs,
1044
1574
  port_key=edge.from_port_key,
1045
1575
  direction=PortDirection.OUTPUT,
1046
- kind=edge.kind,
1047
1576
  )
1048
1577
  if source_port is None:
1049
1578
  return None, f"Output port '{edge.from_port_key}' is invalid"
@@ -1051,10 +1580,15 @@ def update_tab_definition(
1051
1580
  target_node.inputs,
1052
1581
  port_key=edge.to_port_key,
1053
1582
  direction=PortDirection.INPUT,
1054
- kind=edge.kind,
1055
1583
  )
1056
1584
  if target_port is None:
1057
1585
  return None, f"Input port '{edge.to_port_key}' is invalid"
1586
+ if source_port.type != target_port.type:
1587
+ return (
1588
+ None,
1589
+ f"Port type mismatch: '{source_node.id}.{source_port.key}' is {source_port.type.value} "
1590
+ f"but '{target_node.id}.{target_port.key}' is {target_port.type.value}",
1591
+ )
1058
1592
  target_key = (edge.to_node_id, edge.to_port_key)
1059
1593
  if target_key in seen_target_ports and not target_port.multiple:
1060
1594
  return None, f"Input port '{edge.to_port_key}' already has an incoming edge"
@@ -1070,20 +1604,640 @@ def _port_matches(
1070
1604
  *,
1071
1605
  port_key: str,
1072
1606
  direction: PortDirection,
1073
- kind: EdgeKind,
1074
1607
  ) -> WorkflowPort | None:
1075
1608
  return next(
1076
1609
  (
1077
1610
  port
1078
1611
  for port in ports
1079
- if port.key == port_key
1080
- and port.direction == direction
1081
- and port.kind == kind
1612
+ if port.key == port_key and port.direction == direction
1082
1613
  ),
1083
1614
  None,
1084
1615
  )
1085
1616
 
1086
1617
 
1618
+ def _semantic_definition(definition: WorkflowDefinition) -> dict[str, object]:
1619
+ payload = definition.serialize()
1620
+ payload.pop("view", None)
1621
+ return payload
1622
+
1623
+
1624
+ _PORT_TYPES = {item.value for item in PortType}
1625
+ _TRIGGER_KINDS = {"manual", "cron"}
1626
+ _LLM_RESPONSE_FORMAT_KINDS = {"text", "json_schema"}
1627
+ _IF_OPERATORS = {
1628
+ "eq",
1629
+ "neq",
1630
+ "contains",
1631
+ "not_contains",
1632
+ "is_empty",
1633
+ "is_not_empty",
1634
+ "gt",
1635
+ "lt",
1636
+ "gte",
1637
+ "lte",
1638
+ "is_truthy",
1639
+ "is_falsy",
1640
+ }
1641
+ _MERGE_STRATEGIES = {"collect", "named_object", "first_completed"}
1642
+ _CODE_RUNTIMES = {"javascript", "python"}
1643
+
1644
+
1645
+ def _validation_error(
1646
+ errors: list[dict[str, str]],
1647
+ *,
1648
+ message: str,
1649
+ node_id: str | None = None,
1650
+ edge_id: str | None = None,
1651
+ path: str | None = None,
1652
+ ) -> None:
1653
+ error: dict[str, str] = {"message": message}
1654
+ if node_id is not None:
1655
+ error["node_id"] = node_id
1656
+ if edge_id is not None:
1657
+ error["edge_id"] = edge_id
1658
+ if path is not None:
1659
+ error["path"] = path
1660
+ errors.append(error)
1661
+
1662
+
1663
+ def _is_json_serializable(value: object) -> bool:
1664
+ try:
1665
+ json.dumps(value)
1666
+ except (TypeError, ValueError):
1667
+ return False
1668
+ return True
1669
+
1670
+
1671
+ def _is_valid_parts_value(value: object) -> bool:
1672
+ if not isinstance(value, list) or not value:
1673
+ return False
1674
+ for part in value:
1675
+ if not isinstance(part, dict):
1676
+ return False
1677
+ part_type = part.get("type")
1678
+ if part_type == "text":
1679
+ text = part.get("text")
1680
+ if not isinstance(text, str) or not text:
1681
+ return False
1682
+ continue
1683
+ if part_type == "image":
1684
+ asset_id = part.get("asset_id")
1685
+ if not isinstance(asset_id, str) or not asset_id.strip():
1686
+ return False
1687
+ continue
1688
+ return False
1689
+ return True
1690
+
1691
+
1692
+ def _validate_typed_value(value: object, port_type: PortType) -> bool:
1693
+ if port_type == PortType.PARTS:
1694
+ return _is_valid_parts_value(value)
1695
+ if port_type == PortType.STRING:
1696
+ return isinstance(value, str) and bool(value)
1697
+ return isinstance(value, dict) and _is_json_serializable(value)
1698
+
1699
+
1700
+ def _get_string_config(config: dict[str, object], key: str) -> str:
1701
+ value = config.get(key)
1702
+ return value.strip() if isinstance(value, str) else ""
1703
+
1704
+
1705
+ def _parse_response_format_kind(value: object) -> str:
1706
+ if isinstance(value, str):
1707
+ return value.strip()
1708
+ if isinstance(value, dict):
1709
+ kind = value.get("kind")
1710
+ return kind.strip() if isinstance(kind, str) else ""
1711
+ return ""
1712
+
1713
+
1714
+ def _response_format_schema(value: object) -> object:
1715
+ if not isinstance(value, dict):
1716
+ return None
1717
+ return value.get("schema")
1718
+
1719
+
1720
+ def _validate_cron_expression(value: object) -> bool:
1721
+ if not isinstance(value, str):
1722
+ return False
1723
+ fields = value.split()
1724
+ if len(fields) not in {5, 6}:
1725
+ return False
1726
+ return all(field.strip() for field in fields)
1727
+
1728
+
1729
+ def _validate_timezone(value: object) -> bool:
1730
+ if not isinstance(value, str) or not value.strip():
1731
+ return False
1732
+ try:
1733
+ ZoneInfo(value.strip())
1734
+ except ZoneInfoNotFoundError:
1735
+ return False
1736
+ return True
1737
+
1738
+
1739
+ def _validate_trigger_node(
1740
+ node: WorkflowNodeDefinition,
1741
+ errors: list[dict[str, str]],
1742
+ ) -> None:
1743
+ kind = _get_string_config(node.config, "kind")
1744
+ if kind not in _TRIGGER_KINDS:
1745
+ _validation_error(
1746
+ errors,
1747
+ node_id=node.id,
1748
+ path="config.kind",
1749
+ message="trigger kind must be manual or cron",
1750
+ )
1751
+ output_type = _coerce_port_type(node.config.get("output_type"), PortType.PARTS)
1752
+ if str(node.config.get("output_type", output_type.value)) not in _PORT_TYPES:
1753
+ _validation_error(
1754
+ errors,
1755
+ node_id=node.id,
1756
+ path="config.output_type",
1757
+ message="trigger output_type must be parts, string, or json",
1758
+ )
1759
+ if not node.outputs or any(port.type != output_type for port in node.outputs):
1760
+ _validation_error(
1761
+ errors,
1762
+ node_id=node.id,
1763
+ path="outputs",
1764
+ message="trigger output port type must match config.output_type",
1765
+ )
1766
+ if "message" not in node.config or not _validate_typed_value(
1767
+ node.config.get("message"),
1768
+ output_type,
1769
+ ):
1770
+ _validation_error(
1771
+ errors,
1772
+ node_id=node.id,
1773
+ path="config.message",
1774
+ message="trigger message must match output_type",
1775
+ )
1776
+ if kind == "cron":
1777
+ if not _validate_cron_expression(node.config.get("cron")):
1778
+ _validation_error(
1779
+ errors,
1780
+ node_id=node.id,
1781
+ path="config.cron",
1782
+ message="cron trigger requires a 5-field or 6-field expression",
1783
+ )
1784
+ if not _validate_timezone(node.config.get("timezone")):
1785
+ _validation_error(
1786
+ errors,
1787
+ node_id=node.id,
1788
+ path="config.timezone",
1789
+ message="cron trigger requires an IANA timezone",
1790
+ )
1791
+
1792
+
1793
+ def _resolve_llm_model(
1794
+ node: WorkflowNodeDefinition,
1795
+ ) -> tuple[str, str]:
1796
+ model_config = node.config.get("model")
1797
+ if isinstance(model_config, dict):
1798
+ provider_id = model_config.get("provider_id")
1799
+ model = model_config.get("model")
1800
+ return (
1801
+ provider_id.strip() if isinstance(provider_id, str) else "",
1802
+ model.strip() if isinstance(model, str) else "",
1803
+ )
1804
+ provider_id = node.config.get("provider_id")
1805
+ model = node.config.get("model")
1806
+ return (
1807
+ provider_id.strip() if isinstance(provider_id, str) else "",
1808
+ model.strip() if isinstance(model, str) else "",
1809
+ )
1810
+
1811
+
1812
+ def _validate_llm_node(
1813
+ node: WorkflowNodeDefinition,
1814
+ errors: list[dict[str, str]],
1815
+ ) -> None:
1816
+ input_type = _coerce_port_type(node.config.get("input_type"), PortType.PARTS)
1817
+ output_type = _coerce_port_type(node.config.get("output_type"), PortType.PARTS)
1818
+ if str(node.config.get("input_type", input_type.value)) not in _PORT_TYPES:
1819
+ _validation_error(
1820
+ errors,
1821
+ node_id=node.id,
1822
+ path="config.input_type",
1823
+ message="llm input_type must be parts, string, or json",
1824
+ )
1825
+ if str(node.config.get("output_type", output_type.value)) not in _PORT_TYPES:
1826
+ _validation_error(
1827
+ errors,
1828
+ node_id=node.id,
1829
+ path="config.output_type",
1830
+ message="llm output_type must be parts, string, or json",
1831
+ )
1832
+ response_format = node.config.get("response_format", {"kind": "text"})
1833
+ response_format_kind = _parse_response_format_kind(response_format)
1834
+ if response_format_kind not in _LLM_RESPONSE_FORMAT_KINDS:
1835
+ _validation_error(
1836
+ errors,
1837
+ node_id=node.id,
1838
+ path="config.response_format",
1839
+ message="llm response_format must be text or json_schema",
1840
+ )
1841
+ if response_format_kind == "json_schema":
1842
+ schema = _response_format_schema(response_format)
1843
+ if not isinstance(schema, dict) or not _is_json_serializable(schema):
1844
+ _validation_error(
1845
+ errors,
1846
+ node_id=node.id,
1847
+ path="config.response_format.schema",
1848
+ message="json_schema response_format requires a JSON schema object",
1849
+ )
1850
+ if output_type != PortType.JSON:
1851
+ _validation_error(
1852
+ errors,
1853
+ node_id=node.id,
1854
+ path="config.output_type",
1855
+ message="json_schema response_format requires json output_type",
1856
+ )
1857
+ elif output_type == PortType.JSON:
1858
+ _validation_error(
1859
+ errors,
1860
+ node_id=node.id,
1861
+ path="config.output_type",
1862
+ message="text response_format requires parts or string output_type",
1863
+ )
1864
+ provider_id, model_id = _resolve_llm_model(node)
1865
+ settings = settings_module.get_settings()
1866
+ provider = find_provider(settings, provider_id)
1867
+ if provider is None:
1868
+ _validation_error(
1869
+ errors,
1870
+ node_id=node.id,
1871
+ path="config.model",
1872
+ message="llm model provider was not found",
1873
+ )
1874
+ return
1875
+ if not model_id:
1876
+ _validation_error(
1877
+ errors,
1878
+ node_id=node.id,
1879
+ path="config.model",
1880
+ message="llm model must not be empty",
1881
+ )
1882
+ return
1883
+ if provider.models and all(entry.model != model_id for entry in provider.models):
1884
+ _validation_error(
1885
+ errors,
1886
+ node_id=node.id,
1887
+ path="config.model",
1888
+ message="llm model is not in the provider model catalog",
1889
+ )
1890
+ return
1891
+ model_info = resolve_model_info(provider=provider, model_id=model_id)
1892
+ if (
1893
+ response_format_kind == "json_schema"
1894
+ and not model_info.capabilities.structured_output
1895
+ ):
1896
+ _validation_error(
1897
+ errors,
1898
+ node_id=node.id,
1899
+ path="config.model",
1900
+ message="llm model does not support structured_output",
1901
+ )
1902
+
1903
+
1904
+ def _validate_if_node(
1905
+ node: WorkflowNodeDefinition,
1906
+ errors: list[dict[str, str]],
1907
+ ) -> None:
1908
+ expression = node.config.get("expression")
1909
+ if not isinstance(expression, dict):
1910
+ _validation_error(
1911
+ errors,
1912
+ node_id=node.id,
1913
+ path="config.expression",
1914
+ message="if expression must be an object",
1915
+ )
1916
+ return
1917
+ field = expression.get("field")
1918
+ operator = expression.get("operator")
1919
+ if not isinstance(field, str) or not field.strip().startswith("{{input."):
1920
+ _validation_error(
1921
+ errors,
1922
+ node_id=node.id,
1923
+ path="config.expression.field",
1924
+ message="if expression field must reference an input path",
1925
+ )
1926
+ if not isinstance(operator, str) or operator not in _IF_OPERATORS:
1927
+ _validation_error(
1928
+ errors,
1929
+ node_id=node.id,
1930
+ path="config.expression.operator",
1931
+ message="if expression operator is not supported",
1932
+ )
1933
+ if (
1934
+ operator in {"eq", "neq", "contains", "not_contains", "gt", "lt", "gte", "lte"}
1935
+ and "value" not in expression
1936
+ ):
1937
+ _validation_error(
1938
+ errors,
1939
+ node_id=node.id,
1940
+ path="config.expression.value",
1941
+ message="if expression operator requires a value",
1942
+ )
1943
+ input_type = _coerce_port_type(node.config.get("input_type"), PortType.PARTS)
1944
+ if any(port.type != input_type for port in node.inputs + node.outputs):
1945
+ _validation_error(
1946
+ errors,
1947
+ node_id=node.id,
1948
+ path="ports",
1949
+ message="if node input and output port types must match",
1950
+ )
1951
+
1952
+
1953
+ def _validate_merge_node(
1954
+ node: WorkflowNodeDefinition,
1955
+ errors: list[dict[str, str]],
1956
+ ) -> None:
1957
+ strategy = _get_string_config(node.config, "strategy") or "collect"
1958
+ if strategy not in _MERGE_STRATEGIES:
1959
+ _validation_error(
1960
+ errors,
1961
+ node_id=node.id,
1962
+ path="config.strategy",
1963
+ message="merge strategy must be collect, named_object, or first_completed",
1964
+ )
1965
+ if strategy == "named_object" and not isinstance(
1966
+ node.config.get("named_inputs"),
1967
+ dict,
1968
+ ):
1969
+ _validation_error(
1970
+ errors,
1971
+ node_id=node.id,
1972
+ path="config.named_inputs",
1973
+ message="named_object merge requires named_inputs",
1974
+ )
1975
+ if not node.inputs or not node.inputs[0].multiple:
1976
+ _validation_error(
1977
+ errors,
1978
+ node_id=node.id,
1979
+ path="inputs",
1980
+ message="merge input port must allow multiple upstream values",
1981
+ )
1982
+
1983
+
1984
+ def _validate_code_node(
1985
+ node: WorkflowNodeDefinition,
1986
+ errors: list[dict[str, str]],
1987
+ ) -> None:
1988
+ runtime = _get_string_config(node.config, "runtime")
1989
+ source = node.config.get("source")
1990
+ if runtime not in _CODE_RUNTIMES:
1991
+ _validation_error(
1992
+ errors,
1993
+ node_id=node.id,
1994
+ path="config.runtime",
1995
+ message="code runtime must be javascript or python",
1996
+ )
1997
+ if not isinstance(source, str) or not source.strip():
1998
+ _validation_error(
1999
+ errors,
2000
+ node_id=node.id,
2001
+ path="config.source",
2002
+ message="code source must not be empty",
2003
+ )
2004
+ return
2005
+ if runtime == "python":
2006
+ try:
2007
+ ast.parse(source)
2008
+ except SyntaxError as exc:
2009
+ _validation_error(
2010
+ errors,
2011
+ node_id=node.id,
2012
+ path="config.source",
2013
+ message=f"python source is not parseable: {exc.msg}",
2014
+ )
2015
+ elif runtime == "javascript" and shutil.which("node"):
2016
+ completed = subprocess.run(
2017
+ ["node", "--check"],
2018
+ input=source,
2019
+ text=True,
2020
+ capture_output=True,
2021
+ timeout=5,
2022
+ check=False,
2023
+ )
2024
+ if completed.returncode != 0:
2025
+ _validation_error(
2026
+ errors,
2027
+ node_id=node.id,
2028
+ path="config.source",
2029
+ message="javascript source is not parseable",
2030
+ )
2031
+
2032
+
2033
+ def _validate_node_config(
2034
+ node: WorkflowNodeDefinition,
2035
+ errors: list[dict[str, str]],
2036
+ ) -> None:
2037
+ if node.type == WorkflowNodeKind.TRIGGER:
2038
+ _validate_trigger_node(node, errors)
2039
+ elif node.type == WorkflowNodeKind.LLM:
2040
+ _validate_llm_node(node, errors)
2041
+ elif node.type == WorkflowNodeKind.IF:
2042
+ _validate_if_node(node, errors)
2043
+ elif node.type == WorkflowNodeKind.MERGE:
2044
+ _validate_merge_node(node, errors)
2045
+ elif node.type == WorkflowNodeKind.CODE:
2046
+ _validate_code_node(node, errors)
2047
+
2048
+
2049
+ def _is_legacy_required_agent_input(
2050
+ node: WorkflowNodeDefinition,
2051
+ port: WorkflowPort,
2052
+ ) -> bool:
2053
+ return (
2054
+ node.type == WorkflowNodeKind.AGENT
2055
+ and port.key == "in"
2056
+ and port.direction == PortDirection.INPUT
2057
+ and port.type == PortType.PARTS
2058
+ and not port.multiple
2059
+ )
2060
+
2061
+
2062
+ def validate_workflow_activation(tab: Tab) -> list[dict[str, str]]:
2063
+ errors: list[dict[str, str]] = []
2064
+ if not tab.definition.nodes:
2065
+ _validation_error(
2066
+ errors,
2067
+ message="Add at least one node before activating this workflow",
2068
+ path="definition.nodes",
2069
+ )
2070
+ node_ids = [node.id for node in tab.definition.nodes]
2071
+ if len(node_ids) != len(set(node_ids)):
2072
+ _validation_error(
2073
+ errors,
2074
+ message="workflow definition contains duplicate node ids",
2075
+ path="definition.nodes",
2076
+ )
2077
+ edge_ids = [edge.id for edge in tab.definition.edges]
2078
+ if len(edge_ids) != len(set(edge_ids)):
2079
+ _validation_error(
2080
+ errors,
2081
+ message="workflow definition contains duplicate edge ids",
2082
+ path="definition.edges",
2083
+ )
2084
+ incoming_edges_by_port: dict[tuple[str, str], list[GraphEdge]] = {}
2085
+ seen_edge_endpoints: set[tuple[str, str, str, str]] = set()
2086
+ for edge in tab.definition.edges:
2087
+ edge_endpoint = (
2088
+ edge.from_node_id,
2089
+ edge.from_port_key,
2090
+ edge.to_node_id,
2091
+ edge.to_port_key,
2092
+ )
2093
+ if edge_endpoint in seen_edge_endpoints:
2094
+ _validation_error(
2095
+ errors,
2096
+ edge_id=edge.id,
2097
+ message="duplicate edges are not allowed",
2098
+ )
2099
+ seen_edge_endpoints.add(edge_endpoint)
2100
+ source_node = tab.definition.get_node(edge.from_node_id)
2101
+ target_node = tab.definition.get_node(edge.to_node_id)
2102
+ if source_node is None:
2103
+ _validation_error(
2104
+ errors,
2105
+ edge_id=edge.id,
2106
+ message=f"edge source node '{edge.from_node_id}' does not exist",
2107
+ )
2108
+ continue
2109
+ if target_node is None:
2110
+ _validation_error(
2111
+ errors,
2112
+ edge_id=edge.id,
2113
+ message=f"edge target node '{edge.to_node_id}' does not exist",
2114
+ )
2115
+ continue
2116
+ source_port = _port_matches(
2117
+ source_node.outputs,
2118
+ port_key=edge.from_port_key,
2119
+ direction=PortDirection.OUTPUT,
2120
+ )
2121
+ target_port = _port_matches(
2122
+ target_node.inputs,
2123
+ port_key=edge.to_port_key,
2124
+ direction=PortDirection.INPUT,
2125
+ )
2126
+ if source_port is None:
2127
+ _validation_error(
2128
+ errors,
2129
+ edge_id=edge.id,
2130
+ path="from_port_key",
2131
+ message=f"output port '{edge.from_port_key}' is invalid",
2132
+ )
2133
+ continue
2134
+ if target_port is None:
2135
+ _validation_error(
2136
+ errors,
2137
+ edge_id=edge.id,
2138
+ path="to_port_key",
2139
+ message=f"input port '{edge.to_port_key}' is invalid",
2140
+ )
2141
+ continue
2142
+ if source_port.type != target_port.type:
2143
+ _validation_error(
2144
+ errors,
2145
+ edge_id=edge.id,
2146
+ message=(
2147
+ f"port type mismatch: '{source_node.id}.{source_port.key}' is {source_port.type.value} "
2148
+ f"but '{target_node.id}.{target_port.key}' is {target_port.type.value}"
2149
+ ),
2150
+ )
2151
+ incoming_edges_by_port.setdefault(
2152
+ (edge.to_node_id, edge.to_port_key), []
2153
+ ).append(edge)
2154
+ for node in tab.definition.nodes:
2155
+ for port in node.inputs:
2156
+ edges = incoming_edges_by_port.get((node.id, port.key), [])
2157
+ if (
2158
+ port.required
2159
+ and not edges
2160
+ and not _is_legacy_required_agent_input(node, port)
2161
+ ):
2162
+ _validation_error(
2163
+ errors,
2164
+ node_id=node.id,
2165
+ path=f"inputs.{port.key}",
2166
+ message=f"required input port '{port.key}' has no upstream edge",
2167
+ )
2168
+ if len(edges) > 1 and not port.multiple:
2169
+ _validation_error(
2170
+ errors,
2171
+ node_id=node.id,
2172
+ path=f"inputs.{port.key}",
2173
+ message=f"input port '{port.key}' accepts only one upstream edge",
2174
+ )
2175
+ _validate_node_config(node, errors)
2176
+ return errors
2177
+
2178
+
2179
+ def activate_tab(
2180
+ *,
2181
+ tab_id: str,
2182
+ actor_id: str = "assistant",
2183
+ ) -> tuple[Tab | None, list[dict[str, str]] | None, str | None]:
2184
+ tab = workspace_store.get_tab(tab_id)
2185
+ if tab is None:
2186
+ return None, None, f"Tab '{tab_id}' not found"
2187
+ errors = validate_workflow_activation(tab)
2188
+ if errors:
2189
+ return None, errors, None
2190
+ if tab.activation_state != WorkflowActivationState.ACTIVE:
2191
+ tab.activation_state = WorkflowActivationState.ACTIVE
2192
+ _persist_tab(tab, actor_id=actor_id)
2193
+ return tab, None, None
2194
+
2195
+
2196
+ def deactivate_tab(
2197
+ *,
2198
+ tab_id: str,
2199
+ actor_id: str = "assistant",
2200
+ timeout: float = SYSTEM_NODE_TIMEOUT,
2201
+ ) -> tuple[Tab | None, str | None]:
2202
+ tab = workspace_store.get_tab(tab_id)
2203
+ if tab is None:
2204
+ return None, f"Tab '{tab_id}' not found"
2205
+
2206
+ lingering_node_ids: list[str] = []
2207
+ ordinary_node_ids = {
2208
+ node.id
2209
+ for node in tab.definition.nodes
2210
+ if node.type == WorkflowNodeKind.AGENT
2211
+ and not is_tab_leader(node_id=node.id, tab_id=tab_id)
2212
+ }
2213
+ for node_id in ordinary_node_ids:
2214
+ live_node = registry.get(node_id)
2215
+ if live_node is not None:
2216
+ if live_node.state in {AgentState.RUNNING, AgentState.SLEEPING}:
2217
+ live_node.request_interrupt()
2218
+ if not live_node.wait_until_idle(timeout=timeout):
2219
+ lingering_node_ids.append(live_node.uuid)
2220
+ continue
2221
+ record = workspace_store.get_node_record(node_id)
2222
+ if record is None:
2223
+ continue
2224
+ if record.state in {AgentState.RUNNING, AgentState.SLEEPING}:
2225
+ record.state = AgentState.IDLE
2226
+ workspace_store.upsert_node_record(record)
2227
+
2228
+ if lingering_node_ids:
2229
+ return (
2230
+ None,
2231
+ "Failed to deactivate workflow because some nodes did not stop: "
2232
+ + ", ".join(node_id[:8] for node_id in lingering_node_ids),
2233
+ )
2234
+
2235
+ if tab.activation_state != WorkflowActivationState.INACTIVE:
2236
+ tab.activation_state = WorkflowActivationState.INACTIVE
2237
+ _persist_tab(tab, actor_id=actor_id)
2238
+ return tab, None
2239
+
2240
+
1087
2241
  def create_edge(
1088
2242
  *,
1089
2243
  tab_id: str | None = None,
@@ -1093,7 +2247,7 @@ def create_edge(
1093
2247
  to_port_key: str = "in",
1094
2248
  kind: EdgeKind | str = EdgeKind.CONTROL,
1095
2249
  ) -> tuple[GraphEdge | None, str | None]:
1096
- resolved_kind = kind if isinstance(kind, EdgeKind) else EdgeKind(str(kind))
2250
+ del kind
1097
2251
  resolved_tab_id = tab_id
1098
2252
  if resolved_tab_id is None:
1099
2253
  source_record = workspace_store.get_node_record(from_node_id)
@@ -1107,6 +2261,8 @@ def create_edge(
1107
2261
  tab = workspace_store.get_tab(resolved_tab_id)
1108
2262
  if tab is None:
1109
2263
  return None, f"Tab '{resolved_tab_id}' not found"
2264
+ if _is_active(tab):
2265
+ return None, _active_edit_error("edges")
1110
2266
  if is_tab_leader(node_id=from_node_id, tab_id=resolved_tab_id) or is_tab_leader(
1111
2267
  node_id=to_node_id,
1112
2268
  tab_id=resolved_tab_id,
@@ -1124,7 +2280,6 @@ def create_edge(
1124
2280
  source_node.outputs,
1125
2281
  port_key=from_port_key,
1126
2282
  direction=PortDirection.OUTPUT,
1127
- kind=resolved_kind,
1128
2283
  )
1129
2284
  if source_port is None:
1130
2285
  return None, f"Output port '{from_port_key}' is invalid"
@@ -1132,16 +2287,20 @@ def create_edge(
1132
2287
  target_node.inputs,
1133
2288
  port_key=to_port_key,
1134
2289
  direction=PortDirection.INPUT,
1135
- kind=resolved_kind,
1136
2290
  )
1137
2291
  if target_port is None:
1138
2292
  return None, f"Input port '{to_port_key}' is invalid"
2293
+ if source_port.type != target_port.type:
2294
+ return (
2295
+ None,
2296
+ f"Port type mismatch: '{source_node.id}.{source_port.key}' is {source_port.type.value} "
2297
+ f"but '{target_node.id}.{target_port.key}' is {target_port.type.value}",
2298
+ )
1139
2299
  if any(
1140
2300
  edge.from_node_id == from_node_id
1141
2301
  and edge.from_port_key == from_port_key
1142
2302
  and edge.to_node_id == to_node_id
1143
2303
  and edge.to_port_key == to_port_key
1144
- and edge.kind == resolved_kind
1145
2304
  for edge in tab.definition.edges
1146
2305
  ):
1147
2306
  return None, "Duplicate edges are not allowed"
@@ -1158,7 +2317,6 @@ def create_edge(
1158
2317
  from_port_key=from_port_key,
1159
2318
  to_node_id=to_node_id,
1160
2319
  to_port_key=to_port_key,
1161
- kind=resolved_kind,
1162
2320
  )
1163
2321
  tab.definition.edges.append(edge)
1164
2322
  _persist_tab(tab, actor_id=from_node_id)
@@ -1177,6 +2335,8 @@ def delete_edge(
1177
2335
  tab = workspace_store.get_tab(tab_id)
1178
2336
  if tab is None:
1179
2337
  return None, f"Tab '{tab_id}' not found"
2338
+ if _is_active(tab):
2339
+ return None, _active_edit_error("edges")
1180
2340
 
1181
2341
  matched_edge: GraphEdge | None = None
1182
2342
  for edge in tab.definition.edges:
@@ -1212,6 +2372,8 @@ def delete_agent_node(
1212
2372
  tab = workspace_store.get_tab(tab_id)
1213
2373
  if tab is None:
1214
2374
  return None, f"Tab '{tab_id}' not found"
2375
+ if _is_active(tab):
2376
+ return None, _active_edit_error("nodes")
1215
2377
 
1216
2378
  node_definition = tab.definition.get_node(node_id)
1217
2379
  if node_definition is None: