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,164 @@
1
+ """Local command step (D13). The connector fixes the program and the folder; the step adds arguments.
2
+
3
+ params: args (list[str], appended to the connector's command), stdin? (str),
4
+ subdir? (str, must stay inside the connector's cwd), ok_exit_codes? (default [0])
5
+ output: {"kind": "text", "stdout": str, "stderr": str, "exit_code": int}
6
+
7
+ Safety rules, all enforced here:
8
+ - no shell: the program starts from an argv list; step values are separate arguments,
9
+ never pasted into a command string. .bat/.cmd are refused, because Windows runs them
10
+ through cmd.exe, which would bring shell parsing back
11
+ - subdir is resolved (`..`, symlinks) and must stay inside cwd
12
+ - the environment is explicit: connector env + resolved env_refs + the minimum the OS
13
+ needs to start a program; nothing else is inherited
14
+ - on timeout or cancellation (the executor's wait_for) the process is killed and reaped
15
+ - stdout/stderr are capped at max_output_bytes; the rest is read and discarded
16
+ An exit code outside ok_exit_codes is a permanent error. Never cached across runs by default.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import os
23
+ import shutil
24
+ import sys
25
+ from pathlib import Path, PurePosixPath, PureWindowsPath
26
+ from typing import Any
27
+
28
+ from flowforge.connectors.models import LocalConnector
29
+ from flowforge.connectors.secrets import SecretRefError, resolve_env
30
+ from flowforge.nodes.base import Node, NodeError
31
+
32
+ # What a process needs to start at all; everything else must be configured explicitly.
33
+ _OS_ENV = ("PATH", "SYSTEMROOT", "TEMP", "TMP") if sys.platform == "win32" else ("PATH",)
34
+ _SHELL_SCRIPTS = {".bat", ".cmd"}
35
+ _CHUNK = 64 * 1024
36
+
37
+
38
+ class LocalNode(Node):
39
+ type = "local"
40
+
41
+ def __init__(self, connector: LocalConnector) -> None:
42
+ self.connector = connector
43
+ self.connector_id = connector.id
44
+ self.rate_limit_key = connector.id
45
+ self.fallback = connector.fallback
46
+
47
+ @classmethod
48
+ def from_connector(cls, connector: LocalConnector) -> LocalNode:
49
+ return cls(connector)
50
+
51
+ @property
52
+ def _label(self) -> str:
53
+ return f"local '{self.connector.id}'"
54
+
55
+ def _env(self) -> dict[str, str]:
56
+ conn = self.connector.connection
57
+ env = {name: os.environ[name] for name in _OS_ENV if name in os.environ}
58
+ try:
59
+ env.update(resolve_env(conn.env, conn.env_refs))
60
+ except SecretRefError as exc:
61
+ raise NodeError(f"{self._label}: {exc}") from exc
62
+ return env
63
+
64
+ def _workdir(self, subdir: Any) -> Path:
65
+ base = Path(self.connector.connection.cwd).resolve()
66
+ if not base.is_dir():
67
+ raise NodeError(f"{self._label}: working folder {base} does not exist")
68
+ if subdir is None:
69
+ return base
70
+ if not isinstance(subdir, str):
71
+ raise NodeError(f"{self._label}: params.subdir must be a string")
72
+ # reject absolute or drive paths of either platform before joining
73
+ win, posix = PureWindowsPath(subdir), PurePosixPath(subdir)
74
+ if win.is_absolute() or win.drive or win.root or posix.is_absolute():
75
+ raise NodeError(f"{self._label}: subdir '{subdir}' is outside the connector's folder")
76
+ target = (base / subdir).resolve()
77
+ if not target.is_relative_to(base):
78
+ raise NodeError(f"{self._label}: subdir '{subdir}' is outside the connector's folder")
79
+ if not target.is_dir():
80
+ raise NodeError(f"{self._label}: subdir '{subdir}' does not exist")
81
+ return target
82
+
83
+ def _program(self, env: dict[str, str]) -> str:
84
+ name = self.connector.connection.command[0]
85
+ if os.path.dirname(name):
86
+ program = name if Path(name).is_file() else None
87
+ else:
88
+ program = shutil.which(name, path=env.get("PATH", ""))
89
+ if program is None:
90
+ raise NodeError(f"{self._label}: program '{name}' not found")
91
+ if Path(program).suffix.lower() in _SHELL_SCRIPTS:
92
+ raise NodeError(f"{self._label}: '{name}' is a batch script, which Windows runs through cmd.exe "
93
+ "(shell parsing); point the connector at the real program instead")
94
+ return program
95
+
96
+ async def run(self, params: dict[str, Any]) -> Any:
97
+ args = params.get("args", [])
98
+ if not isinstance(args, list) or not all(isinstance(a, str) for a in args):
99
+ raise NodeError(f"{self._label}: params.args must be a list of strings")
100
+ stdin = params.get("stdin")
101
+ if stdin is not None and not isinstance(stdin, str):
102
+ raise NodeError(f"{self._label}: params.stdin must be a string")
103
+ ok_codes = params.get("ok_exit_codes", [0])
104
+ cwd = self._workdir(params.get("subdir"))
105
+ env = self._env()
106
+ program = self._program(env)
107
+ cap = self.connector.connection.max_output_bytes
108
+
109
+ try:
110
+ proc = await asyncio.create_subprocess_exec(
111
+ program, *self.connector.connection.command[1:], *args,
112
+ cwd=cwd, env=env,
113
+ stdin=asyncio.subprocess.PIPE if stdin is not None else asyncio.subprocess.DEVNULL,
114
+ stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
115
+ )
116
+ except OSError as exc:
117
+ raise NodeError(f"{self._label}: could not start '{program}': {exc}") from exc
118
+
119
+ try:
120
+ (out, out_cut), (err, err_cut), _ = await asyncio.gather(
121
+ _read_capped(proc.stdout, cap), _read_capped(proc.stderr, cap), _feed(proc, stdin)
122
+ )
123
+ code = await proc.wait()
124
+ except BaseException: # timeout/cancellation from the executor, or anything else
125
+ if proc.returncode is None:
126
+ proc.kill()
127
+ await proc.wait()
128
+ raise
129
+
130
+ stdout, stderr = _text(out, out_cut, cap), _text(err, err_cut, cap)
131
+ if code not in ok_codes:
132
+ raise NodeError(f"{self._label}: exit code {code}: {stderr[-500:].strip()}")
133
+ return {"kind": "text", "stdout": stdout, "stderr": stderr, "exit_code": code}
134
+
135
+
136
+ async def _read_capped(stream: asyncio.StreamReader | None, cap: int) -> tuple[bytes, bool]:
137
+ """Keep the first `cap` bytes; keep reading (and discarding) so the child never blocks."""
138
+ kept = bytearray()
139
+ truncated = False
140
+ if stream is None:
141
+ return b"", False
142
+ while chunk := await stream.read(_CHUNK):
143
+ room = cap - len(kept)
144
+ if len(chunk) > room:
145
+ truncated = True
146
+ if room > 0:
147
+ kept += chunk[:room]
148
+ return bytes(kept), truncated
149
+
150
+
151
+ async def _feed(proc: asyncio.subprocess.Process, data: str | None) -> None:
152
+ if data is None or proc.stdin is None:
153
+ return
154
+ try:
155
+ proc.stdin.write(data.encode())
156
+ await proc.stdin.drain()
157
+ proc.stdin.close()
158
+ except (BrokenPipeError, ConnectionResetError):
159
+ pass # the program exited without reading all of it
160
+
161
+
162
+ def _text(data: bytes, truncated: bool, cap: int) -> str:
163
+ text = data.decode("utf-8", errors="replace")
164
+ return text + f"\n[output truncated at {cap} bytes]" if truncated else text
@@ -0,0 +1,243 @@
1
+ """MCP tool call with the official `mcp` SDK (D9, D12).
2
+
3
+ params: server — a command list for a stdio server (default ["uvx", "mcp-server-fetch"])
4
+ or the name of a server registered with MCPNode(servers={...});
5
+ not allowed on a connector node, whose connector fixes the server
6
+ tool — e.g. "fetch"
7
+ arguments (dict)
8
+ output: {"content": str (text blocks joined), "structured": dict | None,
9
+ "blocks": [...] — only when the result has non-text blocks (D12)}
10
+
11
+ One connection per server is opened on first use and reused for the whole run
12
+ (spawning a process per call is slow). Its tool list is discovered once and every call
13
+ is checked against it — unknown tool, or arguments that fail the tool's JSON Schema —
14
+ before anything is sent (D12). A tool that reports is_error fails the step.
15
+ Not cached across runs unless the step sets cache: true (tools can have side effects).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import base64
22
+ import hashlib
23
+ import mimetypes
24
+ import re
25
+ from typing import Any
26
+
27
+ import jsonschema
28
+ from mcp import Client, MCPError, StdioServerParameters
29
+
30
+ from flowforge.connectors.models import MCPConnector
31
+ from flowforge.connectors.secrets import SecretRefError, resolve_env
32
+ from flowforge.home import flowforge_home
33
+ from flowforge.nodes.base import Node, NodeError, TransientNodeError, current_run_id
34
+
35
+ DEFAULT_SERVER = ["uvx", "mcp-server-fetch"]
36
+ INLINE_LIMIT_BYTES = 64 * 1024 # larger binary blocks go to an artifact file (D12)
37
+ _RUN_ID = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
38
+
39
+
40
+ class _Connection:
41
+ """Owns one MCP client inside a dedicated task.
42
+
43
+ anyio cancel scopes must be entered and exited by the same task, so the client's
44
+ context manager lives in `_own()` rather than in whichever step happened to open it.
45
+ Steps call tools on the shared client from their own tasks.
46
+ """
47
+
48
+ def __init__(self, server: Any) -> None:
49
+ self.server = server
50
+ self.client: Client | None = None
51
+ self.tools: list[dict[str, Any]] | None = None # discovered once per connection
52
+ self._ready = asyncio.Event()
53
+ self._closing = asyncio.Event()
54
+ self._error: BaseException | None = None
55
+ self._task = asyncio.create_task(self._own())
56
+
57
+ async def _own(self) -> None:
58
+ try:
59
+ # stdio servers like mcp-server-fetch predate `server/discover`; skip the probe
60
+ mode = "legacy" if isinstance(self.server, StdioServerParameters) else "auto"
61
+ async with Client(self.server, cache=None, mode=mode) as client:
62
+ self.client = client
63
+ self._ready.set()
64
+ await self._closing.wait()
65
+ except Exception as exc:
66
+ self._error = exc
67
+ finally:
68
+ self.client = None
69
+ self._ready.set()
70
+
71
+ @property
72
+ def alive(self) -> bool:
73
+ return not self._task.done()
74
+
75
+ async def wait_ready(self) -> Client:
76
+ await self._ready.wait()
77
+ if self.client is None:
78
+ raise TransientNodeError(f"could not connect to MCP server: {self._error!r}")
79
+ return self.client
80
+
81
+ async def close(self) -> None:
82
+ self._closing.set()
83
+ await self._task
84
+
85
+
86
+ def check_call(tools: list[dict[str, Any]], tool: str, arguments: Any) -> str | None:
87
+ """Why calling `tool` with `arguments` would be wrong, or None if it's fine (D12).
88
+ `arguments=None` checks the tool name only (used when arguments hold templates)."""
89
+ by_name = {t["name"]: t for t in tools}
90
+ if tool not in by_name:
91
+ return f"Unknown tool '{tool}'; this server has: {', '.join(sorted(by_name)) or 'no tools'}"
92
+ if arguments is None:
93
+ return None
94
+ schema = by_name[tool].get("input_schema") or {}
95
+ errors = sorted(jsonschema.Draft202012Validator(schema).iter_errors(arguments), key=lambda e: list(e.path))
96
+ if errors:
97
+ first = errors[0]
98
+ where = "/".join(map(str, first.path))
99
+ at = f" (at '{where}')" if where else ""
100
+ return f"arguments for tool '{tool}' don't match its schema: {first.message}{at}"
101
+ return None
102
+
103
+
104
+ class MCPNode(Node):
105
+ type = "mcp"
106
+
107
+ def __init__(self, servers: dict[str, Any] | None = None, default_server: Any = None) -> None:
108
+ self.servers = servers or {}
109
+ self.default_server = default_server # used when params.server is absent; None = DEFAULT_SERVER
110
+ self.connector: MCPConnector | None = None
111
+ self._connections: dict[Any, _Connection] = {}
112
+ self._lock = asyncio.Lock()
113
+
114
+ @classmethod
115
+ def from_connector(cls, connector: MCPConnector) -> MCPNode:
116
+ node = cls()
117
+ node.connector = connector
118
+ node.connector_id = connector.id
119
+ node.fallback = connector.fallback
120
+ node.rate_limit_key = connector.id
121
+ return node
122
+
123
+ def _connector_server(self) -> StdioServerParameters:
124
+ """Server parameters for the connector; secret env values are resolved here, at spawn (D12)."""
125
+ assert self.connector is not None
126
+ conn = self.connector.connection
127
+ try:
128
+ env = resolve_env(conn.env, conn.env_refs)
129
+ except SecretRefError as exc:
130
+ raise NodeError(f"MCP connector '{self.connector.id}': {exc}") from exc
131
+ return StdioServerParameters(command=conn.command, args=list(conn.args), env=env or None)
132
+
133
+ def _resolve_server(self, spec: Any) -> tuple[Any, Any]:
134
+ """(connection key, what to pass to mcp.Client)."""
135
+ if self.connector is not None:
136
+ if spec is not None:
137
+ raise NodeError(f"connector '{self.connector.id}' fixes the server; remove params.server")
138
+ return ("connector", self.connector.id), self._connector_server()
139
+ if spec is None:
140
+ if self.default_server is None:
141
+ spec = DEFAULT_SERVER
142
+ elif isinstance(self.default_server, list):
143
+ spec = self.default_server
144
+ else:
145
+ return "__default__", self.default_server
146
+ if isinstance(spec, str):
147
+ if spec not in self.servers:
148
+ raise NodeError(f"unknown MCP server '{spec}'")
149
+ return spec, self.servers[spec]
150
+ if not spec or not all(isinstance(part, str) for part in spec):
151
+ raise NodeError("params.server must be a server name or a command list")
152
+ return tuple(spec), StdioServerParameters(command=spec[0], args=list(spec[1:]))
153
+
154
+ async def _connection(self, spec: Any) -> tuple[_Connection, Client]:
155
+ key, server = self._resolve_server(spec)
156
+ async with self._lock:
157
+ conn = self._connections.get(key)
158
+ if conn is None or not conn.alive:
159
+ conn = self._connections[key] = _Connection(server)
160
+ return conn, await conn.wait_ready()
161
+
162
+ async def aclose(self) -> None:
163
+ for conn in self._connections.values():
164
+ await conn.close()
165
+ self._connections.clear()
166
+
167
+ async def list_tools(self, server: Any = None) -> list[dict[str, Any]]:
168
+ """Every tool the server offers: name, description, JSON input schema (D12)."""
169
+ conn, client = await self._connection(server)
170
+ if conn.tools is None:
171
+ tools: list[dict[str, Any]] = []
172
+ cursor: str | None = None
173
+ try:
174
+ while True:
175
+ page = await client.list_tools(cursor=cursor)
176
+ tools += [{"name": t.name, "description": t.description or "", "input_schema": t.input_schema}
177
+ for t in page.tools]
178
+ if not (cursor := page.next_cursor):
179
+ break
180
+ except MCPError as exc:
181
+ raise NodeError(f"MCP error listing tools: {exc}") from exc
182
+ except (OSError, EOFError) as exc:
183
+ raise TransientNodeError(f"MCP connection lost: {exc}") from exc
184
+ conn.tools = tools
185
+ return conn.tools
186
+
187
+ async def run(self, params: dict[str, Any]) -> Any:
188
+ if "tool" not in params:
189
+ raise NodeError("mcp step needs params.tool")
190
+ server = params.get("server")
191
+ arguments = params.get("arguments", {})
192
+ problem = check_call(await self.list_tools(server), params["tool"], arguments)
193
+ if problem:
194
+ raise NodeError(problem)
195
+ _, client = await self._connection(server)
196
+ try:
197
+ result = await client.call_tool(params["tool"], arguments)
198
+ except MCPError as exc:
199
+ raise NodeError(f"MCP error: {exc}") from exc
200
+ except (OSError, EOFError) as exc:
201
+ raise TransientNodeError(f"MCP connection lost: {exc}") from exc
202
+
203
+ text = "\n".join(block.text for block in result.content if getattr(block, "type", None) == "text")
204
+ if result.is_error:
205
+ raise NodeError(f"MCP tool '{params['tool']}' failed: {text}")
206
+ output: dict[str, Any] = {"content": text, "structured": result.structured_content}
207
+ blocks = [b for b in map(_convert_block, result.content) if b is not None]
208
+ if blocks:
209
+ output["blocks"] = blocks
210
+ return output
211
+
212
+
213
+ # --- non-text result blocks (D12) ----------------------------------------------------------------
214
+
215
+ def _convert_block(block: Any) -> dict[str, Any] | None:
216
+ kind = getattr(block, "type", None)
217
+ if kind == "text":
218
+ return None
219
+ if kind in ("image", "audio"):
220
+ return _binary(kind, block.mime_type, base64.b64decode(block.data))
221
+ if kind == "resource_link":
222
+ return {"kind": "resource", "uri": str(block.uri), "name": block.name}
223
+ if kind == "resource":
224
+ res = block.resource
225
+ if getattr(res, "text", None) is not None:
226
+ return {"kind": "text", "uri": str(res.uri), "text": res.text}
227
+ mime = res.mime_type or "application/octet-stream"
228
+ major = mime.split("/", 1)[0]
229
+ return _binary(major if major in ("image", "audio", "video") else "file", mime, base64.b64decode(res.blob))
230
+ return {"kind": "unknown", "type": str(kind)}
231
+
232
+
233
+ def _binary(kind: str, mime: str | None, data: bytes) -> dict[str, Any]:
234
+ mime = mime or "application/octet-stream"
235
+ if len(data) <= INLINE_LIMIT_BYTES:
236
+ return {"kind": kind, "mime": mime, "data_b64": base64.b64encode(data).decode()}
237
+ run_id = current_run_id.get()
238
+ folder = flowforge_home() / "artifacts" / (run_id if run_id and _RUN_ID.match(run_id) else "adhoc")
239
+ folder.mkdir(parents=True, exist_ok=True)
240
+ path = folder / f"{hashlib.sha256(data).hexdigest()}{mimetypes.guess_extension(mime) or '.bin'}"
241
+ if not path.exists():
242
+ path.write_bytes(data)
243
+ return {"kind": kind, "mime": mime, "path": str(path), "bytes": len(data)}
@@ -0,0 +1,40 @@
1
+ """Sleep-based node for tests and the simulated benchmark track (D6). Never touches the network.
2
+
3
+ params:
4
+ duration_ms: how long to "work"
5
+ output: value to return (default: {"step_done": true})
6
+ fail: "transient" | "permanent" to simulate errors
7
+ fail_times: how many calls fail before succeeding (default: always)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ from typing import Any
14
+
15
+ from flowforge.nodes.base import Node, NodeError, TransientNodeError
16
+
17
+
18
+ class MockNode(Node):
19
+ type = "mock"
20
+
21
+ def __init__(self) -> None:
22
+ self.calls = 0
23
+ self._failures: dict[str, int] = {}
24
+
25
+ async def run(self, params: dict[str, Any]) -> Any:
26
+ self.calls += 1
27
+ await asyncio.sleep(params.get("duration_ms", 100) / 1000)
28
+
29
+ fail = params.get("fail")
30
+ if fail:
31
+ key = repr(sorted(params.items()))
32
+ done = self._failures.get(key, 0)
33
+ limit = params.get("fail_times")
34
+ if limit is None or done < limit:
35
+ self._failures[key] = done + 1
36
+ if fail == "transient":
37
+ raise TransientNodeError("simulated transient failure")
38
+ raise NodeError("simulated permanent failure")
39
+
40
+ return params.get("output", {"step_done": True})
@@ -0,0 +1,6 @@
1
+ """The repo scanner (D21): `scan(root) -> ScanResult`. Plain code, read-only, no network and no LLM (D20)."""
2
+
3
+ from flowforge.scan.models import Dependency, ScanResult
4
+ from flowforge.scan.scanner import scan
5
+
6
+ __all__ = ["Dependency", "ScanResult", "scan"]
@@ -0,0 +1,47 @@
1
+ """What one file shows, before the scanner merges files into dependencies (D21). Internal to `scan/`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+
8
+ @dataclass
9
+ class UrlHit:
10
+ """A URL literal, or a constant's name, used where a URL goes. `confirmed`: a recognised call or key."""
11
+
12
+ line: int
13
+ url: str | None = None # scheme://host[:port]/path, query dropped; None while `name` is unresolved
14
+ name: str | None = None # a module-level constant holding the URL (resolved across files)
15
+ confirmed: bool = False
16
+ call_strings: tuple[str, ...] = () # the enclosing call's string arguments: call("fetch", ...) → via
17
+
18
+
19
+ @dataclass
20
+ class Ref:
21
+ """A value given either as a literal or as the name of a constant (resolved across files)."""
22
+
23
+ literal: str | None = None
24
+ name: str | None = None
25
+
26
+
27
+ @dataclass
28
+ class LLMClient:
29
+ line: int
30
+ sdk: str # the imported package the client class comes from
31
+ base_url: Ref = field(default_factory=Ref)
32
+
33
+
34
+ @dataclass
35
+ class FileFacts:
36
+ path: str
37
+ language: str
38
+ imports: list[tuple[str, int]] = field(default_factory=list) # (package, line)
39
+ urls: list[UrlHit] = field(default_factory=list)
40
+ clients: list[LLMClient] = field(default_factory=list)
41
+ models: list[tuple[Ref, int]] = field(default_factory=list) # model= values
42
+ env: list[tuple[str, int]] = field(default_factory=list) # env var names read, never values
43
+ commands: list[tuple[list[str], int]] = field(default_factory=list) # [program] or [interpreter, script]
44
+ stdio: list[tuple[list[str], int]] = field(default_factory=list) # MCP servers started from code: argv
45
+ constants: dict[str, tuple[str, int]] = field(default_factory=dict) # NAME -> (string value, line)
46
+ strings: set[str] = field(default_factory=set) # short string literals: does the code name ".mcp.json"?
47
+ call_strings: set[str] = field(default_factory=set) # string arguments of calls: does it name an MCP server?
@@ -0,0 +1,112 @@
1
+ """Which files the scan reads (D21, D22): code and known config files, never anything vendored, built or ignored."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import subprocess
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+
11
+ from flowforge.scan.manifests import is_manifest
12
+ from flowforge.scan.mcp_config import is_config
13
+
14
+ # never walked into, and not counted: other people's code and build output
15
+ VENDOR_DIRS = frozenset({".git", "node_modules", ".venv", "venv", "build", "dist", ".dart_tool", "__pycache__",
16
+ "vendor"})
17
+ # skipped on purpose, and counted as "ignored": their URLs are fixtures, not calls (D21, D23)
18
+ TEST_DIRS = frozenset({"tests", "test", "__tests__", "fixtures", "mocks", "__mocks__", "fakes"})
19
+ TEST_FILE = re.compile(r"^(?:fake|mock|dummy)_|^test_.*\.py$|_test\.(?:py|dart)$|\.(?:test|spec)\.[^.]+$")
20
+ DOC_SUFFIXES = frozenset({".md", ".txt", ".rst"})
21
+ MAX_BYTES = 1_000_000
22
+ BINARY_PROBE = 8192
23
+ LANGUAGES = {".py": "python", ".js": "javascript", ".jsx": "javascript", ".mjs": "javascript", ".cjs": "javascript",
24
+ ".ts": "typescript", ".tsx": "typescript", ".dart": "dart"}
25
+
26
+
27
+ @dataclass
28
+ class Listing:
29
+ files: list[str] = field(default_factory=list) # paths relative to the root, "/"-separated
30
+ skipped: dict[str, int] = field(default_factory=dict) # why -> how many: ignored, binary, too_big, outside…
31
+
32
+ def skip(self, why: str) -> None:
33
+ self.skipped[why] = self.skipped.get(why, 0) + 1
34
+
35
+
36
+ def language(rel: str) -> str | None:
37
+ return LANGUAGES.get(Path(rel).suffix.lower())
38
+
39
+
40
+ def classify(rel: str) -> str | None:
41
+ """"read", "ignored" (code or docs skipped on purpose: tests, fake data, docs, your tools' hidden folders) or None
42
+ (not something the scan looks at, such as images or lock files).
43
+
44
+ Hidden folders (.claude, .cursor, .github …) hold your tools, not your app: only their MCP configs are read.
45
+ """
46
+ path = Path(rel)
47
+ relevant = language(rel) is not None or is_config(rel) or is_manifest(rel)
48
+ if any(part.startswith(".") for part in path.parent.parts):
49
+ return "read" if is_config(rel) else "ignored" if relevant else None
50
+ if any(part in TEST_DIRS for part in path.parent.parts) or TEST_FILE.search(path.name):
51
+ return "ignored" if relevant else None
52
+ if path.suffix.lower() in DOC_SUFFIXES and not is_manifest(rel):
53
+ return "ignored"
54
+ return "read" if relevant else None
55
+
56
+
57
+ def _git_files(root: Path) -> list[str] | None:
58
+ """Files git tracks or doesn't ignore, relative to `root`; None when `root` isn't in a git repo.
59
+ A local command only: `ls-files` never touches the network."""
60
+ try:
61
+ done = subprocess.run(["git", "-C", str(root), "ls-files", "-z", "--cached", "--others", "--exclude-standard"],
62
+ capture_output=True, timeout=20, check=False,
63
+ env={**os.environ, "GIT_TERMINAL_PROMPT": "0", "GIT_OPTIONAL_LOCKS": "0"})
64
+ except (OSError, subprocess.SubprocessError):
65
+ return None
66
+ if done.returncode != 0:
67
+ return None
68
+ return sorted({p for p in done.stdout.decode("utf-8", "replace").split("\0") if p})
69
+
70
+
71
+ def _walk(root: Path) -> list[str]:
72
+ found = []
73
+ for folder, dirs, names in os.walk(root):
74
+ dirs[:] = sorted(d for d in dirs if d not in VENDOR_DIRS)
75
+ found += [(Path(folder) / name).relative_to(root).as_posix() for name in sorted(names)]
76
+ return found
77
+
78
+
79
+ def _is_binary(path: Path) -> bool:
80
+ with path.open("rb") as handle:
81
+ return b"\0" in handle.read(BINARY_PROBE)
82
+
83
+
84
+ def list_files(root: Path) -> Listing:
85
+ out = Listing()
86
+ root = root.resolve()
87
+ candidates = _git_files(root)
88
+ if candidates is None:
89
+ candidates = _walk(root)
90
+ for rel in candidates:
91
+ if any(part in VENDOR_DIRS for part in Path(rel).parent.parts):
92
+ continue
93
+ kind = classify(rel)
94
+ if kind is None:
95
+ continue
96
+ if kind == "ignored":
97
+ out.skip("ignored")
98
+ continue
99
+ path = root / rel
100
+ if path.is_symlink() and not path.resolve().is_relative_to(root):
101
+ out.skip("outside")
102
+ continue
103
+ if not path.is_file():
104
+ continue
105
+ if path.stat().st_size > MAX_BYTES:
106
+ out.skip("too_big")
107
+ continue
108
+ if _is_binary(path):
109
+ out.skip("binary")
110
+ continue
111
+ out.files.append(rel)
112
+ return out
@@ -0,0 +1,65 @@
1
+ """Installed packages from dependency files (D21): requirements*.txt, pyproject.toml, package.json, pubspec.yaml.
2
+
3
+ Only package names and their line are kept. An installed service SDK the code never imports becomes an
4
+ unconfirmed hint on the map.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import re
11
+ import tomllib
12
+ from pathlib import PurePosixPath
13
+
14
+ _REQUIREMENT = re.compile(r"^\s*([A-Za-z0-9][A-Za-z0-9._\-]*)")
15
+ _PUBSPEC_DEP = re.compile(r"^ ([A-Za-z0-9_]+)\s*:")
16
+
17
+
18
+ def is_manifest(rel: str) -> bool:
19
+ name = PurePosixPath(rel).name
20
+ return name in ("pyproject.toml", "package.json", "pubspec.yaml") or \
21
+ (name.startswith("requirements") and name.endswith(".txt"))
22
+
23
+
24
+ def _line_of(lines: list[str], package: str) -> int:
25
+ pattern = re.compile(rf"""(^|[\s"'\[]){re.escape(package)}([\s"'=<>~!:\[;,]|$)""")
26
+ return next((i for i, raw in enumerate(lines, 1) if pattern.search(raw)), 1)
27
+
28
+
29
+ def parse(rel: str, text: str) -> list[tuple[str, int]]:
30
+ """(package, line) for every dependency the file lists."""
31
+ name = PurePosixPath(rel).name
32
+ lines = text.splitlines()
33
+ if name.endswith(".txt"):
34
+ out = []
35
+ for i, raw in enumerate(lines, 1):
36
+ raw = raw.split("#")[0]
37
+ if raw.strip().startswith("-"):
38
+ continue
39
+ if m := _REQUIREMENT.match(raw):
40
+ out.append((m.group(1), i))
41
+ return out
42
+ if name == "pubspec.yaml":
43
+ out, inside = [], False
44
+ for i, raw in enumerate(lines, 1):
45
+ if raw and not raw[0].isspace():
46
+ inside = raw.rstrip().rstrip(":") in ("dependencies", "dev_dependencies")
47
+ elif inside and (m := _PUBSPEC_DEP.match(raw)) and m.group(1) != "flutter":
48
+ out.append((m.group(1), i))
49
+ return out
50
+ try:
51
+ data = json.loads(text) if name == "package.json" else tomllib.loads(text)
52
+ except ValueError:
53
+ return []
54
+ names: list[str] = []
55
+ if name == "package.json":
56
+ for key in ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies"):
57
+ names += list(data.get(key, {}) or {})
58
+ else:
59
+ project = data.get("project", {})
60
+ specs = list(project.get("dependencies", []))
61
+ for group in project.get("optional-dependencies", {}).values():
62
+ specs += group
63
+ names += [m.group(1) for spec in specs if (m := _REQUIREMENT.match(spec))]
64
+ names += [n for n in data.get("tool", {}).get("poetry", {}).get("dependencies", {}) if n != "python"]
65
+ return [(n, _line_of(lines, n)) for n in dict.fromkeys(names)]