← Files SavvyARCHIVED FILE
skills/savvy/scripts/savant_api/recipes.py
14 KB · Oct 4, 2026 · 12:27 UTC
from __future__ import annotations
import copy
import json
import urllib.parse
from pathlib import Path
from typing import Any
from .httpclient import poll_promise, promise_id_from_response, request, request_multipart
from .models import SavantAppApiError, SavantSessionContext
def list_folder_recipes(context: SavantSessionContext, folder_id: str) -> dict[str, Any]:
if not folder_id:
raise SavantAppApiError("Folder workflow listing requires a folder id.")
query = urllib.parse.urlencode({"folderId": folder_id})
response = request(context, f"/api/recipes?{query}")
if isinstance(response, list):
return {"slice": [recipe for recipe in response if isinstance(recipe, dict)], "totalCount": len(response)}
if not isinstance(response, dict):
raise SavantAppApiError("GET /api/recipes?folderId=... did not return a JSON object.")
recipes = response.get("slice")
if recipes is None and isinstance(response.get("recipes"), list):
recipes = response["recipes"]
if not isinstance(recipes, list):
raise SavantAppApiError("GET /api/recipes?folderId=... did not return a recipe array.")
return {**response, "slice": [recipe for recipe in recipes if isinstance(recipe, dict)]}
def _json_copy(value: Any) -> Any:
return copy.deepcopy(value)
def import_recipe_json(
context: SavantSessionContext,
json_path: Path,
*,
folder_id: str | None = None,
folder_name: str | None = None,
) -> dict[str, Any]:
if folder_id and folder_name:
raise SavantAppApiError("Provide folder_id or folder_name, not both.")
json_path = Path(json_path)
if not json_path.exists() or not json_path.is_file():
raise SavantAppApiError(f"Workflow JSON file does not exist: {json_path}")
content = json_path.read_bytes()
try:
workflow = json.loads(content)
except json.JSONDecodeError as exc:
raise SavantAppApiError(f"Workflow JSON is not valid JSON: {json_path}") from exc
if not isinstance(workflow, dict) or not isinstance(workflow.get("name"), str) or not isinstance(workflow.get("nodes"), list):
raise SavantAppApiError("Workflow JSON must be an object with a string `name` and array `nodes`.")
fields: dict[str, str] = {}
if folder_id:
fields["folderId"] = folder_id
if folder_name:
fields["folderName"] = folder_name
response = request_multipart(
context,
"/api/recipes/import",
fields=fields,
files={"file": (json_path.name, content, "application/json")},
)
if not isinstance(response, dict):
raise SavantAppApiError("POST /api/recipes/import did not return a JSON object.")
return response
def _walk_json(value: Any) -> list[Any]:
values = [value]
if isinstance(value, dict):
for child in value.values():
values.extend(_walk_json(child))
elif isinstance(value, list):
for child in value:
values.extend(_walk_json(child))
return values
def created_flow_id_from_import(import_response: dict[str, Any], promise_response: dict[str, Any] | None = None) -> str | None:
candidates = [import_response]
if promise_response is not None:
candidates.append(promise_response)
for value in _walk_json(candidates):
if not isinstance(value, dict):
continue
for key in ("flowId", "recipeId", "analysisId"):
candidate = value.get(key)
if isinstance(candidate, str) and candidate:
return candidate
candidate = value.get("id")
if isinstance(candidate, str) and candidate and (
"folderId" in value or "numNodes" in value or ("namespace" in value and "name" in value)
):
return candidate
if value.get("type") == "recipe" and isinstance(candidate, str) and candidate:
return candidate
return None
def create_workflow_from_json(
context: SavantSessionContext,
json_path: Path,
*,
folder_id: str | None,
poll: bool = True,
) -> dict[str, Any]:
# folder_id None (or empty) means the Home folder (namespace root); import_recipe_json omits
# the folderId field in that case so the workflow lands in the Home folder.
import_response = import_recipe_json(context, json_path, folder_id=folder_id)
result: dict[str, Any] = {"importResponse": import_response}
if poll:
promise_id = promise_id_from_response(import_response)
promise_response = poll_promise(context, promise_id)
result["promiseResponse"] = promise_response
flow_id = created_flow_id_from_import(import_response, result.get("promiseResponse"))
if flow_id:
result["flowId"] = flow_id
result["flowUrl"] = f"{context.origin}/en/app/flow/{flow_id}"
if context.namespace:
result["flowUrl"] += f"?rns={urllib.parse.quote(context.namespace)}"
# The folder check that used to run here needs the created recipe, which this toolchain no
# longer reads. It moved to `workflow verify --operation create --expect-folder-id`, which
# the caller runs on the MCP-fetched recipe. Carried out so the caller can pass it straight
# back in, and so the report still records what was requested.
result["requestedFolderId"] = folder_id or None
result["verifyCommand"] = (
f"fetch savant://workflow/{flow_id} -> after.json, then: python3 savant.py workflow verify "
f"--operation create --workflow-json after.json --expect-flow-id {flow_id} "
f"--expect-folder-id {folder_id or ''} --source-json {json_path}"
)
result["verified"] = False
return result
def diff_has_changes(diff: dict[str, Any]) -> bool:
nodes = diff.get("nodes") if isinstance(diff, dict) else None
if not isinstance(nodes, dict):
return bool(diff.get("parametersChanged")) if isinstance(diff, dict) else False
return bool(nodes.get("added") or nodes.get("removed") or nodes.get("changed") or diff.get("parametersChanged"))
def recipe_nodes(recipe: dict[str, Any]) -> list[dict[str, Any]]:
nodes = recipe.get("nodes")
if isinstance(nodes, list):
return nodes
model = recipe.get("model")
if isinstance(model, dict) and isinstance(model.get("nodes"), list):
return model["nodes"]
return []
def recipe_parameters(recipe: dict[str, Any]) -> list[dict[str, Any]]:
parameters = recipe.get("parameters")
if isinstance(parameters, list):
return parameters
model = recipe.get("model")
if isinstance(model, dict) and isinstance(model.get("parameters"), list):
return model["parameters"]
return []
def prepare_recipe_for_save(
recipe: dict[str, Any],
*,
nodes: list[dict[str, Any]] | None = None,
parameters: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
if not isinstance(recipe, dict):
raise SavantAppApiError("Recipe save requires a JSON object.")
next_nodes = recipe_nodes(recipe) if nodes is None else nodes
next_parameters = recipe_parameters(recipe) if parameters is None else parameters
if not isinstance(next_nodes, list):
raise SavantAppApiError("Recipe save requires a nodes array.")
if not isinstance(next_parameters, list):
raise SavantAppApiError("Recipe save requires a parameters array.")
updated = _json_copy(recipe)
copied_nodes = _json_copy(next_nodes)
copied_parameters = _json_copy(next_parameters)
model = updated.get("model") if isinstance(updated.get("model"), dict) else {}
updated["nodes"] = copied_nodes
updated["parameters"] = copied_parameters
updated["model"] = {**model, "nodes": copied_nodes, "parameters": copied_parameters}
return updated
def save_recipe(context: SavantSessionContext, recipe: dict[str, Any]) -> Any:
prepared = prepare_recipe_for_save(recipe)
return request(context, "/api/recipes", method="PUT", body={"recipe": prepared})
def save_metadata(context: SavantSessionContext, flow_id: str, metadata: dict[str, Any]) -> Any:
# Flow metadata (name, description, tags) is NOT updated by `PUT /api/recipes`
# (that writes only nodes/parameters). The app updates it through a dedicated
# endpoint, sending the full flow object. `description` renders as Markdown.
return request(context, f"/api/recipes/{flow_id}/metadata", method="PUT", body=metadata)
def save_recipe_model(
context: SavantSessionContext,
recipe: dict[str, Any],
*,
nodes: list[dict[str, Any]] | None = None,
parameters: list[dict[str, Any]] | None = None,
) -> Any:
prepared = prepare_recipe_for_save(recipe, nodes=nodes, parameters=parameters)
return request(context, "/api/recipes", method="PUT", body={"recipe": prepared})
def update_workflow_recipe(
context: SavantSessionContext,
flow_id: str,
edited_recipe: dict[str, Any],
*,
before: dict[str, Any],
) -> dict[str, Any]:
"""Update an existing workflow recipe in place.
This is intentionally separate from ``create_workflow_from_json``. Creation-shaped workflow
JSON files do not carry the live workflow identity fields and must not be sent through the edit
path; doing so can create a new workflow-like object instead of mutating the requested flow.
``before`` is the pre-edit recipe the caller fetched through MCP. It serves the same two
purposes the in-process read-back used to: it proves the requested change is non-empty before
anything is written, and it is the baseline `workflow verify --before-json` diffs the saved
result against. Proving the save *persisted* now happens there, because it needs a recipe read
back after the write and this toolchain no longer reads recipes.
"""
if not flow_id:
raise SavantAppApiError("Workflow update requires an existing flow id.")
if not isinstance(edited_recipe, dict):
raise SavantAppApiError("Workflow update requires a recipe JSON object.")
edited_id = edited_recipe.get("id")
if not isinstance(edited_id, str) or not edited_id:
raise SavantAppApiError(
"Workflow update requires an edited recipe exported from the existing workflow. "
"Creation-shaped workflow JSON has no `id`; use create_workflow_from_json for creation "
"or merge the edit onto the live recipe before updating."
)
if edited_id != flow_id:
raise SavantAppApiError(f"Edited recipe id `{edited_id}` does not match target flow id `{flow_id}`.")
if not isinstance(before, dict):
raise SavantAppApiError(
"Workflow update requires the pre-edit recipe (`before`) so the requested change can be "
"diffed before it is written. Fetch it with the MCP `fetch` tool on "
"savant://workflow/{flowId}."
)
requested_diff = recipe_model_diff(before, edited_recipe)
if not diff_has_changes(requested_diff):
raise SavantAppApiError("Workflow update has no recipe changes to persist.")
save_response = save_recipe_model(context, edited_recipe)
response_id = save_response.get("id") if isinstance(save_response, dict) else None
if isinstance(response_id, str) and response_id and response_id != flow_id:
raise SavantAppApiError(
f"Workflow update returned id `{response_id}` instead of target flow id `{flow_id}`; "
"refusing to treat this as an in-place edit."
)
return {
"flowId": flow_id,
"requestedDiff": requested_diff,
"saveResponse": save_response,
# Whether the save actually landed is not knowable from here any more — it needs a recipe
# read back after the write. The caller re-fetches through MCP and runs this.
"verified": False,
"verifyCommand": (
f"fetch savant://workflow/{flow_id} -> after.json, then: python3 savant.py workflow "
f"verify --operation edit --workflow-json after.json --expect-flow-id {flow_id} "
"--before-json <the pre-edit recipe>"
),
}
def _node_map(recipe: dict[str, Any]) -> dict[str, dict[str, Any]]:
return {node["id"]: node for node in recipe_nodes(recipe) if isinstance(node, dict) and isinstance(node.get("id"), str)}
def _node_changed_fields(before: dict[str, Any], after: dict[str, Any]) -> list[str]:
fields = ["name", "type", "config", "position", "canvasConfig", "inlets", "outlets"]
return [field for field in fields if before.get(field) != after.get(field)]
def recipe_model_diff(before: dict[str, Any], after: dict[str, Any]) -> dict[str, Any]:
before_nodes = _node_map(before)
after_nodes = _node_map(after)
before_ids = set(before_nodes)
after_ids = set(after_nodes)
changed = []
for node_id in sorted(before_ids & after_ids):
fields = _node_changed_fields(before_nodes[node_id], after_nodes[node_id])
if fields:
changed.append(
{
"id": node_id,
"nameBefore": before_nodes[node_id].get("name"),
"nameAfter": after_nodes[node_id].get("name"),
"type": after_nodes[node_id].get("type") or before_nodes[node_id].get("type"),
"fields": fields,
}
)
return {
"nodes": {
"added": sorted(after_ids - before_ids),
"removed": sorted(before_ids - after_ids),
"changed": changed,
},
"parametersChanged": recipe_parameters(before) != recipe_parameters(after),
}
def assert_expected_model_diff(actual: dict[str, Any], expected: dict[str, Any]) -> None:
for key in ("added", "removed"):
if key in expected:
actual_values = actual.get("nodes", {}).get(key)
if actual_values != expected[key]:
raise SavantAppApiError(f"Unexpected node {key}: expected {expected[key]}, got {actual_values}")
if "changed" in expected:
expected_changed = sorted(expected["changed"])
actual_changed = sorted(item["id"] for item in actual.get("nodes", {}).get("changed", []))
if actual_changed != expected_changed:
raise SavantAppApiError(f"Unexpected changed nodes: expected {expected_changed}, got {actual_changed}")
SHA-256: c6beee0f55c1eb2199cf13c6dbf55d35bf7e15ddda77474bf07f24300a9005af