flowforge-map 0.1.0__py3-none-any.whl

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 (113) hide show
  1. flowforge/__init__.py +0 -0
  2. flowforge/_classic.html +289 -0
  3. flowforge/_dashboard/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  4. flowforge/_dashboard/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  5. flowforge/_dashboard/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  6. flowforge/_dashboard/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  7. flowforge/_dashboard/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
  8. flowforge/_dashboard/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
  9. flowforge/_dashboard/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
  10. flowforge/_dashboard/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
  11. flowforge/_dashboard/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
  12. flowforge/_dashboard/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
  13. flowforge/_dashboard/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  14. flowforge/_dashboard/assets/index-BGT5seIN.css +1 -0
  15. flowforge/_dashboard/assets/index-BoGikST6.js +52 -0
  16. flowforge/_dashboard/favicon.svg +1 -0
  17. flowforge/_dashboard/index.html +16 -0
  18. flowforge/_example/trip-planner/.env.example +3 -0
  19. flowforge/_example/trip-planner/.gitignore +2 -0
  20. flowforge/_example/trip-planner/.mcp.json +20 -0
  21. flowforge/_example/trip-planner/README.md +48 -0
  22. flowforge/_example/trip-planner/config.py +27 -0
  23. flowforge/_example/trip-planner/mcp_servers/currency.py +10 -0
  24. flowforge/_example/trip-planner/planner.py +113 -0
  25. flowforge/_example/trip-planner/requirements.txt +3 -0
  26. flowforge/_example/trip-planner/services/__init__.py +0 -0
  27. flowforge/_example/trip-planner/services/geo.py +49 -0
  28. flowforge/_example/trip-planner/services/llm.py +18 -0
  29. flowforge/_example/trip-planner/services/mcp_tools.py +26 -0
  30. flowforge/_example/trip-planner/services/weather.py +25 -0
  31. flowforge/_example/trip-planner/tools/packing_list.py +24 -0
  32. flowforge/_example/trip-planner/trips/.gitkeep +0 -0
  33. flowforge/_workflows/diamond_mock.json +71 -0
  34. flowforge/_workflows/stripe_to_razorpay.json +38 -0
  35. flowforge/agent/__init__.py +5 -0
  36. flowforge/agent/server.py +333 -0
  37. flowforge/api/__init__.py +0 -0
  38. flowforge/api/agent.py +79 -0
  39. flowforge/api/checks.py +255 -0
  40. flowforge/api/connectors.py +289 -0
  41. flowforge/api/flowmap.py +174 -0
  42. flowforge/api/runs.py +284 -0
  43. flowforge/api/scan.py +117 -0
  44. flowforge/api/security.py +31 -0
  45. flowforge/api/stats.py +80 -0
  46. flowforge/checks/__init__.py +13 -0
  47. flowforge/checks/models.py +41 -0
  48. flowforge/checks/probes.py +359 -0
  49. flowforge/cli.py +108 -0
  50. flowforge/connectors/__init__.py +0 -0
  51. flowforge/connectors/catalog.py +84 -0
  52. flowforge/connectors/models.py +149 -0
  53. flowforge/connectors/presets.py +192 -0
  54. flowforge/connectors/registry.py +85 -0
  55. flowforge/connectors/runtime.py +47 -0
  56. flowforge/connectors/secrets.py +53 -0
  57. flowforge/example_data.py +247 -0
  58. flowforge/flow/__init__.py +328 -0
  59. flowforge/flow/advice.py +132 -0
  60. flowforge/flow/curly.py +884 -0
  61. flowforge/flow/index.py +116 -0
  62. flowforge/flow/order.py +181 -0
  63. flowforge/flow/reach.py +145 -0
  64. flowforge/flow/rewrite.py +146 -0
  65. flowforge/flowmap/__init__.py +1 -0
  66. flowforge/flowmap/diff.py +145 -0
  67. flowforge/flowmap/fixture.py +283 -0
  68. flowforge/flowmap/models.py +258 -0
  69. flowforge/flowmap/planner.py +131 -0
  70. flowforge/flowmap/service.py +379 -0
  71. flowforge/flowmap/validate.py +228 -0
  72. flowforge/home.py +58 -0
  73. flowforge/main.py +137 -0
  74. flowforge/nodes/__init__.py +3 -0
  75. flowforge/nodes/base.py +74 -0
  76. flowforge/nodes/check_node.py +50 -0
  77. flowforge/nodes/http_node.py +161 -0
  78. flowforge/nodes/llm_node.py +158 -0
  79. flowforge/nodes/local_node.py +164 -0
  80. flowforge/nodes/mcp_node.py +243 -0
  81. flowforge/nodes/mock_node.py +40 -0
  82. flowforge/scan/__init__.py +6 -0
  83. flowforge/scan/facts.py +47 -0
  84. flowforge/scan/files.py +112 -0
  85. flowforge/scan/manifests.py +65 -0
  86. flowforge/scan/mcp_config.py +75 -0
  87. flowforge/scan/models.py +73 -0
  88. flowforge/scan/patterns.py +86 -0
  89. flowforge/scan/python_ast.py +246 -0
  90. flowforge/scan/scanner.py +344 -0
  91. flowforge/scan/services.py +58 -0
  92. flowforge/scan/urls.py +60 -0
  93. flowforge/scheduler/__init__.py +0 -0
  94. flowforge/scheduler/cache.py +76 -0
  95. flowforge/scheduler/critical_path.py +65 -0
  96. flowforge/scheduler/durations.py +35 -0
  97. flowforge/scheduler/executor.py +390 -0
  98. flowforge/scheduler/graph.py +108 -0
  99. flowforge/scheduler/rate_limit.py +36 -0
  100. flowforge/schema.py +81 -0
  101. flowforge/security/__init__.py +0 -0
  102. flowforge/security/finders.py +127 -0
  103. flowforge/security/findings.py +169 -0
  104. flowforge/security/logfilter.py +129 -0
  105. flowforge/spa.py +52 -0
  106. flowforge/state.py +73 -0
  107. flowforge/storage.py +481 -0
  108. flowforge/templating.py +60 -0
  109. flowforge_map-0.1.0.dist-info/METADATA +335 -0
  110. flowforge_map-0.1.0.dist-info/RECORD +113 -0
  111. flowforge_map-0.1.0.dist-info/WHEEL +4 -0
  112. flowforge_map-0.1.0.dist-info/entry_points.txt +3 -0
  113. flowforge_map-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,379 @@
