diff --git a/backend/fluksio/api/routes/dashboards.py b/backend/fluksio/api/routes/dashboards.py index 1058f75..cc2fe54 100644 --- a/backend/fluksio/api/routes/dashboards.py +++ b/backend/fluksio/api/routes/dashboards.py @@ -26,10 +26,6 @@ router = APIRouter( ) -class RenameRequest(BaseModel): - name: str - - class PublishRequest(BaseModel): version: int @@ -101,7 +97,10 @@ async def generate_results_dashboard( if await run_in_threadpool(store.exists, name): raise HTTPException(status_code=409, detail=f"'{name}' already exists") - written = await run_in_threadpool(store.write, results_dashboard(definition)) + try: + written = await run_in_threadpool(store.write, results_dashboard(definition)) + except DashboardExists as exc: + raise HTTPException(status_code=409, detail=str(exc)) await run_in_threadpool(_apply_history_limits, store, controller) event_bus.publish( {"type": "dashboard_changed", "dashboard": name, "ts": time.time()} @@ -123,6 +122,8 @@ async def save_dashboard( """ if body.name != name: raise HTTPException(status_code=422, detail="The name in the body must match") + if body.id and body.id != name: + raise HTTPException(status_code=422, detail="The id in the body must match") try: saved = await run_in_threadpool(store.write_draft, body, body.version) except StaleVersion as exc: @@ -133,6 +134,8 @@ async def save_dashboard( "current_version": exc.current, }, ) + except DashboardExists as exc: + raise HTTPException(status_code=409, detail=str(exc)) return saved @@ -211,17 +214,3 @@ async def duplicate_dashboard(name: str, store: DashboardStoreDep) -> Any: return await run_in_threadpool(store.duplicate, name) except DashboardNotFound: raise HTTPException(status_code=404, detail=f"No dashboard named '{name}'") - - -@router.post("/{name}/rename", response_model=DashboardDef) -async def rename_dashboard( - name: str, body: RenameRequest, store: DashboardStoreDep -) -> Any: - try: - return await run_in_threadpool(store.rename, name, body.name) - except DashboardNotFound: - raise HTTPException(status_code=404, detail=f"No dashboard named '{name}'") - except DashboardExists: - raise HTTPException(status_code=409, detail=f"'{body.name}' already exists") - except ValueError as exc: - raise HTTPException(status_code=422, detail=str(exc)) diff --git a/backend/fluksio/api/routes/flows.py b/backend/fluksio/api/routes/flows.py index 978b2be..de1c831 100644 --- a/backend/fluksio/api/routes/flows.py +++ b/backend/fluksio/api/routes/flows.py @@ -140,10 +140,6 @@ class ValidationResult(BaseModel): issues: list[ValidationIssue] = [] -class RenameRequest(BaseModel): - new_name: str - - class PublishRequest(BaseModel): version: int @@ -338,6 +334,7 @@ def read_flows(controller: FlowControllerDep) -> Any: statuses = controller.node_statuses(name) summaries.append( FlowSummary( + id=definition.id, name=definition.name, title=definition.title, mode=definition.mode, @@ -539,6 +536,10 @@ async def save_flow( raise HTTPException( status_code=400, detail="The flow name in the body must match the URL" ) + if definition.id and definition.id != name: + raise HTTPException( + status_code=400, detail="The flow id in the body must match the URL" + ) duplicates = {n.id for n in definition.nodes} if len(duplicates) != len(definition.nodes): @@ -559,6 +560,8 @@ async def save_flow( status_code=409, detail={"message": str(exc), "current_version": exc.current}, ) + except FlowExists as exc: + raise HTTPException(status_code=409, detail=str(exc)) return await run_in_threadpool(_detail, controller, stored) @@ -641,37 +644,6 @@ async def delete_flow( return Message(message=f"Deleted flow '{name}'") -@router.post("/{name}/rename", response_model=FlowDetail) -async def rename_flow( - name: str, - body: RenameRequest, - controller: FlowControllerDep, -) -> Any: - """Rename a flow, along with every reference to its messages.""" - if not NAME_PATTERN.match(body.new_name): - raise HTTPException( - status_code=400, - detail=( - "Use lowercase letters, digits and underscores, starting with a letter" - ), - ) - try: - renamed = await run_in_threadpool( - controller.store.rename_flow, name, body.new_name - ) - except FlowNotFound: - raise HTTPException(status_code=404, detail=f"No flow named '{name}'") - except FlowExists as exc: - raise HTTPException(status_code=409, detail=str(exc)) - - # The old name is nobody's namespace now; its values would sit there under a - # flow that no longer exists. They repopulate under the new name on the next - # run, so this is cleanup rather than a migration. - await run_in_threadpool(controller.forget_flow, name) - await controller.reload() - return await run_in_threadpool(_detail, controller, renamed) - - @router.post("/{name}/duplicate", response_model=FlowDetail) async def duplicate_flow( name: str, controller: FlowControllerDep, user: CurrentUser diff --git a/backend/fluksio/cli.py b/backend/fluksio/cli.py index aaef877..90145a1 100644 --- a/backend/fluksio/cli.py +++ b/backend/fluksio/cli.py @@ -1,4 +1,4 @@ -"""`fluksio serve`, `fluksio enroll`, `fluksio worker`, `sync`, `run`, `runs`. +"""`fluksio serve`, `enroll`, `worker`, `store`, `sync`, `run`, `runs`. The point of this module is a machine nobody can route to: a node on a cluster where ports cannot be opened, or a laptop with no Docker. `fluksio serve` @@ -686,6 +686,71 @@ def cmd_enroll(args: argparse.Namespace) -> int: return result +def _store_dir(raw: str) -> Path | None: + path = Path(raw).expanduser() + if path.is_dir(): + return path + print(f"{path} is not a directory", file=sys.stderr, flush=True) + return None + + +def cmd_store_migrate(args: argparse.Namespace) -> int: + """Say what the engine's next start would do to a store's ids. + + The same check the start makes, reading and nothing else: no store is + opened, so no repository is created and no other migration runs. Point it + at a copy of the store; the engine does the migration itself, with the + store to itself. + """ + from fluksio.flow.identity import plan_ids + + root = _store_dir(args.dir) + if root is None: + return 2 + plan = plan_ids(root) + flows = sum(1 for kind, _, _ in plan.objects if kind == "flow") + _say(f"{root}: {flows} flows, {len(plan.objects) - flows} dashboards") + if plan.problems: + _say("The engine would refuse to start, and change nothing, because:") + for problem in plan.problems: + _say(f" {problem}") + return 1 + if plan.pending: + _say("An interrupted start gave these their id, and would commit them:") + for rel in sorted(plan.pending): + _say(f" {rel}") + if plan.stamps: + _say("The engine would give these their key as id, in one commit:") + for rel, version in sorted(plan.stamps.items()): + _say(f" {rel} (version {version})") + if not plan.pending and not plan.stamps: + _say("Nothing to do: every flow and dashboard has its id.") + return 0 + + +def cmd_store_ls(args: argparse.Namespace) -> int: + """Every flow and dashboard in a store, by id, key and kind.""" + from fluksio.flow.identity import plan_ids + + root = _store_dir(args.dir) + if root is None: + return 2 + plan = plan_ids(root) + width = max([len("ID"), *(len(obj_id) for _, obj_id, _ in plan.objects)]) + keys = max([len("KEY"), *(len(key) for _, _, key in plan.objects)]) + _say(f"{'ID':<{width}} {'KEY':<{keys}} KIND") + for kind, obj_id, key in sorted(plan.objects): + _say(f"{obj_id:<{width}} {key:<{keys}} {kind}") + if plan.problems: + print( + f"{len(plan.problems)} problem(s): `fluksio store migrate --dry-run " + f"{root}` lists them", + file=sys.stderr, + flush=True, + ) + return 0 + + def cmd_worker(rest: list[str]) -> int: from fluksio_worker.agent import main as worker_main @@ -804,6 +869,21 @@ def _parser() -> argparse.ArgumentParser: add_help=False, ) + store = subparsers.add_parser("store", help="look into a flow store directory") + store_commands = store.add_subparsers(dest="store_command", required=True) + migrate = store_commands.add_parser( + "migrate", help="check what the next engine start migrates" + ) + # Required: the engine migrates on its start, with the store to itself. + migrate.add_argument( + "--dry-run", action="store_true", required=True, help="change nothing" + ) + migrate.add_argument("dir", help="the store, e.g. a copy of /flows") + migrate.set_defaults(func=cmd_store_migrate) + ls = store_commands.add_parser("ls", help="list flows and dashboards by id") + ls.add_argument("dir", help="the store, e.g. /flows") + ls.set_defaults(func=cmd_store_ls) + # The client half: talking to an engine rather than being one. from fluksio.sdk.cli import add_parsers diff --git a/backend/fluksio/flow/dashboards.py b/backend/fluksio/flow/dashboards.py index 73dcf76..21ee215 100644 --- a/backend/fluksio/flow/dashboards.py +++ b/backend/fluksio/flow/dashboards.py @@ -25,13 +25,15 @@ from typing import Any, Literal from pydantic import BaseModel, Field, field_validator, model_validator from fluksio.flow.messages import DType, qualify -from fluksio.flow.schemas import FlowDef, _validate_name +from fluksio.flow.schemas import FlowDef, _validate_id, _validate_name from fluksio.flow.store import ( FlowStore, StaleVersion, _updated_at, checked, + claim, copy_name, + read_keys, ) #: Sibling of the shared-node library, and likewise not a flow. @@ -500,6 +502,11 @@ def _flatten_pages(pages: list[Any]) -> list[dict[str, Any]]: class DashboardDef(BaseModel): """A dashboard as stored, and as the API hands it over.""" + #: Which dashboard this is, like a flow's (``FlowDef.id``), in an id space + #: of its own: the directory, never changed once given, the key for one + #: from before ids. Optional on the way in, always set on the way out. + id: str = "" + #: The key, unique among dashboards. Nothing binds to it, so it is a label. name: str title: str = "" #: How many columns the grid is cut into, so a dashboard can be matched to @@ -550,6 +557,11 @@ class DashboardDef(BaseModel): data["widgets"] = _flatten_pages(pages) return data + @field_validator("id") + @classmethod + def _check_id(cls, value: str) -> str: + return _validate_id(value) + @field_validator("name") @classmethod def _check_name(cls, value: str) -> str: @@ -610,6 +622,7 @@ class DashboardDef(BaseModel): class DashboardSummary(BaseModel): """A dashboard in a list, without its contents.""" + id: str = "" name: str title: str = "" #: The glyph this dashboard draws on a panel's rail, so a list can show it @@ -648,6 +661,9 @@ class DashboardExists(ValueError): super().__init__(name) self.name = name + def __str__(self) -> str: + return f"There is already a dashboard named '{self.name}'" + class DashboardStore: """Dashboards in the flow store's repository, invisible to the flow listing. @@ -665,6 +681,8 @@ class DashboardStore: self._published: dict[str, DashboardDef | None] = {} self._published_at = -1 self._published_lock = threading.Lock() + #: `key_index` and the flow store revision it was read at. + self._keys: tuple[int, dict[str, str]] = (-1, {}) def _file(self, name: str) -> Path: return self.root / checked(name) / "dashboard.json" @@ -689,6 +707,7 @@ class DashboardStore: continue summaries.append( DashboardSummary( + id=defn.id, name=defn.name, title=defn.title, icon=defn.icon, @@ -725,10 +744,45 @@ class DashboardStore: path = self._file(name) if not path.exists(): raise DashboardNotFound(name) - return DashboardDef.model_validate_json(path.read_text()).model_copy( - update={"has_draft": self.has_draft(name)} + defn = DashboardDef.model_validate_json(path.read_text()) + # One from before ids is known by its directory, which is its key. + return defn.model_copy( + update={"id": defn.id or name, "has_draft": self.has_draft(name)} ) + def key_index(self) -> dict[str, str]: + """Every dashboard's key, to its id, whether published or only a draft. + + Read again after any commit of the store, as `FlowStore.key_index`. + Shared: read it, never change it. + """ + at, keys = self._keys + if at == self.flows.revision: + return keys + at = self.flows.revision + found = {p.parent for p in self.root.glob("*/dashboard.json")} + found |= {p.parent for p in self.root.glob("*/dashboard.draft.json")} + keys = read_keys(sorted(found), "dashboard") + self._keys = (at, keys) + return keys + + def _claim(self, defn: DashboardDef) -> DashboardDef: + """*defn* with its id, once it is certain to be a save the store allows. + + Under the flow store's write lock; see `claim`. An id left out is the + key, which is how every dashboard is created for now. + """ + dash_id = checked(defn.id or defn.name) + current = self.read(dash_id, draft=True) if self.exists(dash_id) else None + claim( + dash_id, + defn.name, + (current.id, current.name) if current else None, + self.key_index(), + DashboardExists, + ) + return defn.model_copy(update={"id": dash_id}) + def published(self, name: str) -> DashboardDef | None: """The published document, or None, as of the store's last commit. @@ -756,7 +810,8 @@ class DashboardStore: caller that already has the finished document — a test, or a seed. """ with self._lock, self.flows._write_lock: - path = self._file(defn.name) + defn = self._claim(defn) + path = self._file(defn.id) current = 0 if path.exists(): current = DashboardDef.model_validate_json(path.read_text()).version @@ -779,14 +834,15 @@ class DashboardStore: does not exist yet: creating one is its first draft. """ with self._lock, self.flows._write_lock: + defn = self._claim(defn) current = 0 - if self.exists(defn.name): - current = self.read(defn.name, draft=True).version + if self.exists(defn.id): + current = self.read(defn.id, draft=True).version if base_version is not None and base_version != current: raise StaleVersion(defn.name, current) saved = defn.model_copy(update={"version": current + 1, "has_draft": True}) - path = self._draft_file(defn.name) + path = self._draft_file(defn.id) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(self._dump(saved)) self.flows._commit(f"Update draft of dashboard '{defn.name}'") @@ -828,30 +884,6 @@ class DashboardStore: pass self.flows._commit(f"Delete dashboard '{name}'") - def rename(self, name: str, new_name: str) -> DashboardDef: - defn = self.read(name, draft=True) - if self.exists(new_name): - raise DashboardExists(new_name) - with self.flows._write_lock: - renamed = defn.model_copy(update={"name": new_name}) - self._file(new_name).parent.mkdir(parents=True, exist_ok=True) - # Whichever files the dashboard has move; one nobody published yet - # has only the draft, and renaming it must not publish it. - if self.is_published(name): - published = self.read(name).model_copy(update={"name": new_name}) - self._file(new_name).write_text(self._dump(published)) - self._file(name).unlink() - # An unpublished edit belongs to the dashboard, so it moves too. - if self.has_draft(name): - self._draft_file(new_name).write_text(self._dump(renamed)) - self._draft_file(name).unlink() - try: - self._file(name).parent.rmdir() - except OSError: - pass - self.flows._commit(f"Rename dashboard '{name}' to '{new_name}'") - return renamed - def duplicate(self, name: str) -> DashboardDef: """Copy the working copy into a new dashboard, as a draft only. @@ -860,9 +892,12 @@ class DashboardStore: """ with self._lock, self.flows._write_lock: source = self.read(name, draft=True) - new_name = copy_name(name, self.exists) + # The copy's id is its key, so it has to be free as both. + keys = self.key_index() + new_name = copy_name(source.name, lambda n: self.exists(n) or n in keys) copy = source.model_copy( update={ + "id": new_name, "name": new_name, "title": f"{source.title} (copy)" if source.title else "", "version": 1, diff --git a/backend/fluksio/flow/identity.py b/backend/fluksio/flow/identity.py new file mode 100644 index 0000000..7e551eb --- /dev/null +++ b/backend/fluksio/flow/identity.py @@ -0,0 +1,280 @@ +"""Giving every flow and dashboard stored before ids existed its id, once. + +Before ids, a flow was its key: ``name`` was its directory, its URL, its hook +and its rows in the database. The id now does those jobs and the key keeps its +own, the namespace of the flow's messages (see `fluksio.flow.store`). A store +from before is moved over by writing each document's key into it as its id. +Nothing else changes, since every directory, URL, hook, device and row already +holds that string. + +`assign_ids` does it when the engine starts, and refuses to touch a store whose +documents do not say clearly who they are. `plan_ids` is the same reading +without the writing, which is what ``fluksio store migrate --dry-run`` prints +and ``fluksio store ls`` lists. +""" + +from __future__ import annotations + +import json +import logging +import os +import subprocess +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from fluksio.flow.dashboards import DASHBOARD_DIR + +logger = logging.getLogger(__name__) + +#: The one commit the documents given an id land in. +COMMIT_MESSAGE = "Assign ids to flows and dashboards" + +#: Each kind's documents, published first. +_DOCUMENTS = { + "flow": ("flow.json", "flow.draft.json"), + "dashboard": ("dashboard.json", "dashboard.draft.json"), +} + +Documents = dict[tuple[str, str], dict[str, dict[str, Any]]] + + +class IdsRefused(RuntimeError): + """A store whose documents do not say clearly enough who they are.""" + + def __init__(self, root: Path, problems: list[str]) -> None: + super().__init__(root, problems) + self.root = root + self.problems = problems + + def __str__(self) -> str: + lines = "\n".join(f" {problem}" for problem in self.problems) + return ( + f"Could not give the flows and dashboards in {self.root} their ids, " + f"so nothing was changed:\n{lines}\n" + "Fix these by hand and start again. `fluksio store migrate " + f"--dry-run {self.root}` checks a store without starting the engine." + ) + + +@dataclass +class IdPlan: + """What `assign_ids` would do to a store, read without changing it.""" + + #: Every object found, as ``(kind, id, key)``. The id is the directory, + #: which is also the one a document without an id would be given. + objects: list[tuple[str, str, str]] = field(default_factory=list) + #: Documents to give their id, relative to the store, with the version + #: each moves to. + stamps: dict[str, int] = field(default_factory=dict) + #: Documents an interrupted run already gave theirs, not yet committed. + pending: dict[str, int] = field(default_factory=dict) + #: Why it refuses, a line each. Nothing is written while there is one. + problems: list[str] = field(default_factory=list) + + +def _marker(root: Path) -> Path: + """Where a run writes down what it is about to do, before doing any of it. + + Inside ``.git``, where no commit picks it up, or in the store itself when + there is no repository, and so no commit either. + """ + git = root / ".git" + return (git if git.is_dir() else root) / ".assign-ids.json" + + +def _scan(root: Path) -> tuple[Documents, list[str]]: + """Every document as plain JSON, by ``(kind, directory)``, and the unreadable.""" + found: Documents = {} + problems = [] + for kind, base in (("flow", root), ("dashboard", root / DASHBOARD_DIR)): + for name in _DOCUMENTS[kind]: + for path in sorted(base.glob(f"*/{name}")): + directory = path.parent.name + # `_devices/flow.json` is a device named "flow", and no other + # `_` directory is a flow either. + if kind == "flow" and directory.startswith(("_", ".")): + continue + rel = path.relative_to(root).as_posix() + try: + data = json.loads(path.read_text()) + except (OSError, ValueError) as exc: + problems.append(f"{rel}: not readable as JSON ({exc})") + continue + if not isinstance(data, dict) or not isinstance(data.get("name"), str): + problems.append(f"{rel}: has no name") + continue + found.setdefault((kind, directory), {})[rel] = data + return found, problems + + +def plan_ids(root: Path) -> IdPlan: + """What `assign_ids` would do to the store at *root*, changing nothing. + + A document without an id is to get its directory as one, which has to be + its ``name`` as well: the engine runs a flow under the name in its + document, so when the two differ which one the flow is cannot be told, and + that is for a person to settle. Refused likewise: a document that is not + JSON, an id that is not its directory, a published document and draft + with different keys, and two objects of one kind sharing a key. + """ + plan = IdPlan() + found, plan.problems = _scan(root) + try: + recorded = json.loads(_marker(root).read_text()) + except FileNotFoundError: + recorded = {} + + owners: dict[tuple[str, str], list[str]] = {} + for (kind, directory), docs in sorted(found.items()): + before = len(plan.problems) + keys = sorted({data["name"] for data in docs.values()}) + for key in keys: + owners.setdefault((kind, key), []).append(directory) + if len(keys) > 1: + plan.problems.append( + f"{kind} '{directory}': its published document and its draft " + f"have different keys, '{keys[0]}' and '{keys[1]}'" + ) + # The working copy's key: the draft's, when there is one. + plan.objects.append((kind, directory, list(docs.values())[-1]["name"])) + + unstamped = [] + for rel, data in docs.items(): + given, name = data.get("id"), data["name"] + if given and given != directory: + plan.problems.append(f"{rel}: its id '{given}' is not its directory") + elif not given and name != directory: + plan.problems.append( + f"{rel}: its name '{name}' is not its directory '{directory}'" + ) + elif not isinstance(data.get("version", 1), int): + plan.problems.append(f"{rel}: its version is not a number") + elif not given: + unstamped.append(rel) + if not unstamped or len(plan.problems) > before: + continue + # Past both, so neither an editor on the draft nor one on what a + # discard goes back to can save over it. + target = max(data.get("version", 1) for data in docs.values()) + 1 + for rel in unstamped: + # An interrupted run's number, unless the document moved past it. + planned = recorded.get(rel, 0) + own = docs[rel].get("version", 1) + plan.stamps[rel] = planned if planned > own else target + + for (kind, key), directories in sorted(owners.items()): + if len(directories) > 1: + plan.problems.append( + f"the {kind}s {' and '.join(map(repr, directories))} share " + f"the key '{key}'" + ) + plan.pending = { + rel: version + for rel, version in recorded.items() + if rel not in plan.stamps and (root / rel).exists() + } + return plan + + +def assign_ids(root: Path) -> list[str]: + """Give every flow and dashboard at *root* that has no id its key as one. + + For the engine's startup, with the store to itself and before anything + reads a definition. Raises `IdsRefused`, having written nothing, on a + problem `plan_ids` finds. Otherwise each document gets its id and moves + past the higher version of itself and its sibling, so an editor holding + the old one gets a conflict, after a discard too. All of it is one commit + of only those files, whatever else in the store is uncommitted. + + The versions are written down before the first document is, and the note + goes once the commit is made, so a run interrupted anywhere in between is + finished by the next, with the same numbers. A store that has been through + it changes nothing and says nothing. Returns the documents changed. + """ + plan = plan_ids(root) + if plan.problems: + raise IdsRefused(root, plan.problems) + changed = {**plan.pending, **plan.stamps} + if not changed: + return [] + + marker = _marker(root) + if plan.stamps: + _replace(marker, json.dumps(changed, indent=2) + "\n") + for rel, version in sorted(plan.stamps.items()): + path = root / rel + data = json.loads(path.read_text()) + data.pop("id", None) + stamped = {"id": path.parent.name, **data, "version": version} + _replace(path, json.dumps(stamped, indent=2, ensure_ascii=False) + "\n") + + paths = sorted(changed) + committed = _commit(root, paths) + logger.warning( + "Gave each flow and dashboard without an id its key as one, in %s", + ", ".join(paths), + ) + if committed: + marker.unlink(missing_ok=True) + return paths + + +def _replace(path: Path, text: str) -> None: + """Write *path* whole or not at all: a crash leaves the old file or the new.""" + temp = path.with_name(f".{path.name}.tmp") + with temp.open("w") as handle: + handle.write(text) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temp, path) + + +def _commit(root: Path, paths: list[str]) -> bool: + """Commit *paths* alone, leaving whatever else is uncommitted as it was. + + True once nothing is left to commit: done now, done by the run this one + finishes, or no git to do it with, in which case the store is just files, + as `FlowStore` takes it too. + """ + if not (root / ".git").exists(): + return True + + def git(*args: str) -> subprocess.CompletedProcess[str]: + # `gc.auto=0` for the reason `FlowStore._git` gives. + return subprocess.run( + ["git", "-C", str(root), "-c", "gc.auto=0", *args], + capture_output=True, + text=True, + check=False, + ) + + try: + git("add", "--", *paths) + if git("diff", "--cached", "--quiet", "HEAD", "--", *paths).returncode == 0: + return True + result = git( + "-c", + "user.name=fluksio", + "-c", + "user.email=fluksio@localhost", + "commit", + "-q", + "--only", + "-m", + COMMIT_MESSAGE, + "--", + *paths, + ) + except FileNotFoundError: + return True + if result.returncode != 0: + logger.warning( + "Could not commit the ids, so the next start tries again: %s", + (result.stderr or result.stdout).strip(), + ) + return False + return True + + +__all__ = ["COMMIT_MESSAGE", "IdPlan", "IdsRefused", "assign_ids", "plan_ids"] diff --git a/backend/fluksio/flow/schemas.py b/backend/fluksio/flow/schemas.py index acd4165..c345de8 100644 --- a/backend/fluksio/flow/schemas.py +++ b/backend/fluksio/flow/schemas.py @@ -32,6 +32,11 @@ def _validate_name(value: str) -> str: return value +def _validate_id(value: str) -> str: + """An id is a name too, so it stays one safe path segment. Empty is unset.""" + return value and _validate_name(value) + + def _megabytes(value: Any) -> Any: """``2G`` and ``512M`` and ``512`` all mean a number of megabytes.""" if not isinstance(value, str): @@ -302,6 +307,11 @@ class FlowInput(BaseModel): class FlowDef(BaseModel): """One atomic flow.""" + #: Which flow this is: the directory it is stored in, never changed once + #: given. A flow from before ids existed has its key as its id. Optional + #: on the way in, since the store fills it, and always set on the way out. + id: str = "" + #: The key: unique among flows, and the namespace of the flow's messages. name: str title: str = "" nodes: list[NodeDef] = Field(default_factory=list) @@ -331,6 +341,11 @@ class FlowDef(BaseModel): ), ) + @field_validator("id") + @classmethod + def _check_id(cls, value: str) -> str: + return _validate_id(value) + @field_validator("name") @classmethod def _check_name(cls, value: str) -> str: @@ -402,6 +417,7 @@ class MessageHistory(BaseModel): class FlowSummary(BaseModel): + id: str = "" name: str title: str = "" #: Whether running it means one finite execution or leaving it running. diff --git a/backend/fluksio/flow/store.py b/backend/fluksio/flow/store.py index e37de9e..9b75fe2 100644 --- a/backend/fluksio/flow/store.py +++ b/backend/fluksio/flow/store.py @@ -10,6 +10,12 @@ Editing is separated from running. The editor writes ``flow.draft.json`` and ``nodes/*.py``. Publishing promotes the draft over the published files and removes it, so a flow directory without draft files is simply one with nothing unpublished — which is what every flow written before this existed looks like. + +A flow is two names. Its id is which flow it is: the directory, the argument +every method here takes, and never changed once given. Its key, ``name``, is +the namespace of its messages and unique among flows. A flow from before ids +existed has its key as its id (`fluksio.flow.identity`), and so does every new +one for now. Published and draft documents always share both. """ from __future__ import annotations @@ -17,12 +23,15 @@ from __future__ import annotations import json import logging import re +import secrets import shutil +import string import subprocess import threading from collections.abc import Callable, Iterable from datetime import UTC, datetime from pathlib import Path +from typing import Literal from fluksio.flow.schemas import FlowDef, _validate_name @@ -66,6 +75,91 @@ def checked(name: str) -> str: raise InvalidName(name) from None +#: The shape of an id the server issues: a kind, then 40 random bits. +ISSUED_ID = re.compile(r"[fd]_[a-z2-7]{8}") + + +class ReservedKey(InvalidName): + """A new key shaped like an issued id, which it could one day equal.""" + + def __str__(self) -> str: + return ( + f"'{self.name}' has the shape of a generated id, f_ or d_ and eight " + "letters or digits, which a new key may not have" + ) + + +class IdentityChange(ValueError): + """A save that would change which object a document is, or its key. + + Ids never change, and a key changes only by renaming, which moves its + messages along with it. A save that did either would bypass that. + """ + + +def claim( + obj_id: str, + key: str, + current: tuple[str, str] | None, + keys: dict[str, str], + taken: type[ValueError], +) -> None: + """Refuse a write that changes who an object is, or takes another's key. + + *current* is the stored ``(id, name)`` at *obj_id*, None for a new object, + and *keys* the store's key index. Called under the store's write lock, so + two creates of one key cannot both pass. *taken* is what a key already in + use raises: `FlowExists`, or the dashboards' own. + """ + if current is not None: + stored_id, stored_key = current + if stored_id != obj_id: + raise IdentityChange(f"'{obj_id}' holds a document of '{stored_id}'") + if stored_key != key: + raise IdentityChange( + f"'{obj_id}' has the key '{stored_key}', which a save cannot " + f"change to '{key}'" + ) + return + # New keys only: one this shape that exists already stays saveable. + if ISSUED_ID.fullmatch(key): + raise ReservedKey(key) + if keys.get(key, obj_id) != obj_id: + raise taken(key) + + +def read_keys(directories: Iterable[Path], document: str) -> dict[str, str]: + """The key each directory's object holds, to the directory: key to id. + + Read from the working copy, ``.draft.json`` when there is one, so + an object that is only a draft holds its key as much as a published one. + """ + keys: dict[str, str] = {} + for directory in directories: + draft = directory / f"{document}.draft.json" + path = draft if draft.exists() else directory / f"{document}.json" + try: + keys.setdefault(json.loads(path.read_text())["name"], directory.name) + except (OSError, ValueError, KeyError, TypeError): + # Unreadable to the engine too, which reports it when it loads. + continue + return keys + + +def new_id(kind: Literal["f", "d"], taken: Callable[[str], bool]) -> str: + """A fresh id for a flow (``f``) or a dashboard (``d``), e.g. ``f_k3q7m2xa``. + + Random is not unique, so *taken* decides, under the store's write lock: it + has to know every live id, every directory left behind and every key, + since a key equal to an id makes an argument naming either ambiguous. + """ + alphabet = string.ascii_lowercase + "234567" + while True: + candidate = f"{kind}_" + "".join(secrets.choice(alphabet) for _ in range(8)) + if not taken(candidate): + return candidate + + class FlowNotFound(KeyError): def __init__(self, name: str) -> None: super().__init__(name) @@ -174,6 +268,8 @@ class FlowStore: self._read_all: list[FlowDef] = [] self._read_all_at = -1 self._read_all_lock = threading.Lock() + #: `key_index` and the revision it was read at. + self._keys: tuple[int, dict[str, str]] = (-1, {}) if not (self.root / ".git").exists(): self._git("init", "-q") self._commit("Initialise flow store", allow_empty=True) @@ -441,7 +537,7 @@ class FlowStore: ): if not path.exists(): continue - definition = FlowDef.model_validate_json(path.read_text()) + definition = self._load(path) users = [n for n in definition.nodes if n.source_ref == name] if not users: continue @@ -549,16 +645,56 @@ class FlowStore: + list(self._draft_nodes_dir(name).glob("*.py")) ) + @staticmethod + def _load(path: Path) -> FlowDef: + """A stored document, with the id one from before ids is known by.""" + flow = FlowDef.model_validate_json(path.read_text()) + if not flow.id: + flow.id = path.parent.name + return flow + def read_flow(self, name: str, draft: bool = False) -> FlowDef: """The published flow, or with ``draft`` the working copy.""" if draft: path = self._draft_file(name) if path.exists(): - return FlowDef.model_validate_json(path.read_text()) + return self._load(path) path = self._flow_file(name) if not path.exists(): raise FlowNotFound(name) - return FlowDef.model_validate_json(path.read_text()) + return self._load(path) + + def key_index(self) -> dict[str, str]: + """Every flow's key, to its id, whether published or only a draft. + + Read again after any commit, which every write makes — a draft-only + create or a delete as much as a save. Shared: read it, never change it. + """ + at, keys = self._keys + if at == self.revision: + return keys + # Taken before reading, so a commit landing meanwhile reads it again. + at = self.revision + keys = read_keys((self.root / n for n in self.list_flows()), "flow") + self._keys = (at, keys) + return keys + + def _claim(self, flow: FlowDef) -> FlowDef: + """*flow* with its id, once it is certain to be a save the store allows. + + Under `_write_lock`. An id left out is the key, which is how every + flow is created for now. + """ + flow_id = checked(flow.id or flow.name) + current = self.read_flow(flow_id, draft=True) if self.exists(flow_id) else None + claim( + flow_id, + flow.name, + (current.id, current.name) if current else None, + self.key_index(), + FlowExists, + ) + return flow.model_copy(update={"id": flow_id}) def read_all(self) -> list[FlowDef]: """Every published flow — what the engine runs. @@ -583,15 +719,17 @@ class FlowStore: def write_flow(self, flow: FlowDef) -> bool: """Publish a flow directly. Returns False when nothing actually changed.""" - path = self._flow_file(flow.name) - content = flow.model_dump_json(indent=2) + "\n" - if path.exists() and path.read_text() == content: - return False + with self._write_lock: + flow = self._claim(flow) + path = self._flow_file(flow.id) + content = flow.model_dump_json(indent=2) + "\n" + if path.exists() and path.read_text() == content: + return False - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(content) - self._commit(f"Update flow '{flow.name}'") - return True + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + self._commit(f"Update flow '{flow.name}'") + return True def write_draft(self, flow: FlowDef, base_version: int) -> FlowDef: """Save unpublished changes, refusing to overwrite someone else's. @@ -601,9 +739,10 @@ class FlowStore: write would silently discard their work. """ with self._write_lock: + flow = self._claim(flow) current: FlowDef | None = None - if self.exists(flow.name): - current = self.read_flow(flow.name, draft=True) + if self.exists(flow.id): + current = self.read_flow(flow.id, draft=True) if base_version != current.version: raise StaleVersion(flow.name, current.version) if _same_content(current, flow): @@ -612,7 +751,7 @@ class FlowStore: stored = flow.model_copy( update={"version": (current.version if current else 0) + 1} ) - path = self._draft_file(flow.name) + path = self._draft_file(flow.id) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(stored.model_dump_json(indent=2) + "\n") self._commit(f"Update draft of flow '{flow.name}'") @@ -656,48 +795,12 @@ class FlowStore: return self.read_flow(name) def delete_flow(self, name: str) -> None: - directory = self._flow_dir(name) - if not directory.exists(): - raise FlowNotFound(name) - shutil.rmtree(directory) - self._commit(f"Delete flow '{name}'") - - def rename_flow(self, name: str, new_name: str) -> FlowDef: - """Rename a flow, carrying its nodes and any references to it. - - A flow's name is the namespace of its messages, so other flows reading - ``old.temperature`` are rewritten to read ``new.temperature`` — leaving - them pointing at a flow that no longer exists would break them silently. - """ - if not self.exists(name): - raise FlowNotFound(name) - if self.exists(new_name): - raise FlowExists(new_name) - - self._flow_dir(name).rename(self._flow_dir(new_name)) - - # Published and draft alike carry the name, and both have to move. - for path in (self._flow_file(new_name), self._draft_file(new_name)): - if not path.exists(): - continue - flow = FlowDef.model_validate_json(path.read_text()) - path.write_text( - flow.model_copy(update={"name": new_name}).model_dump_json(indent=2) - + "\n" - ) - - for other in self.list_flows(): - if other == new_name: - continue - for path in (self._flow_file(other), self._draft_file(other)): - if not path.exists(): - continue - flow = FlowDef.model_validate_json(path.read_text()) - if self._retarget(flow, f"{name}.", f"{new_name}."): - path.write_text(flow.model_dump_json(indent=2) + "\n") - - self._commit(f"Rename flow '{name}' to '{new_name}'") - return self.read_flow(new_name, draft=True) + with self._write_lock: + directory = self._flow_dir(name) + if not directory.exists(): + raise FlowNotFound(name) + shutil.rmtree(directory) + self._commit(f"Delete flow '{name}'") def duplicate_flow(self, name: str) -> FlowDef: """Copy a flow's working copy into a new flow that is only a draft. @@ -710,19 +813,24 @@ class FlowStore: with self._write_lock: source = self.read_flow(name, draft=True) # The directory rather than `exists`: anything left in it would - # otherwise end up in the copy. - new_name = copy_name(name, lambda n: self._flow_dir(n).exists()) + # otherwise end up in the copy. The copy's id is its key, so that + # has to be free among the keys too. + keys = self.key_index() + new_name = copy_name( + source.name, lambda n: self._flow_dir(n).exists() or n in keys + ) copy = source.model_copy( update={ + "id": new_name, "name": new_name, "title": f"{source.title} (copy)" if source.title else "", "version": 1, "origin": None, } ) - # Its own messages named with its own name are the copy's now. - self._retarget(copy, f"{name}.", f"{new_name}.") + # Its own messages named with its own key are the copy's now. + self._retarget(copy, f"{source.name}.", f"{new_name}.") path = self._draft_file(new_name) path.parent.mkdir(parents=True) path.write_text(copy.model_dump_json(indent=2) + "\n") diff --git a/backend/fluksio/main.py b/backend/fluksio/main.py index f597f58..811e96f 100644 --- a/backend/fluksio/main.py +++ b/backend/fluksio/main.py @@ -40,6 +40,7 @@ from fluksio.flow.demand import demand from fluksio.flow.devices import DeviceStore, DeviceUnavailable from fluksio.flow.events import event_bus from fluksio.flow.executor import ExecutionService +from fluksio.flow.identity import assign_ids from fluksio.flow.metrics import MetricsCollector from fluksio.flow.nodes.http import close_shared_client from fluksio.flow.pipeline import ValueSource @@ -58,7 +59,7 @@ from fluksio.flow.resources import ( from fluksio.flow.runs import RUN_STATE_TTL, RunService, sweep_artifacts from fluksio.flow.secrets import init_secrets from fluksio.flow.state import MemoryState, RedisState, StateBackend -from fluksio.flow.store import FlowStore, InvalidName +from fluksio.flow.store import FlowStore, IdentityChange, InvalidName from fluksio.flow.watchdog import LoopWatchdog from fluksio.flow.workers import PythonWorkerPool @@ -301,6 +302,10 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: events=event_bus, max_cascades=settings.FLOW_MAX_CASCADES, ) + # Every flow and dashboard has an id before anything reads one: the + # store's own `_migrate`, the device migrations below and every cache + # after them write documents back with whatever id they read. + await run_in_threadpool(assign_ids, settings.FLOWS_DIR) store = FlowStore(settings.FLOWS_DIR) # The packages node code imports. In the background, because a first # install of anything substantial takes minutes and `uv` is given five @@ -577,6 +582,12 @@ async def invalid_name(request: Request, exc: InvalidName) -> JSONResponse: # n return JSONResponse(status_code=400, content={"detail": str(exc)}) +@app.exception_handler(IdentityChange) +async def identity_change(request: Request, exc: IdentityChange) -> JSONResponse: # noqa: ARG001 + """A save that would change an object's id or key, which no save may.""" + return JSONResponse(status_code=409, content={"detail": str(exc)}) + + @app.exception_handler(DeviceUnavailable) async def device_unavailable(request: Request, exc: DeviceUnavailable) -> JSONResponse: # noqa: ARG001 """A device credential that cannot be checked now is not a refused one.""" diff --git a/backend/tests/api/routes/test_dashboards.py b/backend/tests/api/routes/test_dashboards.py index 609c376..20a98fd 100644 --- a/backend/tests/api/routes/test_dashboards.py +++ b/backend/tests/api/routes/test_dashboards.py @@ -86,3 +86,29 @@ def test_a_save_based_on_a_version_someone_moved_past_is_refused( assert stale.status_code == 409 assert stale.json()["detail"]["current_version"] == 1 + + +def test_a_dashboard_is_known_by_an_id_no_save_changes( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + saved = client.put( + f"{PREFIX}/put_identified", + headers=superuser_token_headers, + json=a_dashboard("put_identified"), + ).json() + assert saved["id"] == "put_identified" + + elsewhere = client.put( + f"{PREFIX}/put_identified", + headers=superuser_token_headers, + json={**saved, "id": "elsewhere"}, + ) + assert elsewhere.status_code == 422 + renamed = client.post( + f"{PREFIX}/put_identified/rename", + headers=superuser_token_headers, + json={"name": "renamed"}, + ) + assert renamed.status_code in (404, 405) + + client.delete(f"{PREFIX}/put_identified", headers=superuser_token_headers) diff --git a/backend/tests/api/routes/test_flows.py b/backend/tests/api/routes/test_flows.py index 82cd461..74a31b1 100644 --- a/backend/tests/api/routes/test_flows.py +++ b/backend/tests/api/routes/test_flows.py @@ -77,6 +77,45 @@ def test_name_mismatch_is_rejected( assert response.status_code == 400 +def test_a_flow_is_known_by_an_id_no_save_changes( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + """For now the id of a new flow is its key, and the URL names both.""" + saved = client.put( + f"{PREFIX}/identified", + headers=superuser_token_headers, + json=a_flow("identified"), + ).json()["definition"] + assert saved["id"] == "identified" + listed = client.get(f"{PREFIX}/", headers=superuser_token_headers).json()["data"] + assert {"id": "identified", "name": "identified"}.items() <= next( + f for f in listed if f["name"] == "identified" + ).items() + + elsewhere = client.put( + f"{PREFIX}/identified", + headers=superuser_token_headers, + json={**saved, "id": "elsewhere"}, + ) + assert elsewhere.status_code == 400 + # A key changes by a rename, and that route is not there yet. + renamed = client.post( + f"{PREFIX}/identified/rename", + headers=superuser_token_headers, + json={"new_name": "renamed"}, + ) + assert renamed.status_code in (404, 405) + # Shaped like an id the server issues, which a new key may not be. + issued = client.put( + f"{PREFIX}/f_k3q7m2xa", + headers=superuser_token_headers, + json=a_flow("f_k3q7m2xa"), + ) + assert issued.status_code == 400 + + client.delete(f"{PREFIX}/identified", headers=superuser_token_headers) + + def test_broken_node_is_reported_and_siblings_stay_active( client: TestClient, superuser_token_headers: dict[str, str] ) -> None: @@ -347,68 +386,6 @@ def test_node_types_are_listed( assert "properties" in by_type["mqtt"]["params_schema"] -def test_rename_flow( - client: TestClient, superuser_token_headers: dict[str, str] -) -> None: - client.put(f"{PREFIX}/demo", headers=superuser_token_headers, json=a_flow()) - - response = client.post( - f"{PREFIX}/demo/rename", - headers=superuser_token_headers, - json={"new_name": "demo_renamed"}, - ) - assert response.status_code == 200 - assert response.json()["definition"]["name"] == "demo_renamed" - - assert ( - client.get(f"{PREFIX}/demo", headers=superuser_token_headers).status_code == 404 - ) - assert ( - client.get( - f"{PREFIX}/demo_renamed", headers=superuser_token_headers - ).status_code - == 200 - ) - - # Put it back so the tests that follow find the flow they expect. - client.post( - f"{PREFIX}/demo_renamed/rename", - headers=superuser_token_headers, - json={"new_name": "demo"}, - ) - - -def test_rename_onto_a_taken_name_is_refused( - client: TestClient, superuser_token_headers: dict[str, str] -) -> None: - client.put(f"{PREFIX}/demo", headers=superuser_token_headers, json=a_flow()) - client.put( - f"{PREFIX}/occupied", headers=superuser_token_headers, json=a_flow("occupied") - ) - - response = client.post( - f"{PREFIX}/demo/rename", - headers=superuser_token_headers, - json={"new_name": "occupied"}, - ) - assert response.status_code == 409 - - client.delete(f"{PREFIX}/occupied", headers=superuser_token_headers) - - -def test_rename_rejects_an_invalid_name( - client: TestClient, superuser_token_headers: dict[str, str] -) -> None: - client.put(f"{PREFIX}/demo", headers=superuser_token_headers, json=a_flow()) - - response = client.post( - f"{PREFIX}/demo/rename", - headers=superuser_token_headers, - json={"new_name": "Not A Flow Name"}, - ) - assert response.status_code == 400 - - def test_a_node_that_raises_answers_with_its_error( client: TestClient, superuser_token_headers: dict[str, str] ) -> None: @@ -467,42 +444,6 @@ def test_acknowledging_a_failure_is_announced_on_the_bus( client.delete(f"{PREFIX}/acked", headers=superuser_token_headers) -def test_renaming_a_flow_leaves_nothing_under_the_old_name( - client: TestClient, superuser_token_headers: dict[str, str] -) -> None: - saved = client.put( - f"{PREFIX}/movable", headers=superuser_token_headers, json=a_flow("movable") - ).json() - client.put( - f"{PREFIX}/movable/nodes/sensor/source", - headers=superuser_token_headers, - json={"code": WORKING_NODE}, - ) - client.post( - f"{PREFIX}/movable/publish", - headers=superuser_token_headers, - json={"version": saved["definition"]["version"]}, - ) - client.post( - f"{PREFIX}/movable/run", headers=superuser_token_headers, json={"inputs": {}} - ) - - state = client.app.state.flow_controller.state - assert [key for key in state.keys() if "movable." in key] - - client.post( - f"{PREFIX}/movable/rename", - headers=superuser_token_headers, - json={"new_name": "moved"}, - ) - - # The values repopulate under the new name on the next run; what the old - # name left behind would sit there for good. - assert [key for key in state.keys() if "movable." in key] == [] - - client.delete(f"{PREFIX}/moved", headers=superuser_token_headers) - - @pytest.mark.anyio async def test_the_live_socket_ends_when_its_client_goes( client: TestClient, monkeypatch: pytest.MonkeyPatch diff --git a/backend/tests/api/routes/test_path_names.py b/backend/tests/api/routes/test_path_names.py index fcbe32a..251e0a6 100644 --- a/backend/tests/api/routes/test_path_names.py +++ b/backend/tests/api/routes/test_path_names.py @@ -58,7 +58,6 @@ def test_the_stores_refuse_them_themselves(tmp_path: Path) -> None: for call in ( lambda: flows.delete_flow(".."), lambda: flows.read_flow("_lib"), - lambda: flows.rename_flow("..", "elsewhere"), lambda: flows.write_node_source("demo", "../x", "code"), lambda: flows.write_lib_source("..", "code"), lambda: flows.exists("demo\n"), diff --git a/backend/tests/flow/test_dashboards.py b/backend/tests/flow/test_dashboards.py index 2957178..bf4636f 100644 --- a/backend/tests/flow/test_dashboards.py +++ b/backend/tests/flow/test_dashboards.py @@ -110,17 +110,11 @@ def test_a_new_dashboard_is_a_draft_until_it_is_published(store: DashboardStore) assert store.read("house").title == created.title -def test_an_unpublished_dashboard_can_be_renamed_and_deleted(store: DashboardStore): - """And renaming it does not put it on a wall either.""" +def test_an_unpublished_dashboard_can_be_deleted(store: DashboardStore): store.write_draft(default_dashboard("house"), 0) - store.rename("house", "home") - + store.delete("house") assert not store.exists("house") - assert store.has_draft("home") and not store.is_published("home") - - store.delete("home") - assert not store.exists("home") def test_a_duplicate_is_an_unpublished_copy_of_the_working_copy( @@ -139,16 +133,12 @@ def test_a_duplicate_is_an_unpublished_copy_of_the_working_copy( assert store.duplicate("house").name == "house_copy_2" -def test_deleting_and_renaming(store: DashboardStore): +def test_deleting(store: DashboardStore): store.write(default_dashboard("house")) - renamed = store.rename("house", "home") - assert renamed.name == "home" - assert not store.exists("house") - - store.delete("home") + store.delete("house") with pytest.raises(DashboardNotFound): - store.read("home") + store.read("house") def test_the_deepest_chart_decides_how_much_past_is_kept(store: DashboardStore): diff --git a/backend/tests/flow/test_drafts.py b/backend/tests/flow/test_drafts.py index 950ad05..bc833fb 100644 --- a/backend/tests/flow/test_drafts.py +++ b/backend/tests/flow/test_drafts.py @@ -164,25 +164,6 @@ def test_resaving_the_published_source_creates_no_draft(store: FlowStore): assert not store.has_draft("heating") -def test_renaming_carries_the_draft_and_repoints_other_drafts(store: FlowStore): - store.write_draft(a_flow(), 0) - store.write_draft( - FlowDef( - name="display", - nodes=[NodeDef(id="gauge", requires=[MessageSpec(name="heating.temp")])], - ), - 0, - ) - - renamed = store.rename_flow("heating", "warmth") - - assert renamed.name == "warmth" - assert store.has_draft("warmth") - assert store.read_flow("display", draft=True).nodes[0].requires[0].name == ( - "warmth.temp" - ) - - def test_a_duplicate_is_a_local_draft_of_what_the_editor_shows(store: FlowStore): flow = a_flow(title="Warm").model_copy(update={"origin": FlowOrigin()}) flow.nodes.append(NodeDef(id="reader", requires=[MessageSpec(name="heating.temp")])) diff --git a/backend/tests/flow/test_identity.py b/backend/tests/flow/test_identity.py new file mode 100644 index 0000000..be7dc84 --- /dev/null +++ b/backend/tests/flow/test_identity.py @@ -0,0 +1,517 @@ +"""Ids: who a flow or dashboard is, apart from what it is called. + +The store keeps them, and the startup migration gives every document from +before them its key as its id. A mistake in either loses data silently, which +is why the migration is driven through crashes, refusals and stores without git. +""" + +import json +import logging +import subprocess +from pathlib import Path + +import pytest + +from fluksio import cli +from fluksio.flow import identity +from fluksio.flow.dashboards import ( + DASHBOARD_DIR, + DashboardDef, + DashboardExists, + DashboardStore, + default_dashboard, +) +from fluksio.flow.identity import COMMIT_MESSAGE, IdsRefused, assign_ids +from fluksio.flow.schemas import FlowDef +from fluksio.flow.store import ( + ISSUED_ID, + FlowExists, + FlowStore, + IdentityChange, + ReservedKey, + new_id, +) + +# ----------------------------------------------------------------------------- +# The store +# ----------------------------------------------------------------------------- + + +@pytest.fixture +def store(tmp_path: Path) -> FlowStore: + return FlowStore(tmp_path / "flows") + + +def test_a_flow_is_given_its_key_as_id_and_keeps_both(store: FlowStore): + created = store.write_draft(FlowDef(name="heating"), 0) + assert (created.id, created.name) == ("heating", "heating") + + published = store.publish_flow("heating", created.version) + store.write_draft(published.model_copy(update={"title": "Warm"}), 1) + discarded = store.discard_draft("heating") + + assert json.loads((store.root / "heating" / "flow.json").read_text())["id"] == ( + "heating" + ) + assert (discarded.id, discarded.name) == ("heating", "heating") + + +def test_a_document_from_before_ids_reads_with_its_directory_as_id(store: FlowStore): + (store.root / "heating").mkdir() + (store.root / "heating" / "flow.json").write_text('{"name": "heating"}') + (store.root / DASHBOARD_DIR / "home").mkdir(parents=True) + (store.root / DASHBOARD_DIR / "home" / "dashboard.json").write_text( + '{"name": "home"}' + ) + + assert store.read_flow("heating").id == "heating" + assert DashboardStore(store).read("home").id == "home" + # And a save of it, without an id, is the same flow and not a new one. + assert store.write_draft(FlowDef(name="heating", title="Warm"), 1).version == 2 + + +def test_a_save_cannot_change_an_id_or_a_key(store: FlowStore): + store.write_flow(FlowDef(id="x_heating", name="heating")) + + with pytest.raises(IdentityChange): + store.write_draft(FlowDef(id="x_heating", name="warmth"), 1) + with pytest.raises(IdentityChange): + store.write_flow(FlowDef(id="x_heating", name="warmth")) + + dashboards = DashboardStore(store) + dashboards.write(DashboardDef(id="x_home", name="home")) + with pytest.raises(IdentityChange): + dashboards.write_draft(DashboardDef(id="x_home", name="house"), 1) + assert dashboards.read("x_home").name == "home" + + +def test_a_key_belongs_to_one_flow_drafts_included(store: FlowStore): + """A draft nobody published holds its key, and a delete lets it go.""" + store.write_draft(FlowDef(id="x_heating", name="heating"), 0) + assert store.key_index() == {"heating": "x_heating"} + + with pytest.raises(FlowExists): + store.write_draft(FlowDef(name="heating"), 0) + with pytest.raises(FlowExists): + store.write_flow(FlowDef(id="y_heating", name="heating")) + + store.delete_flow("x_heating") + assert store.write_draft(FlowDef(name="heating"), 0).id == "heating" + assert store.key_index() == {"heating": "heating"} + + +def test_a_key_belongs_to_one_dashboard_drafts_included(store: FlowStore): + dashboards = DashboardStore(store) + dashboards.write_draft(DashboardDef(id="x_home", name="home"), 0) + # Its own id space: a flow may share the key. + store.write_draft(FlowDef(name="home"), 0) + + with pytest.raises(DashboardExists): + dashboards.write_draft(default_dashboard("home"), 0) + with pytest.raises(DashboardExists): + dashboards.write(DashboardDef(id="y_home", name="home")) + + dashboards.delete("x_home") + assert dashboards.write(default_dashboard("home")).id == "home" + + +def test_a_copy_takes_a_key_nobody_holds(store: FlowStore): + store.write_flow(FlowDef(name="heating")) + store.write_draft(FlowDef(id="x_other", name="heating_copy"), 0) + dashboards = DashboardStore(store) + dashboards.write(default_dashboard("home")) + dashboards.write_draft(DashboardDef(id="x_other", name="home_copy"), 0) + + copy = store.duplicate_flow("heating") + board = dashboards.duplicate("home") + + assert (copy.id, copy.name) == ("heating_copy_2", "heating_copy_2") + assert (board.id, board.name) == ("home_copy_2", "home_copy_2") + + +def test_a_new_key_shaped_like_an_issued_id_is_refused(store: FlowStore): + with pytest.raises(ReservedKey): + store.write_draft(FlowDef(name="f_k3q7m2xa"), 0) + with pytest.raises(ReservedKey): + DashboardStore(store).write_draft(default_dashboard("d_k3q7m2xa"), 0) + assert store.write_draft(FlowDef(name="f_k3q7m2xa_lab"), 0).name == ( + "f_k3q7m2xa_lab" + ) + + +def test_an_existing_key_shaped_like_an_issued_id_stays_saveable(store: FlowStore): + (store.root / "f_k3q7m2xa").mkdir() + (store.root / "f_k3q7m2xa" / "flow.json").write_text('{"name": "f_k3q7m2xa"}') + + saved = store.write_draft(FlowDef(name="f_k3q7m2xa", title="Old"), 1) + + assert (saved.id, saved.title) == ("f_k3q7m2xa", "Old") + + +def test_a_new_id_is_random_and_avoids_whatever_is_taken(): + asked: list[str] = [] + + def taken(candidate: str) -> bool: + asked.append(candidate) + return len(asked) < 4 + + issued = new_id("d", taken) + + assert issued == asked[-1] and len(asked) == 4 + assert all(ISSUED_ID.fullmatch(c) and c.startswith("d_") for c in asked) + fresh = {new_id("f", lambda _: False) for _ in range(50)} + assert len(fresh) == 50 + assert all(ISSUED_ID.fullmatch(c) and c.startswith("f_") for c in fresh) + + +# ----------------------------------------------------------------------------- +# The migration +# ----------------------------------------------------------------------------- + + +def put(root: Path, rel: str, data: dict | str) -> None: + path = root / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(data if isinstance(data, str) else json.dumps(data)) + + +def git(root: Path, *args: str) -> str: + return subprocess.run( + ["git", "-C", str(root), *args], capture_output=True, text=True, check=True + ).stdout + + +#: Every document a store from before ids holds, by path. The id each gets. +GOLDEN = { + # Published and a draft, and a field this version does not model. + "heating/flow.json": { + "name": "heating", + "version": 3, + "later": {"kept": True}, + "nodes": [{"id": "sensor", "provides": [{"name": "temp"}]}], + }, + "heating/flow.draft.json": { + "name": "heating", + "version": 5, + "nodes": [{"id": "reader", "requires": [{"name": "solar.watts"}]}], + }, + # Only a draft. + "lab/flow.draft.json": {"name": "lab", "version": 1}, + # Stopped, which is a file beside it that has no id to get. + "stopped/flow.json": {"name": "stopped"}, + # A dashboard and a flow share a key: separate id spaces. + "home/flow.json": {"name": "home", "version": 2}, + f"{DASHBOARD_DIR}/home/dashboard.json": {"name": "home", "version": 4}, + f"{DASHBOARD_DIR}/home/dashboard.draft.json": { + "name": "home", + "version": 4, + "widgets": [], + }, + # Pages and sections, as a dashboard was written before it was one grid. + f"{DASHBOARD_DIR}/legacy/dashboard.json": { + "name": "legacy", + "version": 2, + "pages": [ + { + "sections": [ + {"widgets": [{"id": "t", "type": "stat", "config": {}}]}, + ] + } + ], + }, + f"{DASHBOARD_DIR}/draft_only/dashboard.draft.json": {"name": "draft_only"}, +} + +#: What is not a flow or a dashboard, and so is never touched. +BYSTANDERS = { + "stopped/runtime.json": '{"enabled": false}', + # A device called "flow", which `*/flow.json` would otherwise find. + "_devices/flow.json": '{"id": "flow", "title": "Hall"}', + "_lib/shared.py": "def process():\n return {}\n", + "heating/nodes/sensor.py": "def process():\n return {'temp': 1}\n", +} + +#: The versions the golden store moves to: past published and draft alike. +VERSIONS = { + "heating/flow.json": 6, + "heating/flow.draft.json": 6, + "lab/flow.draft.json": 2, + "stopped/flow.json": 2, + "home/flow.json": 3, + f"{DASHBOARD_DIR}/home/dashboard.json": 5, + f"{DASHBOARD_DIR}/home/dashboard.draft.json": 5, + f"{DASHBOARD_DIR}/legacy/dashboard.json": 3, + f"{DASHBOARD_DIR}/draft_only/dashboard.draft.json": 2, +} + + +def golden(root: Path, extra: dict | None = None) -> Path: + """A store in the shape it had before ids, committed.""" + FlowStore(root) + for rel, data in {**GOLDEN, **BYSTANDERS, **(extra or {})}.items(): + put(root, rel, data) + git(root, "add", "-A") + git(root, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "Old") + return root + + +def snapshot(root: Path) -> dict[str, bytes]: + """Every file, git's own included, so a write anywhere shows.""" + return { + str(p.relative_to(root)): p.read_bytes() + for p in sorted(root.rglob("*")) + if p.is_file() + } + + +def documents(root: Path) -> dict[str, dict]: + return {rel: json.loads((root / rel).read_text()) for rel in GOLDEN} + + +def assert_migrated(root: Path) -> None: + for rel, data in documents(root).items(): + before = GOLDEN[rel] + assert data["id"] == Path(rel).parent.name, rel + assert data["version"] == VERSIONS[rel], rel + # Nothing else about it changed, the fields nobody models included. + assert {k: v for k, v in data.items() if k not in ("id", "version")} == { + k: v for k, v in before.items() if k != "version" + }, rel + for rel, text in BYSTANDERS.items(): + assert (root / rel).read_text() == text, rel + assert not (root / ".git" / ".assign-ids.json").exists() + + +def test_a_store_from_before_ids_gets_each_key_as_id(tmp_path: Path, caplog): + root = golden(tmp_path / "flows") + commits = git(root, "rev-list", "--count", "HEAD") + + with caplog.at_level(logging.WARNING, logger=identity.__name__): + changed = assign_ids(root) + + assert changed == sorted(GOLDEN) + assert_migrated(root) + assert "heating/flow.json" in caplog.text + # One commit of those documents and nothing else. + assert git(root, "log", "-1", "--format=%s").strip() == COMMIT_MESSAGE + assert sorted(git(root, "show", "--name-only", "--format=", "HEAD").split()) == ( + sorted(GOLDEN) + ) + assert int(git(root, "rev-list", "--count", "HEAD")) == int(commits) + 1 + + # What the stores read is what they read before, but for the version. + flows, dashboards = FlowStore(root), DashboardStore(FlowStore(root)) + assert flows.read_flow("heating", draft=True).nodes[0].id == "reader" + assert flows.key_index() == {n: n for n in ("heating", "home", "lab", "stopped")} + assert [w.id for w in dashboards.read("legacy").widgets] == ["t"] + assert dashboards.key_index() == {n: n for n in ("draft_only", "home", "legacy")} + + # Once is all: a second start changes nothing and says nothing. + before = snapshot(root) + caplog.clear() + with caplog.at_level(logging.WARNING, logger=identity.__name__): + assert assign_ids(root) == [] + assert snapshot(root) == before + assert caplog.text == "" + + +@pytest.mark.parametrize("survives", range(len(GOLDEN) + 1)) +def test_a_run_interrupted_part_way_is_finished_by_the_next( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, survives: int +): + """The note of the versions is the first write, so it is always there.""" + root = golden(tmp_path / "flows") + real = identity._replace + writes: list[Path] = [] + + def crash(path: Path, text: str) -> None: + if len(writes) == survives: + # Half of the temporary file, and never the replace. + path.with_name(f".{path.name}.tmp").write_text(text[:20]) + raise OSError("power cut") + writes.append(path) + real(path, text) + + monkeypatch.setattr(identity, "_replace", crash) + with pytest.raises(OSError): + assign_ids(root) + monkeypatch.setattr(identity, "_replace", real) + + assert assign_ids(root) == sorted(GOLDEN) + assert_migrated(root) + assert git(root, "log", "-1", "--format=%s").strip() == COMMIT_MESSAGE + assert git(root, "status", "--porcelain") == "" + assert not list(root.rglob("*.tmp")) + + +def test_a_run_interrupted_before_its_commit_is_committed_by_the_next( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + """Every document has its id by then, and the note is what is left.""" + root = golden(tmp_path / "flows") + + def crash(*_: object) -> bool: + raise OSError("power cut") + + monkeypatch.setattr(identity, "_commit", crash) + with pytest.raises(OSError): + assign_ids(root) + monkeypatch.undo() + assert git(root, "log", "-1", "--format=%s").strip() == "Old" + + assert assign_ids(root) == sorted(GOLDEN) + assert_migrated(root) + assert git(root, "log", "-1", "--format=%s").strip() == COMMIT_MESSAGE + assert assign_ids(root) == [] + + +def test_documents_that_have_their_id_keep_their_version(tmp_path: Path): + """A pair half given its id moves the other half past both.""" + root = golden( + tmp_path / "flows", + { + "heating/flow.json": {"id": "heating", "name": "heating", "version": 3}, + # An older engine saved this after the published one got its id. + "heating/flow.draft.json": {"name": "heating", "version": 7}, + "lab/flow.draft.json": {"id": "lab", "name": "lab", "version": 1}, + # Moved apart by a rename, with its id: nothing to do with it. + "warmth/flow.json": {"id": "warmth", "name": "radiators"}, + }, + ) + + changed = assign_ids(root) + + assert "heating/flow.json" not in changed and "lab/flow.draft.json" not in changed + assert "warmth/flow.json" not in changed + assert json.loads((root / "heating/flow.json").read_text())["version"] == 3 + assert json.loads((root / "heating/flow.draft.json").read_text())["version"] == 8 + assert json.loads((root / "lab/flow.draft.json").read_text())["version"] == 1 + + +@pytest.mark.parametrize( + ("extra", "named"), + [ + # Which one the flow is cannot be told: the engine runs it as `warmth`. + ({"heating/flow.json": {"name": "warmth"}}, "heating/flow.json"), + ({"heating/flow.json": {"id": "warmth", "name": "heating"}}, "'warmth'"), + ( + { + "pair/flow.json": {"id": "pair", "name": "one"}, + "pair/flow.draft.json": {"id": "pair", "name": "two"}, + }, + "'one' and 'two'", + ), + ( + { + "a/flow.json": {"id": "a", "name": "same"}, + "c/flow.draft.json": {"id": "c", "name": "same"}, + }, + "'a' and 'c' share the key 'same'", + ), + ({"lab/flow.draft.json": "{not json"}, "lab/flow.draft.json"), + ({f"{DASHBOARD_DIR}/home/dashboard.json": {"name": "hall"}}, "'hall'"), + ], +) +def test_a_store_in_doubt_is_refused_and_left_as_it_was( + tmp_path: Path, extra: dict, named: str +): + root = golden(tmp_path / "flows", extra) + before = snapshot(root) + + with pytest.raises(IdsRefused) as refused: + assign_ids(root) + + assert named in str(refused.value) + assert f"fluksio store migrate --dry-run {root}" in str(refused.value) + assert snapshot(root) == before + + +def test_uncommitted_changes_beside_it_stay_uncommitted(tmp_path: Path): + root = golden(tmp_path / "flows") + (root / "_lib" / "shared.py").write_text("edited behind the API\n") + (root / "notes.txt").write_text("someone's notes\n") + git(root, "add", "notes.txt") + + assign_ids(root) + + assert sorted(git(root, "show", "--name-only", "--format=", "HEAD").split()) == ( + sorted(GOLDEN) + ) + assert git(root, "status", "--porcelain").splitlines() == [ + " M _lib/shared.py", + "A notes.txt", + ] + + +def test_a_store_without_a_repository(tmp_path: Path): + root = tmp_path / "flows" + for rel, data in {**GOLDEN, **BYSTANDERS}.items(): + put(root, rel, data) + + assert assign_ids(root) == sorted(GOLDEN) + + assert_migrated(root) + assert not (root / ".assign-ids.json").exists() + assert not (root / ".git").exists() + + +def test_a_machine_without_git(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + root = golden(tmp_path / "flows") + real = subprocess.run + + def no_git(cmd, *args, **kwargs): + if cmd and cmd[0] == "git": + raise FileNotFoundError(2, "No such file or directory", "git") + return real(cmd, *args, **kwargs) + + monkeypatch.setattr(identity.subprocess, "run", no_git) + + assert assign_ids(root) == sorted(GOLDEN) + assert_migrated(root) + + +# ----------------------------------------------------------------------------- +# `fluksio store` +# ----------------------------------------------------------------------------- + + +def test_the_dry_run_changes_no_bytes(tmp_path: Path, capsys): + root = golden(tmp_path / "flows") + before = snapshot(root) + + assert cli.main(["store", "migrate", "--dry-run", str(root)]) == 0 + + out = capsys.readouterr().out + assert "4 flows, 3 dashboards" in out + assert "heating/flow.draft.json (version 6)" in out + assert snapshot(root) == before + + +def test_the_dry_run_creates_no_repository_and_names_what_it_refuses( + tmp_path: Path, capsys +): + root = tmp_path / "flows" + for rel, data in {**GOLDEN, "odd/flow.json": {"name": "even"}}.items(): + put(root, rel, data) + before = snapshot(root) + + assert cli.main(["store", "migrate", "--dry-run", str(root)]) == 1 + + assert "odd/flow.json: its name 'even' is not its directory 'odd'" in ( + capsys.readouterr().out + ) + assert snapshot(root) == before + assert not (root / ".git").exists() + + +def test_ls_lists_ids_keys_and_kinds(tmp_path: Path, capsys): + root = golden( + tmp_path / "flows", {"warmth/flow.json": {"id": "warmth", "name": "radiators"}} + ) + + assert cli.main(["store", "ls", str(root)]) == 0 + + rows = [line.split() for line in capsys.readouterr().out.splitlines()] + assert rows[0] == ["ID", "KEY", "KIND"] + assert ["home", "home", "dashboard"] in rows and ["home", "home", "flow"] in rows + assert ["warmth", "radiators", "flow"] in rows diff --git a/backend/tests/flow/test_store.py b/backend/tests/flow/test_store.py index f57f162..c3c6558 100644 --- a/backend/tests/flow/test_store.py +++ b/backend/tests/flow/test_store.py @@ -8,7 +8,7 @@ import pytest from fluksio.flow.messages import MessageSpec from fluksio.flow.schemas import FlowDef, NodeDef -from fluksio.flow.store import FlowExists, FlowNotFound, FlowStore +from fluksio.flow.store import FlowNotFound, FlowStore @pytest.fixture @@ -92,48 +92,6 @@ def test_deleting_removes_flow_and_its_nodes(store: FlowStore): assert not (store.root / "heating").exists() -def test_renaming_a_flow_carries_its_nodes(store: FlowStore): - store.write_flow(a_flow()) - store.write_node_source("heating", "sensor", "def process():\n return {}\n") - - renamed = store.rename_flow("heating", "warmth") - - assert renamed.name == "warmth" - assert store.list_flows() == ["warmth"] - assert "def process" in store.read_node_source("warmth", "sensor") - - -def test_renaming_a_flow_repoints_the_flows_reading_it(store: FlowStore): - store.write_flow(a_flow()) - store.write_flow( - FlowDef( - name="display", - nodes=[ - NodeDef( - id="gauge", - # Reads across the flow boundary, so the name must follow. - requires=[MessageSpec(name="heating.temp")], - ) - ], - ) - ) - - store.rename_flow("heating", "warmth") - - display = store.read_flow("display") - assert display.nodes[0].requires[0].name == "warmth.temp" - - -def test_renaming_onto_an_existing_name_is_refused(store: FlowStore): - store.write_flow(a_flow()) - store.write_flow(FlowDef(name="warmth")) - - with pytest.raises(FlowExists): - store.rename_flow("heating", "warmth") - - assert store.list_flows() == ["heating", "warmth"] - - def test_opening_the_store_renames_trigger_nodes_to_debounce(store: FlowStore): """The old name has no alias, so what is stored under it is rewritten once.""" store.write_flow( diff --git a/docs/code/api.md b/docs/code/api.md index 3519742..08ea2d6 100644 --- a/docs/code/api.md +++ b/docs/code/api.md @@ -57,7 +57,6 @@ a token. Password recovery uses `POST /password-recovery/{email}` and | `PUT` | `/flows/{name}` | save a draft; send the current `version` | | `POST` | `/flows/{name}/publish` | publish; send `{"version": }` | | `POST` | `/flows/{name}/discard-draft` | discard unpublished edits | -| `POST` | `/flows/{name}/rename` | rename | | `POST` | `/flows/{name}/duplicate` | copy into an unpublished `{name}_copy`; code nodes need a superuser | | `DELETE` | `/flows/{name}` | delete; refused while a run is queued or running | | `GET` | `/flows/node-types` | list types and parameter schemas | @@ -65,6 +64,10 @@ a token. Password recovery uses `POST /password-recovery/{email}` and Deleting a flow keeps its run history. Delete runs separately. +A flow document carries an `id` beside its `name`. The id never changes, and +for now it equals the name, which is the `{name}` in these paths. A save may +leave the id out, but cannot change the id or the name. + ### Node source ```sh @@ -194,7 +197,7 @@ reclaiming space sooner. | `POST` | `/dashboards/{name}` | create | | `POST` | `/dashboards/from-flow/{flow}` | create a batch flow's results dashboard | | `PUT` | `/dashboards/{name}` | save a draft | -| `POST` | `/dashboards/{name}/publish`, `/dashboards/{name}/discard`, `/dashboards/{name}/rename` | publish, discard, or rename | +| `POST` | `/dashboards/{name}/publish`, `/dashboards/{name}/discard` | publish or discard | | `POST` | `/dashboards/{name}/duplicate` | copy into an unpublished `{name}_copy` | | `GET` | `/devices/` | list devices, with `online`, `last_seen`, `report` and `reported_at` | | `POST`, `GET`, `PUT`, `DELETE` | `/devices/{id}` | create, read, save or delete one; a save sends the `version` it read and gets 409 when someone saved first. A read adds `io` and `bindings`, its ports as the device nodes of published flows wire them | @@ -207,6 +210,9 @@ reclaiming space sooner. | `PUT` | `/devices/{id}/report` | what the device has, from its own credential only; at most 16 KB, and 64 KB once its named `config_schemas` are put in place (413 above either), replaces the last report, read leniently (see [What a device reports](../interface/dashboards.md#what-a-device-reports)) | | `GET` | `/devices/{id}/ice` | STUN and TURN servers for a live view of the device, as `{ice_servers: [{urls, username?, credential?}], ttl}`, with TURN credentials of the caller's own; 403 for whoever may not watch its camera, and for a device credential | +A dashboard document carries an `id` the way a flow does. Dashboards have ids +of their own, so a flow and a dashboard may share one. + `/panels/pair`, `/panels/pair/{code}` and `GET /panels/{id}` answer as their `/devices/` counterparts, for firmware that has not updated yet. diff --git a/docs/code/cli.md b/docs/code/cli.md index 1de324e..aea82a0 100644 --- a/docs/code/cli.md +++ b/docs/code/cli.md @@ -130,6 +130,21 @@ FLUKSIO_WORKER_TOKEN=your-token fluksio worker \ Create the worker token on the engine first. See [Remote workers](workers.md) for token creation, interpreter selection and labels. +## `fluksio store` + +```sh +fluksio store ls ~/.fluksio/flows +fluksio store migrate --dry-run ./flows-copy +``` + +Both read a flow store directory and change nothing. `ls` lists its flows and +dashboards by id, key and kind, which helps when reading a backup. + +`migrate --dry-run` runs the check the engine makes when it starts and prints +what the start would migrate, or why it would refuse to start. It exits with 1 +on a refusal. Point it at a copy of the store. The engine migrates the store +itself, on its next start. + ## Talking to an engine Client commands use the nearest `.fluksio/client.json`, or the global diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index 8c9fe61..05434ce 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -670,6 +670,11 @@ sequence number — which nothing here interprets.` export const DashboardDef_InputSchema = { properties: { + id: { + type: 'string', + title: 'Id', + default: '' + }, name: { type: 'string', title: 'Name' @@ -744,6 +749,11 @@ export const DashboardDef_InputSchema = { export const DashboardDef_OutputSchema = { properties: { + id: { + type: 'string', + title: 'Id', + default: '' + }, name: { type: 'string', title: 'Name' @@ -818,6 +828,11 @@ export const DashboardDef_OutputSchema = { export const DashboardSummarySchema = { properties: { + id: { + type: 'string', + title: 'Id', + default: '' + }, name: { type: 'string', title: 'Name' @@ -1491,6 +1506,11 @@ export const FlavorsPublicSchema = { export const FlowDef_InputSchema = { properties: { + id: { + type: 'string', + title: 'Id', + default: '' + }, name: { type: 'string', title: 'Name' @@ -1554,6 +1574,11 @@ export const FlowDef_InputSchema = { export const FlowDef_OutputSchema = { properties: { + id: { + type: 'string', + title: 'Id', + default: '' + }, name: { type: 'string', title: 'Name' @@ -1877,6 +1902,11 @@ export const FlowStatePublicSchema = { export const FlowSummarySchema = { properties: { + id: { + type: 'string', + title: 'Id', + default: '' + }, name: { type: 'string', title: 'Name' @@ -5338,18 +5368,6 @@ export const fluksio__api__routes__dashboards__PublishRequestSchema = { title: 'PublishRequest' } as const; -export const fluksio__api__routes__dashboards__RenameRequestSchema = { - properties: { - name: { - type: 'string', - title: 'Name' - } - }, - type: 'object', - required: ['name'], - title: 'RenameRequest' -} as const; - export const fluksio__api__routes__flows__PublishRequestSchema = { properties: { version: { @@ -5362,18 +5380,6 @@ export const fluksio__api__routes__flows__PublishRequestSchema = { title: 'PublishRequest' } as const; -export const fluksio__api__routes__flows__RenameRequestSchema = { - properties: { - new_name: { - type: 'string', - title: 'New Name' - } - }, - type: 'object', - required: ['new_name'], - title: 'RenameRequest' -} as const; - export const fluksio__api__routes__messages__MessageValueSchema = { properties: { name: { diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index 9832b4e..8e04977 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -3,7 +3,7 @@ import type { CancelablePromise } from './core/CancelablePromise'; import { OpenAPI } from './core/OpenAPI'; import { request as __request } from './core/request'; -import type { AiReadAiConfigResponse, AiUpdateAiConfigData, AiUpdateAiConfigResponse, AiDeleteAiConfigResponse, AiListAiModelsData, AiListAiModelsResponse, AiReadAiStatusResponse, AiReadControlNodesResponse, AiReadChatResponse, AiChatData, AiChatResponse, AiClearChatResponse, AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsReadWebpushKeyResponse, AlertsAddWebpushSubscriptionData, AlertsAddWebpushSubscriptionResponse, AlertsRemoveWebpushSubscriptionData, AlertsRemoveWebpushSubscriptionResponse, AlertsStartTelegramLinkData, AlertsStartTelegramLinkResponse, AlertsReadTelegramLinkData, AlertsReadTelegramLinkResponse, AlertsCancelTelegramLinkData, AlertsCancelTelegramLinkResponse, AlertsTestChannelData, AlertsTestChannelResponse, ArtifactsPutArtifactData, ArtifactsPutArtifactResponse, ArtifactsGetArtifactData, ArtifactsGetArtifactResponse, ArtifactsDeleteArtifactData, ArtifactsDeleteArtifactResponse, CloudReadStatusResponse, CloudEnrollData, CloudEnrollResponse, CloudAddRemoteUserData, CloudAddRemoteUserResponse, CloudReadMembersResponse, CloudLinkMemberData, CloudLinkMemberResponse, CloudRevokeMemberData, CloudRevokeMemberResponse, CloudDisconnectResponse, ConnectorsReadConnectorStateData, ConnectorsReadConnectorStateResponse, ConnectorsClearConnectorStateData, ConnectorsClearConnectorStateResponse, ConnectorsDeleteConnectorStateData, ConnectorsDeleteConnectorStateResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, DashboardsGenerateResultsDashboardData, DashboardsGenerateResultsDashboardResponse, DashboardsPublishDashboardData, DashboardsPublishDashboardResponse, DashboardsDiscardDashboardDraftData, DashboardsDiscardDashboardDraftResponse, DashboardsDuplicateDashboardData, DashboardsDuplicateDashboardResponse, DashboardsRenameDashboardData, DashboardsRenameDashboardResponse, DevicesStartPairingData, DevicesStartPairingResponse, DevicesPollPairingData, DevicesPollPairingResponse, DevicesReadDevicesResponse, DevicesReadPendingResponse, DevicesPendingDeviceData, DevicesPendingDeviceResponse, DevicesCreateDeviceData, DevicesCreateDeviceResponse, DevicesReadDeviceData, DevicesReadDeviceResponse, DevicesSaveDeviceData, DevicesSaveDeviceResponse, DevicesDeleteDeviceData, DevicesDeleteDeviceResponse, DevicesReadIceData, DevicesReadIceResponse, DevicesPutReportData, DevicesPutReportResponse, DevicesApprovePairingData, DevicesApprovePairingResponse, DevicesUnpairDeviceData, DevicesUnpairDeviceResponse, FlavorsReadFlavorsResponse, FlavorsCreateFlavorData, FlavorsCreateFlavorResponse, FlavorsUpdateFlavorData, FlavorsUpdateFlavorResponse, FlavorsDeleteFlavorData, FlavorsDeleteFlavorResponse, FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadGraphResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsDuplicateFlowData, FlowsDuplicateFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsStepFlowData, FlowsStepFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsCancelNodeData, FlowsCancelNodeResponse, FlowsAcknowledgeNodeErrorData, FlowsAcknowledgeNodeErrorResponse, FlowsTrainNodeData, FlowsTrainNodeResponse, FlowsReadTrainingData, FlowsReadTrainingResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, FlowsIssueSocketTicketResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MessagesReadMessagesResponse, MessagesPublishMessageData, MessagesPublishMessageResponse, MessagesReadMessageHistoryData, MessagesReadMessageHistoryResponse, ModulesReadModulesResponse, ModulesApplyModulesData, ModulesApplyModulesResponse, ModulesRefreshModulesResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, OauthReadClientsResponse, OauthRevokeClientData, OauthRevokeClientResponse, ObservabilityReadSummaryResponse, ObservabilityReadTimeseriesData, ObservabilityReadTimeseriesResponse, ObservabilityReadFlowRollupsData, ObservabilityReadFlowRollupsResponse, ObservabilityReadNodeTypeTimingsData, ObservabilityReadNodeTypeTimingsResponse, ObservabilityReadRunsData, ObservabilityReadRunsResponse, ObservabilityReadEventsData, ObservabilityReadEventsResponse, ObservabilityReadDeadLettersData, ObservabilityReadDeadLettersResponse, ObservabilityFlushMetricsResponse, PrivateCreateUserData, PrivateCreateUserResponse, RunsCreateRunData, RunsCreateRunResponse, RunsCreateSweepData, RunsCreateSweepResponse, RunsImportRunsData, RunsImportRunsResponse, RunsReadRunsData, RunsReadRunsResponse, RunsDeleteRunsData, RunsDeleteRunsResponse, RunsReadOverviewResponse, RunsExportMetricsData, RunsExportMetricsResponse, RunsExportRunsData, RunsExportRunsResponse, RunsReadMetricNamesData, RunsReadMetricNamesResponse, RunsReadRunData, RunsReadRunResponse, RunsDeleteRunData, RunsDeleteRunResponse, RunsReadNodeOutputsData, RunsReadNodeOutputsResponse, RunsCancelRunData, RunsCancelRunResponse, RunsRetryRunData, RunsRetryRunResponse, RunsReadMetricsData, RunsReadMetricsResponse, RunsCompareMetricData, RunsCompareMetricResponse, SearchReadSearchIndexResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsReadConfigResponse, UtilsHealthCheckResponse, UtilsHealthResponse, WorkersReadWorkersResponse, WorkersReadResourcesResponse, WorkersReadTokensResponse, WorkersIssueTokenData, WorkersIssueTokenResponse, WorkersRevokeTokenData, WorkersRevokeTokenResponse, WorkersReadRuntimeResponse } from './types.gen'; +import type { AiReadAiConfigResponse, AiUpdateAiConfigData, AiUpdateAiConfigResponse, AiDeleteAiConfigResponse, AiListAiModelsData, AiListAiModelsResponse, AiReadAiStatusResponse, AiReadControlNodesResponse, AiReadChatResponse, AiChatData, AiChatResponse, AiClearChatResponse, AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsReadWebpushKeyResponse, AlertsAddWebpushSubscriptionData, AlertsAddWebpushSubscriptionResponse, AlertsRemoveWebpushSubscriptionData, AlertsRemoveWebpushSubscriptionResponse, AlertsStartTelegramLinkData, AlertsStartTelegramLinkResponse, AlertsReadTelegramLinkData, AlertsReadTelegramLinkResponse, AlertsCancelTelegramLinkData, AlertsCancelTelegramLinkResponse, AlertsTestChannelData, AlertsTestChannelResponse, ArtifactsPutArtifactData, ArtifactsPutArtifactResponse, ArtifactsGetArtifactData, ArtifactsGetArtifactResponse, ArtifactsDeleteArtifactData, ArtifactsDeleteArtifactResponse, CloudReadStatusResponse, CloudEnrollData, CloudEnrollResponse, CloudAddRemoteUserData, CloudAddRemoteUserResponse, CloudReadMembersResponse, CloudLinkMemberData, CloudLinkMemberResponse, CloudRevokeMemberData, CloudRevokeMemberResponse, CloudDisconnectResponse, ConnectorsReadConnectorStateData, ConnectorsReadConnectorStateResponse, ConnectorsClearConnectorStateData, ConnectorsClearConnectorStateResponse, ConnectorsDeleteConnectorStateData, ConnectorsDeleteConnectorStateResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, DashboardsGenerateResultsDashboardData, DashboardsGenerateResultsDashboardResponse, DashboardsPublishDashboardData, DashboardsPublishDashboardResponse, DashboardsDiscardDashboardDraftData, DashboardsDiscardDashboardDraftResponse, DashboardsDuplicateDashboardData, DashboardsDuplicateDashboardResponse, DevicesStartPairingData, DevicesStartPairingResponse, DevicesPollPairingData, DevicesPollPairingResponse, DevicesReadDevicesResponse, DevicesReadPendingResponse, DevicesPendingDeviceData, DevicesPendingDeviceResponse, DevicesCreateDeviceData, DevicesCreateDeviceResponse, DevicesReadDeviceData, DevicesReadDeviceResponse, DevicesSaveDeviceData, DevicesSaveDeviceResponse, DevicesDeleteDeviceData, DevicesDeleteDeviceResponse, DevicesReadIceData, DevicesReadIceResponse, DevicesPutReportData, DevicesPutReportResponse, DevicesApprovePairingData, DevicesApprovePairingResponse, DevicesUnpairDeviceData, DevicesUnpairDeviceResponse, FlavorsReadFlavorsResponse, FlavorsCreateFlavorData, FlavorsCreateFlavorResponse, FlavorsUpdateFlavorData, FlavorsUpdateFlavorResponse, FlavorsDeleteFlavorData, FlavorsDeleteFlavorResponse, FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadGraphResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsDuplicateFlowData, FlowsDuplicateFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsStepFlowData, FlowsStepFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsCancelNodeData, FlowsCancelNodeResponse, FlowsAcknowledgeNodeErrorData, FlowsAcknowledgeNodeErrorResponse, FlowsTrainNodeData, FlowsTrainNodeResponse, FlowsReadTrainingData, FlowsReadTrainingResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, FlowsIssueSocketTicketResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MessagesReadMessagesResponse, MessagesPublishMessageData, MessagesPublishMessageResponse, MessagesReadMessageHistoryData, MessagesReadMessageHistoryResponse, ModulesReadModulesResponse, ModulesApplyModulesData, ModulesApplyModulesResponse, ModulesRefreshModulesResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, OauthReadClientsResponse, OauthRevokeClientData, OauthRevokeClientResponse, ObservabilityReadSummaryResponse, ObservabilityReadTimeseriesData, ObservabilityReadTimeseriesResponse, ObservabilityReadFlowRollupsData, ObservabilityReadFlowRollupsResponse, ObservabilityReadNodeTypeTimingsData, ObservabilityReadNodeTypeTimingsResponse, ObservabilityReadRunsData, ObservabilityReadRunsResponse, ObservabilityReadEventsData, ObservabilityReadEventsResponse, ObservabilityReadDeadLettersData, ObservabilityReadDeadLettersResponse, ObservabilityFlushMetricsResponse, PrivateCreateUserData, PrivateCreateUserResponse, RunsCreateRunData, RunsCreateRunResponse, RunsCreateSweepData, RunsCreateSweepResponse, RunsImportRunsData, RunsImportRunsResponse, RunsReadRunsData, RunsReadRunsResponse, RunsDeleteRunsData, RunsDeleteRunsResponse, RunsReadOverviewResponse, RunsExportMetricsData, RunsExportMetricsResponse, RunsExportRunsData, RunsExportRunsResponse, RunsReadMetricNamesData, RunsReadMetricNamesResponse, RunsReadRunData, RunsReadRunResponse, RunsDeleteRunData, RunsDeleteRunResponse, RunsReadNodeOutputsData, RunsReadNodeOutputsResponse, RunsCancelRunData, RunsCancelRunResponse, RunsRetryRunData, RunsRetryRunResponse, RunsReadMetricsData, RunsReadMetricsResponse, RunsCompareMetricData, RunsCompareMetricResponse, SearchReadSearchIndexResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsReadConfigResponse, UtilsHealthCheckResponse, UtilsHealthResponse, WorkersReadWorkersResponse, WorkersReadResourcesResponse, WorkersReadTokensResponse, WorkersIssueTokenData, WorkersIssueTokenResponse, WorkersRevokeTokenData, WorkersRevokeTokenResponse, WorkersReadRuntimeResponse } from './types.gen'; export class AiService { /** @@ -851,29 +851,6 @@ export class DashboardsService { } }); } - - /** - * Rename Dashboard - * @param data The data for the request. - * @param data.name - * @param data.requestBody - * @returns DashboardDef_Output Successful Response - * @throws ApiError - */ - public static renameDashboard(data: DashboardsRenameDashboardData): CancelablePromise { - return __request(OpenAPI, { - method: 'POST', - url: '/api/v1/dashboards/{name}/rename', - path: { - name: data.name - }, - body: data.requestBody, - mediaType: 'application/json', - errors: { - 422: 'Validation Error' - } - }); - } } export class DevicesService { @@ -1462,30 +1439,6 @@ export class FlowsService { }); } - /** - * Rename Flow - * Rename a flow, along with every reference to its messages. - * @param data The data for the request. - * @param data.name - * @param data.requestBody - * @returns FlowDetail Successful Response - * @throws ApiError - */ - public static renameFlow(data: FlowsRenameFlowData): CancelablePromise { - return __request(OpenAPI, { - method: 'POST', - url: '/api/v1/flows/{name}/rename', - path: { - name: data.name - }, - body: data.requestBody, - mediaType: 'application/json', - errors: { - 422: 'Validation Error' - } - }); - } - /** * Duplicate Flow * Copy a flow into a new one that is only a draft, named after it. diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index bccee88..cbba3aa 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -172,6 +172,7 @@ export type ControlWatch = { * A dashboard as stored, and as the API hands it over. */ export type DashboardDef_Input = { + id?: string; name: string; title?: string; columns?: number; @@ -193,6 +194,7 @@ export type shape = 'rect' | 'round'; * A dashboard as stored, and as the API hands it over. */ export type DashboardDef_Output = { + id?: string; name: string; title?: string; columns?: number; @@ -217,6 +219,7 @@ export type DashboardsPublic = { * A dashboard in a list, without its contents. */ export type DashboardSummary = { + id?: string; name: string; title?: string; icon?: string; @@ -425,6 +428,7 @@ export type FlavorUpdate = { * One atomic flow. */ export type FlowDef_Input = { + id?: string; name: string; title?: string; nodes?: Array; @@ -453,6 +457,7 @@ export type mode = 'live' | 'batch'; * One atomic flow. */ export type FlowDef_Output = { + id?: string; name: string; title?: string; nodes?: Array; @@ -576,6 +581,7 @@ export type FlowStatePublic = { }; export type FlowSummary = { + id?: string; name: string; title?: string; mode?: 'live' | 'batch'; @@ -593,18 +599,10 @@ export type fluksio__api__routes__dashboards__PublishRequest = { version: number; }; -export type fluksio__api__routes__dashboards__RenameRequest = { - name: string; -}; - export type fluksio__api__routes__flows__PublishRequest = { version: number; }; -export type fluksio__api__routes__flows__RenameRequest = { - new_name: string; -}; - export type fluksio__api__routes__messages__MessageValue = { name: string; value: unknown; @@ -1901,13 +1899,6 @@ export type DashboardsDuplicateDashboardData = { export type DashboardsDuplicateDashboardResponse = (DashboardDef_Output); -export type DashboardsRenameDashboardData = { - name: string; - requestBody: fluksio__api__routes__dashboards__RenameRequest; -}; - -export type DashboardsRenameDashboardResponse = (DashboardDef_Output); - export type DevicesStartPairingData = { requestBody?: (PairHello | null); }; @@ -2050,13 +2041,6 @@ export type FlowsDiscardDraftData = { export type FlowsDiscardDraftResponse = (FlowDetail); -export type FlowsRenameFlowData = { - name: string; - requestBody: fluksio__api__routes__flows__RenameRequest; -}; - -export type FlowsRenameFlowResponse = (FlowDetail); - export type FlowsDuplicateFlowData = { name: string; };