flowforge-map 0.4.0__tar.gz → 0.4.2__tar.gz

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 (162) hide show
  1. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/PKG-INFO +37 -17
  2. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/README.md +36 -16
  3. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/agent/server.py +2 -1
  4. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/api/keys.py +12 -0
  5. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/checks/probes.py +20 -5
  6. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/cli_ui.py +5 -1
  7. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/example_data.py +9 -9
  8. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/flow/__init__.py +3 -1
  9. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/flow/curly.py +3 -0
  10. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/flow/index.py +1 -0
  11. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/flow/order.py +76 -11
  12. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/flow/rewrite.py +22 -3
  13. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/scan/files.py +20 -12
  14. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/scan/scanner.py +16 -4
  15. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/security/finders.py +92 -1
  16. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/security/findings.py +20 -5
  17. flowforge_map-0.4.0/frontend/dist/assets/AddConnector-Bi8J4U4d.js → flowforge_map-0.4.2/frontend/dist/assets/AddConnector-D3dJ-1pw.js +1 -1
  18. flowforge_map-0.4.0/frontend/dist/assets/Catalog-zd0ADYry.js → flowforge_map-0.4.2/frontend/dist/assets/Catalog-_1sdJlO0.js +1 -1
  19. flowforge_map-0.4.0/frontend/dist/assets/ChecksPage-B77bP82v.js → flowforge_map-0.4.2/frontend/dist/assets/ChecksPage-B4VDQZj7.js +1 -1
  20. flowforge_map-0.4.0/frontend/dist/assets/Connector-D4bc9fm5.js → flowforge_map-0.4.2/frontend/dist/assets/Connector-D3hHmyyL.js +1 -1
  21. flowforge_map-0.4.0/frontend/dist/assets/ExportPage-X83lvpEi.js → flowforge_map-0.4.2/frontend/dist/assets/ExportPage-DW0JW4us.js +2 -2
  22. flowforge_map-0.4.0/frontend/dist/assets/FlowMapPage-BrnQhApb.js → flowforge_map-0.4.2/frontend/dist/assets/FlowMapPage-DUuVP3sL.js +1 -1
  23. flowforge_map-0.4.2/frontend/dist/assets/Health-5Ea16UNQ.js +1 -0
  24. flowforge_map-0.4.0/frontend/dist/assets/HowDrawn-CazyemwU.js → flowforge_map-0.4.2/frontend/dist/assets/HowDrawn-W7o-RELm.js +1 -1
  25. flowforge_map-0.4.0/frontend/dist/assets/IssuesPage-DI9W3uIc.js → flowforge_map-0.4.2/frontend/dist/assets/IssuesPage-D1R8nhFJ.js +1 -1
  26. flowforge_map-0.4.0/frontend/dist/assets/KeysPage-B2vcuxeq.js → flowforge_map-0.4.2/frontend/dist/assets/KeysPage-BW2TiEJ4.js +1 -1
  27. flowforge_map-0.4.0/frontend/dist/assets/Later-89_57i-0.js → flowforge_map-0.4.2/frontend/dist/assets/Later-BKVN1K5u.js +1 -1
  28. flowforge_map-0.4.0/frontend/dist/assets/LiveRun-DgaIy_Hy.js → flowforge_map-0.4.2/frontend/dist/assets/LiveRun-BPspDohV.js +1 -1
  29. flowforge_map-0.4.0/frontend/dist/assets/Recheck-IaxmWKc4.js → flowforge_map-0.4.2/frontend/dist/assets/Recheck-Cfl9oCf6.js +1 -1
  30. flowforge_map-0.4.0/frontend/dist/assets/RunHistory-DeusCIFP.js → flowforge_map-0.4.2/frontend/dist/assets/RunHistory-CpOpVa9H.js +1 -1
  31. flowforge_map-0.4.0/frontend/dist/assets/SettingsPage-CF0lFy0X.js → flowforge_map-0.4.2/frontend/dist/assets/SettingsPage-D4KMC8BT.js +1 -1
  32. flowforge_map-0.4.0/frontend/dist/assets/flowmap-DhjGiCkH.js → flowforge_map-0.4.2/frontend/dist/assets/flowmap-DCEYMfCm.js +1 -1
  33. flowforge_map-0.4.0/frontend/dist/assets/index-Bl5rbBs-.js → flowforge_map-0.4.2/frontend/dist/assets/index-X7o1rC4D.js +5 -4
  34. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/frontend/dist/index.html +1 -1
  35. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/pyproject.toml +1 -1
  36. flowforge_map-0.4.0/frontend/dist/assets/Health-DROS3rFg.js +0 -1
  37. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/.gitignore +0 -0
  38. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/LICENSE +0 -0
  39. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/__init__.py +0 -0
  40. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/agent/__init__.py +0 -0
  41. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/api/__init__.py +0 -0
  42. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/api/agent.py +0 -0
  43. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/api/apps.py +0 -0
  44. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/api/checks.py +0 -0
  45. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/api/connectors.py +0 -0
  46. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/api/flowmap.py +0 -0
  47. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/api/issues.py +0 -0
  48. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/api/lock.py +0 -0
  49. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/api/projects.py +0 -0
  50. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/api/runs.py +0 -0
  51. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/api/scan.py +0 -0
  52. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/api/security.py +0 -0
  53. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/api/settings.py +0 -0
  54. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/api/stats.py +0 -0
  55. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/apps.py +0 -0
  56. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/checks/__init__.py +0 -0
  57. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/checks/models.py +0 -0
  58. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/cli.py +0 -0
  59. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/connectors/__init__.py +0 -0
  60. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/connectors/catalog.py +0 -0
  61. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/connectors/models.py +0 -0
  62. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/connectors/presets.py +0 -0
  63. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/connectors/registry.py +0 -0
  64. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/connectors/runtime.py +0 -0
  65. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/connectors/secrets.py +0 -0
  66. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/flow/advice.py +0 -0
  67. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/flow/reach.py +0 -0
  68. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/flowmap/__init__.py +0 -0
  69. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/flowmap/diff.py +0 -0
  70. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/flowmap/fixture.py +0 -0
  71. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/flowmap/models.py +0 -0
  72. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/flowmap/planner.py +0 -0
  73. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/flowmap/service.py +0 -0
  74. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/flowmap/validate.py +0 -0
  75. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/home.py +0 -0
  76. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/main.py +0 -0
  77. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/nodes/__init__.py +0 -0
  78. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/nodes/base.py +0 -0
  79. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/nodes/check_node.py +0 -0
  80. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/nodes/http_node.py +0 -0
  81. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/nodes/llm_node.py +0 -0
  82. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/nodes/local_node.py +0 -0
  83. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/nodes/mcp_node.py +0 -0
  84. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/nodes/mock_node.py +0 -0
  85. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/project_states.py +0 -0
  86. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/projects.py +0 -0
  87. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/scan/__init__.py +0 -0
  88. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/scan/facts.py +0 -0
  89. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/scan/manifests.py +0 -0
  90. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/scan/mcp_config.py +0 -0
  91. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/scan/models.py +0 -0
  92. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/scan/patterns.py +0 -0
  93. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/scan/python_ast.py +0 -0
  94. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/scan/services.py +0 -0
  95. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/scan/urls.py +0 -0
  96. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/scheduler/__init__.py +0 -0
  97. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/scheduler/cache.py +0 -0
  98. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/scheduler/critical_path.py +0 -0
  99. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/scheduler/durations.py +0 -0
  100. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/scheduler/executor.py +0 -0
  101. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/scheduler/graph.py +0 -0
  102. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/scheduler/rate_limit.py +0 -0
  103. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/schema.py +0 -0
  104. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/security/__init__.py +0 -0
  105. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/security/fixes.py +0 -0
  106. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/security/keystore.py +0 -0
  107. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/security/local_only.py +0 -0
  108. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/security/lock.py +0 -0
  109. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/security/logfilter.py +0 -0
  110. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/security/masks.py +0 -0
  111. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/security/wire.py +0 -0
  112. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/settings.py +0 -0
  113. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/spa.py +0 -0
  114. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/state.py +0 -0
  115. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/storage.py +0 -0
  116. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/templating.py +0 -0
  117. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/flowforge/terminal.py +0 -0
  118. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/workflows/diamond_mock.json +0 -0
  119. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/backend/workflows/stripe_to_razorpay.json +0 -0
  120. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/examples/trip-planner/.env.example +0 -0
  121. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/examples/trip-planner/.gitignore +0 -0
  122. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/examples/trip-planner/.mcp.json +0 -0
  123. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/examples/trip-planner/README.md +0 -0
  124. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/examples/trip-planner/config.py +0 -0
  125. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/examples/trip-planner/mcp_servers/currency.py +0 -0
  126. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/examples/trip-planner/planner.py +0 -0
  127. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/examples/trip-planner/requirements.txt +0 -0
  128. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/examples/trip-planner/services/__init__.py +0 -0
  129. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/examples/trip-planner/services/geo.py +0 -0
  130. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/examples/trip-planner/services/llm.py +0 -0
  131. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/examples/trip-planner/services/mcp_tools.py +0 -0
  132. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/examples/trip-planner/services/weather.py +0 -0
  133. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/examples/trip-planner/tools/packing_list.py +0 -0
  134. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/examples/trip-planner/trips/.gitkeep +0 -0
  135. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/frontend/classic.html +0 -0
  136. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/frontend/dist/assets/Catalog-CA9b5nAc.css +0 -0
  137. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/frontend/dist/assets/ExportPage-e2bor7CC.css +0 -0
  138. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/frontend/dist/assets/Health-FLn--j7D.css +0 -0
  139. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/frontend/dist/assets/LiveRun-NyTlzZhk.css +0 -0
  140. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/frontend/dist/assets/SettingsPage-DrMN0pie.css +0 -0
  141. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/frontend/dist/assets/client-8TP7XnGi.js +0 -0
  142. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/frontend/dist/assets/flowmap-qRSp4v09.css +0 -0
  143. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/frontend/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  144. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/frontend/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  145. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/frontend/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  146. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/frontend/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  147. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/frontend/dist/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
  148. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/frontend/dist/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
  149. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/frontend/dist/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
  150. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/frontend/dist/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
  151. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/frontend/dist/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
  152. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/frontend/dist/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
  153. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/frontend/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  154. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/frontend/dist/assets/how-Bunfi3hG.css +0 -0
  155. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/frontend/dist/assets/icons-B5HLu6t-.js +0 -0
  156. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/frontend/dist/assets/index-BygBw1rS.css +0 -0
  157. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/frontend/dist/assets/links-BKyTYkee.js +0 -0
  158. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/frontend/dist/assets/mapId-CAn3Vwt1.js +0 -0
  159. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/frontend/dist/assets/sse-Bbzu7IqU.js +0 -0
  160. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/frontend/dist/assets/status-BtyVLBUd.js +0 -0
  161. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/frontend/dist/assets/useChecks-Dgca8Xe7.js +0 -0
  162. {flowforge_map-0.4.0 → flowforge_map-0.4.2}/frontend/dist/favicon.svg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: flowforge-map
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: Map what your app depends on (LLMs, MCP servers, APIs), check their health and security, and see what could run faster. Local and free; the scan needs no AI.
5
5
  Project-URL: Homepage, https://github.com/Vishwa-Thangapandiyan/flowforge-agent-orchestrator
