foxygit / RPA-Remisser Log in
commit 7d834ac98406d1f6ba7578556b239ed8a24125fb
Author:     Jens Kristoffersson <jenkr@vgregion.se>
AuthorDate: Tue Sep 8 15:25:02 2026 +0200
Commit:     Jens Kristoffersson <jenkr@vgregion.se>
CommitDate: Tue Sep 8 15:25:02 2026 +0200

    Subfloden (fas 2): MCP, export, assist och webui-app

    - rpa/mcp_server.py: save_flow/validate_flow tar 'subflows'. _as_graph baddar in
      dem. Server-instruktionen namner call_flow + subflows.
    - rpa/export.py: exporterade scriptet baddar in SUBFLOWS och skickar dem till
      run_flow.
    - rpa/assist.py: befintligt flode + emit_flow-svaret behaller 'subflows'
      (systemprompten dokumenterar dem redan via step_reference_markdown).
    - webui/app.py: Flow-modellen far 'subflows', RunOptions far 'subflow'
      (-> entry_subflow), assist-endpointen skickar med subflows.
    - Tester: assist behaller subflows, exporterat script baddar in SUBFLOWS. 108 grona.
---
 rpa/assist.py        |  3 +++
 rpa/export.py        |  5 ++++-
 rpa/mcp_server.py    | 28 ++++++++++++++++++----------
 tests/test_assist.py | 17 +++++++++++++++++
 tests/test_export.py | 23 +++++++++++++++++++++++
 webui/app.py         |  5 +++++
 6 files changed, 70 insertions(+), 11 deletions(-)

diff --git a/rpa/assist.py b/rpa/assist.py
index 70528f9..62ca054 100644
--- a/rpa/assist.py
+++ b/rpa/assist.py
@@ -114,6 +114,7 @@ def generate_flow(instruction: str, current_flow: dict | None = None, *, model:
                     "edges": current_flow.get("edges", []),
                     "start": current_flow.get("start"),
                     "settings": current_flow.get("settings", {}),
+                    "subflows": current_flow.get("subflows", []),
                 },
                 ensure_ascii=False,
                 indent=2,
@@ -145,6 +146,8 @@ def generate_flow(instruction: str, current_flow: dict | None = None, *, model:
         }
         if flow.get("start"):
             normalized["start"] = flow["start"]
+        if flow.get("subflows"):
+            normalized["subflows"] = flow["subflows"]
         errors = schema.validate_graph(normalized, strict=True)
         if not errors:
             normalized["explanation"] = str(flow.get("explanation", "")).strip()
diff --git a/rpa/export.py b/rpa/export.py
index 236dfd4..959b48d 100644
--- a/rpa/export.py
+++ b/rpa/export.py
@@ -35,6 +35,8 @@ EDGES = {edges_json}

 START = {start_json}

+SUBFLOWS = {subflows_json}
+
 SETTINGS = {settings_json}

 INPUTS = {inputs_json}
