patch draft common wrappers
This commit is contained in:
@@ -507,7 +507,9 @@ Concrete MCP sequence:
|
|||||||
existing workspace ids.
|
existing workspace ids.
|
||||||
5. `wf.workflow.get_draft_workspace` with `include_draft=true` if the client
|
5. `wf.workflow.get_draft_workspace` with `include_draft=true` if the client
|
||||||
needs to inspect the full current draft.
|
needs to inspect the full current draft.
|
||||||
6. `wf.workflow.patch_draft_workspace` with the current `revision`.
|
6. Use focused helpers such as `wf.workflow.set_draft_name` or
|
||||||
|
`wf.workflow.set_draft_route`, or call `wf.workflow.patch_draft_workspace`
|
||||||
|
with the current `revision` for arbitrary JSON Patch edits.
|
||||||
7. `wf.workflow.create_artifact_from_workspace` after validation is clean.
|
7. `wf.workflow.create_artifact_from_workspace` after validation is clean.
|
||||||
8. `wf.workflow.save_deployment`, then `validate_deployment`, then
|
8. `wf.workflow.save_deployment`, then `validate_deployment`, then
|
||||||
`run_deployment`.
|
`run_deployment`.
|
||||||
|
|||||||
@@ -352,6 +352,7 @@ resending the full draft each turn.
|
|||||||
| List existing draft sessions | `wf.workflow.list_draft_workspaces` |
|
| List existing draft sessions | `wf.workflow.list_draft_workspaces` |
|
||||||
| Fetch current draft workspace | `wf.workflow.get_draft_workspace` |
|
| Fetch current draft workspace | `wf.workflow.get_draft_workspace` |
|
||||||
| Patch current draft workspace | `wf.workflow.patch_draft_workspace` |
|
| Patch current draft workspace | `wf.workflow.patch_draft_workspace` |
|
||||||
|
| Change common draft fields without JSON Patch | `wf.workflow.set_draft_name`, `wf.workflow.set_draft_route`, `wf.workflow.set_step_input_map`, `wf.workflow.set_step_output_map` |
|
||||||
| Save final workspace as artifact | `wf.workflow.create_artifact_from_workspace` |
|
| Save final workspace as artifact | `wf.workflow.create_artifact_from_workspace` |
|
||||||
| Clean up a draft workspace | `wf.workflow.delete_draft_workspace` |
|
| Clean up a draft workspace | `wf.workflow.delete_draft_workspace` |
|
||||||
|
|
||||||
@@ -423,3 +424,15 @@ Patch example:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If you do not want to write JSON Patch by hand, use the focused helpers:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"request": {
|
||||||
|
"workspace_id": "echo_draft",
|
||||||
|
"revision": 1,
|
||||||
|
"name": "echo_v2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ _SEARCH_ALWAYS_VISIBLE_TOOL_NAMES = [
|
|||||||
"wf.workflow.get_draft_workspace",
|
"wf.workflow.get_draft_workspace",
|
||||||
"wf.workflow.delete_draft_workspace",
|
"wf.workflow.delete_draft_workspace",
|
||||||
"wf.workflow.patch_draft_workspace",
|
"wf.workflow.patch_draft_workspace",
|
||||||
|
"wf.workflow.set_draft_name",
|
||||||
|
"wf.workflow.set_draft_route",
|
||||||
|
"wf.workflow.set_step_input_map",
|
||||||
|
"wf.workflow.set_step_output_map",
|
||||||
"wf.workflow.create_minimal_draft_workspace",
|
"wf.workflow.create_minimal_draft_workspace",
|
||||||
"wf.workflow.create_artifact_from_workspace",
|
"wf.workflow.create_artifact_from_workspace",
|
||||||
"wf.workflow.call_capability",
|
"wf.workflow.call_capability",
|
||||||
|
|||||||
@@ -433,6 +433,83 @@ class WorkflowSurfaceHandlers:
|
|||||||
patch=patch,
|
patch=patch,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def set_draft_name(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
workspace_id: str,
|
||||||
|
revision: int,
|
||||||
|
name: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return await self.patch_draft_workspace(
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
revision=revision,
|
||||||
|
patch=[{"op": "replace", "path": "/name", "value": name}],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def set_draft_route(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
workspace_id: str,
|
||||||
|
revision: int,
|
||||||
|
step_id: str,
|
||||||
|
outcome: str,
|
||||||
|
target: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return await self.patch_draft_workspace(
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
revision=revision,
|
||||||
|
patch=[
|
||||||
|
{
|
||||||
|
"op": "add",
|
||||||
|
"path": (
|
||||||
|
f"/routes/{_escape_json_pointer(step_id)}/"
|
||||||
|
f"{_escape_json_pointer(outcome)}"
|
||||||
|
),
|
||||||
|
"value": target,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def set_step_input_map(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
workspace_id: str,
|
||||||
|
revision: int,
|
||||||
|
step_id: str,
|
||||||
|
input_map: dict[str, str],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return await self.patch_draft_workspace(
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
revision=revision,
|
||||||
|
patch=[
|
||||||
|
{
|
||||||
|
"op": "replace",
|
||||||
|
"path": f"/steps/{_escape_json_pointer(step_id)}/in",
|
||||||
|
"value": input_map,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def set_step_output_map(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
workspace_id: str,
|
||||||
|
revision: int,
|
||||||
|
step_id: str,
|
||||||
|
output_map: dict[str, str],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return await self.patch_draft_workspace(
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
revision=revision,
|
||||||
|
patch=[
|
||||||
|
{
|
||||||
|
"op": "replace",
|
||||||
|
"path": f"/steps/{_escape_json_pointer(step_id)}/out",
|
||||||
|
"value": output_map,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
async def create_minimal_draft_workspace(
|
async def create_minimal_draft_workspace(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -759,6 +836,11 @@ def _first_state_path(output_map: dict[str, str]) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _escape_json_pointer(value: str) -> str:
|
||||||
|
"""Escape one JSON Pointer path segment for generated JSON Patch helpers."""
|
||||||
|
return value.replace("~", "~0").replace("/", "~1")
|
||||||
|
|
||||||
|
|
||||||
def _source_id_for_capability(
|
def _source_id_for_capability(
|
||||||
sources: dict[str, CapabilitySource],
|
sources: dict[str, CapabilitySource],
|
||||||
qualified_name: str,
|
qualified_name: str,
|
||||||
|
|||||||
@@ -115,6 +115,42 @@ class PatchDraftWorkspaceRequest(BaseModel):
|
|||||||
patch: JsonPatchOperations
|
patch: JsonPatchOperations
|
||||||
|
|
||||||
|
|
||||||
|
class SetDraftNameRequest(BaseModel):
|
||||||
|
"""Typed MCP request for changing the workflow draft name."""
|
||||||
|
|
||||||
|
workspace_id: WorkspaceId
|
||||||
|
revision: int = Field(ge=1, description="Expected current workspace revision.")
|
||||||
|
name: str = Field(description="New workflow draft name.")
|
||||||
|
|
||||||
|
|
||||||
|
class SetDraftRouteRequest(BaseModel):
|
||||||
|
"""Typed MCP request for setting one outcome route on one draft step."""
|
||||||
|
|
||||||
|
workspace_id: WorkspaceId
|
||||||
|
revision: int = Field(ge=1, description="Expected current workspace revision.")
|
||||||
|
step_id: str = Field(description="Draft step id whose route should be edited.")
|
||||||
|
outcome: str = Field(description="Outcome label to route, for example ok or error.")
|
||||||
|
target: str = Field(description="Target step id or __end__.")
|
||||||
|
|
||||||
|
|
||||||
|
class SetStepInputMapRequest(BaseModel):
|
||||||
|
"""Typed MCP request for replacing 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
|
||||||
|
|
||||||
|
|
||||||
|
class SetStepOutputMapRequest(BaseModel):
|
||||||
|
"""Typed MCP request for replacing 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
|
||||||
|
|
||||||
|
|
||||||
class DeleteDraftWorkspaceRequest(BaseModel):
|
class DeleteDraftWorkspaceRequest(BaseModel):
|
||||||
"""Typed MCP request payload for deleting one draft workspace."""
|
"""Typed MCP request payload for deleting one draft workspace."""
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ from .models import (
|
|||||||
DraftWorkspaceListResult,
|
DraftWorkspaceListResult,
|
||||||
DraftWorkspaceResult,
|
DraftWorkspaceResult,
|
||||||
PatchDraftWorkspaceRequest,
|
PatchDraftWorkspaceRequest,
|
||||||
|
SetDraftNameRequest,
|
||||||
|
SetDraftRouteRequest,
|
||||||
|
SetStepInputMapRequest,
|
||||||
|
SetStepOutputMapRequest,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -273,6 +277,70 @@ def register_workflow_tools(server: FastMCP[Any], service: WfMcpService) -> None
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@server.tool(
|
||||||
|
name="wf.workflow.set_draft_name",
|
||||||
|
title="Set Draft Name",
|
||||||
|
description="Replace the name field of a stored draft workspace.",
|
||||||
|
)
|
||||||
|
async def set_draft_name(request: SetDraftNameRequest) -> DraftWorkspaceResult:
|
||||||
|
return DraftWorkspaceResult.model_validate(
|
||||||
|
await handlers.set_draft_name(
|
||||||
|
workspace_id=request.workspace_id,
|
||||||
|
revision=request.revision,
|
||||||
|
name=request.name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@server.tool(
|
||||||
|
name="wf.workflow.set_draft_route",
|
||||||
|
title="Set Draft Route",
|
||||||
|
description="Set one outcome route on one step in a draft workspace.",
|
||||||
|
)
|
||||||
|
async def set_draft_route(request: SetDraftRouteRequest) -> DraftWorkspaceResult:
|
||||||
|
return DraftWorkspaceResult.model_validate(
|
||||||
|
await handlers.set_draft_route(
|
||||||
|
workspace_id=request.workspace_id,
|
||||||
|
revision=request.revision,
|
||||||
|
step_id=request.step_id,
|
||||||
|
outcome=request.outcome,
|
||||||
|
target=request.target,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@server.tool(
|
||||||
|
name="wf.workflow.set_step_input_map",
|
||||||
|
title="Set Step Input Map",
|
||||||
|
description="Replace one step input map in a draft workspace.",
|
||||||
|
)
|
||||||
|
async def set_step_input_map(
|
||||||
|
request: SetStepInputMapRequest,
|
||||||
|
) -> DraftWorkspaceResult:
|
||||||
|
return DraftWorkspaceResult.model_validate(
|
||||||
|
await handlers.set_step_input_map(
|
||||||
|
workspace_id=request.workspace_id,
|
||||||
|
revision=request.revision,
|
||||||
|
step_id=request.step_id,
|
||||||
|
input_map=request.input_map,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@server.tool(
|
||||||
|
name="wf.workflow.set_step_output_map",
|
||||||
|
title="Set Step Output Map",
|
||||||
|
description="Replace one step output map in a draft workspace.",
|
||||||
|
)
|
||||||
|
async def set_step_output_map(
|
||||||
|
request: SetStepOutputMapRequest,
|
||||||
|
) -> DraftWorkspaceResult:
|
||||||
|
return DraftWorkspaceResult.model_validate(
|
||||||
|
await handlers.set_step_output_map(
|
||||||
|
workspace_id=request.workspace_id,
|
||||||
|
revision=request.revision,
|
||||||
|
step_id=request.step_id,
|
||||||
|
output_map=request.output_map,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
@server.tool(
|
@server.tool(
|
||||||
name="wf.workflow.create_minimal_draft_workspace",
|
name="wf.workflow.create_minimal_draft_workspace",
|
||||||
title="Create Minimal Draft Workspace",
|
title="Create Minimal Draft Workspace",
|
||||||
|
|||||||
@@ -69,6 +69,10 @@ def test_server_exposes_upstream_admin_and_workflow_tools() -> None:
|
|||||||
assert "wf.workflow.get_draft_workspace" in names
|
assert "wf.workflow.get_draft_workspace" in names
|
||||||
assert "wf.workflow.delete_draft_workspace" in names
|
assert "wf.workflow.delete_draft_workspace" in names
|
||||||
assert "wf.workflow.patch_draft_workspace" in names
|
assert "wf.workflow.patch_draft_workspace" in names
|
||||||
|
assert "wf.workflow.set_draft_name" in names
|
||||||
|
assert "wf.workflow.set_draft_route" in names
|
||||||
|
assert "wf.workflow.set_step_input_map" in names
|
||||||
|
assert "wf.workflow.set_step_output_map" in names
|
||||||
assert "wf.workflow.create_minimal_draft_workspace" in names
|
assert "wf.workflow.create_minimal_draft_workspace" in names
|
||||||
assert "wf.workflow.create_artifact_from_workspace" in names
|
assert "wf.workflow.create_artifact_from_workspace" in names
|
||||||
assert "wf.workflow.run_deployment" in names
|
assert "wf.workflow.run_deployment" in names
|
||||||
@@ -194,6 +198,10 @@ def test_server_search_mode_pins_stable_control_and_workflow_tools() -> None:
|
|||||||
assert "wf.workflow.get_draft_workspace" in names
|
assert "wf.workflow.get_draft_workspace" in names
|
||||||
assert "wf.workflow.delete_draft_workspace" in names
|
assert "wf.workflow.delete_draft_workspace" in names
|
||||||
assert "wf.workflow.patch_draft_workspace" in names
|
assert "wf.workflow.patch_draft_workspace" in names
|
||||||
|
assert "wf.workflow.set_draft_name" in names
|
||||||
|
assert "wf.workflow.set_draft_route" in names
|
||||||
|
assert "wf.workflow.set_step_input_map" in names
|
||||||
|
assert "wf.workflow.set_step_output_map" in names
|
||||||
assert "wf.workflow.create_minimal_draft_workspace" in names
|
assert "wf.workflow.create_minimal_draft_workspace" in names
|
||||||
assert "wf.workflow.create_artifact_from_workspace" in names
|
assert "wf.workflow.create_artifact_from_workspace" in names
|
||||||
assert "wf.workflow.call_capability" in names
|
assert "wf.workflow.call_capability" in names
|
||||||
|
|||||||
@@ -434,6 +434,64 @@ def test_workflow_surface_deletes_draft_workspace() -> None:
|
|||||||
assert listed["workspaces"] == []
|
assert listed["workspaces"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_workflow_surface_patch_helpers_update_draft_workspace() -> None:
|
||||||
|
artifact_store = FileWorkflowArtifactStore(
|
||||||
|
local_temp_root() / "surface_workspace_patch_helpers"
|
||||||
|
)
|
||||||
|
handlers = _handlers(artifact_store)
|
||||||
|
asyncio.run(
|
||||||
|
handlers.create_draft_workspace(
|
||||||
|
workspace_id="echo_draft",
|
||||||
|
draft=_echo_draft(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
named = asyncio.run(
|
||||||
|
handlers.set_draft_name(
|
||||||
|
workspace_id="echo_draft",
|
||||||
|
revision=1,
|
||||||
|
name="echo_v2",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
routed = asyncio.run(
|
||||||
|
handlers.set_draft_route(
|
||||||
|
workspace_id="echo_draft",
|
||||||
|
revision=2,
|
||||||
|
step_id="echo",
|
||||||
|
outcome="error",
|
||||||
|
target="__end__",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
input_mapped = asyncio.run(
|
||||||
|
handlers.set_step_input_map(
|
||||||
|
workspace_id="echo_draft",
|
||||||
|
revision=3,
|
||||||
|
step_id="echo",
|
||||||
|
input_map={"input.text": "message"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
output_mapped = asyncio.run(
|
||||||
|
handlers.set_step_output_map(
|
||||||
|
workspace_id="echo_draft",
|
||||||
|
revision=4,
|
||||||
|
step_id="echo",
|
||||||
|
output_map={"echoed": "state.echoed"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
fetched = asyncio.run(
|
||||||
|
handlers.get_draft_workspace(workspace_id="echo_draft", include_draft=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert named["revision"] == 2
|
||||||
|
assert routed["revision"] == 3
|
||||||
|
assert input_mapped["revision"] == 4
|
||||||
|
assert output_mapped["revision"] == 5
|
||||||
|
assert fetched["draft"]["name"] == "echo_v2"
|
||||||
|
assert fetched["draft"]["routes"]["echo"]["error"] == "__end__"
|
||||||
|
assert fetched["draft"]["steps"]["echo"]["in"] == {"input.text": "message"}
|
||||||
|
assert fetched["draft"]["steps"]["echo"]["out"] == {"echoed": "state.echoed"}
|
||||||
|
|
||||||
|
|
||||||
def test_workflow_surface_patches_draft_workspace_by_revision() -> None:
|
def test_workflow_surface_patches_draft_workspace_by_revision() -> None:
|
||||||
artifact_store = FileWorkflowArtifactStore(
|
artifact_store = FileWorkflowArtifactStore(
|
||||||
local_temp_root() / "surface_workspace_patch"
|
local_temp_root() / "surface_workspace_patch"
|
||||||
|
|||||||
Reference in New Issue
Block a user