wf_artifacts to end this "with" usage

This commit is contained in:
lda
2026-05-21 06:02:00 +07:00 Verified
parent 8df4c94c4e
commit 0a079ec5f1
12 changed files with 480 additions and 213 deletions
+12 -6
View File
@@ -265,12 +265,18 @@ arguments:
"steps": { "steps": {
"echo": { "echo": {
"use": "demo.personal.echo_tool", "use": "demo.personal.echo_tool",
"in": { "input": [
"input.text": "text" {
}, "target": {"root": "local", "parts": ["text"]},
"out": { "path": {"root": "input", "parts": ["text"]}
"echoed": "state.echoed" }
} ],
"output": [
{
"source": {"root": "local", "parts": ["echoed"]},
"target": {"root": "state", "parts": ["echoed"]}
}
]
} }
}, },
"routes": { "routes": {
+98 -58
View File
@@ -69,12 +69,18 @@ A minimal draft looks like this:
"steps": { "steps": {
"echo": { "echo": {
"use": "demo.personal.echo_tool", "use": "demo.personal.echo_tool",
"in": { "input": [
"input.text": "text" {
}, "target": {"root": "local", "parts": ["text"]},
"out": { "path": {"root": "input", "parts": ["text"]}
"echoed": "state.echoed" }
} ],
"output": [
{
"source": {"root": "local", "parts": ["echoed"]},
"target": {"root": "state", "parts": ["echoed"]}
}
]
} }
}, },
"routes": { "routes": {
@@ -95,44 +101,63 @@ Important details:
- When saved with source bindings, concrete refs can be normalized to logical - When saved with source bindings, concrete refs can be normalized to logical
refs such as `demo.echo_tool`. refs such as `demo.echo_tool`.
## Mapping Shape ## Binding Shape
Draft maps are JSON objects whose keys and values are strings: Draft `use` steps use the same canonical binding structs as core `NodeUse`:
```ts ```json
type InMap = Record<string, string> {
type OutMap = Record<string, string> "input": [
{
"target": {"root": "local", "parts": ["message"]},
"path": {"root": "input", "parts": ["text"]}
},
{
"target": {"root": "local", "parts": ["limit"]},
"value": 3
}
],
"output": [
{
"source": {"root": "local", "parts": ["echoed"]},
"target": {"root": "state", "parts": ["echoed"]}
}
]
}
``` ```
Both maps are source-to-destination. Legacy draft maps `in`, `with`, and `out` are still accepted as parse-only
compatibility input. Valid drafts are saved and returned with canonical
| Map | Key | Value | Example | `input` and `output` binding lists.
| --- | --- | --- | --- |
| `in` | graph source path | node-local input path | `"input.text": "message"` |
| `out` | node-local output path | graph state destination path | `"echoed": "state.echoed"` |
Draft `in` and `out` maps are an authoring-layer shape. When a draft is
compiled, the core workflow uses canonical `NodeUse.input` and
`NodeUse.output` binding lists. The old core `in_map`, `input_values`, and
`out_map` fields are parse-only compatibility inputs, not the preferred saved
shape.
Graph source paths in `in` normally start with `input.`, `state.`, or Graph source paths in `in` normally start with `input.`, `state.`, or
`context.`. Node-local paths do not use those prefixes; they are paths inside `context.`. Node-local paths do not use those prefixes; they are paths inside
the target capability's input or output payload. the target capability's input or output payload.
For example: For example, this canonical input/output pair:
```json ```json
{ {
"in": { "input": [
"input.user.name": "user.name", {
"state.job.title": "job.title" "target": {"root": "local", "parts": ["user", "name"]},
}, "path": {"root": "input", "parts": ["user", "name"]}
"out": { },
"user.age": "state.person.age", {
"job.years": "state.experience.years" "target": {"root": "local", "parts": ["job", "title"]},
} "path": {"root": "state", "parts": ["job", "title"]}
}
],
"output": [
{
"source": {"root": "local", "parts": ["user", "age"]},
"target": {"root": "state", "parts": ["person", "age"]}
},
{
"source": {"root": "local", "parts": ["job", "years"]},
"target": {"root": "state", "parts": ["experience", "years"]}
}
]
} }
``` ```
@@ -147,28 +172,32 @@ Do not reverse the direction. This is wrong:
```json ```json
{ {
"in": { "input": [
"message": "input.text" {
} "target": {"root": "input", "parts": ["text"]},
"path": {"root": "local", "parts": ["message"]}
}
]
} }
``` ```
That asks the runtime to read from graph path `message` and write into a That asks the runtime to read from graph path `message` and write into a
node-local input field literally named `input.text`. node-local input field literally named `input.text`.
Do not put constants in `in`. This is also wrong: Do not put constants in path bindings. This is wrong:
```json ```json
{ {
"in": { "input": [
"value": { {
"value": "CLICKED" "target": {"root": "local", "parts": ["value"]},
"path": {"root": "input", "parts": ["CLICKED"]}
} }
} ]
} }
``` ```
Use `with` for static node-local values instead. Use an input value binding for static node-local values instead.
## Step Kinds ## Step Kinds
@@ -179,38 +208,49 @@ Calls a workflow capability.
```json ```json
{ {
"use": "demo.personal.echo_tool", "use": "demo.personal.echo_tool",
"in": { "input": [
"input.text": "text" {
}, "target": {"root": "local", "parts": ["text"]},
"out": { "path": {"root": "input", "parts": ["text"]}
"echoed": "state.echoed" }
} ],
"output": [
{
"source": {"root": "local", "parts": ["echoed"]},
"target": {"root": "state", "parts": ["echoed"]}
}
]
} }
``` ```
Use this for normal node calls, including generated workflow wrappers around Use this for normal node calls, including generated workflow wrappers around
MCP tools and local `wf.std` capabilities. MCP tools and local `wf.std` capabilities.
`use` steps can also provide static node-local input values with `with`. `use` steps can also provide static node-local input values.
Use this for hardcoded strings, booleans, numbers, and small JSON values that Use this for hardcoded strings, booleans, numbers, and small JSON values that
are part of the graph definition: are part of the graph definition:
```json ```json
{ {
"use": "wf.std.constant", "use": "wf.std.constant",
"with": { "input": [
"value": "CLICKED" {
}, "target": {"root": "local", "parts": ["value"]},
"out": { "value": "CLICKED"
"value": "state.wait_text" }
} ],
"output": [
{
"source": {"root": "local", "parts": ["value"]},
"target": {"root": "state", "parts": ["wait_text"]}
}
]
} }
``` ```
Static values are not path mappings. Do not put `{"value": "CLICKED"}` inside Static values are not path mappings. Use `{"target": ..., "value": ...}` for
`in`. The `in` object only maps graph paths such as `input.url` or literal JSON values. Invalid draft step shapes are rejected instead of silently
`state.wait_text` to node-local input fields. Invalid draft step shapes are compiling to `join`.
rejected instead of silently compiling to `join`.
Generated MCP tool wrappers are intentionally naive. They normally expose both Generated MCP tool wrappers are intentionally naive. They normally expose both
`ok` and `error` outcomes, because MCP tool calls can report transport/provider `ok` and `error` outcomes, because MCP tool calls can report transport/provider
+30 -3
View File
@@ -3,7 +3,11 @@ from __future__ import annotations
import time import time
from typing import Any from typing import Any
from wf_artifacts.drafts import patch_workflow_draft, validate_workflow_draft from wf_artifacts.drafts import (
WorkflowDraft,
patch_workflow_draft,
validate_workflow_draft,
)
from .models import WorkflowDraftWorkspace, summarize_draft_workspace from .models import WorkflowDraftWorkspace, summarize_draft_workspace
from .store import DraftWorkspaceConflictError, DraftWorkspaceStore from .store import DraftWorkspaceConflictError, DraftWorkspaceStore
@@ -22,11 +26,14 @@ def create_draft_workspace(
"""Validate and save a new mutable draft workspace.""" """Validate and save a new mutable draft workspace."""
now = _now_ms() now = _now_ms()
validation = validate_workflow_draft(draft) validation = validate_workflow_draft(draft)
normalized_draft = _canonical_draft_if_valid(
draft, validation_status=validation["status"]
)
workspace = WorkflowDraftWorkspace( workspace = WorkflowDraftWorkspace(
id=workspace_id, id=workspace_id,
revision=1, revision=1,
title=title, title=title,
draft=draft, draft=normalized_draft,
status=validation["status"], status=validation["status"],
diagnostics=validation["diagnostics"], diagnostics=validation["diagnostics"],
created_at_epoch_ms=now, created_at_epoch_ms=now,
@@ -66,7 +73,10 @@ def patch_draft_workspace(
next_workspace = workspace.model_copy( next_workspace = workspace.model_copy(
update={ update={
"revision": workspace.revision + 1, "revision": workspace.revision + 1,
"draft": patched["draft"], "draft": _canonical_draft_if_valid(
patched["draft"],
validation_status=patched["status"],
),
"status": patched["status"], "status": patched["status"],
"diagnostics": patched["diagnostics"], "diagnostics": patched["diagnostics"],
"updated_at_epoch_ms": _now_ms(), "updated_at_epoch_ms": _now_ms(),
@@ -96,6 +106,23 @@ def _now_ms() -> int:
return int(time.time() * 1000) return int(time.time() * 1000)
def _canonical_draft_if_valid(
draft: JsonObject,
*,
validation_status: object,
) -> JsonObject:
"""Persist valid drafts in the canonical model shape.
Invalid drafts remain as-authored so diagnostics can still point at the
payload the client sent. Once validation passes, legacy `in`/`with`/`out`
maps become canonical `input`/`output` binding lists on disk and in MCP
responses.
"""
if validation_status != "valid":
return draft
return WorkflowDraft.model_validate(draft).model_dump(mode="json")
def _revision_conflict_payload( def _revision_conflict_payload(
workspace: WorkflowDraftWorkspace, workspace: WorkflowDraftWorkspace,
expected_revision: int, expected_revision: int,
+2 -25
View File
@@ -1,7 +1,5 @@
from __future__ import annotations from __future__ import annotations
from typing import Any
from wf_authoring import WorkflowBuilder from wf_authoring import WorkflowBuilder
from wf_authoring.dsl import PathExpr from wf_authoring.dsl import PathExpr
from wf_core import JoinNode, Workflow from wf_core import JoinNode, Workflow
@@ -44,8 +42,8 @@ def _add_step(builder: WorkflowBuilder, step_id: str, step: DraftStep):
return builder.use_ref( return builder.use_ref(
step.use, step.use,
id=step_id, id=step_id,
input=_draft_input_bindings(step), input=step.input,
output=_draft_output_bindings(step), output=step.output,
desc=step.desc, desc=step.desc,
) )
if isinstance(step, DraftForeachStep): if isinstance(step, DraftForeachStep):
@@ -89,24 +87,3 @@ def _add_step(builder: WorkflowBuilder, step_id: str, step: DraftStep):
default=step.match.default, default=step.match.default,
).entry ).entry
raise TypeError(f"unsupported draft step {type(step)!r}") raise TypeError(f"unsupported draft step {type(step)!r}")
def _draft_input_bindings(step: DraftUseStep) -> list[dict[str, Any]]:
"""Translate draft input maps into canonical core input binding structs.
Draft JSON keeps `in` and `with` because they are compact patch targets for
LLM clients. The compiled workflow should not re-emit deprecated builder map
sugar, so this adapter boundary converts them to `NodeUse.input`.
"""
literal_bindings = [
{"target": target, "value": value} for target, value in step.with_.items()
]
path_bindings = [
{"target": target, "path": source} for source, target in step.in_.items()
]
return [*literal_bindings, *path_bindings]
def _draft_output_bindings(step: DraftUseStep) -> list[dict[str, str]]:
"""Translate draft output maps into canonical core output binding structs."""
return [{"source": source, "target": target} for source, target in step.out.items()]
+2
View File
@@ -73,6 +73,8 @@ def patch_workflow_draft(draft: JsonObject, patch: JsonPatch) -> JsonObject:
) )
) )
result = validate_workflow_draft(patched) result = validate_workflow_draft(patched)
if result["status"] == "valid":
patched = WorkflowDraft.model_validate(patched).model_dump(mode="json")
return {"draft": patched, **result} return {"draft": patched, **result}
+63 -27
View File
@@ -5,17 +5,20 @@ from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator from pydantic import BaseModel, ConfigDict, Field, model_validator
from wf_core.models.conditions import Condition from wf_core.models.conditions import Condition
from wf_core.models.steps import InputBinding, OutputBinding
JsonObject = dict[str, Any] JsonObject = dict[str, Any]
STEP_KIND_KEYS = frozenset({ STEP_KIND_KEYS = frozenset(
"use", {
"foreach", "use",
"interrupt", "foreach",
"join", "interrupt",
"when", "join",
"choose", "when",
"match", "choose",
}) "match",
}
)
class DraftUseStep(BaseModel): class DraftUseStep(BaseModel):
@@ -24,34 +27,67 @@ class DraftUseStep(BaseModel):
model_config = ConfigDict(extra="forbid", populate_by_name=True) model_config = ConfigDict(extra="forbid", populate_by_name=True)
use: str use: str
in_: dict[str, str] = Field( input: list[InputBinding] = Field(
default_factory=dict, default_factory=list,
alias="in",
description=( description=(
"Source-to-destination map from graph paths to node-local input " "Canonical input bindings for this capability. Use path bindings "
"paths. Example: {'input.text': 'message'}. Values must be strings; " "for graph-to-local input and value bindings for literals."
"use 'with' for literals."
), ),
) )
with_: dict[str, Any] = Field( output: list[OutputBinding] = Field(
default_factory=dict, default_factory=list,
alias="with",
description=( description=(
"Static node-local input values keyed by destination input field/path. " "Canonical output bindings from node-local output paths to workflow "
"Example: {'value': 'CLICKED'}." "state destinations."
),
)
out: dict[str, str] = Field(
default_factory=dict,
description=(
"Source-to-destination map from node-local output paths to workflow "
"state destinations. Example: {'echoed': 'state.echoed'}."
), ),
) )
desc: str | None = None desc: str | None = None
retry: int | None = Field(default=None, ge=0) retry: int | None = Field(default=None, ge=0)
timeout_seconds: int | None = Field(default=None, gt=0) timeout_seconds: int | None = Field(default=None, gt=0)
@model_validator(mode="before")
@classmethod
def _coerce_legacy_maps(cls, value: object) -> object:
"""Accept draft `in`/`with`/`out` maps as parse-only compatibility."""
if not isinstance(value, dict):
return value
data = dict(value)
legacy_input = data.pop("in", None)
legacy_with = data.pop("with", None)
legacy_output = data.pop("out", None)
if "input" in data and (legacy_input is not None or legacy_with is not None):
raise ValueError("cannot mix canonical input with legacy in/with maps")
if "output" in data and legacy_output is not None:
raise ValueError("cannot mix canonical output with legacy out map")
input_bindings = list(data.get("input", []))
output_bindings = list(data.get("output", []))
if legacy_with is not None:
if not isinstance(legacy_with, dict):
raise ValueError("draft use 'with' must be a mapping")
input_bindings.extend(
{"target": target, "value": literal}
for target, literal in legacy_with.items()
)
if legacy_input is not None:
if not isinstance(legacy_input, dict):
raise ValueError("draft use 'in' must be a mapping")
input_bindings.extend(
{"target": target, "path": source}
for source, target in legacy_input.items()
)
if legacy_output is not None:
if not isinstance(legacy_output, dict):
raise ValueError("draft use 'out' must be a mapping")
output_bindings.extend(
{"source": source, "target": target}
for source, target in legacy_output.items()
)
data["input"] = input_bindings
data["output"] = output_bindings
return data
class DraftForeachPayload(BaseModel): class DraftForeachPayload(BaseModel):
"""Payload for one draft foreach step.""" """Payload for one draft foreach step."""
+48 -9
View File
@@ -38,6 +38,7 @@ from wf_core.models.steps import (
InputValueBinding, InputValueBinding,
OutputBinding, OutputBinding,
) )
from wf_core.paths import GraphSourcePath, LocalPath, StatePath
from ..events import make_event from ..events import make_event
from ..models import RawWorkflowPlan from ..models import RawWorkflowPlan
@@ -603,8 +604,8 @@ class WorkflowSurfaceHandlers:
patch=[ patch=[
{ {
"op": "replace", "op": "replace",
"path": f"/steps/{_escape_json_pointer(step_id)}/in", "path": f"/steps/{_escape_json_pointer(step_id)}/input",
"value": input_map, "value": _draft_input_bindings_payload(input_map, {}),
} }
], ],
) )
@@ -623,8 +624,8 @@ class WorkflowSurfaceHandlers:
patch=[ patch=[
{ {
"op": "replace", "op": "replace",
"path": f"/steps/{_escape_json_pointer(step_id)}/out", "path": f"/steps/{_escape_json_pointer(step_id)}/output",
"value": output_map, "value": _draft_output_bindings_payload(output_map),
} }
], ],
) )
@@ -657,9 +658,8 @@ class WorkflowSurfaceHandlers:
steps: dict[str, Any] = { steps: dict[str, Any] = {
DEFAULT_CALL_STEP_ID: { DEFAULT_CALL_STEP_ID: {
"use": capability_name, "use": capability_name,
"in": draft_input, "input": _draft_input_bindings_payload(draft_input, draft_with),
"with": draft_with, "output": _draft_output_bindings_payload(draft_output),
"out": draft_output,
} }
} }
routes: dict[str, dict[str, str]] = { routes: dict[str, dict[str, str]] = {
@@ -672,8 +672,13 @@ class WorkflowSurfaceHandlers:
# exposes, a concrete state path that can become a runtime message. # exposes, a concrete state path that can become a runtime message.
steps[DEFAULT_ERROR_STEP_ID] = { steps[DEFAULT_ERROR_STEP_ID] = {
"use": RUNTIME_ERROR_CAPABILITY, "use": RUNTIME_ERROR_CAPABILITY,
"in": {error_source: "message"}, "input": [
"out": {}, {
"target": {"root": "local", "parts": ["message"]},
"path": _graph_path_payload(error_source),
}
],
"output": [],
} }
routes[DEFAULT_CALL_STEP_ID][DEFAULT_ERROR_OUTCOME] = DEFAULT_ERROR_STEP_ID routes[DEFAULT_CALL_STEP_ID][DEFAULT_ERROR_OUTCOME] = DEFAULT_ERROR_STEP_ID
routes[DEFAULT_ERROR_STEP_ID] = {DEFAULT_OK_OUTCOME: "__end__"} routes[DEFAULT_ERROR_STEP_ID] = {DEFAULT_OK_OUTCOME: "__end__"}
@@ -1080,6 +1085,40 @@ def _draft_output_map(
return {str(binding.source): str(binding.target) for binding in output} return {str(binding.source): str(binding.target) for binding in output}
def _draft_input_bindings_payload(
input_map: dict[str, str],
input_values: dict[str, Any],
) -> list[dict[str, Any]]:
"""Serialize draft input maps into canonical structural binding payloads."""
return [
{"target": _local_path_payload(target), "value": value}
for target, value in input_values.items()
] + [
{"target": _local_path_payload(target), "path": _graph_path_payload(source)}
for source, target in input_map.items()
]
def _draft_output_bindings_payload(output_map: dict[str, str]) -> list[dict[str, Any]]:
"""Serialize draft output maps into canonical structural binding payloads."""
return [
{"source": _local_path_payload(source), "target": _state_path_payload(target)}
for source, target in output_map.items()
]
def _graph_path_payload(value: str) -> dict[str, str | list[str]]:
return GraphSourcePath._serialize(GraphSourcePath.parse(value))
def _local_path_payload(value: str) -> dict[str, str | list[str]]:
return LocalPath._serialize(LocalPath.parse(value))
def _state_path_payload(value: str) -> dict[str, str | list[str]]:
return StatePath._serialize(StatePath.parse(value))
def _escape_json_pointer(value: str) -> str: def _escape_json_pointer(value: str) -> str:
"""Escape one JSON Pointer path segment for generated JSON Patch helpers.""" """Escape one JSON Pointer path segment for generated JSON Patch helpers."""
return value.replace("~", "~0").replace("/", "~1") return value.replace("~", "~0").replace("/", "~1")
+24 -4
View File
@@ -44,8 +44,18 @@ def test_adapter_lowers_use_steps_to_canonical_bindings() -> None:
"steps": { "steps": {
"echo": { "echo": {
"use": "demo.echo", "use": "demo.echo",
"in": {"input.text": "text"}, "input": [
"out": {"echoed": "state.echoed"}, {
"target": {"root": "local", "parts": ["text"]},
"path": {"root": "input", "parts": ["text"]},
}
],
"output": [
{
"source": {"root": "local", "parts": ["echoed"]},
"target": {"root": "state", "parts": ["echoed"]},
}
],
} }
}, },
"routes": {"echo": {"ok": "__end__"}}, "routes": {"echo": {"ok": "__end__"}},
@@ -76,8 +86,18 @@ def test_adapter_lowers_static_inputs_for_constant_like_steps() -> None:
"steps": { "steps": {
"constant": { "constant": {
"use": "wf.std.constant", "use": "wf.std.constant",
"with": {"value": "CLICKED"}, "input": [
"out": {"value": "state.message"}, {
"target": {"root": "local", "parts": ["value"]},
"value": "CLICKED",
}
],
"output": [
{
"source": {"root": "local", "parts": ["value"]},
"target": {"root": "state", "parts": ["message"]},
}
],
} }
}, },
"routes": {"constant": {"ok": "__end__"}}, "routes": {"constant": {"ok": "__end__"}},
+17 -4
View File
@@ -17,14 +17,17 @@ def test_patch_workflow_draft_uses_stable_step_paths() -> None:
[ [
{ {
"op": "replace", "op": "replace",
"path": "/steps/echo/in/input.text", "path": "/steps/echo/input/0/target/parts/0",
"value": "message", "value": "message",
} }
], ],
) )
assert result["status"] == "valid" assert result["status"] == "valid"
assert result["draft"]["steps"]["echo"]["in"]["input.text"] == "message" assert result["draft"]["steps"]["echo"]["input"][0]["target"] == {
"root": "local",
"parts": ["message"],
}
def _keyed_echo_draft() -> dict[str, object]: def _keyed_echo_draft() -> dict[str, object]:
@@ -37,8 +40,18 @@ def _keyed_echo_draft() -> dict[str, object]:
"steps": { "steps": {
"echo": { "echo": {
"use": "demo.echo", "use": "demo.echo",
"in": {"input.text": "text"}, "input": [
"out": {"echoed": "state.echoed"}, {
"target": {"root": "local", "parts": ["text"]},
"path": {"root": "input", "parts": ["text"]},
}
],
"output": [
{
"source": {"root": "local", "parts": ["echoed"]},
"target": {"root": "state", "parts": ["echoed"]},
}
],
} }
}, },
"routes": {"echo": {"ok": "__end__"}}, "routes": {"echo": {"ok": "__end__"}},
+105 -55
View File
@@ -21,6 +21,40 @@ def test_workflow_draft_uses_keyed_steps() -> None:
assert draft.steps["echo"].use == "demo.echo" assert draft.steps["echo"].use == "demo.echo"
def test_workflow_draft_accepts_legacy_use_maps_but_dumps_canonical_bindings() -> None:
draft = WorkflowDraft.model_validate(
{
**_keyed_echo_draft(),
"steps": {
"echo": {
"use": "demo.echo",
"in": {"input.text": "text"},
"with": {"limit": 3},
"out": {"echoed": "state.echoed"},
}
},
}
)
dumped = draft.model_dump(mode="json")
assert "in" not in dumped["steps"]["echo"]
assert "with" not in dumped["steps"]["echo"]
assert "out" not in dumped["steps"]["echo"]
assert dumped["steps"]["echo"]["input"][0]["target"] == {
"root": "local",
"parts": ["limit"],
}
assert dumped["steps"]["echo"]["input"][1]["path"] == {
"root": "input",
"parts": ["text"],
}
assert dumped["steps"]["echo"]["output"][0]["target"] == {
"root": "state",
"parts": ["echoed"],
}
def test_draft_step_requires_exactly_one_kind_key() -> None: def test_draft_step_requires_exactly_one_kind_key() -> None:
draft = _keyed_echo_draft() draft = _keyed_echo_draft()
steps = draft["steps"] steps = draft["steps"]
@@ -36,72 +70,78 @@ def test_draft_step_requires_exactly_one_kind_key() -> None:
def test_workflow_draft_accepts_when_step() -> None: def test_workflow_draft_accepts_when_step() -> None:
draft = WorkflowDraft.model_validate({ draft = WorkflowDraft.model_validate(
**_keyed_echo_draft(), {
"start": "decide", **_keyed_echo_draft(),
"steps": { "start": "decide",
**_keyed_echo_draft()["steps"], "steps": {
"decide": { **_keyed_echo_draft()["steps"],
"when": { "decide": {
"if": { "when": {
"op": "ge", "if": {
"left": {"path": "state.count"}, "op": "ge",
"right": {"value": 1}, "left": {"path": "state.count"},
}, "right": {"value": 1},
"then": "echo", },
"otherwise": "__end__", "then": "echo",
} "otherwise": "__end__",
}
},
}, },
}, }
}) )
assert isinstance(draft.steps["decide"], DraftWhenStep) assert isinstance(draft.steps["decide"], DraftWhenStep)
def test_workflow_draft_accepts_choose_step() -> None: def test_workflow_draft_accepts_choose_step() -> None:
draft = WorkflowDraft.model_validate({ draft = WorkflowDraft.model_validate(
**_keyed_echo_draft(), {
"start": "choose_next", **_keyed_echo_draft(),
"steps": { "start": "choose_next",
**_keyed_echo_draft()["steps"], "steps": {
"choose_next": { **_keyed_echo_draft()["steps"],
"choose": { "choose_next": {
"clauses": [ "choose": {
{ "clauses": [
"if": { {
"op": "exists", "if": {
"path": "state.text", "op": "exists",
}, "path": "state.text",
"then": "echo", },
} "then": "echo",
], }
"default": "__end__", ],
} "default": "__end__",
}
},
}, },
}, }
}) )
assert isinstance(draft.steps["choose_next"], DraftChooseStep) assert isinstance(draft.steps["choose_next"], DraftChooseStep)
def test_workflow_draft_accepts_match_step() -> None: def test_workflow_draft_accepts_match_step() -> None:
draft = WorkflowDraft.model_validate({ draft = WorkflowDraft.model_validate(
**_keyed_echo_draft(), {
"start": "match_status", **_keyed_echo_draft(),
"steps": { "start": "match_status",
**_keyed_echo_draft()["steps"], "steps": {
"match_status": { **_keyed_echo_draft()["steps"],
"match": { "match_status": {
"value": "state.status", "match": {
"cases": [ "value": "state.status",
{"equals": "ready", "then": "echo"}, "cases": [
{"equals": "done", "then": "__end__"}, {"equals": "ready", "then": "echo"},
], {"equals": "done", "then": "__end__"},
"default": "__end__", ],
} "default": "__end__",
}
},
}, },
}, }
}) )
assert isinstance(draft.steps["match_status"], DraftMatchStep) assert isinstance(draft.steps["match_status"], DraftMatchStep)
@@ -116,8 +156,18 @@ def _keyed_echo_draft() -> dict[str, Any]:
"steps": { "steps": {
"echo": { "echo": {
"use": "demo.echo", "use": "demo.echo",
"in": {"input.text": "text"}, "input": [
"out": {"echoed": "state.echoed"}, {
"target": {"root": "local", "parts": ["text"]},
"path": {"root": "input", "parts": ["text"]},
}
],
"output": [
{
"source": {"root": "local", "parts": ["echoed"]},
"target": {"root": "state", "parts": ["echoed"]},
}
],
} }
}, },
"routes": {"echo": {"ok": "__end__"}}, "routes": {"echo": {"ok": "__end__"}},
+7 -2
View File
@@ -277,8 +277,13 @@ def _draft() -> dict[str, Any]:
"steps": { "steps": {
"echo": { "echo": {
"use": "demo.echo", "use": "demo.echo",
"in": {}, "input": [],
"out": {"echoed": "state.echoed"}, "output": [
{
"source": {"root": "local", "parts": ["echoed"]},
"target": {"root": "state", "parts": ["echoed"]},
}
],
} }
}, },
"routes": {"echo": {"ok": "__end__"}}, "routes": {"echo": {"ok": "__end__"}},
+72 -20
View File
@@ -435,7 +435,7 @@ def test_workflow_surface_patches_draft_without_saving() -> None:
patch=[ patch=[
{ {
"op": "replace", "op": "replace",
"path": "/steps/echo/in/input.text", "path": "/steps/echo/input/0/target/parts/0",
"value": "message", "value": "message",
} }
], ],
@@ -443,7 +443,10 @@ def test_workflow_surface_patches_draft_without_saving() -> None:
) )
assert payload["status"] == "valid" assert payload["status"] == "valid"
assert payload["draft"]["steps"]["echo"]["in"]["input.text"] == "message" assert payload["draft"]["steps"]["echo"]["input"][0]["target"] == {
"root": "local",
"parts": ["message"],
}
assert not artifact_store.list_artifacts() assert not artifact_store.list_artifacts()
@@ -579,8 +582,18 @@ def test_workflow_surface_patch_helpers_update_draft_workspace() -> None:
assert output_mapped["revision"] == 5 assert output_mapped["revision"] == 5
assert fetched["draft"]["name"] == "echo_v2" assert fetched["draft"]["name"] == "echo_v2"
assert fetched["draft"]["routes"]["echo"]["error"] == "__end__" assert fetched["draft"]["routes"]["echo"]["error"] == "__end__"
assert fetched["draft"]["steps"]["echo"]["in"] == {"input.text": "message"} assert fetched["draft"]["steps"]["echo"]["input"] == [
assert fetched["draft"]["steps"]["echo"]["out"] == {"echoed": "state.echoed"} {
"target": {"root": "local", "parts": ["message"]},
"path": {"root": "input", "parts": ["text"]},
}
]
assert fetched["draft"]["steps"]["echo"]["output"] == [
{
"source": {"root": "local", "parts": ["echoed"]},
"target": {"root": "state", "parts": ["echoed"]},
}
]
def test_workflow_surface_validates_draft_workspace_with_live_outcomes() -> None: def test_workflow_surface_validates_draft_workspace_with_live_outcomes() -> None:
@@ -654,7 +667,7 @@ def test_workflow_surface_creates_minimal_draft_workspace_with_error_route() ->
result = asyncio.run( result = asyncio.run(
handlers.create_minimal_draft_workspace( handlers.create_minimal_draft_workspace(
workspace_id="echo_draft", workspace_id="echo_draft_canonical_error",
name="echo", name="echo",
capability_name="demo.personal.mcp_echo_tool", capability_name="demo.personal.mcp_echo_tool",
input_schema={ input_schema={
@@ -673,13 +686,20 @@ def test_workflow_surface_creates_minimal_draft_workspace_with_error_route() ->
) )
) )
assert service.draft_workspace_store is not None assert service.draft_workspace_store is not None
workspace = service.draft_workspace_store.get_workspace("echo_draft") workspace = service.draft_workspace_store.get_workspace(
"echo_draft_canonical_error"
)
assert result["workspace_id"] == "echo_draft" assert result["workspace_id"] == "echo_draft_canonical_error"
assert workspace.draft["routes"]["call"]["ok"] == "__end__" assert workspace.draft["routes"]["call"]["ok"] == "__end__"
assert workspace.draft["routes"]["call"]["error"] == "tool_error" assert workspace.draft["routes"]["call"]["error"] == "tool_error"
assert workspace.draft["steps"]["tool_error"]["use"] == "wf.std.runtime_error" assert workspace.draft["steps"]["tool_error"]["use"] == "wf.std.runtime_error"
assert workspace.draft["steps"]["tool_error"]["in"] == {"state.echoed": "message"} assert workspace.draft["steps"]["tool_error"]["input"] == [
{
"target": {"root": "local", "parts": ["message"]},
"path": {"root": "state", "parts": ["echoed"]},
}
]
def test_workflow_surface_accepts_canonical_bindings_for_minimal_workspace() -> None: def test_workflow_surface_accepts_canonical_bindings_for_minimal_workspace() -> None:
@@ -693,7 +713,7 @@ def test_workflow_surface_accepts_canonical_bindings_for_minimal_workspace() ->
result = asyncio.run( result = asyncio.run(
handlers.create_minimal_draft_workspace( handlers.create_minimal_draft_workspace(
workspace_id="echo_draft", workspace_id="echo_draft_canonical",
name="echo", name="echo",
capability_name="demo.personal.echo_tool", capability_name="demo.personal.echo_tool",
input_schema={"type": "object"}, input_schema={"type": "object"},
@@ -714,11 +734,21 @@ def test_workflow_surface_accepts_canonical_bindings_for_minimal_workspace() ->
) )
) )
assert service.draft_workspace_store is not None assert service.draft_workspace_store is not None
workspace = service.draft_workspace_store.get_workspace("echo_draft") workspace = service.draft_workspace_store.get_workspace("echo_draft_canonical")
assert result["workspace_id"] == "echo_draft" assert result["workspace_id"] == "echo_draft_canonical"
assert workspace.draft["steps"]["call"]["in"] == {"input.text": "text"} assert workspace.draft["steps"]["call"]["input"] == [
assert workspace.draft["steps"]["call"]["out"] == {"echoed": "state.echoed"} {
"target": {"root": "local", "parts": ["text"]},
"path": {"root": "input", "parts": ["text"]},
}
]
assert workspace.draft["steps"]["call"]["output"] == [
{
"source": {"root": "local", "parts": ["echoed"]},
"target": {"root": "state", "parts": ["echoed"]},
}
]
def test_workflow_surface_creates_draft_workspace_from_capability_hints() -> None: def test_workflow_surface_creates_draft_workspace_from_capability_hints() -> None:
@@ -737,20 +767,32 @@ def test_workflow_surface_creates_draft_workspace_from_capability_hints() -> Non
result = asyncio.run( result = asyncio.run(
handlers.create_draft_workspace_from_capability( handlers.create_draft_workspace_from_capability(
workspace_id="echo_from_capability", workspace_id="echo_from_capability_canonical",
capability_name="demo.personal.echo_tool", capability_name="demo.personal.echo_tool",
name="echo_from_capability", name="echo_from_capability",
) )
) )
assert service.draft_workspace_store is not None assert service.draft_workspace_store is not None
workspace = service.draft_workspace_store.get_workspace("echo_from_capability") workspace = service.draft_workspace_store.get_workspace(
"echo_from_capability_canonical"
)
assert result["workspace_id"] == "echo_from_capability" assert result["workspace_id"] == "echo_from_capability_canonical"
assert result["wrapper_hints"]["input_map"] == {"input.text": "text"} assert result["wrapper_hints"]["input_map"] == {"input.text": "text"}
assert result["wrapper_hints"]["output_map"] == {"echoed": "state.echoed"} assert result["wrapper_hints"]["output_map"] == {"echoed": "state.echoed"}
assert workspace.draft["steps"]["call"]["use"] == "demo.personal.echo_tool" assert workspace.draft["steps"]["call"]["use"] == "demo.personal.echo_tool"
assert workspace.draft["steps"]["call"]["in"] == {"input.text": "text"} assert workspace.draft["steps"]["call"]["input"] == [
assert workspace.draft["steps"]["call"]["out"] == {"echoed": "state.echoed"} {
"target": {"root": "local", "parts": ["text"]},
"path": {"root": "input", "parts": ["text"]},
}
]
assert workspace.draft["steps"]["call"]["output"] == [
{
"source": {"root": "local", "parts": ["echoed"]},
"target": {"root": "state", "parts": ["echoed"]},
}
]
def test_workflow_surface_creates_artifact_from_workspace() -> None: def test_workflow_surface_creates_artifact_from_workspace() -> None:
@@ -1262,8 +1304,18 @@ def _echo_draft() -> dict[str, Any]:
"steps": { "steps": {
"echo": { "echo": {
"use": "demo.personal.echo_tool", "use": "demo.personal.echo_tool",
"in": {"input.text": "text"}, "input": [
"out": {"echoed": "state.echoed"}, {
"target": {"root": "local", "parts": ["text"]},
"path": {"root": "input", "parts": ["text"]},
}
],
"output": [
{
"source": {"root": "local", "parts": ["echoed"]},
"target": {"root": "state", "parts": ["echoed"]},
}
],
} }
}, },
"routes": {"echo": {"ok": "__end__"}}, "routes": {"echo": {"ok": "__end__"}},