6
6
  Project-URL: Issues, https://github.com/Vishwa-Thangapandiyan/flowforge-agent-orchestrator/issues
@@ -37,24 +37,30 @@ Description-Content-Type: text/markdown
37
37
 
38
38
  # FlowForge
39
39
 
40
- **An LLMOps tool for apps that already use AI: a map of how your app's LLM, MCP and API calls fit together, and a scheduler that runs them.**
40
+ **See everything your AI app depends on: whether it's reachable, whether it's safe, and what's slowing it down.**
41
41
 
42
- You connect the apps your project uses (LLMs, MCP servers, HTTP APIs, local commands). The **Planner** reads your repo and runs the app in test mode, then draws the flow on the home page: the calls, the decisions, what runs together, the retries and fallbacks, and a gate in front of anything that moves money or sends a message. Only apps your code actually calls go on the map, and every block cites its evidence (a `file:line` or a traced call). You read the map, edit it if you want (your edits never touch your code), and re-check it when the code changes.
42
+ ```bash
43
+ npx flowforge-map --example # a sample app's map in your browser: no keys, nothing sent
44
+ ```
43
45
 
44
- Underneath, FlowForge runs workflows as a DAG: it orders the steps, runs independent ones together under rate limits, caches repeat calls, and (on the roadmap) holds the secrets and pauses risky steps for a human to approve.
46
+ ![The dependency map: every LLM, MCP server and API the app calls, with live health](docs/images/map.png)
45
47
 
46
- What makes it different: **it knows which chain of steps is the bottleneck and schedules around it.**
48
+ | | |
49
+ |---|---|
50
+ | 🗺 **Map** | Every LLM, MCP server, HTTP API and local program your code calls, each with the `file:line` it came from |
51
+ | 💚 **Check now** | Green, amber or red for each one, with its latency. When something is down, it tells you why |
52
+ | ⚡ **Faster** | Reads the order your code makes its calls and shows which ones could run together, with the time you'd save |
53
+ | 🛡 **Issues** | Keys written in code, plain `http://`, MCP servers that aren't pinned. Fix one on a new branch, or send it to your agent |
54
+ | 🔑 **Keys** | Paste a key once. **Add to code** writes it to a git-ignored file your app reads; FlowForge only ever hands your agent a mask |
55
+ | 🤖 **Your agent** | One MCP entry gives Claude Code, Codex or Antigravity the map, the advice and the issues |
47
56
 
