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.
- flowforge/__init__.py +0 -0
- flowforge/_classic.html +289 -0
- flowforge/_dashboard/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- flowforge/_dashboard/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- flowforge/_dashboard/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- flowforge/_dashboard/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- flowforge/_dashboard/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
- flowforge/_dashboard/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
- flowforge/_dashboard/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
- flowforge/_dashboard/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
- flowforge/_dashboard/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
- flowforge/_dashboard/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
- flowforge/_dashboard/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- flowforge/_dashboard/assets/index-BGT5seIN.css +1 -0
- flowforge/_dashboard/assets/index-BoGikST6.js +52 -0
- flowforge/_dashboard/favicon.svg +1 -0
- flowforge/_dashboard/index.html +16 -0
- flowforge/_example/trip-planner/.env.example +3 -0
- flowforge/_example/trip-planner/.gitignore +2 -0
- flowforge/_example/trip-planner/.mcp.json +20 -0
- flowforge/_example/trip-planner/README.md +48 -0
- flowforge/_example/trip-planner/config.py +27 -0
- flowforge/_example/trip-planner/mcp_servers/currency.py +10 -0
- flowforge/_example/trip-planner/planner.py +113 -0
- flowforge/_example/trip-planner/requirements.txt +3 -0
- flowforge/_example/trip-planner/services/__init__.py +0 -0
- flowforge/_example/trip-planner/services/geo.py +49 -0
- flowforge/_example/trip-planner/services/llm.py +18 -0
- flowforge/_example/trip-planner/services/mcp_tools.py +26 -0
- flowforge/_example/trip-planner/services/weather.py +25 -0
- flowforge/_example/trip-planner/tools/packing_list.py +24 -0
- flowforge/_example/trip-planner/trips/.gitkeep +0 -0
- flowforge/_workflows/diamond_mock.json +71 -0
- flowforge/_workflows/stripe_to_razorpay.json +38 -0
- flowforge/agent/__init__.py +5 -0
- flowforge/agent/server.py +333 -0
- flowforge/api/__init__.py +0 -0
- flowforge/api/agent.py +79 -0
- flowforge/api/checks.py +255 -0
- flowforge/api/connectors.py +289 -0
- flowforge/api/flowmap.py +174 -0
- flowforge/api/runs.py +284 -0
- flowforge/api/scan.py +117 -0
- flowforge/api/security.py +31 -0
- flowforge/api/stats.py +80 -0
- flowforge/checks/__init__.py +13 -0
- flowforge/checks/models.py +41 -0
- flowforge/checks/probes.py +359 -0
- flowforge/cli.py +108 -0
- flowforge/connectors/__init__.py +0 -0
- flowforge/connectors/catalog.py +84 -0
- flowforge/connectors/models.py +149 -0
- flowforge/connectors/presets.py +192 -0
- flowforge/connectors/registry.py +85 -0
- flowforge/connectors/runtime.py +47 -0
- flowforge/connectors/secrets.py +53 -0
- flowforge/example_data.py +247 -0
- flowforge/flow/__init__.py +328 -0
- flowforge/flow/advice.py +132 -0
- flowforge/flow/curly.py +884 -0
- flowforge/flow/index.py +116 -0
- flowforge/flow/order.py +181 -0
- flowforge/flow/reach.py +145 -0
- flowforge/flow/rewrite.py +146 -0
- flowforge/flowmap/__init__.py +1 -0
- flowforge/flowmap/diff.py +145 -0
- flowforge/flowmap/fixture.py +283 -0
- flowforge/flowmap/models.py +258 -0
- flowforge/flowmap/planner.py +131 -0
- flowforge/flowmap/service.py +379 -0
- flowforge/flowmap/validate.py +228 -0
- flowforge/home.py +58 -0
- flowforge/main.py +137 -0
- flowforge/nodes/__init__.py +3 -0
- flowforge/nodes/base.py +74 -0
- flowforge/nodes/check_node.py +50 -0
- flowforge/nodes/http_node.py +161 -0
- flowforge/nodes/llm_node.py +158 -0
- flowforge/nodes/local_node.py +164 -0
- flowforge/nodes/mcp_node.py +243 -0
- flowforge/nodes/mock_node.py +40 -0
- flowforge/scan/__init__.py +6 -0
- flowforge/scan/facts.py +47 -0
- flowforge/scan/files.py +112 -0
- flowforge/scan/manifests.py +65 -0
- flowforge/scan/mcp_config.py +75 -0
- flowforge/scan/models.py +73 -0
- flowforge/scan/patterns.py +86 -0
- flowforge/scan/python_ast.py +246 -0
- flowforge/scan/scanner.py +344 -0
- flowforge/scan/services.py +58 -0
- flowforge/scan/urls.py +60 -0
- flowforge/scheduler/__init__.py +0 -0
- flowforge/scheduler/cache.py +76 -0
- flowforge/scheduler/critical_path.py +65 -0
- flowforge/scheduler/durations.py +35 -0
- flowforge/scheduler/executor.py +390 -0
- flowforge/scheduler/graph.py +108 -0
- flowforge/scheduler/rate_limit.py +36 -0
- flowforge/schema.py +81 -0
- flowforge/security/__init__.py +0 -0
- flowforge/security/finders.py +127 -0
- flowforge/security/findings.py +169 -0
- flowforge/security/logfilter.py +129 -0
- flowforge/spa.py +52 -0
- flowforge/state.py +73 -0
- flowforge/storage.py +481 -0
- flowforge/templating.py +60 -0
- flowforge_map-0.1.0.dist-info/METADATA +335 -0
- flowforge_map-0.1.0.dist-info/RECORD +113 -0
- flowforge_map-0.1.0.dist-info/WHEEL +4 -0
- flowforge_map-0.1.0.dist-info/entry_points.txt +3 -0
- 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)
|