@@ -79,7 +81,7 @@ def main() -> None:
         settings["headless"] = True

     run_flow(
-        {{"format": "graph", "nodes": NODES, "edges": EDGES, "start": START}},
+        {{"format": "graph", "nodes": NODES, "edges": EDGES, "start": START, "subflows": SUBFLOWS}},
         log=print,
         download_dir=config.DOWNLOAD_DIR,
         close_mode=args.close_mode,
@@ -106,6 +108,7 @@ def generate_standalone_script(name: str, flow: dict) -> str:
         nodes_json=src(g.get("nodes", [])),
         edges_json=src(g.get("edges", [])),
         start_json=src(g.get("start")),
+        subflows_json=src(flow.get("subflows", [])),
         settings_json=src(flow.get("settings", {})),
         inputs_json=src(flow.get("inputs", [])),
         input_sets_json=src(flow.get("input_sets", {})),
diff --git a/rpa/mcp_server.py b/rpa/mcp_server.py
index fd65c34..030a6ce 100644
--- a/rpa/mcp_server.py
+++ b/rpa/mcp_server.py
@@ -35,8 +35,10 @@ mcp = MCPServer(
     instructions=(
         "Bygg och kör webbautomationsflöden. Anropa describe_steps() först för att "
         "se alla nodtyper, fält och handtagsmodellen. Ett flöde är en graf: "
-        "nodes (platta noder) + edges (kanter). Flöden sparas som JSON och körs i "
-        "en synlig webbläsare mot en beständig inloggningssession."
+        "nodes (platta noder) + edges (kanter). En 'call_flow'-nod kör ett annat "
+        "flöde eller en lokal subgraf (flödesnyckeln 'subflows') som ett steg. "
+        "Flöden sparas som JSON och körs i en synlig webbläsare mot en beständig "
+        "inloggningssession."
     ),
 )

@@ -95,11 +97,14 @@ def get_flow(name: str) -> dict:
         return {"error": str(exc)}


-def _as_graph(nodes, edges, start, settings, steps) -> dict:
+def _as_graph(nodes, edges, start, settings, steps, subflows=None) -> dict:
     """Bygg ett flödesdict från antingen nodes/edges eller det gamla steps-formatet."""
     if nodes is not None:
-        return {"format": "graph", "nodes": nodes, "edges": edges or [],
-                "start": start, "settings": settings or {}}
+        g = {"format": "graph", "nodes": nodes, "edges": edges or [],
+             "start": start, "settings": settings or {}}
+        if subflows:
+            g["subflows"] = subflows
+        return g
     return flow_to_graph({"steps": steps or [], "settings": settings or {}})


@@ -111,12 +116,14 @@ def save_flow(
     start: str | None = None,
     settings: dict | None = None,
     steps: list | None = None,
+    subflows: list | None = None,
 ) -> dict:
     """Skapar eller skriver över ett flöde. Ange grafen som 'nodes' + 'edges'
-    (se describe_steps för nodtyper och handtag). Det gamla 'steps'-formatet
-    accepteras också och migreras. Returnerar valideringsfel utan att spara om
-    flödet är ogiltigt."""
-    g = _as_graph(nodes, edges, start, settings, steps)
+    (se describe_steps för nodtyper och handtag). 'subflows' = lokala namngivna
+    subgrafer som call_flow-noder kan anropa (target '#<id>'). Det gamla
+    'steps'-formatet accepteras också och migreras. Returnerar valideringsfel
+    utan att spara om flödet är ogiltigt."""
+    g = _as_graph(nodes, edges, start, settings, steps, subflows)
     errors = schema.validate_graph(g, strict=True)
     if errors:
         return {"ok": False, "errors": errors}
@@ -146,9 +153,10 @@ def validate_flow(
     start: str | None = None,
     settings: dict | None = None,
     steps: list | None = None,
+    subflows: list | None = None,
 ) -> dict:
     """Validerar ett flöde (grafform) utan att spara det."""
-    g = _as_graph(nodes, edges, start, settings, steps)
+    g = _as_graph(nodes, edges, start, settings, steps, subflows)
     errors = schema.validate_graph(g, strict=True)
     return {"valid": not errors, "errors": errors}

diff --git a/tests/test_assist.py b/tests/test_assist.py
index a454071..f100cd9 100644
--- a/tests/test_assist.py
+++ b/tests/test_assist.py
@@ -50,6 +50,23 @@ def test_generate_flow_happy_path(monkeypatch):
     assert result["explanation"] == "Går till example.com."


+def test_generate_flow_keeps_subflows(monkeypatch):
+    _patch(monkeypatch, [{
+        "nodes": [{"id": "n1", "type": "call_flow", "target": "#hjalp"}],
+        "edges": [],
+        "start": "n1",
+        "settings": {},
+        "subflows": [{
+            "id": "hjalp", "name": "Hjälp",
+            "nodes": [{"id": "s1", "type": "wait", "seconds": 1}], "edges": [],
+        }],
+        "explanation": "Anropar en subgraf.",
+    }])
+    result = assist.generate_flow("gör en subgraf och anropa den")
+    assert result["subflows"][0]["id"] == "hjalp"
+    assert result["nodes"][0]["type"] == "call_flow"
+
+
 def test_generate_flow_repairs_once(monkeypatch):
     client = _patch(monkeypatch, [
         {"nodes": [{"id": "n1", "type": "goto"}], "edges": [], "settings": {}},  # url saknas -> ogiltigt
diff --git a/tests/test_export.py b/tests/test_export.py
index 23f611e..52c6536 100644
--- a/tests/test_export.py
+++ b/tests/test_export.py
@@ -12,6 +12,16 @@ FLOW = {
     "input_sets": {"Test": {"org_id": "9"}, "Prod": {"org_id": "1"}},
 }

+GRAPH_FLOW = {
+    "format": "graph",
+    "nodes": [{"id": "n1", "type": "call_flow", "target": "#sub"}],
+    "edges": [],
+    "start": "n1",
+    "settings": {},
+    "subflows": [{"id": "sub", "name": "S",
+                  "nodes": [{"id": "s1", "type": "wait", "seconds": 1}], "edges": []}],
+}
+

 def _script_module(name="flode"):
     src = generate_standalone_script(name, FLOW)
@@ -48,3 +58,16 @@ def test_build_initial_vars_errors():
         mod.build_initial_vars("Saknas", [])
     with pytest.raises(SystemExit):
         mod.build_initial_vars(None, ["utan_likhetstecken"])
+
+
+def test_script_embeds_subflows():
+    src = generate_standalone_script("m", GRAPH_FLOW)
+    ast.parse(src)
+    mod = types.ModuleType("exported")
+    body = "\n".join(
+        line for line in src.splitlines()
+        if not line.startswith(("import argparse", "from rpa", "    main()", 'if __name__'))
+    )
+    exec(compile(body, "<exported>", "exec"), mod.__dict__)  # noqa: S102
+    assert mod.SUBFLOWS[0]["id"] == "sub"
+    assert mod.NODES[0]["type"] == "call_flow"
diff --git a/webui/app.py b/webui/app.py
index 381c2e5..235c8e8 100644
--- a/webui/app.py
+++ b/webui/app.py
@@ -33,11 +33,13 @@ class Flow(BaseModel):
     layout: dict = {}
     inputs: list = []
     input_sets: dict = {}
+    subflows: list | None = None


 class RunOptions(BaseModel):
     close_mode: str = "always"
     initial_vars: dict = {}
+    subflow: str | None = None


 class AssistRequest(BaseModel):
@@ -47,6 +49,7 @@ class AssistRequest(BaseModel):
     start: str | None = None
     steps: list = []
     settings: dict = {}
+    subflows: list = []


 class CaptureRequest(BaseModel):
@@ -255,6 +258,7 @@ def assist_flow(name: str, req: AssistRequest) -> dict:
         "start": req.start,
         "steps": req.steps,
         "settings": req.settings,
+        "subflows": req.subflows,
     })
     try:
         result = assist.generate_flow(req.instruction, current_flow=current)
@@ -279,6 +283,7 @@ def run_flow_endpoint(name: str, options: RunOptions = RunOptions()) -> dict:
         close_mode=close_mode,
         settings=flow.get("settings", {}),
         initial_vars=initial_vars,
+        entry_subflow=options.subflow,
     )
     return {"run_id": run_id}