feat: merge draft step maps

This commit is contained in:
lda
2026-06-25 18:42:40 +07:00 Verified
parent 2d2477fb12
commit bcbdb81228
20 changed files with 359 additions and 14 deletions
+97 -2
View File
@@ -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)
+4
View File
@@ -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(
+2
View File
@@ -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(
+36 -4
View File
@@ -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,
),
)
)
+16 -2
View File
@@ -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):
+6 -2
View File
@@ -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)
+2
View File
@@ -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):