foxygit / RPA-Remisser Log in
commits tags

/rpa/flows.py · 3.85 KB

raw
"""Flödespersistens - läsa/skriva/lista/ta bort de JSON-flöden som ligger i
data/flows/. Utbrutet ur webui så att både webui:t och MCP-servern
(rpa/mcp_server.py) delar exakt samma regler (namnvalidering, schema-validering
vid spara).

Flöden lagras som nod/kant-grafen ({format:"graph", nodes, edges, start, ...}).
Gamla {steps}-flöden läses fortfarande - de migreras vid inläsning och skrivs
om till grafformat vid nästa sparning.
"""

from __future__ import annotations

import json
import re
from pathlib import Path

from . import config
from .migrate import flow_to_graph
from .schema import validate_graph

NAME_RE = re.compile(r"^[a-zA-Z0-9_\-åäöÅÄÖ ]{1,80}$")
_SLUG_RE = re.compile(r"[^a-z0-9]+")


def _slug(text: str) -> str:
    s = _SLUG_RE.sub("-", str(text).strip().lower()).strip("-")
    return s or "sub"


class InvalidFlowName(ValueError):
    pass


class FlowNotFound(FileNotFoundError):
    pass


class InvalidFlow(ValueError):
    """Flödet strider mot schemat (rpa/schema.py:validate_graph)."""

    def __init__(self, errors: list[str]):
        self.errors = errors
        super().__init__("Ogiltigt flöde:\n- " + "\n- ".join(errors))


def flow_path(name: str) -> Path:
    if not NAME_RE.match(name or ""):
        raise InvalidFlowName(f"Ogiltigt flödesnamn: {name!r}")
    return config.FLOWS_DIR / f"{name}.json"


def list_flows() -> list[str]:
    return sorted(p.stem for p in config.FLOWS_DIR.glob("*.json"))


def _normalize(flow: dict) -> dict:
    """Alltid grafform, med alla nycklar defaultade."""
    flow = flow_to_graph(flow)  # migrerar {steps} vid behov, no-op annars
    flow.setdefault("format", "graph")
    flow.setdefault("nodes", [])
    flow.setdefault("edges", [])
    flow.setdefault("start", None)
    flow.setdefault("settings", {})
    flow.setdefault("layout", {})
    flow.setdefault("inputs", [])
    flow.setdefault("input_sets", {})
    flow["subflows"] = [_normalize_subflow(sf) for sf in flow.get("subflows") or [] if isinstance(sf, dict)]
    return flow


def _normalize_subflow(sf: dict) -> dict:
    name = str(sf.get("name") or sf.get("id") or "Subflöde")
    return {
        "id": str(sf.get("id") or _slug(name)),
        "name": name,
        "nodes": sf.get("nodes", []),
        "edges": sf.get("edges", []),
        "start": sf.get("start"),
        "inputs": sf.get("inputs", []),
    }


def load_flow(name: str) -> dict:
    path = flow_path(name)
    if not path.exists():
        raise FlowNotFound(f"Flödet finns inte: {name}")
    return _normalize(json.loads(path.read_text(encoding="utf-8")))


def save_flow(name: str, flow: dict, *, strict: bool = False) -> None:
    """Sparar ett flöde (grafform). Kastar InvalidFlow om det strider mot schemat.

    strict=False (default) tillåter halvfärdiga flöden med tomma obligatoriska
    fält - webui:t autosparar under redigering. MCP-servern använder strict=True."""
    path = flow_path(name)
    g = _normalize(flow)

    errors = validate_graph(g, strict=strict)
    if errors:
        raise InvalidFlow(errors)

    inputs = g.get("inputs")
    if isinstance(inputs, list) and inputs:
        bad = [i for i, x in enumerate(inputs) if not isinstance(x, dict) or not str(x.get("name", "")).strip()]
        if bad:
            raise InvalidFlow([f"inputs[{i}]: varje indata måste ha ett 'name'." for i in bad])

    out: dict = {
        "format": "graph",
        "nodes": g["nodes"],
        "edges": g["edges"],
        "settings": g["settings"],
    }
    if g.get("start"):
        out["start"] = g["start"]
    for key in ("inputs", "input_sets", "layout", "subflows"):
        val = g.get(key)
        if val:
            out[key] = val

    path.write_text(json.dumps(out, ensure_ascii=False, indent=2), encoding="utf-8")


def delete_flow(name: str) -> bool:
    path = flow_path(name)
    if path.exists():
        path.unlink()
        return True
    return False