48
- - orders steps with **topological sort** (Kahn's), and reports the exact loop if there is a cycle
49
- - finds the bottleneck chain with a **longest-path DP** (critical path), predicted before the run and measured after it
50
- - runs independent steps concurrently under limited resources (`k` slots + per-provider rate limits) using **critical-path list scheduling** (HLFET), which is within `(2 − 1/k)` of optimal (Graham's bound)
51
- - makes duplicate calls only once through **memoization** (single-flight in-run cache + a safe persistent cache)
57
+ ![Flow: 16.5 s as written, 11.6 s if four calls start together](docs/images/flow.png)
52
58
 
53
- **Free by default.** The default install, the tests and CI need no paid service: LLM steps use NVIDIA NIM's free tier, and local models (Ollama) are planned. Paid providers will work if you bring your own key, but are never needed.
59
+ **Free and local.** MIT, no account, no paid service. FlowForge keeps its data on your machine, and the scan is plain code that sends nothing to any LLM.
54
60
 
55
- **Local first.** One process, SQLite, files on disk. No cloud account, no Docker, no Redis.
61
+ **Under the hood:** a DAG scheduler runs the checks concurrently under rate limits. It uses a topological sort, a critical-path DP and HLFET list scheduling (within `(2 − 1/k)` of optimal), and it caches repeat calls. See [Scheduling policies and benchmarks](#scheduling-policies-and-benchmarks).
56
62
 
57
- > FlowForge started as a Design and Analysis of Algorithms course project. That version is frozen on the [`DAA`](../../tree/DAA) branch. `main` is the product.
63
+ > FlowForge started as a Design and Analysis of Algorithms course project. That version is commit [`233a681`](../../tree/233a681c3e17fa4081c883193f8456a53a4c049c) in `main`'s history.
58
64
 
59
65
  ## Quick start
60
66
 
@@ -65,7 +71,7 @@ npx flowforge-map --example # try it: a sample app's maps in your browser,
65
71
  npx flowforge-map --repo . # map the folder you're in
66
72
  ```
67
73
 
68
- That's all: no Python, no virtual env. The first run downloads FlowForge's ready-made program for your computer (about 60 MB, checked against the release's checksums) into `~/.flowforge/bin`; after that it starts at once, even offline. Works on Windows, macOS (Apple and Intel) and Linux x64 with Node 18+. Your data lives in `~/.flowforge`.
74
+ That's all: no Python, no virtual env. The first run downloads FlowForge's ready-made program for your computer (about 60 MB, checked against the release's checksums) into `~/.flowforge/bin`; after that it starts at once, even offline. Works on Windows, macOS (Apple chips) and Linux x64 with Node 18+. Your data lives in `~/.flowforge`.
69
75
 
70
76
  | You have | Run | |
71
77
  |---|---|---|
@@ -74,7 +80,7 @@ That's all: no Python, no virtual env. The first run downloads FlowForge's ready
74
80
  | Python people | `uvx flowforge-map` or `pipx install flowforge-map` | Python 3.12+; no virtual env to manage |
75
81
  | No terminal | the desktop app (Releases) | download and open; **Settings → Terminal** adds the `flowforge` command too (Linux .rpm/.deb have it already) |
76
82
 
77
- Every way gives the same `flowforge` command: `npx flowforge-map status`, `npx flowforge-map key add STRIPE_KEY`, and so on. Linux ARM and Windows ARM have no ready-made build yet, so `npx` uses `uvx` there.
83
+ Every way gives the same `flowforge` command: `npx flowforge-map status`, `npx flowforge-map key add STRIPE_KEY`, and so on. Intel Macs, Linux ARM and Windows ARM have no ready-made build yet, so `npx` uses `uvx` there.
78
84
 
79
85
  The first time, a short welcome walks you through three steps, each one skippable: your app's folder (or the example), pairing your coding agent once, and a lock for your keys. **⚙ Settings** at the bottom of the rail keeps what applies to every project: your agent, the theme and map style, the Vault's lock, and your data (each project's size, the MCP servers it may start, its check history, and the version, with **Check for updates**, which asks pypi.org only when you press it).
80
86
 
@@ -99,9 +105,11 @@ The same dashboard in its own window, with Python inside: nothing else to instal
99
105
  | Your computer | Download |
100
106
  |---|---|
101
107
  | Windows | `FlowForge_x.y.z_x64-setup.exe` or `.msi` |
102
- | Mac (Apple Silicon or Intel) | `FlowForge_x.y.z_aarch64.dmg` or `_x64.dmg` |
108
+ | Mac (Apple chip) | `FlowForge_x.y.z_aarch64.dmg` |
103
109
  | Linux | `.AppImage`, `.deb` or `.rpm` |
104
110
 
111
+ Intel Macs have no desktop build yet: use `npx flowforge-map` (it runs FlowForge through `uv` there) or `uvx flowforge-map`.
112
+
105
113
  The builds aren't signed yet (signing costs money every year), so the first launch asks once:
106
114
  - **Mac:** right-click FlowForge in Applications → **Open** → **Open**.
107
115
  - **Windows:** on "Windows protected your PC", click **More info** → **Run anyway**.
@@ -147,7 +155,6 @@ Switch the map to **Flow**. FlowForge reads your Python (by its syntax tree), Da
147
155
  - **Faster** compares "as written" (the slowest action, one call after another) with "could be", and lists the calls that don't need each other's results. **Show the change** writes out the `asyncio.gather`, Dart's `(a, b).wait` or `Promise.all` (now → could be, ready to copy) when it's safe; **Copy prompt for your coding agent** hands the rest to Claude Code, Codex or any agent. FlowForge never edits your code.
148
156
  - **Watch it run** plays the could-be schedule; pointing at a block lights up its path. Five map styles: Studio, Boxed, Blueprint, Metro, Soft.
149
157
 
150
-
151
158
  ### Many projects
152
159
 
153
160
  The home page lists every app you've mapped, with its dependencies, what needs fixing and missing keys. **+ New project** takes your app's folder, scans it and shows its map. The project tile at the top of the rail switches between the projects you have open, and each one comes back where you left it. **×** closes a project from that list without deleting it. Each project keeps its own map, checks, issues and keys; `flowforge --repo PATH` opens (or adds) that folder's project.
@@ -160,10 +167,21 @@ FlowForge is also an **MCP server**, so Claude Code, Codex or Antigravity can as
160
167
  claude mcp add flowforge -- uvx flowforge-map mcp
161
168
  ```
162
169
 
163
- Pair your agent once and it sees every project: `flowforge mcp` with no folder answers about the project your agent is working in, or any other by name (`list_projects` shows them all). Your agent gets eleven tools: `list_projects`, `get_map`, `get_flow`, `get_advice`, `check_health`, `get_security_findings`, `get_keys`, `get_fix_requests`, `request_key`, `wait_for_keys` and `what_changed`. All read only, except `request_key`, which only asks you for a key by name. Every answer is masked (key values never appear), the agent can't make FlowForge change your code, and a check starts an MCP server only after you said yes to it on the Checks page. FlowForge needs no AI of its own for any of this.
170
+ Pair your agent once and it sees every project: `flowforge mcp` with no folder answers about the project your agent is working in, or any other by name (`list_projects` shows them all). Your agent gets fourteen tools:
171
+
172
+ | Kind | Tools |
173
+ |---|---|
174
+ | Read only | `list_projects`, `get_map`, `get_flow`, `get_advice`, `get_security_findings`, `get_keys`, `wait_for_keys`, `app_output`, `get_fix_requests`, `what_changed` |
175
+ | Checks | `check_health`: makes requests, and starts an MCP server only after you said yes to it |
176
+ | Asks you | `request_key`: asks for a key by name |
177
+ | Runs your app | `run_app`, `stop_app`: start and stop your app with its keys filled in, only after you allow that exact command once (D43) |
178
+
179
+ Every answer is masked (key values never appear), and the agent can't make FlowForge change your code. FlowForge needs no AI of its own for any of this.
164
180
 
165
181
  ### Fix what's unsafe, with one click
166
182
 
183
+ ![Issues: a key in the code, an unpinned MCP server, plain http](docs/images/issues.png)
184
+
167
185
  The **Issues** page lists what the scan found unsafe, and every issue has one button:
168
186
 
169
187
  | Issue | Button | What happens |
@@ -175,6 +193,8 @@ Fix needs a git repo and the file committed. FlowForge works out the change itse
175
193
 
176
194
  ### Keys your coding agent never sees
177
195
 
196
+ ![Keys: paste once, Add to code, the agent sees a mask](docs/images/keys.png)
197
+
178
198
  **Add to code.** The **Keys** page lists each place your code needs a key, with its `file:line`. Paste the key and press **Add to code**:
179
199
 
180
200
  ```
@@ -1,23 +1,29 @@
1
1
  # FlowForge
2
2
 
3
- **An LLMOps tool for apps that already use AI: a map of how your app's LLM, MCP and API calls fit together, and a scheduler that runs them.**
3
+ **See everything your AI app depends on: whether it's reachable, whether it's safe, and what's slowing it down.**
4
4
 
5
- You connect the apps your project uses (LLMs, MCP servers, HTTP APIs, local commands). The **Planner** reads your repo and runs the app in test mode, then draws the flow on the home page: the calls, the decisions, what runs together, the retries and fallbacks, and a gate in front of anything that moves money or sends a message. Only apps your code actually calls go on the map, and every block cites its evidence (a `file:line` or a traced call). You read the map, edit it if you want (your edits never touch your code), and re-check it when the code changes.
5
+ ```bash
6
+ npx flowforge-map --example # a sample app's map in your browser: no keys, nothing sent
7
+ ```
6
8
 
7
- Underneath, FlowForge runs workflows as a DAG: it orders the steps, runs independent ones together under rate limits, caches repeat calls, and (on the roadmap) holds the secrets and pauses risky steps for a human to approve.
9
+ ![The dependency map: every LLM, MCP server and API the app calls, with live health](docs/images/map.png)
8
10
 
9
- What makes it different: **it knows which chain of steps is the bottleneck and schedules around it.**
11
+ | | |
12
+ |---|---|
13
+ | 🗺 **Map** | Every LLM, MCP server, HTTP API and local program your code calls, each with the `file:line` it came from |
14
+ | 💚 **Check now** | Green, amber or red for each one, with its latency. When something is down, it tells you why |
15
+ | ⚡ **Faster** | Reads the order your code makes its calls and shows which ones could run together, with the time you'd save |
16
+ | 🛡 **Issues** | Keys written in code, plain `http://`, MCP servers that aren't pinned. Fix one on a new branch, or send it to your agent |
17
+ | 🔑 **Keys** | Paste a key once. **Add to code** writes it to a git-ignored file your app reads; FlowForge only ever hands your agent a mask |
18
+ | 🤖 **Your agent** | One MCP entry gives Claude Code, Codex or Antigravity the map, the advice and the issues |
10
19
 
11
- - orders steps with **topological sort** (Kahn's), and reports the exact loop if there is a cycle
12
- - finds the bottleneck chain with a **longest-path DP** (critical path), predicted before the run and measured after it
13
- - runs independent steps concurrently under limited resources (`k` slots + per-provider rate limits) using **critical-path list scheduling** (HLFET), which is within `(2 − 1/k)` of optimal (Graham's bound)
14
- - makes duplicate calls only once through **memoization** (single-flight in-run cache + a safe persistent cache)
20
+ ![Flow: 16.5 s as written, 11.6 s if four calls start together](docs/images/flow.png)
15
21
 
16
- **Free by default.** The default install, the tests and CI need no paid service: LLM steps use NVIDIA NIM's free tier, and local models (Ollama) are planned. Paid providers will work if you bring your own key, but are never needed.
22
+ **Free and local.** MIT, no account, no paid service. FlowForge keeps its data on your machine, and the scan is plain code that sends nothing to any LLM.
17
23
 
18
- **Local first.** One process, SQLite, files on disk. No cloud account, no Docker, no Redis.
24
+ **Under the hood:** a DAG scheduler runs the checks concurrently under rate limits. It uses a topological sort, a critical-path DP and HLFET list scheduling (within `(2 − 1/k)` of optimal), and it caches repeat calls. See [Scheduling policies and benchmarks](#scheduling-policies-and-benchmarks).
19
25
 
20
- > FlowForge started as a Design and Analysis of Algorithms course project. That version is frozen on the [`DAA`](../../tree/DAA) branch. `main` is the product.
26
+ > FlowForge started as a Design and Analysis of Algorithms course project. That version is commit [`233a681`](../../tree/233a681c3e17fa4081c883193f8456a53a4c049c) in `main`'s history.
21
27
 
22
28
  ## Quick start
23
29
 
@@ -28,7 +34,7 @@ npx flowforge-map --example # try it: a sample app's maps in your browser,
28
34
  npx flowforge-map --repo . # map the folder you're in
29
35
  ```
30
36
 
31
- That's all: no Python, no virtual env. The first run downloads FlowForge's ready-made program for your computer (about 60 MB, checked against the release's checksums) into `~/.flowforge/bin`; after that it starts at once, even offline. Works on Windows, macOS (Apple and Intel) and Linux x64 with Node 18+. Your data lives in `~/.flowforge`.
37
+ That's all: no Python, no virtual env. The first run downloads FlowForge's ready-made program for your computer (about 60 MB, checked against the release's checksums) into `~/.flowforge/bin`; after that it starts at once, even offline. Works on Windows, macOS (Apple chips) and Linux x64 with Node 18+. Your data lives in `~/.flowforge`.
32
38
 
33
39
  | You have | Run | |
34
40
  |---|---|---|
@@ -37,7 +43,7 @@ That's all: no Python, no virtual env. The first run downloads FlowForge's ready
37
43
  | Python people | `uvx flowforge-map` or `pipx install flowforge-map` | Python 3.12+; no virtual env to manage |
38
44
  | No terminal | the desktop app (Releases) | download and open; **Settings → Terminal** adds the `flowforge` command too (Linux .rpm/.deb have it already) |
39
45
 
40
- Every way gives the same `flowforge` command: `npx flowforge-map status`, `npx flowforge-map key add STRIPE_KEY`, and so on. Linux ARM and Windows ARM have no ready-made build yet, so `npx` uses `uvx` there.
46
+ Every way gives the same `flowforge` command: `npx flowforge-map status`, `npx flowforge-map key add STRIPE_KEY`, and so on. Intel Macs, Linux ARM and Windows ARM have no ready-made build yet, so `npx` uses `uvx` there.
41
47
 
42
48
  The first time, a short welcome walks you through three steps, each one skippable: your app's folder (or the example), pairing your coding agent once, and a lock for your keys. **⚙ Settings** at the bottom of the rail keeps what applies to every project: your agent, the theme and map style, the Vault's lock, and your data (each project's size, the MCP servers it may start, its check history, and the version, with **Check for updates**, which asks pypi.org only when you press it).
43
49
 
@@ -62,9 +68,11 @@ The same dashboard in its own window, with Python inside: nothing else to instal
62
68
  | Your computer | Download |
63
69
  |---|---|
64
70
  | Windows | `FlowForge_x.y.z_x64-setup.exe` or `.msi` |
65
- | Mac (Apple Silicon or Intel) | `FlowForge_x.y.z_aarch64.dmg` or `_x64.dmg` |
71
+ | Mac (Apple chip) | `FlowForge_x.y.z_aarch64.dmg` |
66
72
  | Linux | `.AppImage`, `.deb` or `.rpm` |
67
73
 
74
+ Intel Macs have no desktop build yet: use `npx flowforge-map` (it runs FlowForge through `uv` there) or `uvx flowforge-map`.
75
+
68
76
  The builds aren't signed yet (signing costs money every year), so the first launch asks once:
69
77
  - **Mac:** right-click FlowForge in Applications → **Open** → **Open**.
70
78
  - **Windows:** on "Windows protected your PC", click **More info** → **Run anyway**.
@@ -110,7 +118,6 @@ Switch the map to **Flow**. FlowForge reads your Python (by its syntax tree), Da
110
118
  - **Faster** compares "as written" (the slowest action, one call after another) with "could be", and lists the calls that don't need each other's results. **Show the change** writes out the `asyncio.gather`, Dart's `(a, b).wait` or `Promise.all` (now → could be, ready to copy) when it's safe; **Copy prompt for your coding agent** hands the rest to Claude Code, Codex or any agent. FlowForge never edits your code.
111
119
  - **Watch it run** plays the could-be schedule; pointing at a block lights up its path. Five map styles: Studio, Boxed, Blueprint, Metro, Soft.
112
120
 
113
-
114
121
  ### Many projects
115
122
 
116
123
  The home page lists every app you've mapped, with its dependencies, what needs fixing and missing keys. **+ New project** takes your app's folder, scans it and shows its map. The project tile at the top of the rail switches between the projects you have open, and each one comes back where you left it. **×** closes a project from that list without deleting it. Each project keeps its own map, checks, issues and keys; `flowforge --repo PATH` opens (or adds) that folder's project.
@@ -123,10 +130,21 @@ FlowForge is also an **MCP server**, so Claude Code, Codex or Antigravity can as
123
130
  claude mcp add flowforge -- uvx flowforge-map mcp
124
131
  ```
125
132
 
126
- Pair your agent once and it sees every project: `flowforge mcp` with no folder answers about the project your agent is working in, or any other by name (`list_projects` shows them all). Your agent gets eleven tools: `list_projects`, `get_map`, `get_flow`, `get_advice`, `check_health`, `get_security_findings`, `get_keys`, `get_fix_requests`, `request_key`, `wait_for_keys` and `what_changed`. All read only, except `request_key`, which only asks you for a key by name. Every answer is masked (key values never appear), the agent can't make FlowForge change your code, and a check starts an MCP server only after you said yes to it on the Checks page. FlowForge needs no AI of its own for any of this.
133
+ Pair your agent once and it sees every project: `flowforge mcp` with no folder answers about the project your agent is working in, or any other by name (`list_projects` shows them all). Your agent gets fourteen tools:
134
+
135
+ | Kind | Tools |
136
+ |---|---|
137
+ | Read only | `list_projects`, `get_map`, `get_flow`, `get_advice`, `get_security_findings`, `get_keys`, `wait_for_keys`, `app_output`, `get_fix_requests`, `what_changed` |
138
+ | Checks | `check_health`: makes requests, and starts an MCP server only after you said yes to it |
139
+ | Asks you | `request_key`: asks for a key by name |
140
+ | Runs your app | `run_app`, `stop_app`: start and stop your app with its keys filled in, only after you allow that exact command once (D43) |
141
+
142
+ Every answer is masked (key values never appear), and the agent can't make FlowForge change your code. FlowForge needs no AI of its own for any of this.
127
143
 
128
144
  ### Fix what's unsafe, with one click
129
145
 
146
+ ![Issues: a key in the code, an unpinned MCP server, plain http](docs/images/issues.png)
147
+
130
148
  The **Issues** page lists what the scan found unsafe, and every issue has one button:
131
149
 
132
150
  | Issue | Button | What happens |
@@ -138,6 +156,8 @@ Fix needs a git repo and the file committed. FlowForge works out the change itse
138
156
 
139
157
  ### Keys your coding agent never sees
140
158
 
159
+ ![Keys: paste once, Add to code, the agent sees a mask](docs/images/keys.png)
160
+
141
161
  **Add to code.** The **Keys** page lists each place your code needs a key, with its `file:line`. Paste the key and press **Add to code**:
142
162
 
143
163
  ```
@@ -157,7 +157,8 @@ def headline(tool: str, data: dict[str, Any]) -> str | None:
157
157
  return _plural(len(data["dependencies"]), "dependency", "dependencies")
158
158
  if tool == "check_health":
159
159
  counts = data["summary"]
160
- parts = [f"{counts[s]} {s}" for s in ("up", "warn", "down", "skipped") if counts.get(s)]
160
+ words = {"up": "reachable", "warn": "need a look", "down": "down", "skipped": "not checked"} # D52
161
+ parts = [f"{counts[s]} {w}" for s, w in words.items() if counts.get(s)]
161
162
  return " · ".join(parts) or "Nothing to check"
162
163
  if tool in ("get_flow", "get_advice") and not data["available"]:
163
164
  return "No flow: no Python calls found"
@@ -185,6 +185,18 @@ def _writable(s: AppState) -> Path:
185
185
  return repo
186
186
 
187
187
 
188
+ @router.get("/keys/asked")
189
+ def keys_asked() -> dict[str, list[str]]:
190
+ """The names your agent asked for and you haven't saved yet (D38): cheap enough for the rail's badge to poll,
191
+ since it never reads the code. Names only."""
192
+ s = app_state()
193
+ repo = repo_path(s)
194
+ if repo is None or not repo.is_dir():
195
+ return {"names": []}
196
+ saved = set(KeyStore(repo).names())
197
+ return {"names": [r["name"] for r in key_requests(s.storage, repo) if r["name"] not in saved]}
198
+
199
+
188
200
  @router.get("/keys")
189
201
  def keys() -> dict[str, Any]:
190
202
  s = app_state()
@@ -205,6 +205,13 @@ def _warnings(url: str, answer: _Answer) -> list[str]:
205
205
  return out
206
206
 
207
207
 
208
+ def _reachable(code: int) -> str:
209
+ """What one answer proves (D52): the server is there. Not that a key works, nor that your call does."""
210
+ if code in (401, 403):
211
+ return f"Reachable, needs a key ({code})"
212
+ return "Reachable" if code < 300 else f"Reachable, answered {code}" # a redirect isn't followed: say so
213
+
214
+
208
215
  async def api(t: CheckTarget, http: httpx.AsyncClient) -> CheckResult:
209
216
  if not t.target:
210
217
  return _result(t, "skipped", "Not checked: no address in the code")
@@ -215,7 +222,11 @@ async def api(t: CheckTarget, http: httpx.AsyncClient) -> CheckResult:
215
222
  detail = {"http_status": answer.code}
216
223
  if answer.code >= 500:
217
224
  return _result(t, "down", f"Answered {answer.code}", latency_ms=answer.ms, detail=detail)
218
- up = f"Up, needs a key ({answer.code})" if answer.code in (401, 403) else "Up"
225
+ if answer.code == 429:
226
+ return _result(t, "warn", "Reachable, but rate-limiting this computer (429)", latency_ms=answer.ms,
227
+ detail=detail, fix="Check again later. If it keeps happening, your app may be hitting the "
228
+ "same limit.")
229
+ up = _reachable(answer.code)
219
230
  if warnings := _warnings(url, answer):
220
231
  return _result(t, "warn", f"{up}, but {'; '.join(warnings)}", latency_ms=answer.ms, detail=detail)
221
232
  return _result(t, "up", up, latency_ms=answer.ms, detail=detail)
@@ -252,8 +263,12 @@ async def llm(t: CheckTarget, http: httpx.AsyncClient) -> CheckResult:
252
263
  detail = {"http_status": answer.code}
253
264
  if answer.code >= 500:
254
265
  return _result(t, "down", f"Answered {answer.code}", latency_ms=answer.ms, detail=detail)
266
+ if answer.code == 429:
267
+ return _result(t, "warn", "Reachable, but rate-limiting this computer (429)", latency_ms=answer.ms,
268
+ detail=detail, fix="Check again later. If it keeps happening, your app may be hitting the "
269
+ "same limit.")
255
270
  if answer.code in (401, 403):
256
- return _result(t, "up", f"Up, needs a key ({answer.code}) to list its models", latency_ms=answer.ms,
271
+ return _result(t, "up", f"Reachable, needs a key ({answer.code}) to list its models", latency_ms=answer.ms,
257
272
  detail=detail)
258
273
  if t.model and answer.body is not None:
259
274
  listed = t.model.removesuffix(":latest") in _model_names(answer.body)
@@ -261,8 +276,8 @@ async def llm(t: CheckTarget, http: httpx.AsyncClient) -> CheckResult:
261
276
  if not listed:
262
277
  fix = f"Run `ollama pull {t.model}`." if t.catalog == "ollama" else None
263
278
  what = "isn't pulled" if t.catalog == "ollama" else "isn't in its model list"
264
- return _result(t, "warn", f"Up, but {t.model} {what}", latency_ms=answer.ms, detail=detail, fix=fix)
265
- up = f"Up, {t.model} is ready" if t.model and detail.get("model_listed") else "Up"
279
+ return _result(t, "warn", f"Reachable, but {t.model} {what}", latency_ms=answer.ms, detail=detail, fix=fix)
280
+ up = f"Reachable, {t.model} is ready" if t.model and detail.get("model_listed") else _reachable(answer.code)
266
281
  if warnings := _warnings(url, answer):
267
282
  return _result(t, "warn", f"{up}, but {'; '.join(warnings)}", latency_ms=answer.ms, detail=detail)
268
283
  return _result(t, "up", up, latency_ms=answer.ms, detail=detail)
@@ -329,7 +344,7 @@ async def mcp(t: CheckTarget, root: Path, http: httpx.AsyncClient) -> CheckResul
329
344
  reason += f"\nIts config sets {', '.join(t.env_names)}."
330
345
  return _result(t, "down", "Stopped while starting", reason=reason, fix=_env_fix(t))
331
346
  ms = (time.perf_counter() - start) * 1000
332
- up = f"Up: {len(tools)} tool{'s' if len(tools) != 1 else ''}"
347
+ up = f"Reachable: {len(tools)} tool{'s' if len(tools) != 1 else ''}"
333
348
  detail = {"tools": tools}
334
349
  if ms > MCP_SLOW_MS:
335
350
  return _result(t, "warn", f"{up}, but slow to start: {took(ms)}", latency_ms=ms, detail=detail)
@@ -73,9 +73,13 @@ def level(value: str) -> Text:
73
73
  return Text(value, style=STYLES.get(value, ""))
74
74
 
75
75
 
76
+ HEALTH_WORDS = {"up": "reachable", "warn": "needs a look"} # what a check proves (D52)
77
+
78
+
76
79
  def dot(out: Console, status: str) -> Text:
77
80
  """Health keeps its label even without colour (D41)."""
78
- return Text(f"{'●' if utf8(out) else '*'} {status}", style=STYLES.get(status, "bright_black"))
81
+ word = HEALTH_WORDS.get(status, status)
82
+ return Text(f"{'●' if utf8(out) else '*'} {word}", style=STYLES.get(status, "bright_black"))
79
83
 
80
84
 
81
85
  def table(out: Console, title: str, columns: list[str], rows: list[list[Any]], *, border: bool = True) -> None:
@@ -176,21 +176,21 @@ def _recorded(id: str, status: Status, latency_ms: float | None, summary: str, *
176
176
 
177
177
  # Recorded check results for the trip planner (D24): what a real check run gave, so example mode stays offline.
178
178
  CHECK_RESULTS: dict[str, CheckResult] = {r.id: r for r in [
179
- _recorded("mcp-time", "up", 1900, "Up: 2 tools"),
180
- _recorded("mcp-files", "up", 2400, "Up: 14 tools"),
181
- _recorded("mcp-fetch", "warn", 6800, "Up: 1 tool, but slow to start: 6.8 s"),
179
+ _recorded("mcp-time", "up", 1900, "Reachable: 2 tools"),
180
+ _recorded("mcp-files", "up", 2400, "Reachable: 14 tools"),
181
+ _recorded("mcp-fetch", "warn", 6800, "Reachable: 1 tool, but slow to start: 6.8 s"),
182
182
  _recorded("mcp-currency", "down", None, "Stopped while starting",
183
183
  reason="currency server: CURRENCY_API_URL is not set",
184
184
  fix="Its code reads CURRENCY_API_URL (mcp_servers/currency.py:9), but its MCP config gives it no env."),
185
185
  _recorded("local-packing-list", "up", None, "Found, not run"),
186
186
  _recorded("llm-ollama", "down", None, "Nothing answers at localhost:11434",
187
187
  reason="[Errno 111] Connection refused", fix="Start Ollama (`ollama serve`)."),
188
- _recorded("api-osrm", "up", 640, "Up"),
189
- _recorded("api-open-meteo", "up", 180, "Up"),
190
- _recorded("api-nominatim", "up", 420, "Up"),
191
- _recorded("api-ip-api-com", "warn", 210, "Up, but plain http://: not encrypted"),
192
- _recorded("api-google-maps", "up", 230, "Up, needs a key (403)"),
193
- _recorded("api-wikipedia", "up", 310, "Up"),
188
+ _recorded("api-osrm", "up", 640, "Reachable"),
189
+ _recorded("api-open-meteo", "up", 180, "Reachable"),
190
+ _recorded("api-nominatim", "up", 420, "Reachable"),
191
+ _recorded("api-ip-api-com", "warn", 210, "Reachable, but plain http://: not encrypted"),
192
+ _recorded("api-google-maps", "up", 230, "Reachable, needs a key (403)"),
193
+ _recorded("api-wikipedia", "up", 310, "Reachable"),
194
194
  ]}
195
195
 
196
196
 
@@ -19,8 +19,10 @@ from flowforge.flow.order import Body, Stmt, flatten, label, preds, reduce
19
19
  from flowforge.flow.reach import Hit, Reach, writes
20
20
  from flowforge.flowmap.models import Evidence, Flow, FlowEdge, FlowNode, Op
21
21
  from flowforge.flowmap.validate import DEFAULT_MS, actions_of
22
+ from flowforge.scan.files import language
22
23
  from flowforge.scan.models import Dependency, ScanResult
23
24
  from flowforge.scan.scanner import Places, scan_with_places
25
+ from flowforge.security.finders import masked_lines
24
26
  from flowforge.security.logfilter import Redactor, redact_code
25
27
 
26
28
  NO_CODE = "Flow order reads Python, Dart, JavaScript and TypeScript."
@@ -130,7 +132,7 @@ class _Builder:
130
132
 
131
133
  def entry(self, func: Any, body: Body, lang: Any) -> None:
132
134
  self.lang = lang
133
- lines = self.sources[func.path].splitlines()
135
+ lines = masked_lines(self.sources[func.path], language(func.path), _redactor) # every key masked first
134
136
  start = f"start-{func.name.replace('.', '-')}"[:64]
135
137
  self.nodes.append(FlowNode(id=start, kind="start", title=f"{func.name}()"[:80],
136
138
  what=f"An entry point in {func.path}.",
@@ -16,6 +16,7 @@ from flowforge.flow.rewrite import Call
16
16
  from flowforge.flowmap.models import Change
17
17
  from flowforge.scan import services
18
18
  from flowforge.scan.scanner import Places
19
+ from flowforge.security.finders import split_key_in
19
20
  from flowforge.security.logfilter import redact_code
20
21
 
21
22
  LANGUAGES = frozenset({"dart", "javascript", "typescript"})
@@ -852,6 +853,8 @@ def change(calls: list[Call], lang: Lang) -> Change | None:
852
853
  first_line = plain[0][0].line
853
854
  last_line = lang.end_line(func, plain[-1][0].stmt)
854
855
  lines = src.text.splitlines()
856
+ if split_key_in(src.text, func.language, first_line, last_line):
857
+ return None # a key split over lines sits in the block: the prompt only, never its pieces
855
858
  indent = re.match(r"\s*", lines[first_line - 1]).group(0)
856
859
  semi = ";" if func.language == "dart" or all(p[4] for _, p in plain) else ""
857
860
  if func.language == "dart":
@@ -58,6 +58,7 @@ class Index:
58
58
  def __init__(self, sources: dict[str, str]):
59
59
  self.modules: dict[str, Module] = {}
60
60
  self.funcs: dict[str, Func] = {}
61
+ self.changed: dict[str, set[str]] = {} # func key -> parameters it changes in place (D51)
61
62
  for path, text in sources.items():
62
63
  try:
63
64
  tree = ast.parse(text)
@@ -11,7 +11,7 @@ import ast
11
11
  import re
12
12
  from dataclasses import dataclass, field
13
13
 
14
- from flowforge.flow.index import Func, Index
14
+ from flowforge.flow.index import Func, Index, Module
15
15
  from flowforge.flow.reach import Hit, Reach, gathers
16
16
 
17
17
  MUTATORS = frozenset({"append", "extend", "update", "add", "insert", "pop", "remove", "clear", "setdefault", "write",
@@ -41,17 +41,82 @@ def _reads(node: ast.AST) -> set[str]:
41
41
 
42
42
 
43
43
  def _writes(node: ast.AST) -> set[str]:
44
- out = {n.id for n in ast.walk(node) if isinstance(n, ast.Name) and isinstance(n.ctx, (ast.Store, ast.Del))}
44
+ bound = {n.id for n in ast.walk(node) if isinstance(n, ast.Name) and isinstance(n.ctx, (ast.Store, ast.Del))}
45
+ return bound | changes(node)
46
+
47
+
48
+ def _base(node: ast.AST) -> str | None:
49
+ while isinstance(node, (ast.Subscript, ast.Attribute)):
50
+ node = node.value
51
+ return node.id if isinstance(node, ast.Name) else None
52
+
53
+
54
+ def changes(node: ast.AST) -> set[str]:
55
+ """Names whose object the code changes in place: `x[k] = …`, `x.a = …`, `x.append(…)` (rebinding `x` isn't)."""
56
+ out: set[str] = set()
45
57
  for n in ast.walk(node):
46
- if isinstance(n, (ast.Subscript, ast.Attribute)) and isinstance(n.ctx, ast.Store):
47
- base = n.value
48
- while isinstance(base, (ast.Subscript, ast.Attribute)):
49
- base = base.value
50
- if isinstance(base, ast.Name):
51
- out.add(base.id)
58
+ if isinstance(n, (ast.Subscript, ast.Attribute)) and isinstance(n.ctx, (ast.Store, ast.Del)) \
59
+ and (base := _base(n.value)):
60
+ out.add(base)
52
61
  if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute) and n.func.attr in MUTATORS \
53
- and isinstance(n.func.value, ast.Name):
54
- out.add(n.func.value.id) # facts.append(x) writes facts
62
+ and (base := _base(n.func.value)):
63
+ out.add(base) # facts.append(x) writes facts
64
+ return out
65
+
66
+
67
+ def _params(func: Func) -> list[str]:
68
+ a = func.node.args
69
+ return [p.arg for p in (*a.posonlyargs, *a.args, *a.kwonlyargs)]
70
+
71
+
72
+ def _is_method(func: Func) -> bool:
73
+ static = any(isinstance(d, ast.Name) and d.id == "staticmethod" for d in func.node.decorator_list)
74
+ return "." in func.name and not static
75
+
76
+
77
+ def _passed(call: ast.Call, target: Func, changed: set[str]) -> set[str]:
78
+ """The caller's names that land on `changed` parameters of `target`: by position, by keyword, and the receiver
79
+ of a method (`cart.add(x)` passes `cart` as `self`)."""
80
+ params = _params(target)
81
+ out: set[str] = set()
82
+ if _is_method(target) and isinstance(call.func, ast.Attribute):
83
+ if params and params[0] in changed and (base := _base(call.func.value)):
84
+ out.add(base)
85
+ params = params[1:]
86
+ for i, arg in enumerate(call.args):
87
+ if i < len(params) and params[i] in changed and (base := _base(arg)):
88
+ out.add(base)
89
+ for kw in call.keywords:
90
+ if kw.arg in changed and (base := _base(kw.value)):
91
+ out.add(base)
92
+ return out
93
+
94
+
95
+ def changed_params(index: Index, key: str, _stack: frozenset[str] = frozenset()) -> set[str]:
96
+ """The parameters a function in the repo changes in place, itself or through the repo's functions it calls (D51).
97
+ Library calls are taken not to change their arguments."""
98
+ if key in index.changed:
99
+ return index.changed[key]
100
+ func = index.funcs[key]
101
+ params = set(_params(func))
102
+ found = changes(func.node) & params
103
+ module = index.modules[func.module]
104
+ cls = func.name.split(".")[0] if "." in func.name else None
105
+ for n in ast.walk(func.node):
106
+ if isinstance(n, ast.Call) and (sub := index.resolve(n, module, cls)) and sub not in _stack | {key}:
107
+ found |= _passed(n, index.funcs[sub], changed_params(index, sub, _stack | {key})) & params
108
+ if not _stack:
109
+ index.changed[key] = found # a result found inside a cycle may be partial: kept only from the top
110
+ return found
111
+
112
+
113
+ def call_writes(node: ast.AST, index: Index, module: Module, cls: str | None) -> set[str]:
114
+ """Names a statement changes through the repo's own functions it calls: `await load(ctx)` writes `ctx` when `load`
115
+ fills it (D51)."""
116
+ out: set[str] = set()
117
+ for n in ast.walk(node):
118
+ if isinstance(n, ast.Call) and (key := index.resolve(n, module, cls)):
119
+ out |= _passed(n, index.funcs[key], changed_params(index, key))
55
120
  return out
56
121
 
57
122
 
@@ -71,7 +136,7 @@ def flatten(func: Func, index: Index, reach: Reach) -> Body:
71
136
  def add(node: ast.AST, line: int, ifs: list[tuple[int, str]], loop: bool, *, reads: set[str] | None = None,
72
137
  writes: set[str] | None = None, hit_node: ast.AST | None = None, is_if: bool = False) -> Stmt:
73
138
  s = Stmt(idx=len(body.stmts), node=node, line=line, reads=_reads(node) if reads is None else reads,
74
- writes=_writes(node) if writes is None else writes,
139
+ writes=_writes(node) | call_writes(node, index, module, cls) if writes is None else writes,
75
140
  hits=reach.statement(hit_node if hit_node is not None else node, module, cls) if hit_node is not False
76
141
  else [], ifs=list(ifs), loop=loop, returns=isinstance(node, (ast.Return, ast.Raise)),
77
142
  gathered=gathers(node), is_if=is_if)
@@ -12,7 +12,9 @@ import ast
12
12
  from dataclasses import dataclass
13
13
  from typing import Any
14
14
 
15
+ from flowforge.flow.order import changes
15
16
  from flowforge.flowmap.models import Change
17
+ from flowforge.security.finders import split_key_in
16
18
  from flowforge.security.logfilter import redact_code
17
19
 
18
20
 
@@ -75,6 +77,20 @@ def _names(node: ast.AST, ctx: type) -> set[str]:
75
77
  return {n.id for n in ast.walk(node) if isinstance(n, ast.Name) and isinstance(n.ctx, ctx)}
76
78
 
77
79
 
80
+ def _handed(node: ast.AST) -> set[str]:
81
+ """Names handed to any call: a function we can't see into might change them, so a written change won't move a
82
+ call past it (D51)."""
83
+ out: set[str] = set()
84
+ for n in ast.walk(node):
85
+ if isinstance(n, ast.Call):
86
+ for arg in [*n.args, *(k.value for k in n.keywords)]:
87
+ while isinstance(arg, (ast.Subscript, ast.Attribute, ast.Starred)):
88
+ arg = arg.value
89
+ if isinstance(arg, ast.Name):
90
+ out.add(arg.id)
91
+ return out
92
+
93
+
78
94
  def _quoted(c: Call) -> str:
79
95
  return f'"{c.words}" ({c.ref})'
80
96
 
@@ -102,16 +118,19 @@ def change(calls: list[Call], source: str, module: ast.Module) -> Change | None:
102
118
  targets = set().union(*(_names(c.stmt, ast.Store) for c in plain))
103
119
  reads = set().union(*(_names(_awaited(c.stmt)[1], ast.Load) for c in plain))
104
120
  for st in body[first: last + 1]:
105
- if id(st) not in members and (_names(st, ast.Load) & targets or _names(st, ast.Store) & reads):
106
- return None # code between them uses a result, or changes what a call reads
121
+ if id(st) not in members and (_names(st, ast.Load) & targets or _names(st, ast.Store) & reads
122
+ or (changes(st) | _handed(st)) & reads):
123
+ return None # code between them uses a result, or changes (or may change) what a call reads (D51)
107
124
 
108
125
  lines = source.splitlines()
126
+ end = plain[-1].stmt.end_lineno or plain[-1].stmt.lineno
127
+ if split_key_in(source, "python", plain[0].stmt.lineno, end):
128
+ return None # a key split over lines sits in the block: the prompt only, never its pieces
109
129
  indent = lines[plain[0].stmt.lineno - 1][: plain[0].stmt.col_offset]
110
130
  names = [_awaited(c.stmt)[0] for c in plain]
111
131
  lhs = "" if all(n == "_" for n in names) else f"{', '.join(names)} = "
112
132
  gather = [f"{indent}{lhs}await asyncio.gather(",
113
133
  *(f"{indent} {ast.unparse(_awaited(c.stmt)[1])}," for c in plain), f"{indent})"]
114
- end = plain[-1].stmt.end_lineno or plain[-1].stmt.lineno
115
134
  spans = [(c.stmt.lineno, c.stmt.end_lineno or c.stmt.lineno) for c in plain]
116
135
  after: list[str] = []
117
136
  for n in range(plain[0].stmt.lineno, end + 1):