feat: merge draft step maps
This commit is contained in:
+97
-2
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any
|
||||
|
||||
from wf_artifacts import (
|
||||
@@ -207,7 +207,15 @@ class WorkflowDraftApi:
|
||||
revision: int,
|
||||
step_id: str,
|
||||
input_map: dict[str, str],
|
||||
merge: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
input_values: dict[str, Any] = {}
|
||||
if merge:
|
||||
existing_map, input_values = self._step_input_maps(
|
||||
workspace_id=workspace_id,
|
||||
step_id=step_id,
|
||||
)
|
||||
input_map = {**existing_map, **input_map}
|
||||
return await self.patch_draft_workspace(
|
||||
workspace_id=workspace_id,
|
||||
revision=revision,
|
||||
@@ -215,7 +223,7 @@ class WorkflowDraftApi:
|
||||
{
|
||||
"op": "replace",
|
||||
"path": f"/steps/{_escape_json_pointer(step_id)}/input",
|
||||
"value": _draft_input_bindings_payload(input_map, {}),
|
||||
"value": _draft_input_bindings_payload(input_map, input_values),
|
||||
}
|
||||
],
|
||||
)
|
||||
@@ -227,7 +235,13 @@ class WorkflowDraftApi:
|
||||
revision: int,
|
||||
step_id: str,
|
||||
output_map: dict[str, str],
|
||||
merge: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
if merge:
|
||||
output_map = {
|
||||
**self._step_output_map(workspace_id=workspace_id, step_id=step_id),
|
||||
**output_map,
|
||||
}
|
||||
return await self.patch_draft_workspace(
|
||||
workspace_id=workspace_id,
|
||||
revision=revision,
|
||||
@@ -240,6 +254,21 @@ class WorkflowDraftApi:
|
||||
],
|
||||
)
|
||||
|
||||
def _step_input_maps(
|
||||
self,
|
||||
*,
|
||||
workspace_id: str,
|
||||
step_id: str,
|
||||
) -> tuple[dict[str, str], dict[str, Any]]:
|
||||
workspace = self._draft_store().get_workspace(workspace_id)
|
||||
step = _draft_step(workspace.draft, step_id)
|
||||
return _input_maps_from_payload(step.get("input", []))
|
||||
|
||||
def _step_output_map(self, *, workspace_id: str, step_id: str) -> dict[str, str]:
|
||||
workspace = self._draft_store().get_workspace(workspace_id)
|
||||
step = _draft_step(workspace.draft, step_id)
|
||||
return _output_map_from_payload(step.get("output", []))
|
||||
|
||||
async def create_minimal_draft_workspace(
|
||||
self,
|
||||
*,
|
||||
@@ -374,6 +403,72 @@ def _draft_output_bindings_payload(output_map: dict[str, str]) -> list[dict[str,
|
||||
]
|
||||
|
||||
|
||||
def _draft_step(draft: Mapping[str, Any], step_id: str) -> Mapping[str, Any]:
|
||||
steps = draft.get("steps", {})
|
||||
if not isinstance(steps, Mapping):
|
||||
raise KeyError("draft steps are not available")
|
||||
step = steps[step_id]
|
||||
if not isinstance(step, Mapping):
|
||||
raise KeyError(f"draft step {step_id!r} is not an object")
|
||||
return step
|
||||
|
||||
|
||||
def _input_maps_from_payload(
|
||||
payload: Any,
|
||||
) -> tuple[dict[str, str], dict[str, Any]]:
|
||||
"""Read stored canonical input bindings back into focused draft maps."""
|
||||
input_map: dict[str, str] = {}
|
||||
input_values: dict[str, Any] = {}
|
||||
if not isinstance(payload, list):
|
||||
return input_map, input_values
|
||||
for item in payload:
|
||||
if not isinstance(item, Mapping) or "target" not in item:
|
||||
continue
|
||||
target = _path_text(item["target"], expected_root="local")
|
||||
if "path" in item:
|
||||
input_map[_path_text(item["path"])] = target
|
||||
elif "value" in item:
|
||||
input_values[target] = item["value"]
|
||||
return input_map, input_values
|
||||
|
||||
|
||||
def _output_map_from_payload(payload: Any) -> dict[str, str]:
|
||||
"""Read stored canonical output bindings back into the focused output map."""
|
||||
output_map: dict[str, str] = {}
|
||||
if not isinstance(payload, list):
|
||||
return output_map
|
||||
for item in payload:
|
||||
if not isinstance(item, Mapping):
|
||||
continue
|
||||
if "source" in item and "target" in item:
|
||||
output_map[_path_text(item["source"], expected_root="local")] = _path_text(
|
||||
item["target"],
|
||||
expected_root="state",
|
||||
)
|
||||
return output_map
|
||||
|
||||
|
||||
def _path_text(value: Any, *, expected_root: str | None = None) -> str:
|
||||
"""Return compact dotted text for stored structural path JSON."""
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
if not isinstance(value, Mapping):
|
||||
raise ValueError(f"expected path object, got {value!r}")
|
||||
root = value.get("root")
|
||||
if expected_root is not None and root != expected_root:
|
||||
raise ValueError(f"expected {expected_root} path root")
|
||||
if not isinstance(root, str):
|
||||
raise ValueError("path root must be a string")
|
||||
raw_parts = value.get("parts", [])
|
||||
if not isinstance(raw_parts, list) or not all(
|
||||
isinstance(part, str) for part in raw_parts
|
||||
):
|
||||
raise ValueError("path parts must be strings")
|
||||
if root == "local":
|
||||
return "." if not raw_parts else ".".join(raw_parts)
|
||||
return root if not raw_parts else f"{root}.{'.'.join(raw_parts)}"
|
||||
|
||||
|
||||
def _graph_path_payload(value: str | GraphSourcePath) -> dict[str, str | list[str]]:
|
||||
path = value if isinstance(value, GraphSourcePath) else GraphSourcePath.parse(value)
|
||||
return GraphSourcePath._serialize(path)
|
||||
|
||||
@@ -335,12 +335,14 @@ class WorkflowApi:
|
||||
revision: int,
|
||||
step_id: str,
|
||||
input_map: dict[str, str],
|
||||
merge: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
return await self.drafts.set_step_input_map(
|
||||
workspace_id=workspace_id,
|
||||
revision=revision,
|
||||
step_id=step_id,
|
||||
input_map=input_map,
|
||||
merge=merge,
|
||||
)
|
||||
|
||||
async def set_step_output_map(
|
||||
@@ -350,12 +352,14 @@ class WorkflowApi:
|
||||
revision: int,
|
||||
step_id: str,
|
||||
output_map: dict[str, str],
|
||||
merge: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
return await self.drafts.set_step_output_map(
|
||||
workspace_id=workspace_id,
|
||||
revision=revision,
|
||||
step_id=step_id,
|
||||
output_map=output_map,
|
||||
merge=merge,
|
||||
)
|
||||
|
||||
async def create_minimal_draft_workspace(
|
||||
|
||||
@@ -101,6 +101,7 @@ class WorkflowDraftSurface(Protocol):
|
||||
revision: int,
|
||||
step_id: str,
|
||||
input_map: dict[str, str],
|
||||
merge: bool = False,
|
||||
) -> dict[str, Any]: ...
|
||||
|
||||
async def set_step_output_map(
|
||||
@@ -110,6 +111,7 @@ class WorkflowDraftSurface(Protocol):
|
||||
revision: int,
|
||||
step_id: str,
|
||||
output_map: dict[str, str],
|
||||
merge: bool = False,
|
||||
) -> dict[str, Any]: ...
|
||||
|
||||
async def validate_draft_workspace(
|
||||
|
||||
@@ -193,11 +193,26 @@ def set_step_input_map(
|
||||
list[str] | None,
|
||||
typer.Option(
|
||||
"--map",
|
||||
help="Input binding SOURCE=LOCAL_TARGET. Repeatable. Example: input.text=text",
|
||||
help="One input binding SOURCE=LOCAL_TARGET. Repeat in one command.",
|
||||
),
|
||||
] = None,
|
||||
merge: Annotated[
|
||||
bool,
|
||||
typer.Option(
|
||||
"--merge",
|
||||
help="Preserve existing input bindings and add/update the passed --map entries.",
|
||||
),
|
||||
] = False,
|
||||
) -> None:
|
||||
"""Replace one step's input map without writing JSON Patch manually."""
|
||||
"""Set one step's input map without writing JSON Patch manually.
|
||||
|
||||
Default behavior replaces the full input map for this step. Pass all desired
|
||||
--map entries in one command for a complete replacement. Use --merge only
|
||||
when adding or updating entries across a later revision.
|
||||
|
||||
Run `wf draft validate <workspace_id>` after map edits; validation reports
|
||||
unresolved paths and conflicting writes.
|
||||
"""
|
||||
input_map = _parse_map_flags(mapping)
|
||||
context = load_cli_context(ctx)
|
||||
emit_json(
|
||||
@@ -208,6 +223,7 @@ def set_step_input_map(
|
||||
revision=revision,
|
||||
step_id=step_id,
|
||||
input_map=input_map,
|
||||
merge=merge,
|
||||
),
|
||||
)
|
||||
)
|
||||
@@ -225,11 +241,26 @@ def set_step_output_map(
|
||||
list[str] | None,
|
||||
typer.Option(
|
||||
"--map",
|
||||
help="Output binding LOCAL_SOURCE=STATE_TARGET. Repeatable. Example: text=state.text",
|
||||
help="One output binding LOCAL_SOURCE=STATE_TARGET. Repeat in one command.",
|
||||
),
|
||||
] = None,
|
||||
merge: Annotated[
|
||||
bool,
|
||||
typer.Option(
|
||||
"--merge",
|
||||
help="Preserve existing output bindings and add/update the passed --map entries.",
|
||||
),
|
||||
] = False,
|
||||
) -> None:
|
||||
"""Replace one step's output map without writing JSON Patch manually."""
|
||||
"""Set one step's output map without writing JSON Patch manually.
|
||||
|
||||
Default behavior replaces the full output map for this step. Pass all
|
||||
desired --map entries in one command for a complete replacement. Use
|
||||
--merge only when adding or updating entries across a later revision.
|
||||
|
||||
Run `wf draft validate <workspace_id>` after map edits; validation reports
|
||||
unresolved paths and conflicting writes.
|
||||
"""
|
||||
output_map = _parse_map_flags(mapping)
|
||||
context = load_cli_context(ctx)
|
||||
emit_json(
|
||||
@@ -240,6 +271,7 @@ def set_step_output_map(
|
||||
revision=revision,
|
||||
step_id=step_id,
|
||||
output_map=output_map,
|
||||
merge=merge,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -206,21 +206,35 @@ class SetDraftRouteRequest(BaseModel):
|
||||
|
||||
|
||||
class SetStepInputMapRequest(BaseModel):
|
||||
"""Typed MCP request for replacing one step input map."""
|
||||
"""Typed MCP request for replacing or merging one step input map."""
|
||||
|
||||
workspace_id: WorkspaceId
|
||||
revision: int = Field(ge=1, description="Expected current workspace revision.")
|
||||
step_id: str = Field(description="Draft step id whose input map should change.")
|
||||
input_map: DraftPathMap
|
||||
merge: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"When false, replace the full input map. When true, preserve existing "
|
||||
"bindings and add/update only input_map entries."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class SetStepOutputMapRequest(BaseModel):
|
||||
"""Typed MCP request for replacing one step output map."""
|
||||
"""Typed MCP request for replacing or merging one step output map."""
|
||||
|
||||
workspace_id: WorkspaceId
|
||||
revision: int = Field(ge=1, description="Expected current workspace revision.")
|
||||
step_id: str = Field(description="Draft step id whose output map should change.")
|
||||
output_map: DraftPathMap
|
||||
merge: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"When false, replace the full output map. When true, preserve existing "
|
||||
"bindings and add/update only output_map entries."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class DeleteDraftWorkspaceRequest(BaseModel):
|
||||
|
||||
@@ -399,7 +399,8 @@ def register_workflow_tools(server: FastMCP[Any], service: WfMcpService) -> None
|
||||
name="wf.workflow.set_step_input_map",
|
||||
title="Set Step Input Map",
|
||||
description=(
|
||||
"Replace one compatibility step input map in a draft workspace. "
|
||||
"Replace or merge one compatibility step input map in a draft workspace. "
|
||||
"Set merge=true to preserve existing bindings while adding/updating entries. "
|
||||
"New one-capability bootstraps should prefer canonical input bindings."
|
||||
),
|
||||
)
|
||||
@@ -412,6 +413,7 @@ def register_workflow_tools(server: FastMCP[Any], service: WfMcpService) -> None
|
||||
revision=request.revision,
|
||||
step_id=request.step_id,
|
||||
input_map=request.input_map,
|
||||
merge=request.merge,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -419,7 +421,8 @@ def register_workflow_tools(server: FastMCP[Any], service: WfMcpService) -> None
|
||||
name="wf.workflow.set_step_output_map",
|
||||
title="Set Step Output Map",
|
||||
description=(
|
||||
"Replace one compatibility step output map in a draft workspace. "
|
||||
"Replace or merge one compatibility step output map in a draft workspace. "
|
||||
"Set merge=true to preserve existing bindings while adding/updating entries. "
|
||||
"New one-capability bootstraps should prefer canonical output bindings."
|
||||
),
|
||||
)
|
||||
@@ -432,6 +435,7 @@ def register_workflow_tools(server: FastMCP[Any], service: WfMcpService) -> None
|
||||
revision=request.revision,
|
||||
step_id=request.step_id,
|
||||
output_map=request.output_map,
|
||||
merge=request.merge,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -108,6 +108,7 @@ class RpcDraftClientMixin:
|
||||
revision: int,
|
||||
step_id: str,
|
||||
input_map: dict[str, str],
|
||||
merge: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
return await self._call(
|
||||
"workflow.draft_workspaces.set_step_input_map",
|
||||
@@ -116,6 +117,7 @@ class RpcDraftClientMixin:
|
||||
"revision": revision,
|
||||
"step_id": step_id,
|
||||
"input_map": input_map,
|
||||
"merge": merge,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -126,6 +128,7 @@ class RpcDraftClientMixin:
|
||||
revision: int,
|
||||
step_id: str,
|
||||
output_map: dict[str, str],
|
||||
merge: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
return await self._call(
|
||||
"workflow.draft_workspaces.set_step_output_map",
|
||||
@@ -134,6 +137,7 @@ class RpcDraftClientMixin:
|
||||
"revision": revision,
|
||||
"step_id": step_id,
|
||||
"output_map": output_map,
|
||||
"merge": merge,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -154,6 +154,7 @@ def register_methods(
|
||||
revision=params.revision,
|
||||
step_id=params.step_id,
|
||||
input_map=params.input_map,
|
||||
merge=params.merge,
|
||||
)
|
||||
except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
|
||||
raise_workflow_rpc_error(exc)
|
||||
@@ -171,6 +172,7 @@ def register_methods(
|
||||
revision=params.revision,
|
||||
step_id=params.step_id,
|
||||
output_map=params.output_map,
|
||||
merge=params.merge,
|
||||
)
|
||||
except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
|
||||
raise_workflow_rpc_error(exc)
|
||||
|
||||
@@ -130,6 +130,7 @@ class SetStepInputMapParams(RpcParamsModel):
|
||||
revision: int = Field(ge=1)
|
||||
step_id: str = Field(min_length=1)
|
||||
input_map: dict[str, str]
|
||||
merge: bool = False
|
||||
|
||||
|
||||
class SetStepOutputMapParams(RpcParamsModel):
|
||||
@@ -137,6 +138,7 @@ class SetStepOutputMapParams(RpcParamsModel):
|
||||
revision: int = Field(ge=1)
|
||||
step_id: str = Field(min_length=1)
|
||||
output_map: dict[str, str]
|
||||
merge: bool = False
|
||||
|
||||
|
||||
class ValidateDraftWorkspaceParams(RpcParamsModel):
|
||||
|
||||
Reference in New Issue
Block a user