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,333 @@
|
|
|
1
|
+
"""FlowForge as an MCP server (D28, D29): five read-only tools a coding agent can ask about the repo it works in.
|
|
2
|
+
|
|
3
|
+
The map and the flow are read fresh from the repo on every call (plain code, nothing run, no LLM: D20). The
|
|
4
|
+
dashboard's database supplies what only it knows, when it maps the same repo: measured latencies, past checks,
|
|
5
|
+
the user's yes to starting MCP servers, and the saved map to compare with. Every answer is masked (D22).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import uuid
|
|
13
|
+
from collections import Counter
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
from mcp.server.mcpserver import MCPServer
|
|
19
|
+
from mcp.types import ToolAnnotations
|
|
20
|
+
|
|
21
|
+
from flowforge.api.checks import CHECKABLE, check_workflow, command_text, confirmed_commands, target_for
|
|
22
|
+
from flowforge.api.scan import REPO_SETTING
|
|
23
|
+
from flowforge.checks import starts_process
|
|
24
|
+
from flowforge.connectors.registry import Registry
|
|
25
|
+
from flowforge.flow import flow_of
|
|
26
|
+
from flowforge.flowmap.models import CALL_KINDS, Flow
|
|
27
|
+
from flowforge.flowmap.validate import code_writes, critical
|
|
28
|
+
from flowforge.nodes.check_node import CheckNode
|
|
29
|
+
from flowforge.scan import ScanResult, scan
|
|
30
|
+
from flowforge.scheduler.executor import StepState, run_workflow
|
|
31
|
+
from flowforge.security.logfilter import Redactor, redact_code, secret_values_for
|
|
32
|
+
from flowforge.storage import Storage, now_iso
|
|
33
|
+
|
|
34
|
+
log = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
AGENT_CALLS_SETTING = "agent_calls" # [{tool, repo, at, got}], newest first: the Connect page's list (D30)
|
|
37
|
+
KEEP_CALLS = 20
|
|
38
|
+
NOT_STARTED = "Not started: say yes in the dashboard first (Check now)"
|
|
39
|
+
|
|
40
|
+
INSTRUCTIONS = (
|
|
41
|
+
"FlowForge maps the dependencies of the repo you are working in (LLMs, MCP servers, HTTP APIs, local programs), "
|
|
42
|
+
"each with the file:line it comes from, checks their health, and shows the order the code calls them, the "
|
|
43
|
+
"critical path and which calls could run together, and what is unsafe (keys in code, plain http, risky MCP "
|
|
44
|
+
"servers). It reads code without running it and sends nothing to any "
|
|
45
|
+
"LLM. Answers are masked: key values never appear. It never edits code: you make the change, then call "
|
|
46
|
+
"what_changed to confirm."
|
|
47
|
+
)
|
|
48
|
+
READ = ToolAnnotations(read_only_hint=True, open_world_hint=False)
|
|
49
|
+
CHECKS = ToolAnnotations(read_only_hint=True, open_world_hint=True) # makes requests and may start servers
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _mask(value: Any, redactor: Redactor) -> Any:
|
|
53
|
+
"""Every string through the same masking as evidence: known key shapes, configured key values, URL credentials
|
|
54
|
+
and queries, values given to key-like names."""
|
|
55
|
+
if isinstance(value, str):
|
|
56
|
+
return redact_code(value, redactor)
|
|
57
|
+
if isinstance(value, dict):
|
|
58
|
+
return {k: _mask(v, redactor) for k, v in value.items()}
|
|
59
|
+
if isinstance(value, list):
|
|
60
|
+
return [_mask(v, redactor) for v in value]
|
|
61
|
+
return value
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _health(row: dict[str, Any] | None) -> dict[str, Any]:
|
|
65
|
+
if row is None:
|
|
66
|
+
return {"status": "unchecked", "summary": "Not checked yet: call check_health"}
|
|
67
|
+
return {k: row.get(k) for k in ("status", "summary", "reason", "fix", "latency_ms", "at") if row.get(k) is not None}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class _Repo:
|
|
71
|
+
"""One repo as the agent sees it, with the dashboard's data when the dashboard maps this same folder."""
|
|
72
|
+
|
|
73
|
+
def __init__(self, root: Path, storage: Storage, transport: httpx.AsyncBaseTransport | None = None, *,
|
|
74
|
+
dashboards: bool = False):
|
|
75
|
+
self.root, self.storage, self.transport = root.resolve(), storage, transport
|
|
76
|
+
self.dashboards = dashboards # the dashboard's own repo (the preview): its data applies even in example mode
|
|
77
|
+
try:
|
|
78
|
+
values = secret_values_for(Registry(storage).all())
|
|
79
|
+
except Exception: # a connector the registry can't read must not stop the server
|
|
80
|
+
values = set()
|
|
81
|
+
self.redactor = Redactor(values)
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def shared(self) -> bool:
|
|
85
|
+
"""Does the dashboard's database map this repo? Only then do its checks and yeses apply."""
|
|
86
|
+
return self.dashboards or self.storage.setting(REPO_SETTING) == str(self.root)
|
|
87
|
+
|
|
88
|
+
def answer(self, tool: str, data: dict[str, Any]) -> dict[str, Any]:
|
|
89
|
+
"""The masked answer, after noting the call for the Connect page (D30)."""
|
|
90
|
+
note = {"tool": tool, "repo": self.root.name, "at": now_iso(), "got": headline(tool, data)}
|
|
91
|
+
note = _mask(note, self.redactor)
|
|
92
|
+
try:
|
|
93
|
+
calls = json.loads(self.storage.setting(AGENT_CALLS_SETTING) or "[]")
|
|
94
|
+
except ValueError:
|
|
95
|
+
calls = []
|
|
96
|
+
self.storage.set_setting(AGENT_CALLS_SETTING, json.dumps([note, *calls][:KEEP_CALLS]))
|
|
97
|
+
return _mask({"repo": self.root.name, **data}, self.redactor)
|
|
98
|
+
|
|
99
|
+
def latest(self) -> dict[str, dict[str, Any]]:
|
|
100
|
+
if not self.shared:
|
|
101
|
+
return {}
|
|
102
|
+
rows: dict[str, dict[str, Any]] = {}
|
|
103
|
+
for row in self.storage.check_history(limit=1):
|
|
104
|
+
rows.setdefault(row["dep_id"], row)
|
|
105
|
+
return rows
|
|
106
|
+
|
|
107
|
+
def flow(self) -> Any:
|
|
108
|
+
return flow_of(self.root, self.storage.latest_latencies() if self.shared else {})
|
|
109
|
+
|
|
110
|
+
def scan(self) -> ScanResult:
|
|
111
|
+
return scan(self.root)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _dependency(dep: Any, health: dict[str, Any] | None) -> dict[str, Any]:
|
|
115
|
+
return {
|
|
116
|
+
"id": dep.id, "kind": dep.kind, "name": dep.name,
|
|
117
|
+
"address": dep.target or (" ".join(dep.command) if dep.command else None), "model": dep.model,
|
|
118
|
+
"places": [e.ref for e in dep.evidence], "places_total": dep.places,
|
|
119
|
+
"confirmed": dep.status == "confirmed",
|
|
120
|
+
"used_by": "your coding tools" if dep.scope == "dev_tool" else "the app",
|
|
121
|
+
"via": dep.via, "health": _health(health),
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _times(flow: Flow) -> dict[str, Any]:
|
|
126
|
+
cp = critical(flow)
|
|
127
|
+
names = {n.id: n for n in flow.nodes}
|
|
128
|
+
return {
|
|
129
|
+
"critical_path": [{"id": i, "name": names[i].what if names[i].kind in CALL_KINDS else names[i].title,
|
|
130
|
+
"ms": names[i].estimate_ms} for i in cp.path],
|
|
131
|
+
"could_be_ms": round(cp.length_ms, 1), "as_written_ms": flow.written_ms,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _seconds(ms: float) -> str:
|
|
136
|
+
return f"{ms / 1000:.1f} s" if ms >= 1000 else f"{round(ms)} ms"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _plural(n: int, word: str, many: str | None = None) -> str:
|
|
140
|
+
return f"{n} {word if n == 1 else many or word + 's'}"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def headline(tool: str, data: dict[str, Any]) -> str | None:
|
|
144
|
+
"""A tool's answer in one line, from counts and times only (D30): the Connect page's cards and call list."""
|
|
145
|
+
if tool == "get_map":
|
|
146
|
+
return _plural(len(data["dependencies"]), "dependency", "dependencies")
|
|
147
|
+
if tool == "check_health":
|
|
148
|
+
counts = data["summary"]
|
|
149
|
+
parts = [f"{counts[s]} {s}" for s in ("up", "warn", "down", "skipped") if counts.get(s)]
|
|
150
|
+
return " · ".join(parts) or "Nothing to check"
|
|
151
|
+
if tool in ("get_flow", "get_advice") and not data["available"]:
|
|
152
|
+
return "No flow: no Python calls found"
|
|
153
|
+
if tool == "get_flow":
|
|
154
|
+
return f"{_seconds(data['as_written_ms'])} → {_seconds(data['could_be_ms'])}"
|
|
155
|
+
if tool == "get_advice":
|
|
156
|
+
n = len(data["advice"])
|
|
157
|
+
if not n:
|
|
158
|
+
return "Nothing to start together"
|
|
159
|
+
return f"{_plural(n, 'way')} to be faster · saves ~{_seconds(data['saved_ms'])}"
|
|
160
|
+
if tool == "get_security_findings":
|
|
161
|
+
counts = data["summary"]
|
|
162
|
+
return " · ".join(f"{counts[k]} {k}" for k in ("high", "medium", "low") if counts.get(k)) or "Nothing found"
|
|
163
|
+
if tool == "what_changed":
|
|
164
|
+
if not data["saved"]:
|
|
165
|
+
return "No saved map yet"
|
|
166
|
+
return f"{len(data['added'])} added · {len(data['removed'])} removed"
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _map_data(r: _Repo) -> dict[str, Any]:
|
|
171
|
+
result = r.scan()
|
|
172
|
+
latest = r.latest()
|
|
173
|
+
deps = [_dependency(d, latest.get(d.id)) for d in result.dependencies]
|
|
174
|
+
counts = Counter(d.kind for d in result.dependencies)
|
|
175
|
+
return {"dependencies": deps, "counts": dict(counts), "files_read": result.files_scanned}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _security_data(result: ScanResult) -> dict[str, Any]:
|
|
179
|
+
findings = [f.model_dump(mode="json") for f in result.findings]
|
|
180
|
+
return {"findings": findings, "summary": dict(Counter(f["level"] for f in findings)),
|
|
181
|
+
"note": "Catches known key formats and these patterns, not everything. Values are never shown."}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _flow_data(result: Any) -> dict[str, Any]:
|
|
185
|
+
if result.flow is None:
|
|
186
|
+
return {"available": False, "message": result.message}
|
|
187
|
+
flow = result.flow
|
|
188
|
+
calls = [{"id": n.id, "kind": n.kind, "service": n.title, "does": n.what, "ref": n.evidence[0].ref,
|
|
189
|
+
"ms": n.estimate_ms, "measured": n.estimate_source == "measured", "writes": code_writes(n),
|
|
190
|
+
"repeats": bool(n.repeats)} for n in flow.nodes if n.kind in CALL_KINDS]
|
|
191
|
+
decisions = [{"id": n.id, "question": n.title, "condition": n.condition, "ref": n.evidence[0].ref}
|
|
192
|
+
for n in flow.nodes if n.kind == "decision"]
|
|
193
|
+
lines = [{"from": e.source, "to": e.target, "when": e.when} for e in flow.edges]
|
|
194
|
+
entries = [n.title for n in flow.nodes if n.kind == "start"]
|
|
195
|
+
return {"available": True, "entry_points": entries, "calls": calls, "decisions": decisions, "lines": lines,
|
|
196
|
+
**_times(flow)}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _advice_data(result: Any) -> dict[str, Any]:
|
|
200
|
+
if result.flow is None:
|
|
201
|
+
return {"available": False, "message": result.message}
|
|
202
|
+
advice = [a.model_dump(mode="json") for a in result.flow.advice]
|
|
203
|
+
return {"available": True, "advice": advice, "saved_ms": round(sum(a["saved_ms"] for a in advice), 1)}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
KIND_WORDS = {"llm": "LLM", "mcp": "MCP server", "api": "API", "local": "program"}
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def preview(repo: Path, storage: Storage) -> dict[str, Any]:
|
|
210
|
+
"""What each tool would answer about the dashboard's repo now, as the Connect page's cards (D30). Health is the
|
|
211
|
+
stored checks: nothing is run, and no call is noted."""
|
|
212
|
+
r = _Repo(repo, storage, dashboards=True)
|
|
213
|
+
deps = _map_data(r)
|
|
214
|
+
counts = deps["counts"]
|
|
215
|
+
kinds = " · ".join(_plural(counts[k], word) for k, word in KIND_WORDS.items() if counts.get(k))
|
|
216
|
+
checked = [d["health"]["status"] for d in deps["dependencies"] if d["health"]["status"] != "unchecked"]
|
|
217
|
+
health = {"summary": dict(Counter(checked))}
|
|
218
|
+
tone = "bad" if "down" in checked else "warn" if "warn" in checked else "ok" if checked else "plain"
|
|
219
|
+
read = r.flow()
|
|
220
|
+
flow, advice = _flow_data(read), _advice_data(read)
|
|
221
|
+
security = _security_data(read.scan or r.scan())
|
|
222
|
+
worst = next((lv for lv in ("high", "medium", "low") if security["summary"].get(lv)), None)
|
|
223
|
+
cards = [
|
|
224
|
+
{"tool": "get_map", "headline": headline("get_map", deps), "sub": kinds, "tone": "plain"},
|
|
225
|
+
{"tool": "check_health", "headline": headline("check_health", health) if checked else "Not checked yet",
|
|
226
|
+
"sub": f"{_plural(len(checked), 'last check')} stored" if checked else "Your agent can run the checks",
|
|
227
|
+
"tone": tone},
|
|
228
|
+
{"tool": "get_security_findings", "headline": headline("get_security_findings", security),
|
|
229
|
+
"sub": "keys in code, plain http, risky MCP servers",
|
|
230
|
+
"tone": {"high": "bad", "medium": "warn"}.get(worst or "", "ok")},
|
|
231
|
+
{"tool": "get_flow", "headline": headline("get_flow", flow) if flow["available"] else None,
|
|
232
|
+
"sub": "as written → could be" if flow["available"] else flow["message"], "tone": "plain"},
|
|
233
|
+
{"tool": "get_advice", "headline": (_plural(len(advice["advice"]), "way") + " to be faster")
|
|
234
|
+
if advice["available"] else None,
|
|
235
|
+
"sub": f"saves ~{_seconds(advice['saved_ms'])}, the change written out" if advice["available"] else None,
|
|
236
|
+
"tone": "warn" if advice["available"] and advice["advice"] else "plain"},
|
|
237
|
+
{"tool": "what_changed", "headline": None, "sub": "After your agent edits: what it added or removed",
|
|
238
|
+
"tone": "plain"},
|
|
239
|
+
]
|
|
240
|
+
return _mask({"repo": r.root.name, "cards": cards}, r.redactor)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def build_server(repo: Path, storage: Storage, *, transport: httpx.AsyncBaseTransport | None = None) -> MCPServer:
|
|
244
|
+
"""The server for one repo. `transport` answers the health checks' requests in tests."""
|
|
245
|
+
r = _Repo(repo, storage, transport)
|
|
246
|
+
server = MCPServer(name="flowforge", title="FlowForge", instructions=INSTRUCTIONS)
|
|
247
|
+
|
|
248
|
+
@server.tool(annotations=READ)
|
|
249
|
+
def get_map() -> dict[str, Any]:
|
|
250
|
+
"""Every dependency the repo uses (LLMs, MCP servers, HTTP APIs, local programs), each with where it appears
|
|
251
|
+
(file:line) and its latest health. Read from the code now; nothing is run."""
|
|
252
|
+
return r.answer("get_map", _map_data(r))
|
|
253
|
+
|
|
254
|
+
@server.tool(annotations=READ)
|
|
255
|
+
def get_flow() -> dict[str, Any]:
|
|
256
|
+
"""The order the code calls its dependencies (Python), from each entry point: every call with its file:line
|
|
257
|
+
and time (measured by the last check, else an estimate), the if-decisions, which call waits for which, and
|
|
258
|
+
the critical path: the chain that sets the total time, as written versus as early as inputs allow."""
|
|
259
|
+
return r.answer("get_flow", _flow_data(r.flow()))
|
|
260
|
+
|
|
261
|
+
@server.tool(annotations=READ)
|
|
262
|
+
def get_advice() -> dict[str, Any]:
|
|
263
|
+
"""Calls that don't need each other's results but wait for each other: how much running them together
|
|
264
|
+
saves, the change written out when it is safe (lines now and could be), what to move by hand, and a prompt
|
|
265
|
+
describing the whole change. FlowForge never applies it: you make the change and run the tests."""
|
|
266
|
+
return r.answer("get_advice", _advice_data(r.flow()))
|
|
267
|
+
|
|
268
|
+
@server.tool(annotations=CHECKS)
|
|
269
|
+
async def check_health(ids: list[str] | None = None) -> dict[str, Any]:
|
|
270
|
+
"""Check the dependencies now (all, or the ids from get_map), like the dashboard's Check now: one request
|
|
271
|
+
without any key to an API, the model list of an LLM, an MCP server started and asked for its tools, a local
|
|
272
|
+
program looked for. An MCP server starts only if the user already said yes to its exact command in the
|
|
273
|
+
dashboard. Each result says why something is down and the likely fix."""
|
|
274
|
+
result = r.scan()
|
|
275
|
+
deps = {d.id: d for d in result.dependencies if d.kind in CHECKABLE and d.status == "confirmed"}
|
|
276
|
+
if ids is not None:
|
|
277
|
+
unknown = sorted(set(ids) - set(deps))
|
|
278
|
+
if unknown:
|
|
279
|
+
raise ValueError(f"not a checkable dependency of this repo: {', '.join(unknown)} (see get_map)")
|
|
280
|
+
deps = {i: deps[i] for i in ids}
|
|
281
|
+
targets = [target_for(d, result) for d in deps.values()]
|
|
282
|
+
yes = confirmed_commands(r.storage) if r.shared else set()
|
|
283
|
+
waiting = [t for t in targets if starts_process(t) and command_text(t) not in yes]
|
|
284
|
+
runnable = [t for t in targets if t not in waiting]
|
|
285
|
+
rows = [{"id": t.id, "kind": t.kind, "status": "skipped", "summary": NOT_STARTED} for t in waiting]
|
|
286
|
+
if runnable:
|
|
287
|
+
node = CheckNode(r.transport)
|
|
288
|
+
try:
|
|
289
|
+
run = await run_workflow(check_workflow(runnable, str(r.root)), {"check": node}, use_cache=False)
|
|
290
|
+
finally:
|
|
291
|
+
await node.aclose()
|
|
292
|
+
by_step = {s.id: s.params["target"] for s in check_workflow(runnable, str(r.root)).steps}
|
|
293
|
+
for step_id, step in run.steps.items():
|
|
294
|
+
if step.state == StepState.SUCCEEDED and isinstance(step.output, dict):
|
|
295
|
+
rows.append(step.output)
|
|
296
|
+
else:
|
|
297
|
+
target = by_step[step_id]
|
|
298
|
+
rows.append({"id": target["id"], "kind": target["kind"], "status": "skipped",
|
|
299
|
+
"summary": "The check couldn't run", "reason": step.error})
|
|
300
|
+
rows.sort(key=lambda row: list(deps).index(row["id"]))
|
|
301
|
+
if r.shared and rows:
|
|
302
|
+
r.storage.add_checks(f"agent-{uuid.uuid4().hex[:12]}", rows) # kept, like Check now (D24)
|
|
303
|
+
summary = dict(Counter(row["status"] for row in rows))
|
|
304
|
+
return r.answer("check_health", {"results": rows, "summary": summary,
|
|
305
|
+
"kept": r.shared, "waiting_for_yes": [t.id for t in waiting]})
|
|
306
|
+
|
|
307
|
+
@server.tool(annotations=READ)
|
|
308
|
+
def get_security_findings() -> dict[str, Any]:
|
|
309
|
+
"""What in the repo is unsafe, read from the code now: keys written in code or MCP configs, APIs called over
|
|
310
|
+
plain http, MCP servers with no pinned version or given the whole disk. Each finding has its file:line, the
|
|
311
|
+
dependency it belongs to, a masked excerpt, the fix, and a prompt describing the change. Values are never
|
|
312
|
+
shown. Catches known key formats and these patterns, not everything."""
|
|
313
|
+
return r.answer("get_security_findings", _security_data(r.scan()))
|
|
314
|
+
|
|
315
|
+
@server.tool(annotations=READ)
|
|
316
|
+
def what_changed() -> dict[str, Any]:
|
|
317
|
+
"""After an edit: the dependencies added or removed since the map saved in the dashboard, and the flow's
|
|
318
|
+
times now. Use it to confirm a change did what you meant."""
|
|
319
|
+
saved = r.storage.latest_flow_version("deps") if r.shared else None
|
|
320
|
+
if saved is None or not saved.get("flow"):
|
|
321
|
+
return r.answer("what_changed", {"saved": False, "message": "The dashboard has no saved map of this repo "
|
|
322
|
+
f"yet: open it with `flowforge --repo {r.root}` first."})
|
|
323
|
+
before = {n["id"]: n for n in saved["flow"]["nodes"] if n["kind"] in CALL_KINDS}
|
|
324
|
+
result = r.scan()
|
|
325
|
+
now = {d.id: d for d in result.dependencies}
|
|
326
|
+
added = [{"id": d.id, "kind": d.kind, "name": d.name, "places": [e.ref for e in d.evidence]}
|
|
327
|
+
for i, d in now.items() if i not in before]
|
|
328
|
+
removed = [{"id": i, "kind": n["kind"], "name": n["title"]} for i, n in before.items() if i not in now]
|
|
329
|
+
flow = r.flow().flow
|
|
330
|
+
return r.answer("what_changed", {"saved": True, "saved_version": saved["version"], "added": added,
|
|
331
|
+
"removed": removed, "flow": _times(flow) if flow else None})
|
|
332
|
+
|
|
333
|
+
return server
|
|
File without changes
|
flowforge/api/agent.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Connect an agent (D29): the set-up each coding agent needs to start FlowForge's MCP server on this repo, with the
|
|
2
|
+
paths filled in, and the agent's calls, which prove the connection works. The preview is what each tool would answer
|
|
3
|
+
about this repo now (D30).
|
|
4
|
+
|
|
5
|
+
GET /agent/connect {repo, command, agents: {claude, codex, antigravity}, last_call, calls}
|
|
6
|
+
GET /agent/preview {repo, cards: [{tool, headline, sub, tone}]}
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import shlex
|
|
13
|
+
import shutil
|
|
14
|
+
import sys
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from fastapi import APIRouter, HTTPException
|
|
18
|
+
|
|
19
|
+
from flowforge import home
|
|
20
|
+
from flowforge.agent import AGENT_CALLS_SETTING, preview
|
|
21
|
+
from flowforge.api.scan import repo_path
|
|
22
|
+
from flowforge.state import app_state
|
|
23
|
+
|
|
24
|
+
router = APIRouter()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _command(repo: str, db: str) -> list[str]:
|
|
28
|
+
"""How the agent starts FlowForge's MCP server, matching how FlowForge was installed (D34): from the checkout
|
|
29
|
+
through uv, through uvx with the version pinned (as FlowForge advises for MCP servers, D31), or the same Python.
|
|
30
|
+
Full paths: an agent started from a desktop app may not have your shell's PATH."""
|
|
31
|
+
tail = ["mcp", "--repo", repo, "--db", db]
|
|
32
|
+
kind = home.install_kind()
|
|
33
|
+
if kind == "checkout":
|
|
34
|
+
return [shutil.which("uv") or "uv", "--directory", str(home.CHECKOUT), "run", "flowforge", *tail]
|
|
35
|
+
if kind == "uvx":
|
|
36
|
+
return [shutil.which("uvx") or "uvx", f"{home.DIST_NAME}=={home.version()}", *tail]
|
|
37
|
+
return [sys.executable, "-m", "flowforge.cli", *tail]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@router.get("/agent/connect")
|
|
41
|
+
def agent_connect() -> dict[str, Any]:
|
|
42
|
+
s = app_state()
|
|
43
|
+
repo = repo_path(s)
|
|
44
|
+
command = _command(str(repo) if repo else ".", str(s.storage.path.resolve()))
|
|
45
|
+
line = shlex.join(command)
|
|
46
|
+
config = {"mcpServers": {"flowforge": {"command": command[0], "args": command[1:]}}}
|
|
47
|
+
# JSON strings are valid TOML basic strings, Windows backslashes included
|
|
48
|
+
args = ", ".join(map(json.dumps, command[1:]))
|
|
49
|
+
toml = f"[mcp_servers.flowforge]\ncommand = {json.dumps(command[0])}\nargs = [{args}]"
|
|
50
|
+
try:
|
|
51
|
+
calls = json.loads(s.storage.setting(AGENT_CALLS_SETTING) or "[]")
|
|
52
|
+
except ValueError:
|
|
53
|
+
calls = []
|
|
54
|
+
return {
|
|
55
|
+
"repo": str(repo) if repo else None,
|
|
56
|
+
"command": command,
|
|
57
|
+
"agents": {
|
|
58
|
+
"claude": {"name": "Claude Code", "how": "terminal", "checked": True,
|
|
59
|
+
"text": f"claude mcp add flowforge -- {line}"},
|
|
60
|
+
"codex": {"name": "Codex", "how": "terminal", "checked": True,
|
|
61
|
+
"text": f"codex mcp add flowforge -- {line}",
|
|
62
|
+
"file": "~/.codex/config.toml",
|
|
63
|
+
"file_text": toml},
|
|
64
|
+
"antigravity": {"name": "Antigravity", "how": "file", "checked": False,
|
|
65
|
+
"file": "mcp_config.json (… menu → MCP Servers → Manage MCP Servers → View raw config)",
|
|
66
|
+
"text": json.dumps(config, indent=2)},
|
|
67
|
+
},
|
|
68
|
+
"last_call": calls[0] if calls else None,
|
|
69
|
+
"calls": calls,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@router.get("/agent/preview")
|
|
74
|
+
def agent_preview() -> dict[str, Any]:
|
|
75
|
+
s = app_state()
|
|
76
|
+
repo = repo_path(s)
|
|
77
|
+
if repo is None:
|
|
78
|
+
raise HTTPException(404, "No folder mapped yet: scan one first")
|
|
79
|
+
return preview(repo, s.storage)
|