flowforge-map 0.1.0__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/PKG-INFO +21 -3
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/README.md +18 -2
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/agent/server.py +27 -2
- flowforge_map-0.2.0/backend/flowforge/api/keys.py +107 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/api/scan.py +3 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/cli.py +102 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/flowmap/diff.py +27 -2
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/flowmap/models.py +1 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/flowmap/planner.py +12 -2
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/flowmap/service.py +10 -2
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/home.py +14 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/main.py +5 -0
- flowforge_map-0.2.0/backend/flowforge/security/keystore.py +280 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/security/logfilter.py +12 -1
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/examples/trip-planner/README.md +1 -1
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/examples/trip-planner/config.py +3 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/examples/trip-planner/services/llm.py +2 -2
- flowforge_map-0.2.0/frontend/dist/assets/index-C-GjahSy.js +52 -0
- flowforge_map-0.2.0/frontend/dist/assets/index-CWfr5fii.css +1 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/frontend/dist/index.html +2 -2
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/pyproject.toml +3 -1
- flowforge_map-0.1.0/frontend/dist/assets/index-BGT5seIN.css +0 -1
- flowforge_map-0.1.0/frontend/dist/assets/index-BoGikST6.js +0 -52
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/.gitignore +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/LICENSE +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/__init__.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/agent/__init__.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/api/__init__.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/api/agent.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/api/checks.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/api/connectors.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/api/flowmap.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/api/runs.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/api/security.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/api/stats.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/checks/__init__.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/checks/models.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/checks/probes.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/connectors/__init__.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/connectors/catalog.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/connectors/models.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/connectors/presets.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/connectors/registry.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/connectors/runtime.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/connectors/secrets.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/example_data.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/flow/__init__.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/flow/advice.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/flow/curly.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/flow/index.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/flow/order.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/flow/reach.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/flow/rewrite.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/flowmap/__init__.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/flowmap/fixture.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/flowmap/validate.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/nodes/__init__.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/nodes/base.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/nodes/check_node.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/nodes/http_node.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/nodes/llm_node.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/nodes/local_node.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/nodes/mcp_node.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/nodes/mock_node.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/scan/__init__.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/scan/facts.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/scan/files.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/scan/manifests.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/scan/mcp_config.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/scan/models.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/scan/patterns.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/scan/python_ast.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/scan/scanner.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/scan/services.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/scan/urls.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/scheduler/__init__.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/scheduler/cache.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/scheduler/critical_path.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/scheduler/durations.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/scheduler/executor.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/scheduler/graph.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/scheduler/rate_limit.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/schema.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/security/__init__.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/security/finders.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/security/findings.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/spa.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/state.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/storage.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/flowforge/templating.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/workflows/diamond_mock.json +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/backend/workflows/stripe_to_razorpay.json +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/examples/trip-planner/.env.example +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/examples/trip-planner/.gitignore +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/examples/trip-planner/.mcp.json +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/examples/trip-planner/mcp_servers/currency.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/examples/trip-planner/planner.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/examples/trip-planner/requirements.txt +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/examples/trip-planner/services/__init__.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/examples/trip-planner/services/geo.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/examples/trip-planner/services/mcp_tools.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/examples/trip-planner/services/weather.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/examples/trip-planner/tools/packing_list.py +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/examples/trip-planner/trips/.gitkeep +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/frontend/classic.html +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/frontend/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/frontend/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/frontend/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/frontend/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/frontend/dist/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/frontend/dist/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/frontend/dist/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/frontend/dist/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/frontend/dist/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/frontend/dist/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/frontend/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- {flowforge_map-0.1.0 → flowforge_map-0.2.0}/frontend/dist/favicon.svg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: flowforge-map
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Map what your app depends on (LLMs, MCP servers, APIs), check their health and security, and see what could run faster. Local and free; the scan needs no AI.
|
|
5
5
|
Project-URL: Homepage, https://github.com/Vishwa-Thangapandiyan/flowforge-agent-orchestrator
|
|
6
6
|
Project-URL: Issues, https://github.com/Vishwa-Thangapandiyan/flowforge-agent-orchestrator/issues
|
|
@@ -19,9 +19,11 @@ Classifier: Topic :: Software Development :: Quality Assurance
|
|
|
19
19
|
Classifier: Topic :: System :: Monitoring
|
|
20
20
|
Requires-Python: >=3.12
|
|
21
21
|
Requires-Dist: anthropic>=1.11.0
|
|
22
|
+
Requires-Dist: cryptography>=43
|
|
22
23
|
Requires-Dist: fastapi>=0.115
|
|
23
24
|
Requires-Dist: httpx>=0.27
|
|
24
25
|
Requires-Dist: jsonschema>=4.26.0
|
|
26
|
+
Requires-Dist: keyring>=25
|
|
25
27
|
Requires-Dist: mcp>=1.2
|
|
26
28
|
Requires-Dist: openai>=1.40
|
|
27
29
|
Requires-Dist: pydantic>=2.8
|
|
@@ -53,7 +55,7 @@ What makes it different: **it knows which chain of steps is the bottleneck and s
|
|
|
53
55
|
|
|
54
56
|
## Quick start
|
|
55
57
|
|
|
56
|
-
### Install
|
|
58
|
+
### Install
|
|
57
59
|
|
|
58
60
|
```bash
|
|
59
61
|
uvx flowforge-map --example # try it: a sample app's maps in your browser, no keys, nothing sent
|
|
@@ -111,7 +113,22 @@ FlowForge is also an **MCP server**, so Claude Code, Codex or Antigravity can as
|
|
|
111
113
|
claude mcp add flowforge -- uv --directory /path/to/flowforge run flowforge mcp --repo /path/to/your/app
|
|
112
114
|
```
|
|
113
115
|
|
|
114
|
-
Your agent gets
|
|
116
|
+
Your agent gets seven read-only tools: `get_map`, `get_flow`, `get_advice`, `check_health`, `get_security_findings`, `get_keys` and `what_changed`. Every answer is masked (key values never appear), FlowForge never edits your code, and a check starts an MCP server only after you said yes to it on the Checks page. FlowForge needs no AI of its own for any of this.
|
|
117
|
+
|
|
118
|
+
### Keys your coding agent never sees
|
|
119
|
+
|
|
120
|
+
Your agent only needs a key's **name**: the code it writes reads `os.environ["STRIPE_SECRET_KEY"]`. You give FlowForge the value once, and your app gets it when it starts:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
flowforge key add STRIPE_SECRET_KEY # typed hidden; saved in your OS keychain
|
|
124
|
+
flowforge key list # each key the code reads: saved · in .env · missing
|
|
125
|
+
flowforge key import # move a .env into FlowForge (then delete the .env)
|
|
126
|
+
flowforge run -- python app.py # your app starts with the keys in its environment
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Each command takes `--repo PATH` (default: the folder you're in). On the map, a 🔑 badge marks a block whose key is missing, and the ⚠ list has a box to paste it into. Your agent's `get_keys` tool gets names, statuses and the command to ask you to run, never a value. With no keychain (a server, CI), keys go to an encrypted file in `~/.flowforge`.
|
|
130
|
+
|
|
131
|
+
What this does and doesn't promise: the key is never handed to your agent and isn't left in a file it reads by habit. An agent that can run commands as you could still run `flowforge run -- printenv`, or read your keychain, so it isn't impossible. MCP servers your coding tool starts get their environment from that tool, not from FlowForge.
|
|
115
132
|
|
|
116
133
|
### Send someone the map
|
|
117
134
|
|
|
@@ -238,6 +255,7 @@ FlowForge itself keeps keys out of its runs, events, cache, logs and the dashboa
|
|
|
238
255
|
| `POST /connectors/{id}/test` | One harmless check (MCP and local apps) |
|
|
239
256
|
| `GET /connectors/{id}/activity`, `GET /connectors/{id}/tools` | What an app has done; an MCP server's tools |
|
|
240
257
|
| `GET /security` | The scan's security findings, by level, with the fix and a prompt for each (D31) |
|
|
258
|
+
| `GET /keys`, `PUT /keys/{name}`, `DELETE /keys/{name}` | The keys the code reads and their status; save one (write-only: the value never comes back) or forget it (D36) |
|
|
241
259
|
| `GET /health/tools`, `GET /stats/savings`, `GET /meta`, `GET /presets` | Tool health, money and time, app info, ready-made apps |
|
|
242
260
|
| `GET /workflows`, `GET /workflows/{name}` | The example workflows |
|
|
243
261
|
| `GET /flow?layer=mine\|planner` | The flow map: blocks, lines, positions, critical path, your edits |
|
|
@@ -21,7 +21,7 @@ What makes it different: **it knows which chain of steps is the bottleneck and s
|
|
|
21
21
|
|
|
22
22
|
## Quick start
|
|
23
23
|
|
|
24
|
-
### Install
|
|
24
|
+
### Install
|
|
25
25
|
|
|
26
26
|
```bash
|
|
27
27
|
uvx flowforge-map --example # try it: a sample app's maps in your browser, no keys, nothing sent
|
|
@@ -79,7 +79,22 @@ FlowForge is also an **MCP server**, so Claude Code, Codex or Antigravity can as
|
|
|
79
79
|
claude mcp add flowforge -- uv --directory /path/to/flowforge run flowforge mcp --repo /path/to/your/app
|
|
80
80
|
```
|
|
81
81
|
|
|
82
|
-
Your agent gets
|
|
82
|
+
Your agent gets seven read-only tools: `get_map`, `get_flow`, `get_advice`, `check_health`, `get_security_findings`, `get_keys` and `what_changed`. Every answer is masked (key values never appear), FlowForge never edits your code, and a check starts an MCP server only after you said yes to it on the Checks page. FlowForge needs no AI of its own for any of this.
|
|
83
|
+
|
|
84
|
+
### Keys your coding agent never sees
|
|
85
|
+
|
|
86
|
+
Your agent only needs a key's **name**: the code it writes reads `os.environ["STRIPE_SECRET_KEY"]`. You give FlowForge the value once, and your app gets it when it starts:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
flowforge key add STRIPE_SECRET_KEY # typed hidden; saved in your OS keychain
|
|
90
|
+
flowforge key list # each key the code reads: saved · in .env · missing
|
|
91
|
+
flowforge key import # move a .env into FlowForge (then delete the .env)
|
|
92
|
+
flowforge run -- python app.py # your app starts with the keys in its environment
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Each command takes `--repo PATH` (default: the folder you're in). On the map, a 🔑 badge marks a block whose key is missing, and the ⚠ list has a box to paste it into. Your agent's `get_keys` tool gets names, statuses and the command to ask you to run, never a value. With no keychain (a server, CI), keys go to an encrypted file in `~/.flowforge`.
|
|
96
|
+
|
|
97
|
+
What this does and doesn't promise: the key is never handed to your agent and isn't left in a file it reads by habit. An agent that can run commands as you could still run `flowforge run -- printenv`, or read your keychain, so it isn't impossible. MCP servers your coding tool starts get their environment from that tool, not from FlowForge.
|
|
83
98
|
|
|
84
99
|
### Send someone the map
|
|
85
100
|
|
|
@@ -206,6 +221,7 @@ FlowForge itself keeps keys out of its runs, events, cache, logs and the dashboa
|
|
|
206
221
|
| `POST /connectors/{id}/test` | One harmless check (MCP and local apps) |
|
|
207
222
|
| `GET /connectors/{id}/activity`, `GET /connectors/{id}/tools` | What an app has done; an MCP server's tools |
|
|
208
223
|
| `GET /security` | The scan's security findings, by level, with the fix and a prompt for each (D31) |
|
|
224
|
+
| `GET /keys`, `PUT /keys/{name}`, `DELETE /keys/{name}` | The keys the code reads and their status; save one (write-only: the value never comes back) or forget it (D36) |
|
|
209
225
|
| `GET /health/tools`, `GET /stats/savings`, `GET /meta`, `GET /presets` | Tool health, money and time, app info, ready-made apps |
|
|
210
226
|
| `GET /workflows`, `GET /workflows/{name}` | The example workflows |
|
|
211
227
|
| `GET /flow?layer=mine\|planner` | The flow map: blocks, lines, positions, critical path, your edits |
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""FlowForge as an MCP server (D28, D29):
|
|
1
|
+
"""FlowForge as an MCP server (D28, D29, D36): read-only tools a coding agent can ask about the repo it works in.
|
|
2
2
|
|
|
3
3
|
The map and the flow are read fresh from the repo on every call (plain code, nothing run, no LLM: D20). The
|
|
4
4
|
dashboard's database supplies what only it knows, when it maps the same repo: measured latencies, past checks,
|
|
@@ -28,6 +28,7 @@ from flowforge.flowmap.validate import code_writes, critical
|
|
|
28
28
|
from flowforge.nodes.check_node import CheckNode
|
|
29
29
|
from flowforge.scan import ScanResult, scan
|
|
30
30
|
from flowforge.scheduler.executor import StepState, run_workflow
|
|
31
|
+
from flowforge.security.keystore import KeyStore, report
|
|
31
32
|
from flowforge.security.logfilter import Redactor, redact_code, secret_values_for
|
|
32
33
|
from flowforge.storage import Storage, now_iso
|
|
33
34
|
|
|
@@ -160,6 +161,10 @@ def headline(tool: str, data: dict[str, Any]) -> str | None:
|
|
|
160
161
|
if tool == "get_security_findings":
|
|
161
162
|
counts = data["summary"]
|
|
162
163
|
return " · ".join(f"{counts[k]} {k}" for k in ("high", "medium", "low") if counts.get(k)) or "Nothing found"
|
|
164
|
+
if tool == "get_keys":
|
|
165
|
+
counts = data["summary"]
|
|
166
|
+
parts = [f"{counts[k]} {w}" for k, w in (("missing", "missing"), ("env_file", "in .env")) if counts.get(k)]
|
|
167
|
+
return " · ".join(parts) or ("All saved" if data["keys"] else "No keys read")
|
|
163
168
|
if tool == "what_changed":
|
|
164
169
|
if not data["saved"]:
|
|
165
170
|
return "No saved map yet"
|
|
@@ -181,6 +186,15 @@ def _security_data(result: ScanResult) -> dict[str, Any]:
|
|
|
181
186
|
"note": "Catches known key formats and these patterns, not everything. Values are never shown."}
|
|
182
187
|
|
|
183
188
|
|
|
189
|
+
def _keys_data(root: Path, result: ScanResult) -> dict[str, Any]:
|
|
190
|
+
"""Names and statuses only: this server, which the agent starts, never reads a value (D36)."""
|
|
191
|
+
found = report(KeyStore(root), result, verify=False)
|
|
192
|
+
return {"keys": found, "summary": dict(Counter(k["status"] for k in found if k["status"] != "saved")),
|
|
193
|
+
"run": "flowforge run -- <the command that starts the app>",
|
|
194
|
+
"note": "saved means FlowForge has it on record; this server never reads a value, so the user's "
|
|
195
|
+
"`flowforge key list` is what confirms it can be read"}
|
|
196
|
+
|
|
197
|
+
|
|
184
198
|
def _flow_data(result: Any) -> dict[str, Any]:
|
|
185
199
|
if result.flow is None:
|
|
186
200
|
return {"available": False, "message": result.message}
|
|
@@ -218,7 +232,8 @@ def preview(repo: Path, storage: Storage) -> dict[str, Any]:
|
|
|
218
232
|
tone = "bad" if "down" in checked else "warn" if "warn" in checked else "ok" if checked else "plain"
|
|
219
233
|
read = r.flow()
|
|
220
234
|
flow, advice = _flow_data(read), _advice_data(read)
|
|
221
|
-
|
|
235
|
+
scanned = read.scan or r.scan()
|
|
236
|
+
security, keys = _security_data(scanned), _keys_data(r.root, scanned)
|
|
222
237
|
worst = next((lv for lv in ("high", "medium", "low") if security["summary"].get(lv)), None)
|
|
223
238
|
cards = [
|
|
224
239
|
{"tool": "get_map", "headline": headline("get_map", deps), "sub": kinds, "tone": "plain"},
|
|
@@ -228,6 +243,8 @@ def preview(repo: Path, storage: Storage) -> dict[str, Any]:
|
|
|
228
243
|
{"tool": "get_security_findings", "headline": headline("get_security_findings", security),
|
|
229
244
|
"sub": "keys in code, plain http, risky MCP servers",
|
|
230
245
|
"tone": {"high": "bad", "medium": "warn"}.get(worst or "", "ok")},
|
|
246
|
+
{"tool": "get_keys", "headline": headline("get_keys", keys),
|
|
247
|
+
"sub": "names only: the user types each key, hidden", "tone": "warn" if keys["summary"] else "ok"},
|
|
231
248
|
{"tool": "get_flow", "headline": headline("get_flow", flow) if flow["available"] else None,
|
|
232
249
|
"sub": "as written → could be" if flow["available"] else flow["message"], "tone": "plain"},
|
|
233
250
|
{"tool": "get_advice", "headline": (_plural(len(advice["advice"]), "way") + " to be faster")
|
|
@@ -312,6 +329,14 @@ def build_server(repo: Path, storage: Storage, *, transport: httpx.AsyncBaseTran
|
|
|
312
329
|
shown. Catches known key formats and these patterns, not everything."""
|
|
313
330
|
return r.answer("get_security_findings", _security_data(r.scan()))
|
|
314
331
|
|
|
332
|
+
@server.tool(annotations=READ)
|
|
333
|
+
def get_keys() -> dict[str, Any]:
|
|
334
|
+
"""The keys the repo's code reads from its environment (names that look like keys), each with where it is
|
|
335
|
+
read, the dependencies that use it, and its status: saved in FlowForge, only in .env, or missing. You never
|
|
336
|
+
get a value and never need one: write code that reads the name from the environment, then ask the user to
|
|
337
|
+
run the `command` given (they type the key, hidden). Their app gets the keys with `flowforge run -- <app>`."""
|
|
338
|
+
return r.answer("get_keys", _keys_data(r.root, r.scan()))
|
|
339
|
+
|
|
315
340
|
@server.tool(annotations=READ)
|
|
316
341
|
def what_changed() -> dict[str, Any]:
|
|
317
342
|
"""After an edit: the dependencies added or removed since the map saved in the dashboard, and the flow's
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Keys the coding agent never sees (D36): their statuses for the map, and the dashboard's write-only box.
|
|
2
|
+
|
|
3
|
+
GET /keys {repo, example, summary: {missing, env_file}, keys: [{name, status, ref, deps, command}]}
|
|
4
|
+
PUT /keys/{name} {"value": "..."} → {name, status: "saved"}; the value is never sent back
|
|
5
|
+
DELETE /keys/{name} → {name, removed}
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from collections import Counter
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
from urllib.parse import urlsplit
|
|
15
|
+
|
|
16
|
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
17
|
+
from pydantic import BaseModel, ConfigDict
|
|
18
|
+
|
|
19
|
+
from flowforge.api.scan import repo_path
|
|
20
|
+
from flowforge.scan import ScanResult
|
|
21
|
+
from flowforge.scan import scan as scan_repo
|
|
22
|
+
from flowforge.security.keystore import BadKey, KeyStore, report
|
|
23
|
+
from flowforge.state import AppState, app_state
|
|
24
|
+
|
|
25
|
+
router = APIRouter()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class KeyValue(BaseModel):
|
|
29
|
+
model_config = ConfigDict(extra="forbid")
|
|
30
|
+
value: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def learn_saved_keys(s: AppState, repo: Path | None) -> None:
|
|
34
|
+
"""The redactor masks every value saved for this folder from now on, wherever it would show (D36)."""
|
|
35
|
+
if repo is None or s.example:
|
|
36
|
+
return
|
|
37
|
+
try:
|
|
38
|
+
s.redactor.learn(KeyStore(repo).env().values())
|
|
39
|
+
except Exception: # a keychain that can't be read must not stop FlowForge
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _scan(s: AppState, repo: Path) -> ScanResult:
|
|
44
|
+
"""The map's stored scan, so a re-scan updates the keys; the folder read again if none is stored."""
|
|
45
|
+
analysis = s.flows["deps"].analysis() if "deps" in s.flows else None
|
|
46
|
+
stored = (analysis or {}).get("scan")
|
|
47
|
+
if stored:
|
|
48
|
+
try:
|
|
49
|
+
return ScanResult.model_validate(stored)
|
|
50
|
+
except ValueError:
|
|
51
|
+
pass
|
|
52
|
+
return scan_repo(repo)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
_LOOPBACK = {"localhost", "127.0.0.1", "::1"}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _this_machine(name: str | None) -> bool:
|
|
59
|
+
chosen = os.getenv("FLOWFORGE_HOST", "")
|
|
60
|
+
return bool(name) and (name in _LOOPBACK or (name == chosen and chosen not in ("0.0.0.0", "::")))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def from_this_machine(request: Request) -> None:
|
|
64
|
+
"""Saving or forgetting a key needs the dashboard's own address (D36). A web page can't do it: through DNS
|
|
65
|
+
rebinding its Host is its own name, and from another site its Origin is."""
|
|
66
|
+
host = urlsplit(f"//{request.headers.get('host', '')}").hostname
|
|
67
|
+
origin = request.headers.get("origin")
|
|
68
|
+
if not _this_machine(host) or (origin is not None and not _this_machine(urlsplit(origin).hostname)):
|
|
69
|
+
raise HTTPException(403, "Keys can only be saved from the dashboard on this machine.")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _writable(s: AppState) -> Path:
|
|
73
|
+
repo = repo_path(s)
|
|
74
|
+
if s.example:
|
|
75
|
+
raise HTTPException(409, "Example data: keys aren't saved. Map your own folder to add keys.")
|
|
76
|
+
if repo is None:
|
|
77
|
+
raise HTTPException(409, "Map a folder first: keys belong to one folder.")
|
|
78
|
+
return repo
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@router.get("/keys")
|
|
82
|
+
def keys() -> dict[str, Any]:
|
|
83
|
+
s = app_state()
|
|
84
|
+
repo = repo_path(s)
|
|
85
|
+
if repo is None or not repo.is_dir():
|
|
86
|
+
return {"repo": None, "example": s.example, "summary": {}, "keys": []}
|
|
87
|
+
found = report(KeyStore(repo), _scan(s, repo))
|
|
88
|
+
summary = Counter(k["status"] for k in found if k["status"] != "saved")
|
|
89
|
+
return {"repo": repo.name, "example": s.example, "summary": dict(summary), "keys": found}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@router.put("/keys/{name}", dependencies=[Depends(from_this_machine)])
|
|
93
|
+
def save_key(name: str, body: KeyValue) -> dict[str, Any]:
|
|
94
|
+
s = app_state()
|
|
95
|
+
repo = _writable(s)
|
|
96
|
+
try:
|
|
97
|
+
KeyStore(repo).add(name, body.value)
|
|
98
|
+
except BadKey as e:
|
|
99
|
+
raise HTTPException(400, str(e)) from None
|
|
100
|
+
s.redactor.learn([body.value.strip()])
|
|
101
|
+
return {"name": name, "status": "saved"}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@router.delete("/keys/{name}", dependencies=[Depends(from_this_machine)])
|
|
105
|
+
def remove_key(name: str) -> dict[str, Any]:
|
|
106
|
+
repo = _writable(app_state())
|
|
107
|
+
return {"name": name, "removed": KeyStore(repo).remove(name)}
|
|
@@ -110,6 +110,9 @@ def post_scan(body: ScanRequest) -> dict[str, Any]:
|
|
|
110
110
|
if not path.is_dir():
|
|
111
111
|
raise HTTPException(422, "that isn't a folder on this machine")
|
|
112
112
|
path = path.resolve()
|
|
113
|
+
from flowforge.api.keys import learn_saved_keys # here: keys imports this module
|
|
114
|
+
|
|
115
|
+
learn_saved_keys(s, path) # before the map is stored, so a saved key's value is masked in it (D36)
|
|
113
116
|
try:
|
|
114
117
|
choose_repo(s.storage, s.flows, path, body.replace)
|
|
115
118
|
except RepoChangeError as err:
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
uv run flowforge scan PATH print the folder's dependencies as JSON; nothing stored, nothing sent
|
|
7
7
|
uv run flowforge mcp [--repo P] an MCP server for your coding agent, over stdio (D29); P defaults to here
|
|
8
8
|
uv run flowforge serve --reload (used by `npm run dev`; `serve` is optional)
|
|
9
|
+
uv run flowforge key add NAME save a key for this folder, typed hidden (D36); also: list, remove, import
|
|
10
|
+
uv run flowforge run -- CMD ... start your app with the saved keys in its environment (D36)
|
|
9
11
|
"""
|
|
10
12
|
|
|
11
13
|
from __future__ import annotations
|
|
@@ -41,6 +43,14 @@ def parse(argv: list[str] | None) -> argparse.Namespace:
|
|
|
41
43
|
|
|
42
44
|
|
|
43
45
|
def main(argv: list[str] | None = None) -> None:
|
|
46
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
47
|
+
if argv[:1] == ["key"]:
|
|
48
|
+
keys_command(argv[1:])
|
|
49
|
+
return
|
|
50
|
+
if argv[:1] == ["run"]:
|
|
51
|
+
run_with_keys(argv[1:])
|
|
52
|
+
return
|
|
53
|
+
|
|
44
54
|
import uvicorn
|
|
45
55
|
|
|
46
56
|
args = parse(argv)
|
|
@@ -104,5 +114,97 @@ def scan_folder(path: str | None) -> None:
|
|
|
104
114
|
print(json.dumps(scan(Path(path).expanduser()).model_dump(mode="json"), indent=2))
|
|
105
115
|
|
|
106
116
|
|
|
117
|
+
_SHOWN = {"saved": "saved", "unreadable": "can't read", "env_file": "in .env", "missing": "missing"}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def keys_command(argv: list[str]) -> None:
|
|
121
|
+
"""`flowforge key add|list|remove|import`: keys for one folder, which only `flowforge run` hands out (D36).
|
|
122
|
+
Values are never printed."""
|
|
123
|
+
from flowforge.security.keystore import NAME, BadKey, KeyStore, report
|
|
124
|
+
|
|
125
|
+
parser = argparse.ArgumentParser(prog="flowforge key", description="Keep your app's keys out of files and away "
|
|
126
|
+
"from your coding agent. Values are never shown.")
|
|
127
|
+
parser.add_argument("action", choices=["add", "list", "remove", "import"])
|
|
128
|
+
parser.add_argument("name", nargs="?", help="add/remove: the env var name, e.g. STRIPE_SECRET_KEY; "
|
|
129
|
+
"import: the file (default .env in the folder)")
|
|
130
|
+
parser.add_argument("--repo", default=".", help="the app's folder (default: here)")
|
|
131
|
+
args = parser.parse_args(argv)
|
|
132
|
+
repo = Path(args.repo).expanduser()
|
|
133
|
+
if not repo.is_dir():
|
|
134
|
+
parser.error(f"{args.repo} is not a folder")
|
|
135
|
+
store = KeyStore(repo)
|
|
136
|
+
if args.action in ("add", "remove") and not (args.name and NAME.fullmatch(args.name)):
|
|
137
|
+
parser.error("name the key as an env var name, e.g. STRIPE_SECRET_KEY")
|
|
138
|
+
|
|
139
|
+
if args.action == "add":
|
|
140
|
+
value = _read_value(args.name)
|
|
141
|
+
try:
|
|
142
|
+
where = store.add(args.name, value)
|
|
143
|
+
except BadKey as e:
|
|
144
|
+
parser.error(str(e))
|
|
145
|
+
print(f"Saved {args.name} in {'your keychain' if where == 'keychain' else 'FlowForge (encrypted file)'}.")
|
|
146
|
+
print(" Your app gets it with: flowforge run -- <the command that starts it>")
|
|
147
|
+
elif args.action == "remove":
|
|
148
|
+
print(f"Removed {args.name}." if store.remove(args.name) else f"{args.name} wasn't saved.")
|
|
149
|
+
elif args.action == "import":
|
|
150
|
+
source = Path(args.name).expanduser() if args.name else repo / ".env"
|
|
151
|
+
if not source.is_file():
|
|
152
|
+
parser.error(f"{source} isn't a file")
|
|
153
|
+
names = store.import_env(source)
|
|
154
|
+
print(f"Saved {len(names)} from {source.name}: {', '.join(names) or 'nothing to save'}.")
|
|
155
|
+
if names:
|
|
156
|
+
print(f" Delete {source.name} once your app runs with `flowforge run -- ...`: a coding agent can read it.")
|
|
157
|
+
else:
|
|
158
|
+
from flowforge.scan import scan
|
|
159
|
+
|
|
160
|
+
found = report(store, scan(repo))
|
|
161
|
+
if not found:
|
|
162
|
+
print("No keys: the code reads none, and none are saved.")
|
|
163
|
+
width = max((len(k["name"]) for k in found), default=0)
|
|
164
|
+
for k in found:
|
|
165
|
+
print(f"{k['name']:<{width}} {_SHOWN[k['status']]:<10} {k['ref'] or ''}")
|
|
166
|
+
todo = [k for k in found if k["command"]]
|
|
167
|
+
if todo:
|
|
168
|
+
print()
|
|
169
|
+
for k in todo:
|
|
170
|
+
print(f" {k['command']}")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _read_value(name: str) -> str:
|
|
174
|
+
"""The key, typed hidden; or one line from a pipe (`pass show x | flowforge key add X`). Never echoed."""
|
|
175
|
+
import getpass
|
|
176
|
+
|
|
177
|
+
if sys.stdin.isatty():
|
|
178
|
+
return getpass.getpass(f"{name} (typed hidden): ")
|
|
179
|
+
return sys.stdin.readline().rstrip("\r\n")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def run_with_keys(argv: list[str]) -> None:
|
|
183
|
+
"""`flowforge run [--repo P] -- CMD ...`: CMD starts with this folder's saved keys added to its environment.
|
|
184
|
+
The command is a list (never a shell), and its exit code is FlowForge's (D36)."""
|
|
185
|
+
import subprocess
|
|
186
|
+
|
|
187
|
+
from flowforge.security.keystore import KeyStore
|
|
188
|
+
|
|
189
|
+
parser = argparse.ArgumentParser(prog="flowforge run", description="Start your app with its saved keys.")
|
|
190
|
+
parser.add_argument("--repo", default=".", help="the folder whose keys to use (default: here)")
|
|
191
|
+
parser.add_argument("command", nargs=argparse.REMAINDER, help="-- then the command that starts your app")
|
|
192
|
+
args = parser.parse_args(argv)
|
|
193
|
+
command = args.command[1:] if args.command[:1] == ["--"] else args.command
|
|
194
|
+
if not command:
|
|
195
|
+
parser.error("give the command after --, e.g. flowforge run -- python app.py")
|
|
196
|
+
if not Path(args.repo).expanduser().is_dir():
|
|
197
|
+
parser.error(f"{args.repo} is not a folder")
|
|
198
|
+
env = {**os.environ, **KeyStore(Path(args.repo)).env()}
|
|
199
|
+
try:
|
|
200
|
+
code = subprocess.call(command, env=env)
|
|
201
|
+
except FileNotFoundError:
|
|
202
|
+
print(f"flowforge run: {command[0]} not found", file=sys.stderr)
|
|
203
|
+
code = 127
|
|
204
|
+
except KeyboardInterrupt:
|
|
205
|
+
code = 130
|
|
206
|
+
raise SystemExit(code)
|
|
207
|
+
|
|
208
|
+
|
|
107
209
|
if __name__ == "__main__":
|
|
108
210
|
main()
|
|
@@ -141,5 +141,30 @@ def apply_choices(old: Flow, new: Flow, changes: list[dict[str, Any]],
|
|
|
141
141
|
|
|
142
142
|
kept_edges = [e for e in edges if e.source in nodes and e.target in nodes]
|
|
143
143
|
suggestions = [s for s in new.suggestions if s.node in nodes]
|
|
144
|
-
|
|
145
|
-
|
|
144
|
+
# the new map as it is (its source, reader, advice, times and actions: D25, D33, D35), with the blocks and lines
|
|
145
|
+
# you chose; advice about a block you declined goes with it
|
|
146
|
+
advice = [a for a in new.advice if all(i in nodes for i in a.nodes)]
|
|
147
|
+
return new.model_copy(deep=True, update={
|
|
148
|
+
"nodes": [nodes[i] for i in order if i in nodes], "edges": kept_edges, "suggestions": suggestions,
|
|
149
|
+
"advice": advice, "actions": _actions_kept(old, new, set(nodes))}), drop_edits
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _actions_kept(old: Flow, new: Flow, kept: set[str]) -> list:
|
|
153
|
+
"""Each action's call groups (D33) for the blocks you kept: a call you kept against the re-scan comes back
|
|
154
|
+
into its action's "as written", in the group it had; a call you declined leaves it."""
|
|
155
|
+
restored = kept - {n.id for n in new.nodes}
|
|
156
|
+
old_parts = {a.id: a.parts for a in old.actions}
|
|
157
|
+
out = []
|
|
158
|
+
for action in new.actions:
|
|
159
|
+
parts = [[i for i in p if i in kept] for p in action.parts]
|
|
160
|
+
have = {i for p in parts for i in p}
|
|
161
|
+
for p in old_parts.get(action.id, []):
|
|
162
|
+
if extra := [i for i in p if i in restored and i not in have]:
|
|
163
|
+
# back into the group it ran with, if one of its partners is still there; else on its own
|
|
164
|
+
group = next((g for g in parts if any(i in g for i in p if i not in restored)), None)
|
|
165
|
+
if group is None:
|
|
166
|
+
parts.append(extra)
|
|
167
|
+
else:
|
|
168
|
+
group += extra
|
|
169
|
+
out.append(action.model_copy(update={"parts": [p for p in parts if p]}))
|
|
170
|
+
return out
|
|
@@ -153,6 +153,7 @@ class Flow(_Model):
|
|
|
153
153
|
written_ms: float | None = None # code flows: every call one after another, as the code is written (D25)
|
|
154
154
|
patterns: list[str] = Field(default_factory=list) # code flows: languages read by patterns, less exact (D32)
|
|
155
155
|
actions: list[Action] = Field(default_factory=list) # code flows: one per entry point, slowest first (D33)
|
|
156
|
+
reader: int = 0 # the reader that made it (flowmap.planner.READER); an older one is read again on start (D35)
|
|
156
157
|
|
|
157
158
|
@model_validator(mode="after")
|
|
158
159
|
def _structure(self) -> Flow:
|
|
@@ -56,9 +56,16 @@ class FixturePlanner:
|
|
|
56
56
|
APP_ID, TOOLS_ID = "app", "tools"
|
|
57
57
|
|
|
58
58
|
|
|
59
|
+
# The version of what the scan and the flow reader put on a map. Raise it when a change to either changes a map:
|
|
60
|
+
# maps saved by an older reader are read again on start (D35).
|
|
61
|
+
READER = 1
|
|
62
|
+
|
|
63
|
+
|
|
59
64
|
class ScanPlanner:
|
|
60
65
|
"""A real repo (D21): scan the folder, draw one block per dependency off the app (or your coding tools)."""
|
|
61
66
|
|
|
67
|
+
reader = READER
|
|
68
|
+
|
|
62
69
|
def __init__(self, root: str | Path):
|
|
63
70
|
self.root = Path(root)
|
|
64
71
|
|
|
@@ -95,7 +102,7 @@ def draft_from_scan(result: ScanResult) -> Draft:
|
|
|
95
102
|
files=sorted({e.ref.rsplit(":", 1)[0] for d in deps for e in d.evidence})[:200],
|
|
96
103
|
scan=result.model_dump(mode="json"))
|
|
97
104
|
facts = [(d.id, d.kind, d.target, d.status, [e.ref for e in d.evidence]) for d in deps]
|
|
98
|
-
return Draft(flow=Flow(kind="dependencies", nodes=nodes, edges=edges), trace=None, analysis=analysis,
|
|
105
|
+
return Draft(flow=Flow(kind="dependencies", nodes=nodes, edges=edges, reader=READER), trace=None, analysis=analysis,
|
|
99
106
|
fact_sheet_hash=fact_hash(facts))
|
|
100
107
|
|
|
101
108
|
|
|
@@ -103,6 +110,8 @@ class CodeFlowPlanner:
|
|
|
103
110
|
"""A real repo's flow (D25): read its Python, draw the order its entry points call their dependencies, weighted
|
|
104
111
|
by the latest checks (D24). `latencies` is asked each time, so a re-scan picks up newer checks."""
|
|
105
112
|
|
|
113
|
+
reader = READER
|
|
114
|
+
|
|
106
115
|
def __init__(self, root: str | Path, latencies: Callable[[], dict[str, float]] = dict):
|
|
107
116
|
self.root = Path(root)
|
|
108
117
|
self.latencies = latencies
|
|
@@ -114,11 +123,12 @@ class CodeFlowPlanner:
|
|
|
114
123
|
if result.flow is None:
|
|
115
124
|
raise NoFlow(result.message or "No flow to draw.")
|
|
116
125
|
flow = result.flow
|
|
126
|
+
flow.reader = READER
|
|
117
127
|
steps = [n for n in flow.nodes if n.kind in CALL_KINDS]
|
|
118
128
|
measured = sum(n.estimate_source == "measured" for n in steps)
|
|
119
129
|
analysis = Analysis(
|
|
120
130
|
stages=[
|
|
121
|
-
AnalysisStage(title="Read your
|
|
131
|
+
AnalysisStage(title="Read your code", done=f"{len(result.entries)} entry point"
|
|
122
132
|
f"{'s' if len(result.entries) != 1 else ''}, nothing run or sent"),
|
|
123
133
|
AnalysisStage(title="Followed the calls", done=f"{len(steps)} calls, "
|
|
124
134
|
f"{sum(n.kind == 'decision' for n in flow.nodes)} decisions"),
|
|
@@ -222,9 +222,17 @@ class FlowService:
|
|
|
222
222
|
return self.first_map()
|
|
223
223
|
|
|
224
224
|
def first_map(self) -> int | None:
|
|
225
|
-
"""Draw the first map if there is none
|
|
226
|
-
|
|
225
|
+
"""Draw the first map if there is none, or read the folder again when the saved map was made by an older
|
|
226
|
+
reader (D35). Returns the new version, or None when nothing was drawn."""
|
|
227
|
+
if self.planner is None:
|
|
227
228
|
return None
|
|
229
|
+
if self.store.count_flow_versions():
|
|
230
|
+
latest = self.store.latest_flow_version("accepted")
|
|
231
|
+
if latest is None or (latest["flow"] or {}).get("reader", 0) >= getattr(self.planner, "reader", 0):
|
|
232
|
+
return None
|
|
233
|
+
pending = self.store.latest_flow_version("pending")
|
|
234
|
+
if pending is not None: # a re-scan made by the older reader: read again below, so it is out of date
|
|
235
|
+
self.store.update_flow_version(pending["version"], "discarded")
|
|
228
236
|
try:
|
|
229
237
|
draft = self.planner.analyze(0)
|
|
230
238
|
except NoFlow as why:
|
|
@@ -44,6 +44,20 @@ def install_kind() -> str:
|
|
|
44
44
|
return "uvx" if "archive-v" in Path(sys.prefix).as_posix() else "pip"
|
|
45
45
|
|
|
46
46
|
|
|
47
|
+
def command_line(*args: str) -> str:
|
|
48
|
+
"""A `flowforge …` command for a person to type, matching how FlowForge was installed (D34, D36)."""
|
|
49
|
+
import shlex
|
|
50
|
+
|
|
51
|
+
kind = install_kind()
|
|
52
|
+
if kind == "checkout":
|
|
53
|
+
prefix = ["uv", "--directory", str(CHECKOUT), "run", "flowforge"]
|
|
54
|
+
elif kind == "uvx":
|
|
55
|
+
prefix = ["uvx", DIST_NAME]
|
|
56
|
+
else:
|
|
57
|
+
prefix = ["flowforge"]
|
|
58
|
+
return shlex.join([*prefix, *args])
|
|
59
|
+
|
|
60
|
+
|
|
47
61
|
def flowforge_home() -> Path:
|
|
48
62
|
return Path(os.getenv("FLOWFORGE_HOME") or Path.home() / ".flowforge")
|
|
49
63
|
|
|
@@ -12,6 +12,7 @@ import json
|
|
|
12
12
|
import os
|
|
13
13
|
from collections.abc import AsyncIterator
|
|
14
14
|
from contextlib import asynccontextmanager
|
|
15
|
+
from pathlib import Path
|
|
15
16
|
from typing import Any
|
|
16
17
|
|
|
17
18
|
from dotenv import load_dotenv
|
|
@@ -23,6 +24,7 @@ from flowforge.api import agent as agent_api
|
|
|
23
24
|
from flowforge.api import checks as checks_api
|
|
24
25
|
from flowforge.api import connectors as connectors_api
|
|
25
26
|
from flowforge.api import flowmap as flowmap_api
|
|
27
|
+
from flowforge.api import keys as keys_api
|
|
26
28
|
from flowforge.api import runs as runs_api
|
|
27
29
|
from flowforge.api import scan as scan_api
|
|
28
30
|
from flowforge.api import security as security_api
|
|
@@ -85,6 +87,8 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
|
|
85
87
|
s.flows["deps"].planner = ScanPlanner(scan_api.EXAMPLE_REPO)
|
|
86
88
|
s.flows["flow"].planner = CodeFlowPlanner(scan_api.EXAMPLE_REPO, lambda: example_data.latencies(storage))
|
|
87
89
|
else: # your folder: from --repo or the last one chosen, mapped now if it has no maps yet
|
|
90
|
+
keys_api.learn_saved_keys(s, Path(os.environ["FLOWFORGE_REPO"]) if os.getenv("FLOWFORGE_REPO")
|
|
91
|
+
else scan_api.repo_path(s))
|
|
88
92
|
scan_api.startup_repo(storage, s.flows)
|
|
89
93
|
if example: # offline nodes and a seeded history, never mixed with real data (D16)
|
|
90
94
|
example_data.install(s)
|
|
@@ -114,6 +118,7 @@ app.include_router(scan_api.router)
|
|
|
114
118
|
app.include_router(checks_api.router)
|
|
115
119
|
app.include_router(agent_api.router)
|
|
116
120
|
app.include_router(security_api.router)
|
|
121
|
+
app.include_router(keys_api.router)
|
|
117
122
|
|
|
118
123
|
|
|
119
124
|
@app.get("/workflows")
|