feat: replace draft output bind with general bind

This commit is contained in:
lda
2026-06-28 00:38:42 +07:00 Verified
parent b96ba0f7d1
commit 955c43d808
25 changed files with 1246 additions and 138 deletions
+5 -4
View File
@@ -63,9 +63,10 @@ clear operator feedback before adding more architecture.
- Completed: `wf schema` now lists workflow document/component models, emits - Completed: `wf schema` now lists workflow document/component models, emits
compact JSON outlines for agent discovery, and emits valid self-contained compact JSON outlines for agent discovery, and emits valid self-contained
JSON Schema with `--verbose`. JSON Schema with `--verbose`.
- Completed: `wf draft bind-output-to-state` composes state schema projection - Completed: `wf draft bind --from ... --to ...` composes input/state/output
with output binding merge, reducing manual draft patch repairs in agent schema projection with step binding merge, replacing the narrower
challenge runs. `bind-output-to-state` helper and reducing manual draft patch repairs in
agent challenge runs.
- Completed: `wf draft add-step-from-capability` inserts one explicit - Completed: `wf draft add-step-from-capability` inserts one explicit
capability-backed step with route, input, and output-to-state schema/binding capability-backed step with route, input, and output-to-state schema/binding
wiring in a single revision, reducing brittle JSON Patch authoring for wiring in a single revision, reducing brittle JSON Patch authoring for
@@ -75,7 +76,7 @@ clear operator feedback before adding more architecture.
- Completed: `wf draft compile` returns the compiled raw plan plus required - Completed: `wf draft compile` returns the compiled raw plan plus required
capabilities without mutating or saving the draft workspace. capabilities without mutating or saving the draft workspace.
- Completed: draft validation now preserves structured core validation issues - Completed: draft validation now preserves structured core validation issues
and adds exact `bind-output-to-state` repair hints for missing state fields. and adds exact `wf draft bind` repair hints for missing state fields.
- Keep status read-only; do not mutate registry, auth, config, or stores. - Keep status read-only; do not mutate registry, auth, config, or stores.
## Priority 2: Durable Run/Resume Hardening ## Priority 2: Durable Run/Resume Hardening
@@ -0,0 +1,841 @@
# Draft Bind From/To Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a general `wf draft bind --from <path> --to <path>` operation that handles capability-aware input/output binding plus schema projection.
**Architecture:** Extend the existing semantic draft authoring service rather than adding another parallel helper. Generalize schema projection in `wf_api.schema_projection`, then expose one `bind_draft` method through API, service, RPC, MCP, and CLI. Remove the recently-added narrow `bind_output_to_state` surface instead of preserving ghost compatibility.
**Tech Stack:** Python 3.14, Pydantic, Typer, JSON-RPC, MCP tool models, `jsonschema.Draft202012Validator`, pytest, basedpyright.
---
## File Map
- Modify `src/wf_api/schema_projection.py`: general schema projection helper with nested target insertion.
- Modify `src/wf_api/draft_authoring.py`: add `bind_draft`, remove `bind_output_to_state`.
- Modify `src/wf_api/service.py` and `src/wf_api/surface.py`: facade/protocol methods.
- Modify `src/wf_transport_rpc_http/models.py`, `methods/drafts.py`, `client/drafts.py`, and `__init__.py`: RPC DTO/method/client/export.
- Modify `src/wf_mcp/workflow_surface/models.py` and `tools.py`: MCP request/tool.
- Modify `src/wf_cli/commands/drafts.py`: add `wf draft bind` and remove `bind-output-to-state`.
- Modify `src/wf_api/drafts.py`: update repair hints from `bind-output-to-state` to `bind`.
- Modify docs and skills: `docs/wf_cli.md`, `docs/current_roadmap.md`, `docs/superpowers/specs/2026-06-27-draft-semantic-authoring-boundary.md`, `skills/wf-cli/SKILL.md`, `skills/wf-workflow/references/draft-workspaces.md`, `skills/wf-workflow/references/workflow-lifecycle.md`.
---
### Task 1: Generalize Schema Projection
**Files:**
- Modify: `src/wf_api/schema_projection.py`
- Test: `tests/wf_api/test_schema_projection.py`
- [ ] **Step 1: Add failing tests for nested insertion and overwrite rejection**
Append tests:
```python
def test_project_schema_property_inserts_nested_path_and_defs() -> None:
projected = project_property_to_schema_path(
target_schema={"type": "object", "properties": {}},
source_schema={
"type": "object",
"properties": {"after": {"$ref": "#/$defs/Snapshot"}},
"$defs": {
"Snapshot": {
"type": "object",
"properties": {"clicked": {"type": "boolean"}},
}
},
},
source_field="after",
target_parts=("session", "after"),
)
assert projected["properties"]["session"]["type"] == "object"
assert projected["properties"]["session"]["properties"]["after"] == {
"$ref": "#/$defs/Snapshot"
}
assert projected["$defs"]["Snapshot"]["properties"]["clicked"] == {
"type": "boolean"
}
def test_project_schema_property_rejects_existing_nested_target() -> None:
with pytest.raises(ValueError, match="schema path 'session.after' already exists"):
project_property_to_schema_path(
target_schema={
"type": "object",
"properties": {
"session": {
"type": "object",
"properties": {"after": {"type": "string"}},
}
},
},
source_schema={
"type": "object",
"properties": {"after": {"type": "object"}},
},
source_field="after",
target_parts=("session", "after"),
)
def test_project_schema_property_rejects_non_object_ancestor() -> None:
with pytest.raises(ValueError, match="schema path 'session' is not an object"):
project_property_to_schema_path(
target_schema={
"type": "object",
"properties": {"session": {"type": "string"}},
},
source_schema={
"type": "object",
"properties": {"after": {"type": "object"}},
},
source_field="after",
target_parts=("session", "after"),
)
```
- [ ] **Step 2: Run tests and confirm failure**
Run:
```powershell
uv run pytest tests/wf_api/test_schema_projection.py -q
```
Expected: failures because `project_property_to_schema_path` does not exist.
- [ ] **Step 3: Implement general helper and keep the root-state convenience function**
In `src/wf_api/schema_projection.py`, add:
```python
def project_property_to_schema_path(
*,
target_schema: JsonObject,
source_schema: JsonObject,
source_field: str,
target_parts: tuple[str, ...],
) -> JsonObject:
"""Copy one source property schema into a target JSON Schema object path."""
if not target_parts:
raise ValueError("target schema path must not be empty")
_check_schema("target_schema", target_schema)
_check_schema("source_schema", source_schema)
source_properties = source_schema.get("properties")
if not isinstance(source_properties, dict) or source_field not in source_properties:
raise ValueError(f"source field {source_field!r} is not declared")
source_property = source_properties[source_field]
if not isinstance(source_property, dict):
raise ValueError(f"source field {source_field!r} is not a JSON Schema object")
projected = deepcopy(target_schema)
_ensure_object_schema(projected, "target_schema")
parent = projected
for index, part in enumerate(target_parts[:-1]):
properties = _properties_for_object(parent, ".".join(target_parts[:index]) or "target_schema")
child = properties.get(part)
if child is None:
child = {"type": "object", "properties": {}}
properties[part] = child
if not isinstance(child, dict):
raise ValueError(f"schema path {'.'.join(target_parts[: index + 1])!r} is not an object")
_ensure_object_schema(child, ".".join(target_parts[: index + 1]))
parent = child
properties = _properties_for_object(parent, ".".join(target_parts[:-1]) or "target_schema")
leaf = target_parts[-1]
if leaf in properties:
raise ValueError(f"schema path {'.'.join(target_parts)!r} already exists")
properties[leaf] = deepcopy(source_property)
_merge_definition_block(projected, source_schema, "$defs")
_merge_definition_block(projected, source_schema, "definitions")
_check_schema("projected target_schema", projected)
return projected
def _ensure_object_schema(schema: JsonObject, label: str) -> None:
schema_type = schema.get("type")
if schema_type is not None and schema_type != "object":
raise ValueError(f"{label} must be an object schema")
schema.setdefault("type", "object")
def _properties_for_object(schema: JsonObject, label: str) -> JsonObject:
properties = schema.setdefault("properties", {})
if not isinstance(properties, dict):
raise ValueError(f"{label}.properties must be an object")
return properties
```
Then rewrite `project_output_property_to_state_schema` as:
```python
def project_output_property_to_state_schema(
*,
state_schema: JsonObject,
output_schema: JsonObject,
output_field: str,
state_field: str,
) -> JsonObject:
"""Root state projection convenience wrapper."""
return project_property_to_schema_path(
target_schema=state_schema,
source_schema=output_schema,
source_field=output_field,
target_parts=(state_field,),
)
```
- [ ] **Step 4: Run schema projection tests**
Run:
```powershell
uv run pytest tests/wf_api/test_schema_projection.py -q
```
Expected: all pass.
- [ ] **Step 5: Commit**
```powershell
git add src/wf_api/schema_projection.py tests/wf_api/test_schema_projection.py
git commit -m "feat: generalize draft schema projection"
```
---
### Task 2: Add API-Level `bind_draft`
**Files:**
- Modify: `src/wf_api/draft_authoring.py`
- Test: `tests/wf_api/test_drafts_service.py`
- [ ] **Step 1: Add failing API tests**
Add tests:
```python
@pytest.mark.asyncio
async def test_bind_draft_workflow_input_to_step_input_projects_input_schema(
tmp_path: Path,
) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_bind_input")
api, service, authoring = _draft_api(artifact_store, register_echo=True)
service.register_specs("demo.personal", _snapshot_tool)
await api.create_draft_workspace(
workspace_id="bind_ws",
draft=_echo_draft(),
)
result = await authoring.bind_draft(
workspace_id="bind_ws",
revision=1,
step_id="echo",
source_path="input.message",
target_path="local.message",
)
workspace = await api.get_draft_workspace(workspace_id="bind_ws", include_draft=True)
assert result["revision"] == 2
assert workspace["draft"]["input_schema"]["properties"]["message"]["type"] == "string"
assert workspace["draft"]["steps"]["echo"]["input"] == [
{"target": "message", "path": "input.message"}
]
@pytest.mark.asyncio
async def test_bind_draft_output_to_nested_state_projects_state_schema(
tmp_path: Path,
) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_bind_output_nested")
api, service, authoring = _draft_api(artifact_store, register_echo=True)
service.register_specs("demo.personal", _snapshot_tool)
await api.create_draft_workspace(
workspace_id="snapshot_ws",
draft={
"name": "snapshot",
"input_schema": {"type": "object", "properties": {}},
"state_schema": {"type": "object", "properties": {}},
"output_schema": {"type": "object", "properties": {}},
"start": "snap",
"steps": {"snap": {"use": "demo.personal.snapshot_tool", "input": [], "output": []}},
"routes": {"snap": {"ok": "__end__"}},
},
)
result = await authoring.bind_draft(
workspace_id="snapshot_ws",
revision=1,
step_id="snap",
source_path="local.after",
target_path="state.session.after",
)
workspace = await api.get_draft_workspace(workspace_id="snapshot_ws", include_draft=True)
assert result["revision"] == 2
assert (
workspace["draft"]["state_schema"]["properties"]["session"]["properties"]["after"]["$ref"]
== "#/$defs/_Snapshot"
)
assert workspace["draft"]["steps"]["snap"]["output"] == [
{"source": "after", "target": "state.session.after"}
]
@pytest.mark.asyncio
async def test_bind_draft_rejects_unsupported_direction(tmp_path: Path) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_bind_bad_direction")
api, _service, authoring = _draft_api(artifact_store, register_echo=True)
await api.create_draft_workspace(workspace_id="bind_ws", draft=_echo_draft())
with pytest.raises(ValueError, match="unsupported bind direction"):
await authoring.bind_draft(
workspace_id="bind_ws",
revision=1,
step_id="echo",
source_path="input.message",
target_path="state.message",
)
```
- [ ] **Step 2: Run tests and confirm failure**
Run:
```powershell
uv run pytest tests/wf_api/test_drafts_service.py -q -k "bind_draft"
```
Expected: failures because `bind_draft` does not exist.
- [ ] **Step 3: Implement `bind_draft`**
In `src/wf_api/draft_authoring.py`, import:
```python
from wf_core.paths import GraphSourcePath, LocalPath
from .schema_projection import (
project_output_property_to_state_schema,
project_property_to_schema_path,
)
```
Add helpers:
```python
def _graph_parts(path: str) -> tuple[str, tuple[str, ...]]:
parsed = GraphSourcePath.parse(path)
return parsed.root, parsed.parts
def _local_field(path: str) -> str:
parsed = LocalPath.parse(path)
if len(parsed.parts) != 1:
raise ValueError("local path must name one capability field")
return parsed.parts[0]
```
Add method:
```python
async def bind_draft(
self,
*,
workspace_id: str,
revision: int,
step_id: str,
source_path: str,
target_path: str,
) -> dict[str, Any]:
"""Bind a graph path to/from one capability local field with schema projection."""
workspace = self.drafts._draft_store().get_workspace(workspace_id)
step = draft_step(workspace.draft, step_id)
capability_name = step.get("use")
if not isinstance(capability_name, str) or not capability_name:
raise ValueError(f"draft step {step_id!r} does not declare a capability use")
spec = self.context.specs.get_qualified_spec(capability_name)
source_root, source_parts = _graph_parts(source_path) if not source_path.startswith("local.") else ("local", LocalPath.parse(source_path).parts)
target_root, target_parts = _graph_parts(target_path) if not target_path.startswith("local.") else ("local", LocalPath.parse(target_path).parts)
if target_root == "local" and source_root in {"input", "state"}:
local_field = _local_field(target_path)
input_schema = spec.input_schema_contract or spec.input_model.model_json_schema()
schema_key = "input_schema" if source_root == "input" else "state_schema"
target_schema = workspace.draft.get(schema_key, {})
if not isinstance(target_schema, dict):
raise ValueError(f"draft {schema_key} must be an object")
projected = project_property_to_schema_path(
target_schema=target_schema,
source_schema=input_schema,
source_field=local_field,
target_parts=source_parts,
)
input_map = {
**_draft_input_maps(workspace.draft, step_id),
source_path: local_field,
}
return await self.drafts.patch_draft_workspace(
workspace_id=workspace_id,
revision=revision,
patch=[
{"op": "replace", "path": f"/{schema_key}", "value": projected},
{
"op": "replace",
"path": f"/steps/{escape_json_pointer(step_id)}/input",
"value": input_bindings_payload(input_map, {}),
},
],
)
if source_root == "local" and target_root in {"state", "output"}:
local_field = _local_field(source_path)
output_schema = spec.output_schema_contract or spec.output_model.model_json_schema()
schema_key = "state_schema" if target_root == "state" else "output_schema"
target_schema = workspace.draft.get(schema_key, {})
if not isinstance(target_schema, dict):
raise ValueError(f"draft {schema_key} must be an object")
projected = project_property_to_schema_path(
target_schema=target_schema,
source_schema=output_schema,
source_field=local_field,
target_parts=target_parts,
)
output_map = {
**self.drafts._step_output_map(workspace_id=workspace_id, step_id=step_id),
local_field: target_path,
}
return await self.drafts.patch_draft_workspace(
workspace_id=workspace_id,
revision=revision,
patch=[
{"op": "replace", "path": f"/{schema_key}", "value": projected},
{
"op": "replace",
"path": f"/steps/{escape_json_pointer(step_id)}/output",
"value": output_bindings_payload(output_map),
},
],
)
raise ValueError(f"unsupported bind direction: {source_path!r} -> {target_path!r}")
```
Delete the old `bind_output_to_state` method from `WorkflowDraftAuthoringApi`.
It is superseded by `bind_draft` and should not remain as a compatibility shim.
- [ ] **Step 4: Run API tests**
Run:
```powershell
uv run pytest tests/wf_api/test_drafts_service.py -q -k "bind_draft or bind_output_to_state"
```
Expected: new `bind_draft` tests pass. Existing `bind_output_to_state` tests
should be updated or removed in this task because the old method is gone.
- [ ] **Step 5: Commit**
```powershell
git add src/wf_api/draft_authoring.py tests/wf_api/test_drafts_service.py
git commit -m "feat: replace narrow draft output bind with general bind"
```
---
### Task 3: Expose Bind Through Facade, RPC, MCP, And CLI
**Files:**
- Modify: `src/wf_api/service.py`, `src/wf_api/surface.py`
- Modify: `src/wf_transport_rpc_http/models.py`, `src/wf_transport_rpc_http/methods/drafts.py`, `src/wf_transport_rpc_http/client/drafts.py`, `src/wf_transport_rpc_http/__init__.py`
- Modify: `src/wf_mcp/workflow_surface/models.py`, `src/wf_mcp/workflow_surface/tools.py`
- Modify: `src/wf_cli/commands/drafts.py`
- Test: `tests/wf_transport_rpc_http/test_app.py`, `tests/wf_transport_rpc_http/test_client.py`, `tests/wf_cli/test_app.py`, `tests/wf_cli/test_remote_target.py`, `tests/wf_mcp/server/test_config.py`
- [ ] **Step 1: Add failing surface tests**
In RPC app/client tests, replace existing `bind_output_to_state` coverage with:
```python
{
"workspace_id": "rpc_ws",
"revision": 1,
"step_id": "call",
"source_path": "local.echoed",
"target_path": "state.echoed",
}
```
Expected method name:
```text
workflow.draft_workspaces.bind
```
In CLI remote target test, invoke:
```python
[
"draft",
"bind",
"rpc_ws",
"--revision",
"1",
"--step",
"call",
"--from",
"local.echoed",
"--to",
"state.echoed",
]
```
In MCP config test, assert:
```python
assert "wf.workflow.bind" in names
```
- [ ] **Step 2: Run tests and confirm failure**
Run:
```powershell
uv run pytest tests/wf_transport_rpc_http/test_app.py tests/wf_transport_rpc_http/test_client.py tests/wf_cli/test_app.py tests/wf_cli/test_remote_target.py tests/wf_mcp/server/test_config.py -q -k "bind"
```
Expected: failures because public bind surfaces do not exist.
- [ ] **Step 3: Add facade and protocol**
In `src/wf_api/surface.py`, add:
```python
async def bind_draft(
self,
*,
workspace_id: str,
revision: int,
step_id: str,
source_path: str,
target_path: str,
) -> dict[str, Any]: ...
```
Remove the old `bind_output_to_state` protocol method.
In `src/wf_api/service.py`, add delegate:
```python
async def bind_draft(
self,
*,
workspace_id: str,
revision: int,
step_id: str,
source_path: str,
target_path: str,
) -> dict[str, Any]:
return await self.draft_authoring.bind_draft(
workspace_id=workspace_id,
revision=revision,
step_id=step_id,
source_path=source_path,
target_path=target_path,
)
```
Remove the old `bind_output_to_state` facade method.
- [ ] **Step 4: Add RPC model/method/client**
In `src/wf_transport_rpc_http/models.py`:
```python
class BindDraftParams(RpcParamsModel):
workspace_id: str = Field(min_length=1)
revision: int = Field(ge=1)
step_id: str = Field(min_length=1)
source_path: str = Field(min_length=1)
target_path: str = Field(min_length=1)
```
In `src/wf_transport_rpc_http/methods/drafts.py`:
```python
@entrypoint.method(
name="workflow.draft_workspaces.bind",
errors=[WorkflowRpcError],
)
async def workflow_draft_workspaces_bind(
params: BindDraftParams = RpcParams(),
) -> dict[str, Any]:
try:
return await server.api.bind_draft(
workspace_id=params.workspace_id,
revision=params.revision,
step_id=params.step_id,
source_path=params.source_path,
target_path=params.target_path,
)
except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
raise_workflow_rpc_error(exc)
```
In `src/wf_transport_rpc_http/client/drafts.py`:
```python
async def bind_draft(
self,
*,
workspace_id: str,
revision: int,
step_id: str,
source_path: str,
target_path: str,
) -> dict[str, Any]:
return await self._request(
"workflow.draft_workspaces.bind",
{
"workspace_id": workspace_id,
"revision": revision,
"step_id": step_id,
"source_path": source_path,
"target_path": target_path,
},
)
```
Export `BindDraftParams` from `src/wf_transport_rpc_http/__init__.py`.
Remove `BindOutputToStateParams`, the
`workflow.draft_workspaces.bind_output_to_state` RPC method, and
`RpcDraftClientMixin.bind_output_to_state`.
- [ ] **Step 5: Add MCP request/tool**
In `src/wf_mcp/workflow_surface/models.py`:
```python
class BindDraftRequest(BaseModel):
"""Typed MCP request for binding one draft step path with schema projection."""
workspace_id: WorkspaceId
revision: int = Field(ge=1, description="Expected current workspace revision.")
step_id: str = Field(description="Capability-backed draft step id.")
source_path: str = Field(description="Source path, for example input.x or local.y.")
target_path: str = Field(description="Target path, for example local.x or state.y.")
```
In `src/wf_mcp/workflow_surface/tools.py`, register `wf.workflow.bind` beside
the other draft authoring tools.
Remove `BindOutputToStateRequest` and the `wf.workflow.bind_output_to_state`
tool.
- [ ] **Step 6: Add CLI command**
In `src/wf_cli/commands/drafts.py`, add:
```python
@app.command("bind")
def bind_draft(
ctx: typer.Context,
workspace_id: Annotated[str, typer.Argument(help="Draft workspace id.")],
revision: Annotated[
int, typer.Option("--revision", min=1, help="Expected workspace revision.")
],
step_id: Annotated[str, typer.Option("--step", help="Draft step id.")],
source_path: Annotated[
str,
typer.Option("--from", help="Source path, for example input.x or local.y."),
],
target_path: Annotated[
str,
typer.Option("--to", help="Target path, for example local.x or state.y."),
],
) -> None:
"""Bind a capability step path and project the matching schema.
Direction matters. Use input/state -> local for step inputs and local ->
state/output for step outputs. Run `wf draft validate <workspace_id>` after
this command.
"""
context = load_cli_context(ctx)
emit_json(
run_cli_operation(
context,
context.handlers.bind_draft(
workspace_id=workspace_id,
revision=revision,
step_id=step_id,
source_path=source_path,
target_path=target_path,
),
)
)
```
Then remove the old `@app.command("bind-output-to-state")` command.
- [ ] **Step 7: Run surface tests**
Run:
```powershell
uv run pytest tests/wf_transport_rpc_http/test_app.py tests/wf_transport_rpc_http/test_client.py tests/wf_cli/test_app.py tests/wf_cli/test_remote_target.py tests/wf_mcp/server/test_config.py -q -k "bind"
```
Expected: all selected tests pass.
- [ ] **Step 8: Commit**
```powershell
git add src/wf_api/service.py src/wf_api/surface.py src/wf_transport_rpc_http src/wf_mcp/workflow_surface src/wf_cli/commands/drafts.py tests/wf_transport_rpc_http tests/wf_cli tests/wf_mcp
git commit -m "feat: expose general draft bind across transports"
```
---
### Task 4: Update Repair Hints, Docs, And Skills
**Files:**
- Modify: `src/wf_api/drafts.py`
- Modify: `docs/wf_cli.md`, `docs/current_roadmap.md`, `docs/superpowers/specs/2026-06-27-draft-semantic-authoring-boundary.md`
- Modify: `skills/wf-cli/SKILL.md`, `skills/wf-workflow/references/draft-workspaces.md`, `skills/wf-workflow/references/workflow-lifecycle.md`
- Test: `tests/wf_api/test_drafts_service.py`, `tests/wf_cli/test_remote_target.py`
- [ ] **Step 1: Update repair hint tests**
Change expectations from:
```python
"wf draft bind-output-to-state snapshot_ws --revision 1 --step snap --output after --state state.after"
```
to:
```python
"wf draft bind snapshot_ws --revision 1 --step snap --from local.after --to state.after"
```
- [ ] **Step 2: Update repair hint implementation**
In `src/wf_api/drafts.py`, update `_draft_repair_hint` return value:
```python
return (
f"wf draft bind {workspace_id} --revision {revision} "
f"--step {step_id} --from local.{output_field} --to {state_path}"
)
```
- [ ] **Step 3: Update docs and skills**
Docs should state:
```markdown
Use `wf draft bind --from ... --to ...` for schema-aware step wiring.
`bind-output-to-state` has been removed; use `bind --from local.<field>
--to state.<field>` instead.
```
Include examples:
```powershell
wf draft bind browser_ws --revision 2 --step click --from input.simulate --to local.simulate
wf draft bind browser_ws --revision 3 --step click --from local.after --to state.after
wf draft bind report_ws --revision 4 --step render --from local.markdown --to output.markdown
```
- [ ] **Step 4: Run tests**
Run:
```powershell
uv run pytest tests/wf_api/test_drafts_service.py tests/wf_cli/test_remote_target.py -q -k "repair_hint or bind"
```
Expected: all selected tests pass.
- [ ] **Step 5: Commit**
```powershell
git add src/wf_api/drafts.py docs/wf_cli.md docs/current_roadmap.md docs/superpowers/specs/2026-06-27-draft-semantic-authoring-boundary.md skills/wf-cli/SKILL.md skills/wf-workflow/references/draft-workspaces.md skills/wf-workflow/references/workflow-lifecycle.md tests/wf_api/test_drafts_service.py tests/wf_cli/test_remote_target.py
git commit -m "docs: document general draft bind helper"
```
---
### Task 5: Final Verification
**Files:**
- No new files.
- [ ] **Step 1: Run focused test set**
Run:
```powershell
uv run pytest tests/wf_api/test_schema_projection.py tests/wf_api/test_drafts_service.py tests/wf_transport_rpc_http/test_app.py tests/wf_transport_rpc_http/test_client.py tests/wf_cli/test_app.py tests/wf_cli/test_remote_target.py tests/wf_mcp/server/test_config.py -q -k "bind or schema_projection or repair_hint"
```
Expected: all selected tests pass.
- [ ] **Step 2: Run lint, format, and type checks**
Run:
```powershell
uv run ruff check
uv run ruff format --check
uv run basedpyright --level error
git diff --check
```
Expected:
- Ruff clean.
- Format clean.
- Basedpyright reports `0 errors`.
- `git diff --check` has no whitespace errors. CRLF warnings are acceptable on Windows.
- [ ] **Step 3: Optional live smoke**
If `wf-rpc-server --config wf.config.json --host 127.0.0.1 --port 8765` is running, run:
```powershell
uv run wf --url http://127.0.0.1:8765/rpc draft bind --help
```
Then create a temporary draft and verify:
```powershell
uv run wf --url http://127.0.0.1:8765/rpc draft bind <workspace_id> --revision <n> --step <step_id> --from local.<field> --to state.<field>
```
Expected: command routes through RPC and returns a revised workspace summary.
- [ ] **Step 4: Commit final cleanup if needed**
```powershell
git status --short
git add <only files changed by cleanup>
git commit -m "fix: polish draft bind implementation"
```
Skip this commit if the tree is already clean after Task 4.
---
## Self-Review Notes
- Spec coverage: tasks cover schema projection, API, transports, CLI, repair hints, docs, and deletion of the old narrow helper.
- Scope control: this removes `bind-output-to-state` because it is recent, narrow, and not a durable external contract.
- Risk: nested schema projection must stay object-property only. Do not support array item projection in this slice.
@@ -0,0 +1,211 @@
# Draft Bind From/To Design
## Status
Implemented. This design replaces `bind-output-to-state` with the general
`bind --from ... --to ...` operation. `bind-output-to-state` was introduced
recently as an incomplete narrow helper and should be removed rather than
preserved as compatibility.
## Problem
Agents authoring drafts repeatedly hit the same boundary: step input/output
bindings are not just path edits. They often require workflow-level schema
declarations too.
Today:
- `wf draft set-input` edits a step input map, but it does not declare
`input_schema.properties.<field>`.
- `wf draft bind-output-to-state` declares one root state field and writes a
step output binding, but only covers `local.output -> state.field`. It is too
narrow now that input-side schema projection is needed too.
- Agents interpret this split inconsistently, then fall back to raw JSON Patch.
The product needs one capability-aware operation with the mental model:
```text
bind <destination> from <source>
```
## User-Facing Shape
Primary CLI:
```powershell
wf draft bind <workspace_id> `
--revision <n> `
--step <step_id> `
--from <path> `
--to <path>
```
Examples:
```powershell
# Workflow input into a capability input.
wf draft bind browser_ws --revision 2 --step click `
--from input.simulate `
--to local.simulate
```
```powershell
# Capability output into workflow state.
wf draft bind browser_ws --revision 3 --step click `
--from local.after `
--to state.after
```
```powershell
# Capability output into final workflow output.
wf draft bind report_ws --revision 7 --step render `
--from local.markdown `
--to output.markdown
```
## Semantics
`bind` is capability-aware and step-scoped. The step must be a capability-backed
draft step with a `use` field.
Supported directions:
| From | To | Draft edit | Schema projection |
|---|---|---|---|
| `input.*` | `local.*` | add/merge step input binding | copy capability input schema for `local.*` into workflow `input_schema` at `input.*` |
| `state.*` | `local.*` | add/merge step input binding | copy capability input schema for `local.*` into workflow `state_schema` at `state.*` |
| `local.*` | `state.*` | add/merge step output binding | copy capability output schema for `local.*` into workflow `state_schema` at `state.*` |
| `local.*` | `output.*` | add/merge step output binding | copy capability output schema for `local.*` into workflow `output_schema` at `output.*` |
Unsupported directions should fail clearly:
- `local.* -> local.*`
- `input.* -> state.*`
- `state.* -> output.*`
- `output.* -> local.*`
- Any path root outside `input`, `state`, `output`, or `local`
## Schema Projection
The schema projection helper should be generalized from the current
output-to-state helper.
Requirements:
- Use `jsonschema.Draft202012Validator.check_schema` to validate input schemas
and projected schemas. Do not hand-roll JSON Schema validation.
- Copy the selected local field schema from the capability input/output schema.
- Preserve `$defs` and `definitions`, rejecting conflicting definitions.
- Insert the copied field schema at the target workflow path.
- Support nested target paths such as `state.options.timeout_seconds`.
- Create missing ancestor object schemas only when the ancestor does not exist.
- Reject inserting through an existing non-object ancestor.
- Reject overwriting an existing target property by default.
Nested insertion example:
```json
{
"type": "object",
"properties": {
"options": {
"type": "object",
"properties": {
"timeout_seconds": { "type": "number" }
}
}
}
}
```
This is a schema projection operation, not a general JSON Schema editor. It only
copies one capability local input/output field schema to one workflow graph path.
## API Shape
Add the general method:
```python
async def bind_draft(
self,
*,
workspace_id: str,
revision: int,
step_id: str,
source_path: str,
target_path: str,
) -> dict[str, Any]:
...
```
Name notes:
- User-facing CLI says `bind --from ... --to ...`.
- Python/RPC names should avoid reserved words and use
`source_path` / `target_path`.
- Remove `bind_output_to_state` end-to-end. It has no long-lived compatibility
contract and is replaced by `bind_draft`.
## RPC And MCP
New JSON-RPC method:
```text
workflow.draft_workspaces.bind
```
Params:
```json
{
"workspace_id": "browser_ws",
"revision": 3,
"step_id": "click",
"source_path": "local.after",
"target_path": "state.after"
}
```
New MCP tool:
```text
wf.workflow.bind
```
Remove existing RPC/MCP `bind_output_to_state` surfaces during this slice.
## Repair Hints
Existing invalid-destination hints currently recommend:
```text
wf draft bind-output-to-state ...
```
They should instead recommend:
```text
wf draft bind <workspace_id> --revision <n> --step <step_id> --from local.<field> --to state.<field>
```
Future missing-input-schema diagnostics can also point to:
```text
wf draft bind <workspace_id> --revision <n> --step <step_id> --from input.<field> --to local.<field>
```
## Non-Goals
- Do not preserve `bind-output-to-state` as a compatibility alias.
- Do not add arbitrary schema editing commands.
- Do not support array item schema projection.
- Do not infer routes.
- Do not mutate bindings without revision checks.
## Acceptance Criteria
- Agents can fix browser-click input schema issues with `wf draft bind --from
input.simulate --to local.simulate` instead of raw JSON Patch.
- Agents can bind outputs with `wf draft bind --from local.after --to
state.after`; the old `bind-output-to-state` command/method/tool is gone.
- Nested target paths are handled safely or rejected with clear errors.
@@ -109,7 +109,7 @@ The public draft surface is documented in descending order of preference.
- `create-from-capability` - `create-from-capability`
- `add-step-from-capability` - `add-step-from-capability`
- `bind-output-to-state` - `bind`
- `branch` - `branch`
- `handle` - `handle`
@@ -123,9 +123,10 @@ graph intent. They are the preferred agent authoring surface.
- `set-input` - `set-input`
- `set-output` - `set-output`
These remain available for precise repairs. `set-output` does not project the These remain available for precise repairs. `set-input` and `set-output` do
destination state schema; callers should prefer `bind-output-to-state` when not project workflow schemas; callers should prefer `bind` when a capability
writing a capability output into state. input/output should also declare the matching workflow input, state, or output
schema.
### Escape Hatch ### Escape Hatch
@@ -213,11 +214,22 @@ pairs remain unchanged.
`handle` is not a join. It creates ordinary directed edges to one target. `handle` is not a join. It creates ordinary directed edges to one target.
### Bind Output To State ### Bind
`bind-output-to-state` remains the capability-aware schema propagation `bind` is the capability-aware schema propagation operation. It projects the
operation. It projects the selected output property and required `$defs` into selected capability local input/output property and required `$defs` into the
the root state schema, then merges the output binding in the same revision. workflow input, state, or output schema, then merges the matching step input or
output binding in the same revision.
```powershell
wf draft bind WORKSPACE --revision 4 --step wait `
--from input.simulate `
--to local.simulate
wf draft bind WORKSPACE --revision 5 --step wait `
--from local.after `
--to state.after
```
The partial `add-state-from-output` operation is removed from API, RPC, MCP, The partial `add-state-from-output` operation is removed from API, RPC, MCP,
CLI, docs, and skills. It was superseded before acquiring a real caller or CLI, docs, and skills. It was superseded before acquiring a real caller or
+11 -10
View File
@@ -313,24 +313,25 @@ Use repeated `--map` flags in one command when you know the complete map. Use
`--merge` when adding or updating one entry across a later revision while `--merge` when adding or updating one entry across a later revision while
preserving existing bindings. preserving existing bindings.
### Bind A Step Output To State ### Bind A Step Path
Use `bind-output-to-state` when a step output should become workflow state and Use `bind` when a capability step input/output binding also needs workflow
the state schema should match that capability output field. schema projection. The selected step must be capability-backed (`use: ...`)
The selected step must be capability-backed (`use: ...`) because the command because the command derives the schema from that capability. Direction matters:
derives the output schema from that capability. Use JSON Patch for uncommon use `input.*` or `state.*` to `local.*` for step inputs, and `local.*` to
control-flow or non-capability draft steps. `state.*` or `output.*` for step outputs.
```bash ```bash
wf draft bind-output-to-state concat_ws --revision 6 --step call --output value --state state.value wf draft bind concat_ws --revision 6 --step call --from local.value --to state.value
wf draft bind concat_ws --revision 7 --step call --from input.text --to local.text
wf draft validate concat_ws wf draft validate concat_ws
``` ```
The command combines two common edits: The command combines two common edits:
- It copies the selected capability output field schema into the root state - It copies the selected capability local field schema into the workflow input,
field. state, or output schema at the graph path.
- It merges the output binding `local.<output> -> state.<field>` for the step. - It merges the matching step input or output binding.
Use `set-route` separately for outcome routing. Use `set-route` separately for outcome routing.
+6 -6
View File
@@ -47,7 +47,8 @@ wf draft set-output <workspace_id> --revision <n> --step <step_id> --merge --map
wf draft branch <workspace_id> --revision <n> --step <step_id> --route ok=__end__ --route error=fail wf draft branch <workspace_id> --revision <n> --step <step_id> --route ok=__end__ --route error=fail
wf draft handle <workspace_id> --revision <n> --to fail --branch lookup:error --branch transform:error wf draft handle <workspace_id> --revision <n> --to fail --branch lookup:error --branch transform:error
wf draft compile <workspace_id> wf draft compile <workspace_id>
wf draft bind-output-to-state <workspace_id> --revision <n> --step <step_id> --output <field> --state state.<field> wf draft bind <workspace_id> --revision <n> --step <step_id> --from local.<field> --to state.<field>
wf draft bind <workspace_id> --revision <n> --step <step_id> --from input.<field> --to local.<field>
wf draft add-step-from-capability <workspace_id> --revision <n> --step <step_id> --capability <qualified_name> --from-step <prev> --from-outcome ok --route ok=__end__ --route error=fail --input input.text=text --bind-output result=state.result wf draft add-step-from-capability <workspace_id> --revision <n> --step <step_id> --capability <qualified_name> --from-step <prev> --from-outcome ok --route ok=__end__ --route error=fail --input input.text=text --bind-output result=state.result
wf draft validate <workspace_id> wf draft validate <workspace_id>
wf draft save <workspace_id> --artifact <artifact_id> --version <n> --title <title> wf draft save <workspace_id> --artifact <artifact_id> --version <n> --title <title>
@@ -82,11 +83,10 @@ For `draft set-input` and `draft set-output`, repeated `--map` flags in one
command define the complete replacement map. If you split map edits across command define the complete replacement map. If you split map edits across
multiple commands, pass `--merge` or the later command replaces the earlier map. multiple commands, pass `--merge` or the later command replaces the earlier map.
Prefer `draft bind-output-to-state` when a step output should write to a new Prefer `draft bind` when a capability step binding also needs schema
root state field. It declares the matching state schema and merges the output projection. Use `input/state -> local` for step inputs and `local ->
binding in one revision-checked edit. state/output` for step outputs. It requires a capability-backed step with
`bind-output-to-state` requires a capability-backed step with `use`; use JSON `use`; use JSON Patch for non-capability/control draft steps.
Patch for non-capability/control draft steps.
To add a capability step, prefer `wf draft add-step-from-capability` over raw To add a capability step, prefer `wf draft add-step-from-capability` over raw
JSON Patch when the route, input bindings, and output-to-state bindings are JSON Patch when the route, input bindings, and output-to-state bindings are
@@ -73,7 +73,7 @@ Prefer focused helpers over JSON Patch for common edits:
- `set_draft_route` - `set_draft_route`
- `set_step_input_map` - `set_step_input_map`
- `set_step_output_map` - `set_step_output_map`
- `bind_output_to_state` - `bind_draft`
- `add_step_from_capability` - `add_step_from_capability`
- `branch_draft` - `branch_draft`
- `handle_draft` - `handle_draft`
@@ -91,7 +91,8 @@ wf draft set-output <workspace_id> --revision <n> --step <step_id> --merge --map
wf draft branch <workspace_id> --revision <n> --step <step_id> --route ok=__end__ --route error=fail wf draft branch <workspace_id> --revision <n> --step <step_id> --route ok=__end__ --route error=fail
wf draft handle <workspace_id> --revision <n> --to fail --branch lookup:error --branch transform:error wf draft handle <workspace_id> --revision <n> --to fail --branch lookup:error --branch transform:error
wf draft compile <workspace_id> wf draft compile <workspace_id>
wf draft bind-output-to-state <workspace_id> --revision <n> --step <step_id> --output <field> --state state.<field> wf draft bind <workspace_id> --revision <n> --step <step_id> --from local.<field> --to state.<field>
wf draft bind <workspace_id> --revision <n> --step <step_id> --from input.<field> --to local.<field>
wf draft add-step-from-capability <workspace_id> --revision <n> --step <step_id> --capability <qualified_name> --from-step <prev> --from-outcome ok --route ok=__end__ --route error=fail --input input.text=text --bind-output result=state.result wf draft add-step-from-capability <workspace_id> --revision <n> --step <step_id> --capability <qualified_name> --from-step <prev> --from-outcome ok --route ok=__end__ --route error=fail --input input.text=text --bind-output result=state.result
``` ```
@@ -105,18 +106,19 @@ Without `--merge`, `set-input` and `set-output` replace the whole map for that
step. Use repeated `--map` flags in one command for a complete replacement. Use step. Use repeated `--map` flags in one command for a complete replacement. Use
`--merge` only when adding/updating entries over multiple revisions. `--merge` only when adding/updating entries over multiple revisions.
- `bind_output_to_state` - `bind_draft`
Declares one root state field from a step capability output schema and merges Declares a workflow input/state/output schema field from a capability local
`local.<output> -> state.<field>` into that step's output map. Prefer this input/output schema and merges the matching step binding. Use `input/state ->
over manual JSON Patch when validation says a state output target is missing local` for step inputs and `local -> state/output` for step outputs. Prefer
from `state_schema`. this over manual JSON Patch when validation says a target schema field is
The selected step must have `use` so the helper can find the capability missing. The selected step must have `use` so the helper can find the
output schema. It intentionally rejects non-capability/control steps instead capability schema. It intentionally rejects non-capability/control steps
of guessing. instead of guessing.
```bash ```bash
wf draft bind-output-to-state <workspace_id> --revision <n> --step <step_id> --output <field> --state state.<field> wf draft bind <workspace_id> --revision <n> --step <step_id> --from local.<field> --to state.<field>
wf draft bind <workspace_id> --revision <n> --step <step_id> --from input.<field> --to local.<field>
wf draft validate <workspace_id> wf draft validate <workspace_id>
``` ```
@@ -156,8 +158,7 @@ wf draft validate <workspace_id>
a `compiled_plan`. a `compiled_plan`.
Validation repair hints are product guidance. If a diagnostic suggests Validation repair hints are product guidance. If a diagnostic suggests
`bind-output-to-state`, use it before hand-editing `state_schema` or step output `wf draft bind`, use it before hand-editing schemas or step bindings.
bindings.
Use JSON Patch for structural edits the helpers do not cover. Use JSON Patch for structural edits the helpers do not cover.
@@ -18,10 +18,10 @@ validated, runnable deployment.
for common edits. for common edits.
- `set-input` and `set-output` replace full maps by default; pass `--merge` - `set-input` and `set-output` replace full maps by default; pass `--merge`
only when adding or updating one entry across a later revision. only when adding or updating one entry across a later revision.
- Before output-mapping into a new state field, declare it with - Before mapping into a new workflow input, state, or output field, prefer
`bind-output-to-state` when it should mirror a capability output property. `wf draft bind --from ... --to ...` when it should mirror a capability
It declares the matching state schema and merges the output binding in one local input/output property. It declares the matching schema and merges
revision-checked edit. the binding in one revision-checked edit.
- When adding a new capability-backed step, prefer: - When adding a new capability-backed step, prefer:
```bash ```bash
wf draft add-step-from-capability ... wf draft add-step-from-capability ...
+25 -7
View File
@@ -180,15 +180,27 @@ class WorkflowDraftAuthoringApi:
step = draft_step(workspace.draft, step_id) step = draft_step(workspace.draft, step_id)
capability_name = step.get("use") capability_name = step.get("use")
if not isinstance(capability_name, str) or not capability_name: if not isinstance(capability_name, str) or not capability_name:
raise ValueError(f"draft step {step_id!r} does not declare a capability use") raise ValueError(
f"draft step {step_id!r} does not declare a capability use"
)
spec = self.context.specs.get_qualified_spec(capability_name) spec = self.context.specs.get_qualified_spec(capability_name)
source_root, source_parts = _graph_parts(source_path) if not source_path.startswith("local.") else ("local", LocalPath.parse(source_path).parts) source_root, source_parts = (
target_root, target_parts = _graph_parts(target_path) if not target_path.startswith("local.") else ("local", LocalPath.parse(target_path).parts) _graph_parts(source_path)
if not source_path.startswith("local.")
else ("local", LocalPath.parse(source_path).parts)
)
target_root, target_parts = (
_graph_parts(target_path)
if not target_path.startswith("local.")
else ("local", LocalPath.parse(target_path).parts)
)
if target_root == "local" and source_root in {"input", "state"}: if target_root == "local" and source_root in {"input", "state"}:
local_field = _local_field(target_path) local_field = _local_field(target_path)
input_schema = spec.input_schema_contract or spec.input_model.model_json_schema() input_schema = (
spec.input_schema_contract or spec.input_model.model_json_schema()
)
schema_key = "input_schema" if source_root == "input" else "state_schema" schema_key = "input_schema" if source_root == "input" else "state_schema"
target_schema = workspace.draft.get(schema_key, {}) target_schema = workspace.draft.get(schema_key, {})
if not isinstance(target_schema, dict): if not isinstance(target_schema, dict):
@@ -218,7 +230,9 @@ class WorkflowDraftAuthoringApi:
if source_root == "local" and target_root in {"state", "output"}: if source_root == "local" and target_root in {"state", "output"}:
local_field = _local_field(source_path) local_field = _local_field(source_path)
output_schema = spec.output_schema_contract or spec.output_model.model_json_schema() output_schema = (
spec.output_schema_contract or spec.output_model.model_json_schema()
)
schema_key = "state_schema" if target_root == "state" else "output_schema" schema_key = "state_schema" if target_root == "state" else "output_schema"
target_schema = workspace.draft.get(schema_key, {}) target_schema = workspace.draft.get(schema_key, {})
if not isinstance(target_schema, dict): if not isinstance(target_schema, dict):
@@ -230,7 +244,9 @@ class WorkflowDraftAuthoringApi:
target_parts=target_parts, target_parts=target_parts,
) )
output_map = { output_map = {
**self.drafts._step_output_map(workspace_id=workspace_id, step_id=step_id), **self.drafts._step_output_map(
workspace_id=workspace_id, step_id=step_id
),
local_field: target_path, local_field: target_path,
} }
return await self.drafts.patch_draft_workspace( return await self.drafts.patch_draft_workspace(
@@ -246,7 +262,9 @@ class WorkflowDraftAuthoringApi:
], ],
) )
raise ValueError(f"unsupported bind direction: {source_path!r} -> {target_path!r}") raise ValueError(
f"unsupported bind direction: {source_path!r} -> {target_path!r}"
)
async def add_step_from_capability( async def add_step_from_capability(
self, self,
+4 -1
View File
@@ -39,7 +39,10 @@ def input_bindings_payload(
def output_bindings_payload(output_map: dict[str, str]) -> list[dict[str, Any]]: def output_bindings_payload(output_map: dict[str, str]) -> list[dict[str, Any]]:
"""Serialize draft output maps into canonical string-path binding payloads.""" """Serialize draft output maps into canonical string-path binding payloads."""
return [ return [
{"source": _local_path_payload(source), "target": _graph_source_path_payload(target)} {
"source": _local_path_payload(source),
"target": _graph_source_path_payload(target),
}
for source, target in output_map.items() for source, target in output_map.items()
] ]
+2 -2
View File
@@ -473,6 +473,6 @@ def _draft_repair_hint(
if not isinstance(output_field, str) or not isinstance(state_path, str): if not isinstance(output_field, str) or not isinstance(state_path, str):
return None return None
return ( return (
f"wf draft bind-output-to-state {workspace_id} --revision {revision} " f"wf draft bind {workspace_id} --revision {revision} "
f"--step {step_id} --output {output_field} --state {state_path}" f"--step {step_id} --from local.{output_field} --to {state_path}"
) )
+18 -6
View File
@@ -31,17 +31,23 @@ def project_property_to_schema_path(
_ensure_object_schema(projected, "target_schema") _ensure_object_schema(projected, "target_schema")
parent = projected parent = projected
for index, part in enumerate(target_parts[:-1]): for index, part in enumerate(target_parts[:-1]):
properties = _properties_for_object(parent, ".".join(target_parts[:index]) or "target_schema") properties = _properties_for_object(
parent, ".".join(target_parts[:index]) or "target_schema"
)
child = properties.get(part) child = properties.get(part)
if child is None: if child is None:
child = {"type": "object", "properties": {}} child = {"type": "object", "properties": {}}
properties[part] = child properties[part] = child
if not isinstance(child, dict): if not isinstance(child, dict):
raise ValueError(f"schema path {'.'.join(target_parts[: index + 1])!r} is not an object") raise ValueError(
f"schema path {'.'.join(target_parts[: index + 1])!r} is not an object"
)
_ensure_object_schema(child, ".".join(target_parts[: index + 1])) _ensure_object_schema(child, ".".join(target_parts[: index + 1]))
parent = child parent = child
properties = _properties_for_object(parent, ".".join(target_parts[:-1]) or "target_schema") properties = _properties_for_object(
parent, ".".join(target_parts[:-1]) or "target_schema"
)
leaf = target_parts[-1] leaf = target_parts[-1]
if leaf in properties: if leaf in properties:
raise ValueError(f"schema path {'.'.join(target_parts)!r} already exists") raise ValueError(f"schema path {'.'.join(target_parts)!r} already exists")
@@ -76,15 +82,21 @@ def project_output_property_to_state_schema(
if msg.startswith("source field ") and "is not declared" in msg: if msg.startswith("source field ") and "is not declared" in msg:
raise ValueError(f"output field {output_field!r} is not declared") from exc raise ValueError(f"output field {output_field!r} is not declared") from exc
if msg.startswith("source field ") and "not a JSON Schema" in msg: if msg.startswith("source field ") and "not a JSON Schema" in msg:
raise ValueError(f"output field {output_field!r} is not a JSON Schema object") from exc raise ValueError(
f"output field {output_field!r} is not a JSON Schema object"
) from exc
if "schema path 'target_schema'" in msg and "is not an object" in msg: if "schema path 'target_schema'" in msg and "is not an object" in msg:
raise ValueError("state_schema must be an object schema") from exc raise ValueError("state_schema must be an object schema") from exc
if msg.startswith("schema path ") and "already exists" in msg: if msg.startswith("schema path ") and "already exists" in msg:
raise ValueError(f"state field {state_field!r} already exists") from exc raise ValueError(f"state field {state_field!r} already exists") from exc
if "target_schema is not valid JSON Schema" in msg: if "target_schema is not valid JSON Schema" in msg:
raise ValueError(f"state_schema is not valid JSON Schema: {msg.split(': ', 1)[1]}") from exc raise ValueError(
f"state_schema is not valid JSON Schema: {msg.split(': ', 1)[1]}"
) from exc
if "source_schema is not valid JSON Schema" in msg: if "source_schema is not valid JSON Schema" in msg:
raise ValueError(f"output_schema is not valid JSON Schema: {msg.split(': ', 1)[1]}") from exc raise ValueError(
f"output_schema is not valid JSON Schema: {msg.split(': ', 1)[1]}"
) from exc
raise raise
+13 -16
View File
@@ -299,42 +299,39 @@ def set_step_output_map(
) )
@app.command("bind-output-to-state") @app.command("bind")
def bind_output_to_state( def bind_draft(
ctx: typer.Context, ctx: typer.Context,
workspace_id: Annotated[str, typer.Argument(help="Draft workspace id.")], workspace_id: Annotated[str, typer.Argument(help="Draft workspace id.")],
revision: Annotated[ revision: Annotated[
int, typer.Option("--revision", min=1, help="Expected workspace revision.") int, typer.Option("--revision", min=1, help="Expected workspace revision.")
], ],
step_id: Annotated[str, typer.Option("--step", help="Draft step id.")], step_id: Annotated[str, typer.Option("--step", help="Draft step id.")],
output_field: Annotated[ source_path: Annotated[
str, str,
typer.Option("--output", help="Top-level capability output field."), typer.Option("--from", help="Source path, for example input.x or local.y."),
], ],
state_path: Annotated[ target_path: Annotated[
str, str,
typer.Option("--state", help="Root state path, for example state.after."), typer.Option("--to", help="Target path, for example local.x or state.y."),
], ],
) -> None: ) -> None:
"""Declare state schema and bind one step output to that state field. """Bind a capability step path and project the matching schema.
This is the common command to run before validation when a step output Direction matters. Use input/state -> local for step inputs and local ->
should write to a new state field. It copies the selected capability output state/output for step outputs. Run `wf draft validate <workspace_id>` after
field schema into state_schema and merges the output binding this command.
local.<output> -> state.<field>.
Run `wf draft validate <workspace_id>` after this command.
""" """
context = load_cli_context(ctx) context = load_cli_context(ctx)
emit_json( emit_json(
run_cli_operation( run_cli_operation(
context, context,
context.handlers.bind_output_to_state( context.handlers.bind_draft(
workspace_id=workspace_id, workspace_id=workspace_id,
revision=revision, revision=revision,
step_id=step_id, step_id=step_id,
output_field=output_field, source_path=source_path,
state_path=state_path, target_path=target_path,
), ),
) )
) )
+5 -9
View File
@@ -243,18 +243,14 @@ class SetStepOutputMapRequest(BaseModel):
) )
class BindOutputToStateRequest(BaseModel): class BindDraftRequest(BaseModel):
"""Typed MCP request for binding one step output to one root state field.""" """Typed MCP request for binding one draft step path with schema projection."""
workspace_id: WorkspaceId workspace_id: WorkspaceId
revision: int = Field(ge=1, description="Expected current workspace revision.") revision: int = Field(ge=1, description="Expected current workspace revision.")
step_id: str = Field(description="Draft step id whose capability output is used.") step_id: str = Field(description="Capability-backed draft step id.")
output_field: str = Field( source_path: str = Field(description="Source path, for example input.x or local.y.")
description="Top-level output field to bind, for example after." target_path: str = Field(description="Target path, for example local.x or state.y.")
)
state_path: str = Field(
description="Root state path to declare and bind, for example state.after."
)
class AddStepFromCapabilityRequest(BaseModel): class AddStepFromCapabilityRequest(BaseModel):
+11 -10
View File
@@ -13,7 +13,7 @@ from wf_mcp.broker.service.workflow_operation_context import context_from_servic
from .models import ( from .models import (
AddStepFromCapabilityRequest, AddStepFromCapabilityRequest,
BindOutputToStateRequest, BindDraftRequest,
BranchDraftRequest, BranchDraftRequest,
CallCapabilityResult, CallCapabilityResult,
CompileDraftWorkspaceRequest, CompileDraftWorkspaceRequest,
@@ -461,23 +461,24 @@ def register_workflow_tools(server: FastMCP[Any], service: WfMcpService) -> None
) )
@server.tool( @server.tool(
name="wf.workflow.bind_output_to_state", name="wf.workflow.bind",
title="Bind Output To State", title="Bind Draft",
description=( description=(
"Declare one root state field from a draft step capability output " "Bind a capability step path and project the matching schema. "
"schema and bind local.<output> to that state path." "Use input/state -> local for step inputs and local -> state/output "
"for step outputs."
), ),
) )
async def bind_output_to_state( async def bind_draft(
request: BindOutputToStateRequest, request: BindDraftRequest,
) -> DraftWorkspaceResult: ) -> DraftWorkspaceResult:
return DraftWorkspaceResult.model_validate( return DraftWorkspaceResult.model_validate(
await handlers.bind_output_to_state( await handlers.bind_draft(
workspace_id=request.workspace_id, workspace_id=request.workspace_id,
revision=request.revision, revision=request.revision,
step_id=request.step_id, step_id=request.step_id,
output_field=request.output_field, source_path=request.source_path,
state_path=request.state_path, target_path=request.target_path,
) )
) )
+2
View File
@@ -6,6 +6,7 @@ from .errors import WorkflowRpcError
from .models import ( from .models import (
AddStepFromCapabilityParams, AddStepFromCapabilityParams,
AdminEmptyParams, AdminEmptyParams,
BindDraftParams,
BranchDraftParams, BranchDraftParams,
CallCapabilityParams, CallCapabilityParams,
CompileDraftWorkspaceParams, CompileDraftWorkspaceParams,
@@ -48,6 +49,7 @@ from .models import (
__all__ = [ __all__ = [
"AddStepFromCapabilityParams", "AddStepFromCapabilityParams",
"AdminEmptyParams", "AdminEmptyParams",
"BindDraftParams",
"BranchDraftParams", "BranchDraftParams",
"CallCapabilityParams", "CallCapabilityParams",
"CompileDraftWorkspaceParams", "CompileDraftWorkspaceParams",
+6 -6
View File
@@ -141,23 +141,23 @@ class RpcDraftClientMixin:
}, },
) )
async def bind_output_to_state( async def bind_draft(
self: RpcCaller, self: RpcCaller,
*, *,
workspace_id: str, workspace_id: str,
revision: int, revision: int,
step_id: str, step_id: str,
output_field: str, source_path: str,
state_path: str, target_path: str,
) -> dict[str, Any]: ) -> dict[str, Any]:
return await self._call( return await self._call(
"workflow.draft_workspaces.bind_output_to_state", "workflow.draft_workspaces.bind",
{ {
"workspace_id": workspace_id, "workspace_id": workspace_id,
"revision": revision, "revision": revision,
"step_id": step_id, "step_id": step_id,
"output_field": output_field, "source_path": source_path,
"state_path": state_path, "target_path": target_path,
}, },
) )
+7 -7
View File
@@ -9,7 +9,7 @@ from wf_server import WorkflowServer
from ..errors import WorkflowRpcError, raise_workflow_rpc_error from ..errors import WorkflowRpcError, raise_workflow_rpc_error
from ..models import ( from ..models import (
AddStepFromCapabilityParams, AddStepFromCapabilityParams,
BindOutputToStateParams, BindDraftParams,
BranchDraftParams, BranchDraftParams,
CompileDraftWorkspaceParams, CompileDraftWorkspaceParams,
CreateArtifactFromWorkspaceParams, CreateArtifactFromWorkspaceParams,
@@ -183,19 +183,19 @@ def register_methods(
raise_workflow_rpc_error(exc) raise_workflow_rpc_error(exc)
@entrypoint.method( @entrypoint.method(
name="workflow.draft_workspaces.bind_output_to_state", name="workflow.draft_workspaces.bind",
errors=[WorkflowRpcError], errors=[WorkflowRpcError],
) )
async def workflow_draft_workspaces_bind_output_to_state( async def workflow_draft_workspaces_bind(
params: BindOutputToStateParams = RpcParams(), params: BindDraftParams = RpcParams(),
) -> dict[str, Any]: ) -> dict[str, Any]:
try: try:
return await server.api.bind_output_to_state( return await server.api.bind_draft(
workspace_id=params.workspace_id, workspace_id=params.workspace_id,
revision=params.revision, revision=params.revision,
step_id=params.step_id, step_id=params.step_id,
output_field=params.output_field, source_path=params.source_path,
state_path=params.state_path, target_path=params.target_path,
) )
except (ValueError, KeyError, LookupError, FileNotFoundError) as exc: except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
raise_workflow_rpc_error(exc) raise_workflow_rpc_error(exc)
+3 -3
View File
@@ -141,12 +141,12 @@ class SetStepOutputMapParams(RpcParamsModel):
merge: bool = False merge: bool = False
class BindOutputToStateParams(RpcParamsModel): class BindDraftParams(RpcParamsModel):
workspace_id: str = Field(min_length=1) workspace_id: str = Field(min_length=1)
revision: int = Field(ge=1) revision: int = Field(ge=1)
step_id: str = Field(min_length=1) step_id: str = Field(min_length=1)
output_field: str = Field(min_length=1) source_path: str = Field(min_length=1)
state_path: str = Field(min_length=1) target_path: str = Field(min_length=1)
class AddStepFromCapabilityParams(RpcParamsModel): class AddStepFromCapabilityParams(RpcParamsModel):
+19 -7
View File
@@ -342,7 +342,7 @@ async def test_validate_draft_workspace_refreshes_status(tmp_path: Path) -> None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_validate_draft_workspace_suggests_bind_output_to_state( async def test_validate_draft_workspace_suggests_bind(
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_repair_hint") artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_repair_hint")
@@ -381,8 +381,8 @@ async def test_validate_draft_workspace_suggests_bind_output_to_state(
assert diagnostic["code"] == "invalid_destination_path" assert diagnostic["code"] == "invalid_destination_path"
assert diagnostic["step_id"] == "snap" assert diagnostic["step_id"] == "snap"
assert diagnostic["repair_hint"] == ( assert diagnostic["repair_hint"] == (
"wf draft bind-output-to-state snapshot_ws --revision 1 " "wf draft bind snapshot_ws --revision 1 "
"--step snap --output after --state state.after" "--step snap --from local.after --to state.after"
) )
@@ -560,7 +560,9 @@ async def test_bind_draft_workflow_input_to_step_input_projects_input_schema(
source_path="input.text", source_path="input.text",
target_path="local.text", target_path="local.text",
) )
workspace = await api.get_draft_workspace(workspace_id="bind_ws", include_draft=True) workspace = await api.get_draft_workspace(
workspace_id="bind_ws", include_draft=True
)
assert result["revision"] == 2 assert result["revision"] == 2
assert workspace["draft"]["input_schema"]["properties"]["text"]["type"] == "string" assert workspace["draft"]["input_schema"]["properties"]["text"]["type"] == "string"
@@ -584,7 +586,13 @@ async def test_bind_draft_output_to_nested_state_projects_state_schema(
"state_schema": {"type": "object", "properties": {}}, "state_schema": {"type": "object", "properties": {}},
"output_schema": {"type": "object", "properties": {}}, "output_schema": {"type": "object", "properties": {}},
"start": "snap", "start": "snap",
"steps": {"snap": {"use": "demo.personal.snapshot_tool", "input": [], "output": []}}, "steps": {
"snap": {
"use": "demo.personal.snapshot_tool",
"input": [],
"output": [],
}
},
"routes": {"snap": {"ok": "__end__"}}, "routes": {"snap": {"ok": "__end__"}},
}, },
) )
@@ -596,11 +604,15 @@ async def test_bind_draft_output_to_nested_state_projects_state_schema(
source_path="local.after", source_path="local.after",
target_path="state.session.after", target_path="state.session.after",
) )
workspace = await api.get_draft_workspace(workspace_id="snapshot_ws", include_draft=True) workspace = await api.get_draft_workspace(
workspace_id="snapshot_ws", include_draft=True
)
assert result["revision"] == 2 assert result["revision"] == 2
assert ( assert (
workspace["draft"]["state_schema"]["properties"]["session"]["properties"]["after"]["$ref"] workspace["draft"]["state_schema"]["properties"]["session"]["properties"][
"after"
]["$ref"]
== "#/$defs/_Snapshot" == "#/$defs/_Snapshot"
) )
assert workspace["draft"]["steps"]["snap"]["output"] == [ assert workspace["draft"]["steps"]["snap"]["output"] == [
+4 -4
View File
@@ -144,13 +144,13 @@ def test_wf_draft_map_help_explains_replace_merge_and_validate() -> None:
assert "draft validate" in output_help assert "draft validate" in output_help
def test_wf_draft_bind_output_to_state_help_explains_composed_edit() -> None: def test_wf_draft_bind_help_explains_direction() -> None:
result = runner.invoke(app, ["draft", "bind-output-to-state", "--help"]) result = runner.invoke(app, ["draft", "bind", "--help"])
assert result.exit_code == 0 assert result.exit_code == 0
output = " ".join(result.output.split()) output = " ".join(result.output.split())
assert "state schema" in output assert "--from" in output
assert "output binding" in output assert "--to" in output
assert "validate" in output assert "validate" in output
+8 -8
View File
@@ -689,9 +689,9 @@ def test_wf_remote_draft_artifact_deploy_lifecycle(monkeypatch, tmp_path) -> Non
) )
assert invalid_validated.exit_code == 0, invalid_validated.output assert invalid_validated.exit_code == 0, invalid_validated.output
assert '"status": "invalid"' in invalid_validated.output assert '"status": "invalid"' in invalid_validated.output
assert "bind-output-to-state repair_ws --revision 2" in invalid_validated.output assert "bind repair_ws --revision 2" in invalid_validated.output
assert ( assert (
"--step call --output value --state state.missing" in invalid_validated.output "--step call --from local.value --to state.missing" in invalid_validated.output
) )
saved_artifact = runner.invoke( saved_artifact = runner.invoke(
@@ -1139,7 +1139,7 @@ def test_wf_draft_focused_edit_commands_use_rpc_target(monkeypatch, tmp_path) ->
] ]
def test_wf_draft_bind_output_to_state_uses_rpc_target(monkeypatch, tmp_path) -> None: def test_wf_draft_bind_uses_rpc_target(monkeypatch, tmp_path) -> None:
server = build_local_static_workflow_server(tmp_path / "store") server = build_local_static_workflow_server(tmp_path / "store")
_patch_rpc_client_to_server(monkeypatch, server) _patch_rpc_client_to_server(monkeypatch, server)
config_path = tmp_path / "wf.json" config_path = tmp_path / "wf.json"
@@ -1166,16 +1166,16 @@ def test_wf_draft_bind_output_to_state_uses_rpc_target(monkeypatch, tmp_path) ->
[ [
*base_args, *base_args,
"draft", "draft",
"bind-output-to-state", "bind",
"snapshot_ws", "snapshot_ws",
"--revision", "--revision",
"1", "1",
"--step", "--step",
"call", "call",
"--output", "--from",
"value", "local.value",
"--state", "--to",
"state.value", "state.result",
], ],
) )
+1 -1
View File
@@ -56,7 +56,7 @@ def test_server_exposes_upstream_admin_and_workflow_tools() -> None:
assert "wf.workflow.set_draft_route" in names assert "wf.workflow.set_draft_route" in names
assert "wf.workflow.set_step_input_map" in names assert "wf.workflow.set_step_input_map" in names
assert "wf.workflow.set_step_output_map" in names assert "wf.workflow.set_step_output_map" in names
assert "wf.workflow.bind_output_to_state" in names assert "wf.workflow.bind" in names
assert "wf.workflow.add_step_from_capability" in names assert "wf.workflow.add_step_from_capability" in names
assert "wf.workflow.create_minimal_draft_workspace" in names assert "wf.workflow.create_minimal_draft_workspace" in names
assert "wf.workflow.create_draft_workspace_from_capability" in names assert "wf.workflow.create_draft_workspace_from_capability" in names
+3 -3
View File
@@ -724,13 +724,13 @@ async def test_rpc_draft_workspace_focused_edit_methods(tmp_path) -> None:
) )
state_bound = await _rpc( state_bound = await _rpc(
client, client,
"workflow.draft_workspaces.bind_output_to_state", "workflow.draft_workspaces.bind",
{ {
"workspace_id": "focused_ws", "workspace_id": "focused_ws",
"revision": 7, "revision": 7,
"step_id": "call", "step_id": "call",
"output_field": "value", "source_path": "local.value",
"state_path": "state.extra_value", "target_path": "state.extra_value",
}, },
) )
fetched = await _rpc( fetched = await _rpc(
+3 -3
View File
@@ -501,12 +501,12 @@ async def test_rpc_client_draft_workspace_focused_edit_methods(tmp_path) -> None
output_map={"extra": "state.extra"}, output_map={"extra": "state.extra"},
merge=True, merge=True,
) )
state_bound = await client.bind_output_to_state( state_bound = await client.bind_draft(
workspace_id="client_focused_ws", workspace_id="client_focused_ws",
revision=7, revision=7,
step_id="call", step_id="call",
output_field="value", source_path="local.value",
state_path="state.extra_value", target_path="state.extra_value",
) )
assert named["revision"] == 2 assert named["revision"] == 2