draft -> wrapper
This commit is contained in:
@@ -513,6 +513,8 @@ Concrete MCP sequence:
|
|||||||
7. `wf.workflow.validate_draft_workspace` if capabilities changed or you want
|
7. `wf.workflow.validate_draft_workspace` if capabilities changed or you want
|
||||||
to refresh diagnostics without editing the draft.
|
to refresh diagnostics without editing the draft.
|
||||||
8. `wf.workflow.create_artifact_from_workspace` after validation is clean.
|
8. `wf.workflow.create_artifact_from_workspace` after validation is clean.
|
||||||
|
Use `wf.workflow.create_wrapper_from_workspace` instead when the workspace
|
||||||
|
is a reusable wrapper around a raw capability.
|
||||||
9. `wf.workflow.save_deployment`, then `validate_deployment`, then
|
9. `wf.workflow.save_deployment`, then `validate_deployment`, then
|
||||||
`run_deployment`.
|
`run_deployment`.
|
||||||
10. `wf.workflow.delete_draft_workspace` when the mutable authoring session is no
|
10. `wf.workflow.delete_draft_workspace` when the mutable authoring session is no
|
||||||
@@ -535,3 +537,7 @@ Concrete MCP sequence:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`create_wrapper_from_workspace` accepts the same request shape except there is
|
||||||
|
no `kind` field. It always saves `kind="wrapper"` and the result is discoverable
|
||||||
|
as a workflow capability named `workflow.<artifact_id>.v<version>`.
|
||||||
|
|||||||
@@ -355,6 +355,7 @@ resending the full draft each turn.
|
|||||||
| Refresh validation without changing revision | `wf.workflow.validate_draft_workspace` |
|
| Refresh validation without changing revision | `wf.workflow.validate_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` |
|
| 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` |
|
||||||
|
| Save final workspace as callable wrapper | `wf.workflow.create_wrapper_from_workspace` |
|
||||||
| Clean up a draft workspace | `wf.workflow.delete_draft_workspace` |
|
| Clean up a draft workspace | `wf.workflow.delete_draft_workspace` |
|
||||||
|
|
||||||
Workspace patches are optimistic-concurrency guarded. Pass the current
|
Workspace patches are optimistic-concurrency guarded. Pass the current
|
||||||
@@ -365,6 +366,11 @@ Workspace mutation tools use a single `request` object in MCP Inspector. That
|
|||||||
keeps the form grouped and lets the schema describe fields like
|
keeps the form grouped and lets the schema describe fields like
|
||||||
`input_schema`, `output_map`, and `error_message_source`.
|
`input_schema`, `output_map`, and `error_message_source`.
|
||||||
|
|
||||||
|
Use `create_wrapper_from_workspace` when the draft is meant to normalize a raw
|
||||||
|
capability into a reusable workflow-facing wrapper. It is the same validation
|
||||||
|
path as `create_artifact_from_workspace`, but the saved artifact kind is fixed
|
||||||
|
to `wrapper`.
|
||||||
|
|
||||||
Minimal example:
|
Minimal example:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -262,6 +262,7 @@ authoring loop:
|
|||||||
- `wf.workflow.list_capabilities`
|
- `wf.workflow.list_capabilities`
|
||||||
- lists compact paged enabled planner-visible workflow-ready node spec
|
- lists compact paged enabled planner-visible workflow-ready node spec
|
||||||
summaries, with optional query/source filtering
|
summaries, with optional query/source filtering
|
||||||
|
- also includes saved wrapper artifacts under source id `workflow`
|
||||||
- includes the owning `source_id`, outcomes, and top-level input/output field
|
- includes the owning `source_id`, outcomes, and top-level input/output field
|
||||||
names, but not full schemas
|
names, but not full schemas
|
||||||
- `wf.workflow.inspect_capability`
|
- `wf.workflow.inspect_capability`
|
||||||
@@ -298,9 +299,10 @@ prompts
|
|||||||
resources
|
resources
|
||||||
```
|
```
|
||||||
|
|
||||||
Possible later additions may include saved wrappers or workflow artifacts as
|
Possible later additions may include full workflow artifacts as first-class
|
||||||
first-class projected capability kinds, but they should not erase the raw versus
|
projected capability kinds, but they should not erase the raw versus
|
||||||
workflow-facing distinction.
|
workflow-facing distinction. Saved wrapper artifacts are already projected as
|
||||||
|
workflow capabilities because they have a node-like callable boundary today.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
|
||||||
@@ -325,6 +327,8 @@ Today:
|
|||||||
- saved artifacts can be tagged with `kind="workflow"` or `kind="wrapper"`
|
- saved artifacts can be tagged with `kind="workflow"` or `kind="wrapper"`
|
||||||
- `wf.workflow.call_capability` can execute one planner-visible workflow
|
- `wf.workflow.call_capability` can execute one planner-visible workflow
|
||||||
capability directly and return normalized `outcome` / `output`
|
capability directly and return normalized `outcome` / `output`
|
||||||
|
- `wf.workflow.list_capabilities` and `wf.workflow.inspect_capability` project
|
||||||
|
saved wrapper artifacts as workflow capabilities under source id `workflow`
|
||||||
|
|
||||||
Not yet implemented:
|
Not yet implemented:
|
||||||
|
|
||||||
|
|||||||
@@ -299,7 +299,8 @@ The workspace flow is:
|
|||||||
2. `wf.workflow.get_draft_workspace`
|
2. `wf.workflow.get_draft_workspace`
|
||||||
3. `wf.workflow.patch_draft_workspace`
|
3. `wf.workflow.patch_draft_workspace`
|
||||||
4. repeat get/patch until valid
|
4. repeat get/patch until valid
|
||||||
5. `wf.workflow.create_artifact_from_workspace`
|
5. `wf.workflow.create_artifact_from_workspace` for a full workflow, or
|
||||||
|
`wf.workflow.create_wrapper_from_workspace` for a reusable callable wrapper
|
||||||
|
|
||||||
Workspaces are mutable and revisioned. Artifacts are immutable and versioned.
|
Workspaces are mutable and revisioned. Artifacts are immutable and versioned.
|
||||||
Patch calls must include the current `revision`; stale revisions return
|
Patch calls must include the current `revision`; stale revisions return
|
||||||
@@ -314,6 +315,10 @@ In MCP Inspector, workspace mutation tools accept a single `request` object.
|
|||||||
This is deliberate: the request object carries descriptions and validation for
|
This is deliberate: the request object carries descriptions and validation for
|
||||||
the authoring envelope while raw JSON Schema fields remain plain JSON objects.
|
the authoring envelope while raw JSON Schema fields remain plain JSON objects.
|
||||||
|
|
||||||
|
`create_wrapper_from_workspace` is intentionally just the wrapper-specific save
|
||||||
|
path. It validates and compiles the same draft workspace, but fixes the saved
|
||||||
|
artifact kind to `wrapper` so clients do not need to pass `kind` manually.
|
||||||
|
|
||||||
## Patching Drafts
|
## Patching Drafts
|
||||||
|
|
||||||
`patch_draft` accepts JSON Patch operations.
|
`patch_draft` accepts JSON Patch operations.
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ _SEARCH_ALWAYS_VISIBLE_TOOL_NAMES = [
|
|||||||
"wf.workflow.set_step_output_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.create_wrapper_from_workspace",
|
||||||
"wf.workflow.call_capability",
|
"wf.workflow.call_capability",
|
||||||
"wf.workflow.inspect_artifact",
|
"wf.workflow.inspect_artifact",
|
||||||
"wf.workflow.list_deployments",
|
"wf.workflow.list_deployments",
|
||||||
|
|||||||
@@ -97,6 +97,10 @@ class WorkflowSurfaceHandlers:
|
|||||||
and query.casefold() in detail.description.casefold()
|
and query.casefold() in detail.description.casefold()
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
capabilities.extend(
|
||||||
|
self._wrapper_capability_summaries(query=query, source_id=source_id)
|
||||||
|
)
|
||||||
|
capabilities.sort(key=lambda capability: capability["name"])
|
||||||
page = page_items(capabilities, cursor=cursor, limit=limit)
|
page = page_items(capabilities, cursor=cursor, limit=limit)
|
||||||
return {
|
return {
|
||||||
"capabilities": list(page.items),
|
"capabilities": list(page.items),
|
||||||
@@ -112,6 +116,9 @@ class WorkflowSurfaceHandlers:
|
|||||||
for detail in source.as_inventory().capabilities.node_spec_details:
|
for detail in source.as_inventory().capabilities.node_spec_details:
|
||||||
if detail.name == qualified_name:
|
if detail.name == qualified_name:
|
||||||
return detail.model_dump(mode="json")
|
return detail.model_dump(mode="json")
|
||||||
|
wrapper_detail = self._wrapper_capability_detail(qualified_name)
|
||||||
|
if wrapper_detail is not None:
|
||||||
|
return wrapper_detail
|
||||||
raise KeyError(f"unknown workflow capability {qualified_name!r}")
|
raise KeyError(f"unknown workflow capability {qualified_name!r}")
|
||||||
|
|
||||||
async def call_capability(
|
async def call_capability(
|
||||||
@@ -163,6 +170,71 @@ class WorkflowSurfaceHandlers:
|
|||||||
return None
|
return None
|
||||||
return artifact
|
return artifact
|
||||||
|
|
||||||
|
def _wrapper_capability_summaries(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
query: str | None,
|
||||||
|
source_id: str | None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Project saved wrappers into workflow capability discovery rows.
|
||||||
|
|
||||||
|
Wrapper artifacts are not live source NodeSpecs, but authors need to
|
||||||
|
discover and test them through the same workflow-facing REPL surface.
|
||||||
|
Full saved workflows stay out of this projection until graph-as-node is
|
||||||
|
real in core.
|
||||||
|
"""
|
||||||
|
if source_id not in {None, "workflow"} or self.service.artifact_store is None:
|
||||||
|
return []
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
for artifact in self.service.artifact_store.list_artifacts():
|
||||||
|
if artifact.kind != "wrapper":
|
||||||
|
continue
|
||||||
|
name = _artifact_capability_id(artifact)
|
||||||
|
if not _matches_capability_query(
|
||||||
|
name,
|
||||||
|
artifact.description,
|
||||||
|
query=query,
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"source_id": "workflow",
|
||||||
|
"description": artifact.description,
|
||||||
|
"outcomes": list(artifact.outcomes),
|
||||||
|
"is_async": True,
|
||||||
|
"input_fields": _schema_field_names(artifact.input_schema),
|
||||||
|
"output_fields": _schema_field_names(artifact.output_schema),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
def _wrapper_capability_detail(
|
||||||
|
self,
|
||||||
|
qualified_name: str,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Return a NodeSpec-like contract for one saved wrapper artifact."""
|
||||||
|
artifact = self._wrapper_artifact_for_capability_name(qualified_name)
|
||||||
|
if artifact is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"name": _artifact_capability_id(artifact),
|
||||||
|
"source_id": "workflow",
|
||||||
|
"kind": "wrapper_artifact",
|
||||||
|
"artifact_id": artifact.id,
|
||||||
|
"version": artifact.version,
|
||||||
|
"title": artifact.title,
|
||||||
|
"description": artifact.description,
|
||||||
|
"outcomes": list(artifact.outcomes),
|
||||||
|
"is_async": True,
|
||||||
|
"input_schema": artifact.input_schema,
|
||||||
|
"output_schema": artifact.output_schema,
|
||||||
|
"required_capabilities": {
|
||||||
|
name: capability.model_dump(mode="json")
|
||||||
|
for name, capability in sorted(artifact.required_capabilities.items())
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
async def _call_wrapper_artifact(
|
async def _call_wrapper_artifact(
|
||||||
self,
|
self,
|
||||||
artifact: WorkflowArtifact,
|
artifact: WorkflowArtifact,
|
||||||
@@ -562,9 +634,7 @@ class WorkflowSurfaceHandlers:
|
|||||||
"in": {error_source: "message"},
|
"in": {error_source: "message"},
|
||||||
"out": {},
|
"out": {},
|
||||||
}
|
}
|
||||||
routes[DEFAULT_CALL_STEP_ID][DEFAULT_ERROR_OUTCOME] = (
|
routes[DEFAULT_CALL_STEP_ID][DEFAULT_ERROR_OUTCOME] = DEFAULT_ERROR_STEP_ID
|
||||||
DEFAULT_ERROR_STEP_ID
|
|
||||||
)
|
|
||||||
routes[DEFAULT_ERROR_STEP_ID] = {DEFAULT_OK_OUTCOME: "__end__"}
|
routes[DEFAULT_ERROR_STEP_ID] = {DEFAULT_OK_OUTCOME: "__end__"}
|
||||||
draft = {
|
draft = {
|
||||||
"name": name,
|
"name": name,
|
||||||
@@ -618,6 +688,33 @@ class WorkflowSurfaceHandlers:
|
|||||||
created_from_catalog_version=created_from_catalog_version,
|
created_from_catalog_version=created_from_catalog_version,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def create_wrapper_from_workspace(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
workspace_id: str,
|
||||||
|
artifact_id: str,
|
||||||
|
version: int,
|
||||||
|
title: str,
|
||||||
|
outcomes: Sequence[str],
|
||||||
|
description: str | None = None,
|
||||||
|
required_capabilities: dict[str, dict[str, Any]] | None = None,
|
||||||
|
source_bindings: dict[str, str] | None = None,
|
||||||
|
created_from_catalog_version: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Save the current draft workspace as a callable wrapper artifact."""
|
||||||
|
return await self.create_artifact_from_workspace(
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
artifact_id=artifact_id,
|
||||||
|
version=version,
|
||||||
|
title=title,
|
||||||
|
outcomes=outcomes,
|
||||||
|
kind="wrapper",
|
||||||
|
description=description,
|
||||||
|
required_capabilities=required_capabilities,
|
||||||
|
source_bindings=source_bindings,
|
||||||
|
created_from_catalog_version=created_from_catalog_version,
|
||||||
|
)
|
||||||
|
|
||||||
def _outcomes_for_capability(self, qualified_name: str) -> tuple[str, ...] | None:
|
def _outcomes_for_capability(self, qualified_name: str) -> tuple[str, ...] | None:
|
||||||
try:
|
try:
|
||||||
return self.service._get_qualified_spec(qualified_name).outcomes
|
return self.service._get_qualified_spec(qualified_name).outcomes
|
||||||
@@ -842,6 +939,21 @@ def _schema_field_names(schema: dict[str, Any]) -> list[str]:
|
|||||||
return sorted(str(name) for name in properties)
|
return sorted(str(name) for name in properties)
|
||||||
|
|
||||||
|
|
||||||
|
def _matches_capability_query(
|
||||||
|
name: str,
|
||||||
|
description: str | None,
|
||||||
|
*,
|
||||||
|
query: str | None,
|
||||||
|
) -> bool:
|
||||||
|
"""Apply the same compact capability search semantics to every row kind."""
|
||||||
|
if query is None:
|
||||||
|
return True
|
||||||
|
lowered = query.casefold()
|
||||||
|
return lowered in name.casefold() or (
|
||||||
|
description is not None and lowered in description.casefold()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _first_state_path(output_map: dict[str, str]) -> str | None:
|
def _first_state_path(output_map: dict[str, str]) -> str | None:
|
||||||
"""Return the first mapped state path for minimal error-route bootstraps."""
|
"""Return the first mapped state path for minimal error-route bootstraps."""
|
||||||
for target in output_map.values():
|
for target in output_map.values():
|
||||||
|
|||||||
@@ -216,3 +216,26 @@ class CreateArtifactFromWorkspaceRequest(BaseModel):
|
|||||||
default=None,
|
default=None,
|
||||||
description="Optional catalog version used while authoring.",
|
description="Optional catalog version used while authoring.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CreateWrapperFromWorkspaceRequest(BaseModel):
|
||||||
|
"""Typed MCP request for saving a draft workspace as a wrapper artifact."""
|
||||||
|
|
||||||
|
workspace_id: WorkspaceId
|
||||||
|
artifact_id: str = Field(description="Immutable wrapper artifact id to write.")
|
||||||
|
version: int = Field(ge=1, description="Wrapper artifact version to write.")
|
||||||
|
title: str = Field(description="Human-readable wrapper title.")
|
||||||
|
outcomes: list[str] = Field(description="Wrapper-level outcomes.")
|
||||||
|
description: str | None = Field(default=None, description="Optional description.")
|
||||||
|
required_capabilities: dict[str, dict[str, Any]] | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Optional explicit dependency contract override.",
|
||||||
|
)
|
||||||
|
source_bindings: SourceBindings | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Optional logical-to-concrete source bindings.",
|
||||||
|
)
|
||||||
|
created_from_catalog_version: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Optional catalog version used while authoring.",
|
||||||
|
)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from .models import (
|
|||||||
CreateArtifactFromWorkspaceRequest,
|
CreateArtifactFromWorkspaceRequest,
|
||||||
CreateDraftWorkspaceRequest,
|
CreateDraftWorkspaceRequest,
|
||||||
CreateMinimalDraftWorkspaceRequest,
|
CreateMinimalDraftWorkspaceRequest,
|
||||||
|
CreateWrapperFromWorkspaceRequest,
|
||||||
DeleteDraftWorkspaceRequest,
|
DeleteDraftWorkspaceRequest,
|
||||||
DeleteDraftWorkspaceResult,
|
DeleteDraftWorkspaceResult,
|
||||||
DraftWorkspaceListResult,
|
DraftWorkspaceListResult,
|
||||||
@@ -405,9 +406,38 @@ def register_workflow_tools(server: FastMCP[Any], service: WfMcpService) -> None
|
|||||||
if isinstance(capability, RequiredCapability)
|
if isinstance(capability, RequiredCapability)
|
||||||
else capability
|
else capability
|
||||||
)
|
)
|
||||||
for name, capability in (
|
for name, capability in (request.required_capabilities or {}).items()
|
||||||
request.required_capabilities or {}
|
}
|
||||||
).items()
|
or None,
|
||||||
|
source_bindings=dict(request.source_bindings or {}),
|
||||||
|
created_from_catalog_version=request.created_from_catalog_version,
|
||||||
|
)
|
||||||
|
|
||||||
|
@server.tool(
|
||||||
|
name="wf.workflow.create_wrapper_from_workspace",
|
||||||
|
title="Create Wrapper From Workspace",
|
||||||
|
description=(
|
||||||
|
"Validate the current draft workspace and save it as a callable "
|
||||||
|
"wrapper artifact."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def create_wrapper_from_workspace(
|
||||||
|
request: CreateWrapperFromWorkspaceRequest,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return await handlers.create_wrapper_from_workspace(
|
||||||
|
workspace_id=request.workspace_id,
|
||||||
|
artifact_id=request.artifact_id,
|
||||||
|
version=request.version,
|
||||||
|
title=request.title,
|
||||||
|
description=request.description,
|
||||||
|
outcomes=request.outcomes,
|
||||||
|
required_capabilities={
|
||||||
|
name: (
|
||||||
|
capability.model_dump()
|
||||||
|
if isinstance(capability, RequiredCapability)
|
||||||
|
else capability
|
||||||
|
)
|
||||||
|
for name, capability in (request.required_capabilities or {}).items()
|
||||||
}
|
}
|
||||||
or None,
|
or None,
|
||||||
source_bindings=dict(request.source_bindings or {}),
|
source_bindings=dict(request.source_bindings or {}),
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ def test_server_exposes_upstream_admin_and_workflow_tools() -> None:
|
|||||||
assert "wf.workflow.set_step_output_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.create_wrapper_from_workspace" in names
|
||||||
assert "wf.workflow.run_deployment" in names
|
assert "wf.workflow.run_deployment" in names
|
||||||
call_capability_schema = tools_by_name[
|
call_capability_schema = tools_by_name[
|
||||||
"wf.workflow.call_capability"
|
"wf.workflow.call_capability"
|
||||||
@@ -101,6 +102,12 @@ def test_server_exposes_upstream_admin_and_workflow_tools() -> None:
|
|||||||
== "JSON Schema object. Keep this as ordinary JSON; "
|
== "JSON Schema object. Keep this as ordinary JSON; "
|
||||||
"nested schema fields are passed through unchanged."
|
"nested schema fields are passed through unchanged."
|
||||||
)
|
)
|
||||||
|
wrapper_workspace_input = tools_by_name[
|
||||||
|
"wf.workflow.create_wrapper_from_workspace"
|
||||||
|
].inputSchema
|
||||||
|
wrapper_request = wrapper_workspace_input["properties"]["request"]
|
||||||
|
assert "kind" not in wrapper_request["properties"]
|
||||||
|
assert "artifact_id" in wrapper_request["properties"]
|
||||||
|
|
||||||
echo_result = await client.call_tool(
|
echo_result = await client.call_tool(
|
||||||
"fixture.personal.echo_tool",
|
"fixture.personal.echo_tool",
|
||||||
@@ -206,6 +213,7 @@ def test_server_search_mode_pins_stable_control_and_workflow_tools() -> None:
|
|||||||
assert "wf.workflow.set_step_output_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.create_wrapper_from_workspace" in names
|
||||||
assert "wf.workflow.call_capability" in names
|
assert "wf.workflow.call_capability" in names
|
||||||
assert "wf.workflow.inspect_artifact" in names
|
assert "wf.workflow.inspect_artifact" in names
|
||||||
assert "wf.workflow.list_deployments" in names
|
assert "wf.workflow.list_deployments" in names
|
||||||
|
|||||||
@@ -111,6 +111,34 @@ def test_workflow_surface_filters_capabilities_by_source() -> None:
|
|||||||
assert payload["capabilities"][0]["source_id"] == "wf.mcp"
|
assert payload["capabilities"][0]["source_id"] == "wf.mcp"
|
||||||
|
|
||||||
|
|
||||||
|
def test_workflow_surface_lists_saved_wrapper_capabilities() -> None:
|
||||||
|
artifact_store = FileWorkflowArtifactStore(
|
||||||
|
local_temp_root() / "surface_wrapper_caps"
|
||||||
|
)
|
||||||
|
artifact_store.save_artifact(
|
||||||
|
_echo_artifact().model_copy(
|
||||||
|
update={
|
||||||
|
"id": "echo_wrapper",
|
||||||
|
"kind": "wrapper",
|
||||||
|
"description": "Reusable echo wrapper.",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
artifact_store.save_artifact(_echo_artifact())
|
||||||
|
handlers = _handlers(artifact_store)
|
||||||
|
|
||||||
|
payload = asyncio.run(
|
||||||
|
handlers.list_capabilities(source_id="workflow", query="echo")
|
||||||
|
)
|
||||||
|
|
||||||
|
names = [capability["name"] for capability in payload["capabilities"]]
|
||||||
|
assert names == ["workflow.echo_wrapper.v1"]
|
||||||
|
assert payload["capabilities"][0]["source_id"] == "workflow"
|
||||||
|
assert payload["capabilities"][0]["outcomes"] == ["completed"]
|
||||||
|
assert payload["capabilities"][0]["input_fields"] == ["text"]
|
||||||
|
assert payload["capabilities"][0]["output_fields"] == ["echoed"]
|
||||||
|
|
||||||
|
|
||||||
def test_workflow_surface_inspects_one_capability() -> None:
|
def test_workflow_surface_inspects_one_capability() -> None:
|
||||||
handlers = _handlers(
|
handlers = _handlers(
|
||||||
FileWorkflowArtifactStore(local_temp_root() / "surface_inspect_cap")
|
FileWorkflowArtifactStore(local_temp_root() / "surface_inspect_cap")
|
||||||
@@ -125,6 +153,27 @@ def test_workflow_surface_inspects_one_capability() -> None:
|
|||||||
assert "input_schema" in payload
|
assert "input_schema" in payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_workflow_surface_inspects_saved_wrapper_capability() -> None:
|
||||||
|
artifact_store = FileWorkflowArtifactStore(
|
||||||
|
local_temp_root() / "surface_inspect_wrapper_cap"
|
||||||
|
)
|
||||||
|
artifact_store.save_artifact(
|
||||||
|
_echo_artifact().model_copy(update={"id": "echo_wrapper", "kind": "wrapper"})
|
||||||
|
)
|
||||||
|
handlers = _handlers(artifact_store)
|
||||||
|
|
||||||
|
payload = asyncio.run(
|
||||||
|
handlers.inspect_capability(qualified_name="workflow.echo_wrapper.v1")
|
||||||
|
)
|
||||||
|
|
||||||
|
assert payload["name"] == "workflow.echo_wrapper.v1"
|
||||||
|
assert payload["source_id"] == "workflow"
|
||||||
|
assert payload["kind"] == "wrapper_artifact"
|
||||||
|
assert payload["artifact_id"] == "echo_wrapper"
|
||||||
|
assert payload["outcomes"] == ["completed"]
|
||||||
|
assert "input_schema" in payload
|
||||||
|
|
||||||
|
|
||||||
def test_workflow_surface_validates_deployment_dependencies() -> None:
|
def test_workflow_surface_validates_deployment_dependencies() -> None:
|
||||||
artifact_store = FileWorkflowArtifactStore(local_temp_root() / "surface_validate")
|
artifact_store = FileWorkflowArtifactStore(local_temp_root() / "surface_validate")
|
||||||
artifact_store.save_artifact(_artifact())
|
artifact_store.save_artifact(_artifact())
|
||||||
@@ -419,9 +468,7 @@ def test_workflow_surface_deletes_draft_workspace() -> None:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
deleted = asyncio.run(
|
deleted = asyncio.run(handlers.delete_draft_workspace(workspace_id="echo_draft"))
|
||||||
handlers.delete_draft_workspace(workspace_id="echo_draft")
|
|
||||||
)
|
|
||||||
deleted_again = asyncio.run(
|
deleted_again = asyncio.run(
|
||||||
handlers.delete_draft_workspace(workspace_id="echo_draft")
|
handlers.delete_draft_workspace(workspace_id="echo_draft")
|
||||||
)
|
)
|
||||||
@@ -514,9 +561,7 @@ def test_workflow_surface_validates_draft_workspace_with_live_outcomes() -> None
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
payload = asyncio.run(
|
payload = asyncio.run(handlers.validate_draft_workspace(workspace_id="echo_draft"))
|
||||||
handlers.validate_draft_workspace(workspace_id="echo_draft")
|
|
||||||
)
|
|
||||||
fetched = asyncio.run(handlers.get_draft_workspace(workspace_id="echo_draft"))
|
fetched = asyncio.run(handlers.get_draft_workspace(workspace_id="echo_draft"))
|
||||||
|
|
||||||
assert payload["revision"] == 1
|
assert payload["revision"] == 1
|
||||||
@@ -629,6 +674,43 @@ def test_workflow_surface_creates_artifact_from_workspace() -> None:
|
|||||||
assert artifact.id == "workspace_echo"
|
assert artifact.id == "workspace_echo"
|
||||||
|
|
||||||
|
|
||||||
|
def test_workflow_surface_creates_wrapper_from_workspace() -> None:
|
||||||
|
artifact_store = FileWorkflowArtifactStore(
|
||||||
|
local_temp_root() / "surface_workspace_wrapper"
|
||||||
|
)
|
||||||
|
service = WfMcpService(
|
||||||
|
store=FileStore(local_temp_root() / "surface_workspace_wrapper_mcp"),
|
||||||
|
artifact_store=artifact_store,
|
||||||
|
)
|
||||||
|
service.register_connection(
|
||||||
|
ConnectionConfig(id="demo.personal", server="demo", account="personal")
|
||||||
|
)
|
||||||
|
service.register_specs("demo.personal", echo_tool)
|
||||||
|
handlers = WorkflowSurfaceHandlers(service)
|
||||||
|
asyncio.run(
|
||||||
|
handlers.create_draft_workspace(
|
||||||
|
workspace_id="echo_draft",
|
||||||
|
draft=_echo_draft(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = asyncio.run(
|
||||||
|
handlers.create_wrapper_from_workspace(
|
||||||
|
workspace_id="echo_draft",
|
||||||
|
artifact_id="workspace_echo_wrapper",
|
||||||
|
version=1,
|
||||||
|
title="Workspace Echo Wrapper",
|
||||||
|
outcomes=("completed",),
|
||||||
|
source_bindings={"demo": "demo.personal"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
artifact = artifact_store.get_artifact("workspace_echo_wrapper", 1)
|
||||||
|
|
||||||
|
assert result["saved"] is True
|
||||||
|
assert artifact.kind == "wrapper"
|
||||||
|
assert artifact.plan["nodes"][0]["node"] == "demo.echo_tool"
|
||||||
|
|
||||||
|
|
||||||
def test_raw_workflow_plan_uses_core_step_and_edge_models() -> None:
|
def test_raw_workflow_plan_uses_core_step_and_edge_models() -> None:
|
||||||
plan = RawWorkflowPlan.model_validate(_echo_artifact().plan)
|
plan = RawWorkflowPlan.model_validate(_echo_artifact().plan)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user