1
+ """The flow map's state (D17): the accepted Planner version, the user's edits on top, positions,
2
+ re-checks and their diffs. The API is a thin layer over this.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from collections.abc import Callable
8
+ from typing import Any
9
+
10
+ from flowforge.connectors.models import Connector
11
+ from flowforge.flowmap.diff import apply_choices, default_take, diff
12
+ from flowforge.flowmap.models import (
13
+ CALL_KINDS,
14
+ CONNECTOR_TYPE,
15
+ Action,
16
+ Analysis,
17
+ Evidence,
18
+ Flow,
19
+ FlowEdge,
20
+ Override,
21
+ Trace,
22
+ )
23
+ from flowforge.flowmap.planner import NoFlow, Planner
24
+ from flowforge.flowmap.validate import FlowError, actions_of, check, critical
25
+ from flowforge.storage import FlowStore
26
+
27
+ NOT_AVAILABLE = "No map yet. Scan a folder to draw what your app depends on."
28
+ FIXED_KINDS = ("gate", "start", "end")
29
+
30
+
31
+ class OverrideError(ValueError):
32
+ """An edit that isn't allowed. One plain sentence."""
33
+
34
+
35
+ class VersionError(ValueError):
36
+ pass
37
+
38
+
39
+ Edits = dict[str, list[dict[str, Any]]]
40
+
41
+
42
+ def merge(flow: Flow, overrides: list[tuple[int, Override]]) -> tuple[Flow, Edits, list[dict[str, Any]]]:
43
+ """Apply the user's edits to a copy. Returns (flow, edits per block, hidden blocks).
44
+
45
+ Edits naming a block that no longer exists (after a re-check) are skipped, not errors.
46
+ """
47
+ flow = flow.model_copy(deep=True)
48
+ nodes = {n.id: n for n in flow.nodes}
49
+ edits: dict[str, list[dict[str, Any]]] = {}
50
+ hide: list[tuple[int, str]] = []
51
+
52
+ def note(node_id: str, oid: int, o: Override) -> None:
53
+ edits.setdefault(node_id, []).append({"id": oid, "op": o.op})
54
+
55
+ for oid, o in overrides:
56
+ if o.op == "add_node" and o.new_node is not None and o.new_node.id not in nodes:
57
+ node = o.new_node.model_copy(update={"added_by": "user", "status": "confirmed"})
58
+ flow.nodes.append(node)
59
+ nodes[node.id] = node
60
+ note(node.id, oid, o)
61
+ elif o.node not in nodes:
62
+ continue
63
+ elif o.op == "set_connector":
64
+ n = nodes[o.node]
65
+ n.connector = o.connector
66
+ if n.op is not None:
67
+ n.op = n.op.model_copy(update={"model": None})
68
+ note(o.node, oid, o)
69
+ elif o.op == "rename":
70
+ nodes[o.node].title = o.title or nodes[o.node].title
71
+ note(o.node, oid, o)
72
+ elif o.op == "confirm":
73
+ n = nodes[o.node]
74
+ n.status = "confirmed"
75
+ n.evidence = [*n.evidence, Evidence(type="user", text="Confirmed by you.")]
76
+ note(o.node, oid, o)
77
+ elif o.op == "add_edge" and o.source in nodes:
78
+ if not any(e.source == o.source and e.target == o.node for e in flow.edges):
79
+ flow.edges.append(FlowEdge(id=_edge_id(flow, f"user_{o.source}_{o.node}"), source=o.source,
80
+ target=o.node))
81
+ # the "Yours" mark goes on the step you added, not on the Planner's block it connects to
82
+ note(o.source if nodes[o.source].added_by == "user" else o.node, oid, o)
83
+ elif o.op == "hide":
84
+ hide.append((oid, o.node))
85
+
86
+ hidden: list[dict[str, Any]] = []
87
+ for oid, node_id in hide:
88
+ node = nodes.get(node_id)
89
+ if node is None or node.kind in FIXED_KINDS:
90
+ continue
91
+ ins = [e for e in flow.edges if e.target == node_id and e.kind != "retry"]
92
+ outs = [e for e in flow.edges if e.source == node_id and e.kind != "retry"]
93
+ flow.edges = [e for e in flow.edges if node_id not in (e.source, e.target)]
94
+ for a in ins: # bypass the hidden block so the flow stays connected
95
+ for b in outs:
96
+ if not any(e.source == a.source and e.target == b.target for e in flow.edges):
97
+ flow.edges.append(FlowEdge(id=_edge_id(flow, f"via_{a.source}_{b.target}"), source=a.source,
98
+ target=b.target, kind=a.kind, when=a.when))
99
+ flow.nodes = [n for n in flow.nodes if n.id != node_id]
100
+ del nodes[node_id]
101
+ hidden.append({"id": node_id, "title": node.title, "override": oid})
102
+ flow.suggestions = [s for s in flow.suggestions if s.node in nodes]
103
+ return flow, edits, hidden
104
+
105
+
106
+ def _edge_id(flow: Flow, base: str) -> str:
107
+ base = base[:58]
108
+ taken = {e.id for e in flow.edges}
109
+ out, i = base, 2
110
+ while out in taken:
111
+ out, i = f"{base}_{i}", i + 1
112
+ return out
113
+
114
+
115
+ class FlowService:
116
+ def __init__(self, store: FlowStore, connectors: Callable[[], dict[str, Connector]], planner: Planner | None):
117
+ self.store = store # one map's versions, edits and positions (D21)
118
+ self.connectors = connectors
119
+ self.planner = planner
120
+ self.message: str | None = None # why the Planner had nothing to draw (D25)
121
+
122
+ # --- reading ---------------------------------------------------------------------------------
123
+
124
+ def _base(self) -> tuple[dict[str, Any], Flow] | None:
125
+ row = self.store.latest_flow_version("accepted")
126
+ if row is None:
127
+ return None
128
+ return row, Flow.model_validate(row["flow"])
129
+
130
+ def planner_flow(self) -> Flow | None:
131
+ """The accepted Planner version, without the user's edits."""
132
+ base = self._base()
133
+ return base[1] if base else None
134
+
135
+ def overrides(self) -> list[tuple[int, Override]]:
136
+ return [(oid, Override.model_validate(data)) for oid, data in self.store.flow_overrides()]
137
+
138
+ def current(self, layer: str = "mine") -> dict[str, Any]:
139
+ base = self._base()
140
+ if base is None:
141
+ return {"available": False, "message": self.message or NOT_AVAILABLE}
142
+ row, flow = base
143
+ edits: dict[str, list[dict[str, Any]]] = {}
144
+ hidden: list[dict[str, Any]] = []
145
+ if layer == "mine":
146
+ flow, edits, hidden = merge(flow, self.overrides())
147
+ connectors = self.connectors()
148
+ flow = check(flow, connectors, strict=False) # a deleted app shows as unconfirmed, not an error
149
+ cp = critical(flow)
150
+ analysis = Analysis.model_validate(row["analysis"] or {"stages": []})
151
+ on_path = set(cp.path)
152
+ cp_edges = set(zip(cp.path, cp.path[1:], strict=False))
153
+ planner_flow = Flow.model_validate(row["flow"])
154
+ planner_connector = {n.id: n.connector for n in planner_flow.nodes}
155
+ swapped = {node_id for node_id, items in edits.items() if any(i["op"] == "set_connector" for i in items)}
156
+ current_connector = {n.id: n.connector for n in flow.nodes}
157
+ tips = {s.node: s for s in flow.suggestions
158
+ if s.node not in swapped and s.connector != current_connector.get(s.node)}
159
+ nodes = []
160
+ for n in flow.nodes:
161
+ data = n.model_dump(mode="json")
162
+ stats = analysis.stats.get(n.id)
163
+ tip = tips.get(n.id)
164
+ data.update({
165
+ "yours": edits.get(n.id, []), "on_critical_path": n.id in on_path,
166
+ "stats": stats.model_dump(mode="json") if stats else None,
167
+ "tip": {"connector": tip.connector, "text": tip.text} if tip else None,
168
+ "planner_connector": planner_connector.get(n.id),
169
+ })
170
+ nodes.append(data)
171
+ edges = [{**e.model_dump(mode="json"), "on_critical_path": (e.source, e.target) in cp_edges}
172
+ for e in flow.edges]
173
+ positions = {k: list(v) for k, v in analysis.positions.items()}
174
+ positions.update({k: list(v) for k, v in self.store.flow_positions().items()})
175
+ used = {n.connector for n in flow.nodes if n.connector}
176
+ apps = {cid: {"name": c.name, "type": c.type, "model": getattr(c.connection, "model", None)}
177
+ for cid, c in connectors.items() if cid in used}
178
+ pending = self.store.latest_flow_version("pending")
179
+ return {
180
+ "available": True, "kind": flow.kind, "source": flow.source, "version": row["version"],
181
+ "created_at": row["created_at"],
182
+ "advice": [a.model_dump(mode="json") for a in flow.advice], "written_ms": flow.written_ms,
183
+ "patterns": flow.patterns,
184
+ "actions": [a.model_dump(mode="json") for a in _actions(planner_flow, flow)], "layer": layer,
185
+ "fact_sheet_hash": row["fact_sheet_hash"], "nodes": nodes, "edges": edges, "hidden": hidden,
186
+ "critical_path": {"path": cp.path, "length_ms": cp.length_ms, "bottleneck": cp.bottleneck},
187
+ "positions": positions, "apps": apps,
188
+ "counts": {"steps": len(flow.nodes), "unconfirmed": sum(n.status == "unconfirmed" for n in flow.nodes),
189
+ "gates": sum(n.kind == "gate" for n in flow.nodes), "edits": len(self.store.flow_overrides())},
190
+ "pending": pending["version"] if pending else None,
191
+ }
192
+
193
+ def trace(self) -> dict[str, Any] | None:
194
+ base = self._base()
195
+ if base is None or not base[0]["trace"]:
196
+ return None
197
+ return Trace.model_validate(base[0]["trace"]).model_dump(mode="json")
198
+
199
+ def analysis(self) -> dict[str, Any] | None:
200
+ base = self._base()
201
+ if base is None:
202
+ return None
203
+ data = Analysis.model_validate(base[0]["analysis"] or {"stages": []}).model_dump(mode="json")
204
+ data.pop("stats", None)
205
+ data.pop("positions", None)
206
+ return {**data, "version": base[0]["version"], "fact_sheet_hash": base[0]["fact_sheet_hash"]}
207
+
208
+ def versions(self) -> list[dict[str, Any]]:
209
+ return self.store.flow_versions()
210
+
211
+ # --- the first map ---------------------------------------------------------------------------
212
+
213
+ def has_map(self) -> bool:
214
+ return self.store.count_flow_versions() > 0
215
+
216
+ def map_folder(self, planner: Planner, fresh: bool) -> int | None:
217
+ """Point this map at a Planner (a scanned folder, D21) and draw its first map if there is none.
218
+ `fresh` forgets the old map first: its versions, edits and positions belong to another folder."""
219
+ if fresh:
220
+ self.store.clear()
221
+ self.planner = planner
222
+ return self.first_map()
223
+
224
+ def first_map(self) -> int | None:
225
+ """Draw the first map if there is none. Returns its version, or None without a Planner."""
226
+ if self.planner is None or self.store.count_flow_versions():
227
+ return None
228
+ try:
229
+ draft = self.planner.analyze(0)
230
+ except NoFlow as why:
231
+ self.message = str(why)
232
+ return None
233
+ self.message = None
234
+ check(draft.flow, self.connectors()) # the Planner's output must be a valid map
235
+ return self.store.add_flow_version("accepted", draft.flow.model_dump(mode="json"),
236
+ draft.trace.model_dump(mode="json") if draft.trace else None,
237
+ draft.analysis.model_dump(mode="json"), draft.fact_sheet_hash)
238
+
239
+ # --- edits -----------------------------------------------------------------------------------
240
+
241
+ def add_override(self, override: Override) -> dict[str, Any]:
242
+ base = self._base()
243
+ if base is None:
244
+ raise OverrideError("there is no map to edit yet")
245
+ _, flow = base
246
+ connectors = self.connectors()
247
+ merged = check(merge(flow, self.overrides())[0], connectors, strict=False) # statuses as drawn
248
+ node = merged.node(override.node) if override.node else None
249
+ if override.op != "add_node" and node is None:
250
+ raise OverrideError(f"there is no block '{override.node}' on the map")
251
+ if override.op == "hide" and node is not None and node.kind in FIXED_KINDS:
252
+ raise OverrideError("gates, starts and ends can't be hidden; gates always stay (D14)")
253
+ if override.op == "set_connector" and node is not None:
254
+ if node.kind not in CALL_KINDS:
255
+ raise OverrideError(f"'{node.title}' doesn't call an app, so it has no app to swap")
256
+ _need_connector(override.connector, node.kind, connectors)
257
+ if override.op == "confirm" and node is not None and node.status != "unconfirmed":
258
+ raise OverrideError(f"'{node.title}' is already confirmed")
259
+ if override.op == "add_node" and override.new_node is not None:
260
+ new = override.new_node
261
+ if merged.node(new.id) is not None or any(n.id == new.id for n in flow.nodes):
262
+ raise OverrideError(f"there is already a block called '{new.id}'")
263
+ if new.kind not in CALL_KINDS | {"code"}:
264
+ raise OverrideError("you can add app calls and code steps; the Planner draws the rest")
265
+ if new.kind in CALL_KINDS and (flow.kind == "flow" or new.connector):
266
+ _need_connector(new.connector, new.kind, connectors) # a dependency needs no connected app (D21)
267
+ override = override.model_copy(update={"new_node": new.model_copy(update={
268
+ "added_by": "user", "status": "confirmed",
269
+ "evidence": new.evidence or [Evidence(type="user", text="Added by you.")]})})
270
+ if override.op == "add_edge" and merged.node(override.source or "") is None:
271
+ raise OverrideError(f"there is no block '{override.source}' on the map")
272
+ trial, _, _ = merge(flow, [*self.overrides(), (0, override)])
273
+ try:
274
+ check(trial, connectors)
275
+ except FlowError as err:
276
+ raise OverrideError(str(err)) from None
277
+ oid = self.store.add_flow_override(override.model_dump(mode="json", exclude_none=True))
278
+ return {"id": oid, **override.model_dump(mode="json", exclude_none=True)}
279
+
280
+ def delete_override(self, oid: int) -> bool:
281
+ return self.store.delete_flow_overrides([oid]) > 0
282
+
283
+ def reset_node(self, node_id: str) -> int:
284
+ """Back to the Planner's version: drop every edit on this block (and a block you added, with its lines)."""
285
+ ids = []
286
+ for oid, o in self.overrides():
287
+ if o.node == node_id or (o.op == "add_node" and o.new_node is not None and o.new_node.id == node_id) \
288
+ or (o.op == "add_edge" and o.source == node_id and self._user_added(node_id)):
289
+ ids.append(oid)
290
+ return self.store.delete_flow_overrides(ids)
291
+
292
+ def _user_added(self, node_id: str) -> bool:
293
+ return any(o.op == "add_node" and o.new_node is not None and o.new_node.id == node_id
294
+ for _, o in self.overrides())
295
+
296
+ def set_positions(self, positions: dict[str, tuple[float, float]], replace: bool = False) -> None:
297
+ self.store.put_flow_positions(positions, replace=replace)
298
+
299
+ # --- re-check --------------------------------------------------------------------------------
300
+
301
+ def recheck(self) -> dict[str, Any]:
302
+ if self.planner is None:
303
+ raise VersionError(NOT_AVAILABLE)
304
+ base = self._base()
305
+ if base is None:
306
+ raise VersionError("there is no map to re-check yet")
307
+ row, old = base
308
+ try:
309
+ draft = self.planner.analyze(self.store.count_flow_versions())
310
+ except NoFlow as why:
311
+ raise VersionError(str(why)) from None
312
+ try:
313
+ check(draft.flow, self.connectors())
314
+ except FlowError as err:
315
+ raise VersionError(f"the Planner's new map isn't valid: {err}") from None
316
+ pending = self.store.latest_flow_version("pending")
317
+ if pending is not None:
318
+ self.store.update_flow_version(pending["version"], "discarded")
319
+ changes = diff(old, draft.flow, self.overrides(), draft.analysis.notes)
320
+ version = self.store.add_flow_version(
321
+ "pending", draft.flow.model_dump(mode="json"), draft.trace.model_dump(mode="json") if draft.trace else None,
322
+ draft.analysis.model_dump(mode="json"), draft.fact_sheet_hash, changes=changes)
323
+ return self.pending_view(version)
324
+
325
+ def pending_view(self, version: int) -> dict[str, Any]:
326
+ row = self.store.flow_version(version)
327
+ if row is None or row["status"] != "pending":
328
+ raise VersionError(f"version {version} is not waiting for a decision")
329
+ base = self._base()
330
+ new = Flow.model_validate(row["flow"])
331
+ return {"version": version, "base_version": base[0]["version"] if base else None,
332
+ "created_at": row["created_at"], "fact_sheet_hash": row["fact_sheet_hash"],
333
+ "changes": row["changes"] or [], "default_take": default_take(row["changes"] or []),
334
+ "nodes": [n.model_dump(mode="json") for n in new.nodes],
335
+ "edges": [e.model_dump(mode="json") for e in new.edges]}
336
+
337
+ def accept(self, version: int, take: dict[str, bool | str]) -> dict[str, Any]:
338
+ row = self.store.flow_version(version)
339
+ if row is None or row["status"] != "pending":
340
+ raise VersionError(f"version {version} is not waiting for a decision")
341
+ base = self._base()
342
+ if base is None:
343
+ raise VersionError("there is no map to update")
344
+ changes = row["changes"] or []
345
+ known = {c["id"] for c in changes}
346
+ unknown = sorted(set(take) - known)
347
+ if unknown:
348
+ raise VersionError(f"unknown change: {', '.join(unknown)}")
349
+ flow, drop_edits = apply_choices(base[1], Flow.model_validate(row["flow"]), changes, take)
350
+ try:
351
+ check(flow, self.connectors())
352
+ except FlowError as err:
353
+ raise VersionError(f"that mix of changes isn't a valid map: {err}") from None
354
+ self.store.update_flow_version(version, "accepted", flow.model_dump(mode="json"))
355
+ stale = [oid for oid, o in self.overrides() if o.node in drop_edits]
356
+ self.store.delete_flow_overrides(stale)
357
+ return self.current()
358
+
359
+ def discard(self, version: int) -> None:
360
+ row = self.store.flow_version(version)
361
+ if row is None or row["status"] != "pending":
362
+ raise VersionError(f"version {version} is not waiting for a decision")
363
+ self.store.update_flow_version(version, "discarded")
364
+
365
+
366
+ def _need_connector(connector_id: str | None, kind: str, connectors: dict[str, Connector]) -> None:
367
+ if not connector_id or connector_id not in connectors:
368
+ raise OverrideError(f"'{connector_id}' isn't connected; add it from the catalog first")
369
+ want = CONNECTOR_TYPE[kind]
370
+ if connectors[connector_id].type != want:
371
+ raise OverrideError(f"'{connector_id}' is a {connectors[connector_id].type} app; this step needs a {want} app")
372
+
373
+
374
+ def _actions(scanned: Flow, merged: Flow) -> list[Action]:
375
+ """Each action's numbers from the map as it is shown, your edits included (D33): a hidden call leaves its critical
376
+ path and "as written", and a group that runs together still counts at its slowest remaining call."""
377
+ if not scanned.actions:
378
+ return []
379
+ return actions_of(merged, {a.id: a.parts for a in scanned.actions if a.parts})
@@ -0,0 +1,228 @@
1
+ """Checks on a flow map and the gating policy (D17, D14).
2
+
3
+ `check()` runs on the Planner's output and again on the merged map after the user's edits, so
4
+ no edit can open a path around a gate. Code decides what is side-effecting; the Planner can add
5
+ gates but never remove one.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Mapping
11
+ from dataclasses import dataclass, field
12
+
13
+ from flowforge.connectors.models import Connector
14
+ from flowforge.flowmap.models import CALL_KINDS, CONNECTOR_TYPE, Action, Evidence, Flow, FlowEdge, FlowNode
15
+ from flowforge.scheduler.critical_path import critical_path
16
+ from flowforge.scheduler.graph import DAG, CycleError, topological_order
17
+
18
+ # rough per-kind estimates (ms) when the Planner gives none (extends D1)
19
+ DEFAULT_MS = {"llm": 2000.0, "api": 300.0, "mcp": 500.0, "local": 1000.0, "code": 50.0}
20
+ # blocks that need evidence of their own; fork/join/gate/end get their meaning from the lines around them
21
+ NEEDS_EVIDENCE = CALL_KINDS | {"code", "decision", "start"}
22
+ POLICY_TEXT = "Side effects always wait for you (D14). Added by code, so no edit can remove it."
23
+
24
+
25
+ class FlowError(ValueError):
26
+ """The map is structurally wrong. The message is one plain sentence naming the block."""
27
+
28
+
29
+ def side_effecting(node: FlowNode) -> bool:
30
+ """D14 list, decided by code: non-GET HTTP, MCP tools not marked read-only, every local command.
31
+
32
+ D17 refinement: a GET to a payment API reads; only calls that move money or send are gated.
33
+ """
34
+ op = node.op
35
+ if node.kind == "local":
36
+ return True
37
+ if node.kind == "mcp":
38
+ return not (op is not None and op.read_only is True)
39
+ if node.kind == "api":
40
+ return op is None or op.method not in ("GET", "HEAD")
41
+ return False
42
+
43
+
44
+ def code_writes(node: FlowNode) -> bool:
45
+ """D25, on a flow read from code: marked, never gated. An HTTP call other than GET or HEAD, or an MCP tool that
46
+ isn't read-only. A local program or a call with no method isn't claimed to write."""
47
+ op = node.op
48
+ if node.kind == "api":
49
+ return op is not None and op.method is not None and op.method.upper() not in ("GET", "HEAD")
50
+ return node.kind == "mcp" and op is not None and op.read_only is False
51
+
52
+
53
+ def _dag(flow: Flow, kinds: set[str]) -> DAG:
54
+ nodes = [n.id for n in flow.nodes]
55
+ parents: dict[str, list[str]] = {n: [] for n in nodes}
56
+ children: dict[str, list[str]] = {n: [] for n in nodes}
57
+ for e in flow.edges:
58
+ if e.kind in kinds and e.source not in parents[e.target]:
59
+ parents[e.target].append(e.source)
60
+ children[e.source].append(e.target)
61
+ return DAG(nodes=nodes, parents=parents, children=children)
62
+
63
+
64
+ def check(flow: Flow, connectors: Mapping[str, Connector], strict: bool = True) -> Flow:
65
+ """A checked copy: statuses and side effects set, unconfirmed lines marked, policy gates inserted.
66
+
67
+ Raises FlowError for a loop, a call with no app, an unknown app, a type mismatch or a decision
68
+ without two labelled branches. With `strict=False` (drawing the map) an app problem marks the
69
+ block unconfirmed instead, so deleting a connector never breaks the page.
70
+ """
71
+ flow = flow.model_copy(deep=True)
72
+ code = flow.source == "code" # the app's own flow, read from its code: no connected apps, no gates (D25)
73
+ try:
74
+ topological_order(_dag(flow, {"flow", "branch", "fallback", "unconfirmed"}))
75
+ except CycleError as err:
76
+ raise FlowError(f"the map has a loop: {' → '.join(err.cycle)}") from None
77
+
78
+ for node in flow.nodes:
79
+ if node.kind in NEEDS_EVIDENCE and node.status == "confirmed" and node.added_by != "user" \
80
+ and not any(ev.confirms for ev in node.evidence):
81
+ node.status = "unconfirmed"
82
+ if node.kind in CALL_KINDS and ((flow.kind == "flow" and not code) or node.connector):
83
+ try:
84
+ _check_connector(node, connectors) # a dependency needs no connected app (D21)
85
+ except FlowError as err:
86
+ if strict:
87
+ raise
88
+ node.status = "unconfirmed"
89
+ node.evidence = [*node.evidence, Evidence(type="missing", text=str(err).capitalize() + ".")]
90
+ # gates belong to flow maps (D21); a flow read from code only marks its writes (D25)
91
+ node.side_effect = code_writes(node) if code else flow.kind == "flow" and side_effecting(node)
92
+ if node.kind == "decision":
93
+ whens = [e.when for e in flow.edges if e.source == node.id and e.kind == "branch"]
94
+ if len(set(whens)) < (1 if code else 2): # code: an `if` with no else just carries on (D25)
95
+ raise FlowError(f"decision '{node.title}' needs two labelled branches (for example yes and no)")
96
+
97
+ unconfirmed = {n.id for n in flow.nodes if n.status == "unconfirmed"}
98
+ for e in flow.edges:
99
+ if e.kind != "retry" and e.target in unconfirmed:
100
+ e.kind = "unconfirmed"
101
+ elif e.kind == "unconfirmed":
102
+ e.kind = "flow"
103
+
104
+ if not code:
105
+ _insert_gates(flow)
106
+ return flow
107
+
108
+
109
+ def _check_connector(node: FlowNode, connectors: Mapping[str, Connector]) -> None:
110
+ if not node.connector:
111
+ raise FlowError(f"block '{node.title}' is an app call but names no app")
112
+ connector = connectors.get(node.connector)
113
+ if connector is None:
114
+ if node.status == "unconfirmed":
115
+ return # a hint may name an app nobody has connected yet
116
+ raise FlowError(f"block '{node.title}' uses '{node.connector}', which is not connected")
117
+ want = CONNECTOR_TYPE[node.kind]
118
+ if connector.type != want:
119
+ raise FlowError(f"block '{node.title}' is a {node.kind} call but '{node.connector}' is a {connector.type} app")
120
+
121
+
122
+ def _insert_gates(flow: Flow) -> None:
123
+ """Every confirmed side-effecting block gets a gate in front of it unless every way in is a gate."""
124
+ kinds = {n.id: n.kind for n in flow.nodes}
125
+ for node in list(flow.nodes):
126
+ if not node.side_effect or node.status != "confirmed":
127
+ continue
128
+ incoming = [e for e in flow.edges if e.target == node.id and e.kind not in ("retry", "unconfirmed")]
129
+ if incoming and all(kinds[e.source] == "gate" for e in incoming):
130
+ continue
131
+ gate_id = _free_id({n.id for n in flow.nodes}, f"gate_{node.id}")
132
+ gate = FlowNode(id=gate_id, kind="gate", title=f"Approve: {node.title}"[:80], added_by="policy",
133
+ what="This step changes something outside the app, so it waits for you.",
134
+ evidence=[Evidence(type="policy", text=POLICY_TEXT)])
135
+ flow.nodes.insert(flow.nodes.index(node), gate)
136
+ kinds[gate_id] = "gate"
137
+ for e in incoming:
138
+ e.target = gate_id
139
+ flow.edges.append(FlowEdge(id=_free_id({e.id for e in flow.edges}, f"{gate_id}_to_{node.id}")[:64],
140
+ source=gate_id, target=node.id))
141
+
142
+
143
+ def _free_id(taken: set[str], base: str) -> str:
144
+ base = base[:60]
145
+ if base not in taken:
146
+ return base
147
+ i = 2
148
+ while f"{base}_{i}" in taken:
149
+ i += 1
150
+ return f"{base}_{i}"
151
+
152
+
153
+ @dataclass
154
+ class Critical:
155
+ path: list[str] = field(default_factory=list)
156
+ length_ms: float = 0.0
157
+ bottleneck: str | None = None # the slowest block on the path
158
+
159
+
160
+ def weight(node: FlowNode) -> float:
161
+ if node.estimate_ms is not None:
162
+ return node.estimate_ms
163
+ return DEFAULT_MS.get(node.kind, 0.0)
164
+
165
+
166
+ def critical(flow: Flow) -> Critical:
167
+ """The longest expected chain (D1, D5) through the app's own flow: normal and branch lines only.
168
+ Fallbacks, retries, unconfirmed blocks and steps the user added are not the expected path.
169
+ Gates weigh 0, as people are not a resource."""
170
+ if flow.kind == "dependencies":
171
+ return Critical() # what the app uses has no order to be critical along (D21)
172
+ keep = {n.id for n in flow.nodes if n.status == "confirmed" and n.added_by != "user"}
173
+ lines = [e for e in flow.edges if e.kind in ("flow", "branch") and e.source in keep and e.target in keep]
174
+ # only blocks reachable from a start along normal lines (a fallback-only block is not the expected path)
175
+ roots = [n.id for n in flow.nodes if n.id in keep and n.kind == "start"] or \
176
+ [i for i in keep if not any(e.target == i for e in lines)]
177
+ reach, queue = set(roots), list(roots)
178
+ while queue:
179
+ here = queue.pop()
180
+ for e in lines:
181
+ if e.source == here and e.target not in reach:
182
+ reach.add(e.target)
183
+ queue.append(e.target)
184
+ sub = Flow(nodes=[n for n in flow.nodes if n.id in reach],
185
+ edges=[e for e in lines if e.source in reach and e.target in reach])
186
+ if not sub.nodes:
187
+ return Critical()
188
+ weights = {n.id: 0.0 if n.kind == "gate" else weight(n) for n in sub.nodes}
189
+ dag = _dag(sub, {"flow", "branch"})
190
+ cp = critical_path(dag, weights)
191
+ path = list(cp.path)
192
+ while path: # carry on through zero-time blocks (an end, a join) so the chain reaches its end
193
+ nxt = next((c for c in dag.children[path[-1]] if weights[c] == 0 and c not in path), None)
194
+ if nxt is None:
195
+ break
196
+ path.append(nxt)
197
+ bottleneck = max(path, key=lambda i: weights[i]) if path else None
198
+ return Critical(path=path, length_ms=cp.length_ms, bottleneck=bottleneck)
199
+
200
+
201
+ def reach(flow: Flow, start: str) -> set[str]:
202
+ """Everything a start block leads to."""
203
+ seen, queue = {start}, [start]
204
+ while queue:
205
+ here = queue.pop()
206
+ for e in flow.edges:
207
+ if e.source == here and e.target not in seen:
208
+ seen.add(e.target)
209
+ queue.append(e.target)
210
+ return seen
211
+
212
+
213
+ def actions_of(flow: Flow, parts: dict[str, list[list[str]]]) -> list[Action]:
214
+ """Each entry point of a code flow on its own (D33): its time as written (from `parts`, by start id: its calls in
215
+ the groups the code runs them), could be, and the chain that sets it, slowest first. The service recomputes them
216
+ on the map as shown, so a hidden call leaves both times."""
217
+ weight = {n.id: n.estimate_ms or 0.0 for n in flow.nodes if n.kind in CALL_KINDS}
218
+ out = []
219
+ for start in (n for n in flow.nodes if n.kind == "start"):
220
+ ids = reach(flow, start.id)
221
+ sub = Flow(kind="flow", source="code", nodes=[n for n in flow.nodes if n.id in ids],
222
+ edges=[e for e in flow.edges if e.source in ids and e.target in ids])
223
+ cp = critical(sub)
224
+ mine = parts.get(start.id, [[i] for i in ids if i in weight])
225
+ written = sum(max((weight[i] for i in p if i in weight), default=0.0) for p in mine)
226
+ out.append(Action(id=start.id, title=start.title, written_ms=round(written, 1), could_ms=round(cp.length_ms, 1),
227
+ path=list(cp.path), calls=sum(1 for n in sub.nodes if n.kind in CALL_KINDS), parts=mine))
228
+ return sorted(out, key=lambda a: (-a.written_ms, a.title))
flowforge/home.py ADDED
@@ -0,0 +1,58 @@
1
+ """Where FlowForge's files are (D34): per-user state in FLOWFORGE_HOME (databases, artifacts, connectors.json), and
2
+ the files it ships (the dashboard, the example app, sample workflows) inside the installed package, or in the
3
+ checkout when run from source."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ import sys
9
+ from importlib import metadata
10
+ from pathlib import Path
11
+
12
+ PACKAGE = Path(__file__).resolve().parent
13
+ CHECKOUT = PACKAGE.parents[1]
14
+ DIST_NAME = "flowforge-map" # the name on PyPI (D34); the command and the import stay `flowforge`
15
+ IN_CHECKOUT = (CHECKOUT / "pyproject.toml").is_file() and (CHECKOUT / "backend" / "flowforge").is_dir()
16
+
17
+ # name -> (inside the package, in the checkout)
18
+ _FILES = {
19
+ "dashboard": ("_dashboard", "frontend/dist"),
20
+ "example": ("_example/trip-planner", "examples/trip-planner"),
21
+ "workflows": ("_workflows", "backend/workflows"),
22
+ "classic": ("_classic.html", "frontend/classic.html"),
23
+ }
24
+
25
+
26
+ def resource(name: str, package: Path = PACKAGE, checkout: Path = CHECKOUT) -> Path:
27
+ """A shipped file or folder: the packaged copy when installed, else the checkout's."""
28
+ packaged, source = _FILES[name]
29
+ inside = package / packaged
30
+ return inside if inside.exists() else checkout / source
31
+
32
+
33
+ def version() -> str:
34
+ try:
35
+ return metadata.version(DIST_NAME)
36
+ except metadata.PackageNotFoundError:
37
+ return "0.0.0"
38
+
39
+
40
+ def install_kind() -> str:
41
+ """"checkout" (run from source), "uvx" (a throwaway environment uv made for one run) or "pip" (installed)."""
42
+ if IN_CHECKOUT:
43
+ return "checkout"
44
+ return "uvx" if "archive-v" in Path(sys.prefix).as_posix() else "pip"
45
+
46
+
47
+ def flowforge_home() -> Path:
48
+ return Path(os.getenv("FLOWFORGE_HOME") or Path.home() / ".flowforge")
49
+
50
+
51
+ def database_path(example: bool = False) -> Path:
52
+ """The SQLite file: FLOWFORGE_DB, else example.db in FLOWFORGE_HOME for example data, else flowforge.db next to
53
+ the checkout, or in FLOWFORGE_HOME when installed. The dashboard and the MCP server open the same one (D29)."""
54
+ if example:
55
+ default = flowforge_home() / "example.db"
56
+ else:
57
+ default = CHECKOUT / "flowforge.db" if IN_CHECKOUT else flowforge_home() / "flowforge.db"
58
+ return Path(os.getenv("FLOWFORGE_DB") or default)