feat: add semantic draft authoring operations

This commit is contained in:
lda
2026-06-27 17:14:17 +07:00 Verified
parent 5cfe513efc
commit 60a3815452
27 changed files with 1383 additions and 741 deletions
+5 -4
View File
@@ -63,16 +63,17 @@ 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 add-state-from-output` projects capability output
property schemas into draft state schemas, preserving `$defs` / `definitions`
for schema refs and reducing brittle whole-`state_schema` patches.
- Completed: `wf draft bind-output-to-state` composes state schema projection - Completed: `wf draft bind-output-to-state` composes state schema projection
with output binding merge, reducing manual draft patch repairs in agent with output binding merge, reducing manual draft patch repairs in agent
challenge runs. 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
multi-step workflows. multi-step workflows. Accepts `--route OUTCOME=TARGET` for multi-outcome steps.
- Completed: `wf draft branch` and `wf draft handle` provide atomic route
editing for existing draft steps without rewriting the full routes object.
- Completed: `wf draft compile` returns the compiled raw plan plus required
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 `bind-output-to-state` 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.
+31 -10
View File
@@ -297,7 +297,9 @@ wf draft set-route concat_ws --revision 2 --step call --outcome ok --to __end__
wf draft set-input concat_ws --revision 3 --step call --map input.items=items --map input.separator=separator wf draft set-input concat_ws --revision 3 --step call --map input.items=items --map input.separator=separator
wf draft set-output concat_ws --revision 4 --step call --map value=state.value wf draft set-output concat_ws --revision 4 --step call --map value=state.value
wf draft set-input concat_ws --revision 5 --step call --merge --map input.limit=limit wf draft set-input concat_ws --revision 5 --step call --merge --map input.limit=limit
wf draft add-state-from-output concat_ws --revision 5 --step call --output value --state state.value wf draft branch concat_ws --revision 6 --step call --route ok=__end__ --route error=tool_error
wf draft handle concat_ws --revision 7 --to fail --branch lookup:error --branch transform:error
wf draft compile concat_ws
``` ```
`set-input` maps graph source paths to node-local input fields: `set-input` maps graph source paths to node-local input fields:
@@ -311,13 +313,6 @@ 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.
Before mapping a step output to a new state field, the state schema must declare
that root field. `add-state-from-output` copies the selected step capability's
top-level output property schema into `state_schema.properties`, including local
`$defs` / `definitions` blocks needed by `$ref` schemas. It only declares the
state field; still run `set-output` or `draft patch` to write values into that
field, then run `wf draft validate`.
### Bind A Step Output To State ### Bind A Step Output To State
Use `bind-output-to-state` when a step output should become workflow state and Use `bind-output-to-state` when a step output should become workflow state and
@@ -351,8 +346,8 @@ wf draft add-step-from-capability report_ws \
--capability local.report.render_markdown_report \ --capability local.report.render_markdown_report \
--from-step extract \ --from-step extract \
--from-outcome ok \ --from-outcome ok \
--outcome ok \ --route ok=__end__ \
--to __end__ \ --route error=tool_error \
--input state.title=title \ --input state.title=title \
--bind-output markdown=state.markdown --bind-output markdown=state.markdown
``` ```
@@ -360,6 +355,32 @@ wf draft add-step-from-capability report_ws \
Run `wf draft validate report_ws` after adding the step. If validation returns Run `wf draft validate report_ws` after adding the step. If validation returns
a `repair_hint`, prefer the focused helper in that hint before JSON Patch. a `repair_hint`, prefer the focused helper in that hint before JSON Patch.
### Branch And Handle Existing Steps
Use `wf draft branch` to update routes for an existing step in one revision:
```bash
wf draft branch concat_ws --revision 6 --step call --route ok=__end__ --route error=tool_error
```
Use `wf draft handle` to route multiple source step outcomes to a common target:
```bash
wf draft handle concat_ws --revision 7 --to fail --branch lookup:error --branch transform:error
```
### Compile A Draft Workspace
Use `wf draft compile` to return the compiled raw plan plus required
capabilities without mutating or saving the draft:
```bash
wf draft compile concat_ws
```
On success, prints the compiled plan to stdout. On invalid draft status, prints
the structured diagnostic envelope to stderr and exits nonzero.
Validate: Validate:
```bash ```bash
+6
View File
@@ -614,6 +614,9 @@ The workflow MCP surface exposes these draft tools:
| `wf.workflow.compile_draft` | Return the compiled raw plan plus dependency summaries. | | `wf.workflow.compile_draft` | Return the compiled raw plan plus dependency summaries. |
| `wf.workflow.patch_draft` | Apply RFC 6902 JSON Patch and validate the patched draft. | | `wf.workflow.patch_draft` | Apply RFC 6902 JSON Patch and validate the patched draft. |
| `wf.workflow.create_artifact_from_draft` | Compile, normalize, and save a workflow artifact. | | `wf.workflow.create_artifact_from_draft` | Compile, normalize, and save a workflow artifact. |
| `wf.workflow.branch_draft` | Update routes for an existing step in one revision. |
| `wf.workflow.handle_draft` | Route multiple source step outcomes to a common target. |
| `wf.workflow.compile_draft_workspace` | Return compiled plan plus capabilities without mutation. |
Use `validate_draft` before saving. Use `patch_draft` when an LLM client needs a Use `validate_draft` before saving. Use `patch_draft` when an LLM client needs a
small targeted correction instead of rewriting the whole workflow. small targeted correction instead of rewriting the whole workflow.
@@ -665,6 +668,9 @@ wf draft set-name <workspace_id> --revision <n> --name <name>
wf draft set-route <workspace_id> --revision <n> --step <step_id> --outcome ok --to <target_step_or___end__> wf draft set-route <workspace_id> --revision <n> --step <step_id> --outcome ok --to <target_step_or___end__>
wf draft set-input <workspace_id> --revision <n> --step <step_id> --map input.text=text wf draft set-input <workspace_id> --revision <n> --step <step_id> --map input.text=text
wf draft set-output <workspace_id> --revision <n> --step <step_id> --map text=state.text wf draft set-output <workspace_id> --revision <n> --step <step_id> --map text=state.text
wf draft branch <workspace_id> --revision <n> --step <step_id> --route ok=__end__ --route error=tool_error
wf draft handle <workspace_id> --revision <n> --to fail --branch lookup:error --branch transform:error
wf draft compile <workspace_id>
``` ```
Use `draft patch` when these focused commands do not cover the structural edit. Use `draft patch` when these focused commands do not cover the structural edit.
+5 -8
View File
@@ -44,9 +44,11 @@ wf draft set-input <workspace_id> --revision <n> --step <step_id> --map input.te
wf draft set-input <workspace_id> --revision <n> --step <step_id> --merge --map input.other=other wf draft set-input <workspace_id> --revision <n> --step <step_id> --merge --map input.other=other
wf draft set-output <workspace_id> --revision <n> --step <step_id> --map text=state.text wf draft set-output <workspace_id> --revision <n> --step <step_id> --map text=state.text
wf draft set-output <workspace_id> --revision <n> --step <step_id> --merge --map other=state.other wf draft set-output <workspace_id> --revision <n> --step <step_id> --merge --map other=state.other
wf draft add-state-from-output <workspace_id> --revision <n> --step <step_id> --output <field> --state state.<field> 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 compile <workspace_id>
wf draft bind-output-to-state <workspace_id> --revision <n> --step <step_id> --output <field> --state state.<field> wf draft bind-output-to-state <workspace_id> --revision <n> --step <step_id> --output <field> --state state.<field>
wf draft add-step-from-capability <workspace_id> --revision <n> --step <step_id> --capability <qualified_name> --from-step <prev> --from-outcome ok --outcome ok --to <next-or-__end__> --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>
@@ -80,14 +82,9 @@ 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.
If mapping `LOCAL_SOURCE=state.new_field`, declare the state field first with
`draft add-state-from-output` when the schema should match a capability output
field. Do not hand-copy `$defs` unless the helper cannot express the shape.
Prefer `draft bind-output-to-state` when a step output should write to a new Prefer `draft bind-output-to-state` when a step output should write to a new
root state field. It declares the matching state schema and merges the output root state field. It declares the matching state schema and merges the output
binding in one revision-checked edit. Use `draft add-state-from-output` only binding in one revision-checked edit.
when you need the schema declaration without changing bindings.
`bind-output-to-state` requires a capability-backed step with `use`; use JSON `bind-output-to-state` requires a capability-backed step with `use`; use JSON
Patch for non-capability/control draft steps. Patch for non-capability/control draft steps.
@@ -73,9 +73,11 @@ 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`
- `add_state_schema_from_output`
- `bind_output_to_state` - `bind_output_to_state`
- `add_step_from_capability` - `add_step_from_capability`
- `branch_draft`
- `handle_draft`
- `compile_draft_workspace`
CLI equivalents: CLI equivalents:
@@ -86,9 +88,11 @@ wf draft set-input <workspace_id> --revision <n> --step <step_id> --map input.te
wf draft set-input <workspace_id> --revision <n> --step <step_id> --merge --map input.other=other wf draft set-input <workspace_id> --revision <n> --step <step_id> --merge --map input.other=other
wf draft set-output <workspace_id> --revision <n> --step <step_id> --map text=state.text wf draft set-output <workspace_id> --revision <n> --step <step_id> --map text=state.text
wf draft set-output <workspace_id> --revision <n> --step <step_id> --merge --map other=state.other wf draft set-output <workspace_id> --revision <n> --step <step_id> --merge --map other=state.other
wf draft add-state-from-output <workspace_id> --revision <n> --step <step_id> --output <field> --state state.<field> 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 compile <workspace_id>
wf draft bind-output-to-state <workspace_id> --revision <n> --step <step_id> --output <field> --state state.<field> wf draft bind-output-to-state <workspace_id> --revision <n> --step <step_id> --output <field> --state state.<field>
wf draft add-step-from-capability <workspace_id> --revision <n> --step <step_id> --capability <qualified_name> --from-step <prev> --from-outcome ok --outcome ok --to <next-or-__end__> --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
``` ```
`set-input` direction: `input.text=text` means graph source `input.text` maps to `set-input` direction: `input.text=text` means graph source `input.text` maps to
@@ -101,10 +105,6 @@ 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.
Use `add-state-from-output` when the target state field should reuse a capability
output schema. This prevents dangling `$ref` values by copying local `$defs` /
`definitions` with the selected property schema.
- `bind_output_to_state` - `bind_output_to_state`
Declares one root state field from a step capability output schema and merges Declares one root state field from a step capability output schema and merges
@@ -124,15 +124,35 @@ wf draft validate <workspace_id>
Adds a new capability-backed step with explicit route, input bindings, and Adds a new capability-backed step with explicit route, input bindings, and
output-to-state schema/binding wiring in one revision. It can set the incoming output-to-state schema/binding wiring in one revision. It can set the incoming
edge, outgoing edge, input map, and output-to-state schema/binding. It still edge, outgoing edges, input map, and output-to-state schema/binding. Use
`--route OUTCOME=TARGET` for each outcome; when omitted and the capability
declares a single outcome, that outcome routes to `__end__`. It still
requires explicit choices; if you do not know a map, inspect the capability or requires explicit choices; if you do not know a map, inspect the capability or
run validation rather than guessing. run validation rather than guessing.
```bash ```bash
wf draft add-step-from-capability <workspace_id> --revision <n> --step <step_id> --capability <qualified_name> --from-step <prev> --from-outcome ok --outcome ok --to <next-or-__end__> --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>
``` ```
- `branch_draft`
Updates routes for an existing step in one revision without rewriting the
full routes object. Supply `--route OUTCOME=TARGET` for each outcome to
set or update.
- `handle_draft`
Routes multiple source step outcomes to a common target. Supply
`--branch STEP:OUTCOME` for each source outcome and `--to TARGET` for the
shared destination.
- `compile_draft_workspace`
Returns the compiled raw plan plus required capabilities without mutating
or saving the draft workspace. On invalid draft status, returns structured
diagnostics without 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 `bind-output-to-state`, use it before hand-editing `state_schema` or step output
bindings. bindings.
@@ -19,9 +19,8 @@ validated, runnable deployment.
- `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 output-mapping into a new state field, declare it with
`add-state-from-output` when it should mirror a capability output property. `bind-output-to-state` when it should mirror a capability output property.
- Use `bind-output-to-state` when a capability output should become state; It declares the matching state schema and merges the output binding in one
it declares the matching state schema and merges the output binding in one
revision-checked edit. revision-checked edit.
- When adding a new capability-backed step, prefer: - When adding a new capability-backed step, prefer:
```bash ```bash
+3 -1
View File
@@ -18,6 +18,7 @@ from wf_platform import CapabilitySource
from .artifact_plans import raw_plan_from_artifact from .artifact_plans import raw_plan_from_artifact
from .artifact_refs import artifact_capability_id from .artifact_refs import artifact_capability_id
from .capability_requirements import required_capability_payloads from .capability_requirements import required_capability_payloads
from .draft_authoring import WorkflowDraftAuthoringApi
from .drafts import WorkflowDraftApi from .drafts import WorkflowDraftApi
from .listing import matches_query, paged_list_payload from .listing import matches_query, paged_list_payload
from .next_actions import NextActions from .next_actions import NextActions
@@ -64,6 +65,7 @@ class WorkflowCapabilityApi:
def __init__(self, context: WorkflowOperationContext) -> None: def __init__(self, context: WorkflowOperationContext) -> None:
self.context = context self.context = context
self.drafts = WorkflowDraftApi(context) self.drafts = WorkflowDraftApi(context)
self.draft_authoring = WorkflowDraftAuthoringApi(context, self.drafts)
async def list_capabilities( async def list_capabilities(
self, self,
@@ -353,7 +355,7 @@ class WorkflowCapabilityApi:
"""Create a patchable draft workspace from inspect_capability hints.""" """Create a patchable draft workspace from inspect_capability hints."""
capability = await self.inspect_capability(qualified_name=capability_name) capability = await self.inspect_capability(qualified_name=capability_name)
hints = capability["wrapper_hints"] hints = capability["wrapper_hints"]
result = await self.drafts.create_minimal_draft_workspace( result = await self.draft_authoring.create_minimal_draft_workspace(
workspace_id=workspace_id, workspace_id=workspace_id,
name=name or _draft_name_from_capability(capability_name), name=name or _draft_name_from_capability(capability_name),
capability_name=capability_name, capability_name=capability_name,
+392
View File
@@ -0,0 +1,392 @@
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Any
from wf_core.models.steps import (
InputBinding,
OutputBinding,
)
from wf_core.paths import GraphSourcePath
from .constants import (
DEFAULT_CALL_STEP_ID,
DEFAULT_ERROR_OUTCOME,
DEFAULT_ERROR_STEP_ID,
DEFAULT_OK_OUTCOME,
RUNTIME_ERROR_CAPABILITY,
)
from .draft_payloads import (
_graph_path_payload,
draft_step,
escape_json_pointer,
input_bindings_payload,
output_bindings_payload,
state_root_field,
)
from .drafts import (
WorkflowDraftApi,
_draft_input_maps,
_draft_output_map,
)
from .operation_context import WorkflowOperationContext
from .schema_projection import project_output_property_to_state_schema
class WorkflowDraftAuthoringApi:
"""Capability-aware semantic edits over revisioned workflow drafts."""
def __init__(
self,
context: WorkflowOperationContext,
drafts: WorkflowDraftApi,
) -> None:
self.context = context
self.drafts = drafts
def _outcomes_for_capability(self, qualified_name: str) -> tuple[str, ...] | None:
try:
spec = self.context.specs.get_qualified_spec(qualified_name)
except KeyError:
return None
outcomes = getattr(spec, "outcomes", None)
return tuple(outcomes) if outcomes is not None else None
async def create_minimal_draft_workspace(
self,
*,
workspace_id: str,
name: str,
capability_name: str,
input_schema: dict[str, Any],
state_schema: dict[str, Any],
output_schema: dict[str, Any],
input: Sequence[InputBinding] | None = None,
output: Sequence[OutputBinding] | None = None,
input_map: dict[str, str] | None = None,
output_map: dict[str, str] | None = None,
error_message_source: str | GraphSourcePath | None = None,
title: str | None = None,
) -> dict[str, Any]:
"""Bootstrap the smallest patchable draft around one workflow capability."""
draft_input, draft_with = _draft_input_maps(
input=input,
input_map=input_map,
)
draft_output = _draft_output_map(output=output, output_map=output_map)
outcomes = self._outcomes_for_capability(capability_name) or (
DEFAULT_OK_OUTCOME,
)
steps: dict[str, Any] = {
DEFAULT_CALL_STEP_ID: {
"use": capability_name,
"input": input_bindings_payload(draft_input, draft_with),
"output": output_bindings_payload(draft_output),
}
}
routes: dict[str, dict[str, str]] = {
DEFAULT_CALL_STEP_ID: {DEFAULT_OK_OUTCOME: "__end__"}
}
if DEFAULT_ERROR_OUTCOME in outcomes:
error_input: dict[str, Any] = {
"target": "message",
"value": "Capability call failed",
}
if error_message_source is not None:
error_input = {
"target": "message",
"path": _graph_path_payload(error_message_source),
}
steps[DEFAULT_ERROR_STEP_ID] = {
"use": RUNTIME_ERROR_CAPABILITY,
"input": [error_input],
"output": [],
}
routes[DEFAULT_CALL_STEP_ID][DEFAULT_ERROR_OUTCOME] = DEFAULT_ERROR_STEP_ID
routes[DEFAULT_ERROR_STEP_ID] = {DEFAULT_OK_OUTCOME: "__end__"}
draft = {
"name": name,
"input_schema": input_schema,
"state_schema": state_schema,
"output_schema": output_schema,
"start": DEFAULT_CALL_STEP_ID,
"steps": steps,
"routes": routes,
}
return await self.drafts.create_draft_workspace(
workspace_id=workspace_id,
title=title,
draft=draft,
)
async def bind_output_to_state(
self,
*,
workspace_id: str,
revision: int,
step_id: str,
output_field: str,
state_path: str,
) -> dict[str, Any]:
"""Declare a state field from a step output and bind that output to it.
This is the common draft-authoring repair for validation errors where a
step writes to ``state.x`` before ``state_schema.properties.x`` exists.
It deliberately edits only one root state field and one step output map.
Route changes remain explicit through ``set_draft_route``.
"""
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"
)
state_field = state_root_field(state_path)
spec = self.context.specs.get_qualified_spec(capability_name)
output_schema = (
spec.output_schema_contract or spec.output_model.model_json_schema()
)
state_schema = workspace.draft.get("state_schema", {})
if not isinstance(state_schema, dict):
raise ValueError("draft state_schema must be an object")
projected = project_output_property_to_state_schema(
state_schema=state_schema,
output_schema=output_schema,
output_field=output_field,
state_field=state_field,
)
output_map = {
**self.drafts._step_output_map(workspace_id=workspace_id, step_id=step_id),
output_field: state_path,
}
return await self.drafts.patch_draft_workspace(
workspace_id=workspace_id,
revision=revision,
patch=[
{
"op": "replace",
"path": "/state_schema",
"value": projected,
},
{
"op": "replace",
"path": f"/steps/{escape_json_pointer(step_id)}/output",
"value": output_bindings_payload(output_map),
},
],
)
async def add_step_from_capability(
self,
*,
workspace_id: str,
revision: int,
step_id: str,
capability_name: str,
route_from_step: str | None = None,
route_from_outcome: str = DEFAULT_OK_OUTCOME,
routes: dict[str, str] | None = None,
input_map: dict[str, str] | None = None,
bind_outputs: dict[str, str] | None = None,
) -> dict[str, Any]:
"""Add one capability step plus explicit route/map/schema wiring.
This is a composed authoring helper for agents. It edits the draft in
one revision so callers do not have to interleave add-step, route,
input-map, state-schema, and output-map operations by hand.
"""
workspace = self.drafts._draft_store().get_workspace(workspace_id)
steps = workspace.draft.get("steps")
if not isinstance(steps, dict):
raise ValueError("draft steps must be an object")
if step_id in steps:
raise ValueError(f"draft step {step_id!r} already exists")
spec = self.context.specs.get_qualified_spec(capability_name)
output_schema = (
spec.output_schema_contract or spec.output_model.model_json_schema()
)
state_schema = workspace.draft.get("state_schema", {})
if not isinstance(state_schema, dict):
raise ValueError("draft state_schema must be an object")
declared_outcomes = self._outcomes_for_capability(capability_name)
if declared_outcomes is None:
declared_outcomes = (DEFAULT_OK_OUTCOME,)
if routes is not None:
missing_outcomes = set(declared_outcomes) - set(routes.keys())
unknown_outcomes = set(routes.keys()) - set(declared_outcomes)
if missing_outcomes or unknown_outcomes:
details = [
f"declared_outcomes={declared_outcomes!r}",
f"missing_outcomes={sorted(missing_outcomes)!r}",
f"unknown_outcomes={sorted(unknown_outcomes)!r}",
]
raise ValueError(
f"capability {capability_name!r} declares outcomes "
f"{declared_outcomes}, but routes has "
f"missing routes {sorted(missing_outcomes)} and unknown "
f"routes {sorted(unknown_outcomes)}; " + ", ".join(details)
)
step_routes = dict(routes)
else:
if len(declared_outcomes) == 1:
step_routes = {declared_outcomes[0]: "__end__"}
else:
missing_outcomes = sorted(declared_outcomes)
raise ValueError(
f"capability {capability_name!r} declares outcomes "
f"{declared_outcomes} with no routes supplied; missing "
f"routes for {missing_outcomes}"
)
input_map = input_map or {}
bind_outputs = bind_outputs or {}
projected_state_schema = state_schema
for output_field, path in bind_outputs.items():
sf = state_root_field(path)
projected_state_schema = project_output_property_to_state_schema(
state_schema=projected_state_schema,
output_schema=output_schema,
output_field=output_field,
state_field=sf,
)
patch: list[dict[str, Any]] = [
{
"op": "add",
"path": f"/steps/{escape_json_pointer(step_id)}",
"value": {
"use": capability_name,
"input": input_bindings_payload(input_map, {}),
"output": output_bindings_payload(bind_outputs),
},
},
{
"op": "add",
"path": f"/routes/{escape_json_pointer(step_id)}",
"value": step_routes,
},
]
if projected_state_schema != state_schema:
patch.insert(
0,
{
"op": "replace",
"path": "/state_schema",
"value": projected_state_schema,
},
)
if route_from_step is not None:
patch.append(
{
"op": "add",
"path": (
f"/routes/{escape_json_pointer(route_from_step)}/"
f"{escape_json_pointer(route_from_outcome)}"
),
"value": step_id,
}
)
return await self.drafts.patch_draft_workspace(
workspace_id=workspace_id,
revision=revision,
patch=patch,
)
async def branch_draft(
self,
*,
workspace_id: str,
revision: int,
step_id: str,
routes: dict[str, str],
) -> dict[str, Any]:
"""Atomically set routes for one step, preserving unspecified outcomes."""
workspace = self.drafts._draft_store().get_workspace(workspace_id)
draft_routes = workspace.draft.get("routes", {})
if not isinstance(draft_routes, dict):
raise ValueError("draft routes must be an object")
existing = draft_routes.get(step_id, {})
if not isinstance(existing, dict):
raise ValueError(f"routes for step {step_id!r} must be an object")
merged = {**existing, **routes}
if merged == existing:
return await self.drafts.get_draft_workspace(
workspace_id=workspace_id,
)
return await self.drafts.patch_draft_workspace(
workspace_id=workspace_id,
revision=revision,
patch=[
{
"op": "replace",
"path": f"/routes/{escape_json_pointer(step_id)}",
"value": merged,
}
],
)
async def handle_draft(
self,
*,
workspace_id: str,
revision: int,
branches: Sequence[DraftOutcomeRef],
target: str,
) -> dict[str, Any]:
"""Update the target for multiple (step, outcome) pairs atomically."""
if not branches:
return await self.drafts.get_draft_workspace(
workspace_id=workspace_id,
)
workspace = self.drafts._draft_store().get_workspace(workspace_id)
draft_routes = workspace.draft.get("routes", {})
if not isinstance(draft_routes, dict):
raise ValueError("draft routes must be an object")
patch: list[dict[str, Any]] = []
seen: set[tuple[str, str]] = set()
for ref in branches:
key = (ref.step_id, ref.outcome)
if key in seen:
continue
seen.add(key)
step_routes = draft_routes.get(ref.step_id, {})
if not isinstance(step_routes, dict):
continue
if ref.outcome not in step_routes:
continue
if step_routes[ref.outcome] == target:
continue
patch.append(
{
"op": "replace",
"path": (
f"/routes/{escape_json_pointer(ref.step_id)}/"
f"{escape_json_pointer(ref.outcome)}"
),
"value": target,
}
)
if not patch:
return await self.drafts.get_draft_workspace(
workspace_id=workspace_id,
)
return await self.drafts.patch_draft_workspace(
workspace_id=workspace_id,
revision=revision,
patch=patch,
)
@dataclass(frozen=True)
class DraftOutcomeRef:
"""A reference to a specific outcome of a draft step."""
step_id: str
outcome: str
+65
View File
@@ -0,0 +1,65 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from wf_core.paths import GraphSourcePath, LocalPath, StatePath
def draft_step(draft: Mapping[str, Any], step_id: str) -> Mapping[str, Any]:
"""Return one step mapping from a draft, raising on missing or non-object."""
steps = draft.get("steps", {})
if not isinstance(steps, Mapping):
raise KeyError("draft steps are not available")
step = steps[step_id]
if not isinstance(step, Mapping):
raise KeyError(f"draft step {step_id!r} is not an object")
return step
def escape_json_pointer(value: str) -> str:
"""Escape one JSON Pointer path segment for generated JSON Patch helpers."""
return value.replace("~", "~0").replace("/", "~1")
def input_bindings_payload(
input_map: dict[str, str],
input_values: dict[str, Any],
) -> list[dict[str, Any]]:
"""Serialize draft input maps into canonical string-path 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 output_bindings_payload(output_map: dict[str, str]) -> list[dict[str, Any]]:
"""Serialize draft output maps into canonical string-path binding payloads."""
return [
{"source": _local_path_payload(source), "target": _state_path_payload(target)}
for source, target in output_map.items()
]
def state_root_field(value: str) -> str:
"""Return the single root field name from a state path, or raise."""
path = StatePath.parse(value)
if len(path.parts) != 1:
raise ValueError("state_path must name one root field, such as state.after")
return path.parts[0]
def _local_path_payload(value: str) -> str:
return LocalPath._serialize(LocalPath.parse(value))
def _graph_path_payload(value: str | GraphSourcePath) -> str:
path = value if isinstance(value, GraphSourcePath) else GraphSourcePath.parse(value)
return GraphSourcePath._serialize(path)
def _state_path_payload(value: str) -> str:
return StatePath._serialize(StatePath.parse(value))
+19 -326
View File
@@ -25,21 +25,24 @@ from wf_core.models.steps import (
InputValueBinding, InputValueBinding,
OutputBinding, OutputBinding,
) )
from wf_core.paths import GraphSourcePath, LocalPath, StatePath
from .capability_requirements import ( from .capability_requirements import (
required_capabilities_for_plan, required_capabilities_for_plan,
required_capability_payloads, required_capability_payloads,
) )
from .constants import ( from .draft_payloads import (
DEFAULT_CALL_STEP_ID, draft_step as _draft_step,
DEFAULT_ERROR_OUTCOME, )
DEFAULT_ERROR_STEP_ID, from .draft_payloads import (
DEFAULT_OK_OUTCOME, escape_json_pointer as _escape_json_pointer,
RUNTIME_ERROR_CAPABILITY, )
from .draft_payloads import (
input_bindings_payload as _draft_input_bindings_payload,
)
from .draft_payloads import (
output_bindings_payload as _draft_output_bindings_payload,
) )
from .operation_context import WorkflowOperationContext from .operation_context import WorkflowOperationContext
from .schema_projection import project_output_property_to_state_schema
class WorkflowDraftApi: class WorkflowDraftApi:
@@ -181,6 +184,14 @@ class WorkflowDraftApi:
store.save_workspace(refreshed) store.save_workspace(refreshed)
return get_draft_workspace_record(store, workspace_id=workspace_id) return get_draft_workspace_record(store, workspace_id=workspace_id)
async def compile_draft_workspace(self, *, workspace_id: str) -> dict[str, Any]:
"""Compile a stored draft workspace without mutating it."""
workspace = self._draft_store().get_workspace(workspace_id)
validation = await self.validate_draft(draft=workspace.draft)
if validation["status"] != "valid":
return validation
return await self.compile_draft(draft=workspace.draft)
async def patch_draft_workspace( async def patch_draft_workspace(
self, self,
*, *,
@@ -288,197 +299,6 @@ class WorkflowDraftApi:
], ],
) )
async def add_state_schema_from_output(
self,
*,
workspace_id: str,
revision: int,
step_id: str,
output_field: str,
state_path: str,
) -> dict[str, Any]:
workspace = self._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"
)
state_field = _state_root_field(state_path)
spec = self.context.specs.get_qualified_spec(capability_name)
output_schema = (
spec.output_schema_contract or spec.output_model.model_json_schema()
)
state_schema = workspace.draft.get("state_schema", {})
if not isinstance(state_schema, dict):
raise ValueError("draft state_schema must be an object")
projected = project_output_property_to_state_schema(
state_schema=state_schema,
output_schema=output_schema,
output_field=output_field,
state_field=state_field,
)
return await self.patch_draft_workspace(
workspace_id=workspace_id,
revision=revision,
patch=[
{
"op": "replace",
"path": "/state_schema",
"value": projected,
}
],
)
async def bind_output_to_state(
self,
*,
workspace_id: str,
revision: int,
step_id: str,
output_field: str,
state_path: str,
) -> dict[str, Any]:
"""Declare a state field from a step output and bind that output to it.
This is the common draft-authoring repair for validation errors where a
step writes to ``state.x`` before ``state_schema.properties.x`` exists.
It deliberately edits only one root state field and one step output map.
Route changes remain explicit through ``set_draft_route``.
"""
workspace = self._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"
)
state_field = _state_root_field(state_path)
spec = self.context.specs.get_qualified_spec(capability_name)
output_schema = (
spec.output_schema_contract or spec.output_model.model_json_schema()
)
state_schema = workspace.draft.get("state_schema", {})
if not isinstance(state_schema, dict):
raise ValueError("draft state_schema must be an object")
projected = project_output_property_to_state_schema(
state_schema=state_schema,
output_schema=output_schema,
output_field=output_field,
state_field=state_field,
)
output_map = {
**self._step_output_map(workspace_id=workspace_id, step_id=step_id),
output_field: state_path,
}
return await self.patch_draft_workspace(
workspace_id=workspace_id,
revision=revision,
patch=[
{
"op": "replace",
"path": "/state_schema",
"value": projected,
},
{
"op": "replace",
"path": f"/steps/{_escape_json_pointer(step_id)}/output",
"value": _draft_output_bindings_payload(output_map),
},
],
)
async def add_step_from_capability(
self,
*,
workspace_id: str,
revision: int,
step_id: str,
capability_name: str,
route_from_step: str | None = None,
route_from_outcome: str = DEFAULT_OK_OUTCOME,
route_outcome: str = DEFAULT_OK_OUTCOME,
route_to: str = "__end__",
input_map: dict[str, str] | None = None,
bind_outputs: dict[str, str] | None = None,
) -> dict[str, Any]:
"""Add one capability step plus explicit route/map/schema wiring.
This is a composed authoring helper for agents. It edits the draft in
one revision so callers do not have to interleave add-step, route,
input-map, state-schema, and output-map operations by hand.
"""
workspace = self._draft_store().get_workspace(workspace_id)
steps = workspace.draft.get("steps")
if not isinstance(steps, dict):
raise ValueError("draft steps must be an object")
if step_id in steps:
raise ValueError(f"draft step {step_id!r} already exists")
spec = self.context.specs.get_qualified_spec(capability_name)
output_schema = (
spec.output_schema_contract or spec.output_model.model_json_schema()
)
state_schema = workspace.draft.get("state_schema", {})
if not isinstance(state_schema, dict):
raise ValueError("draft state_schema must be an object")
input_map = input_map or {}
bind_outputs = bind_outputs or {}
projected_state_schema = state_schema
for output_field, state_path in bind_outputs.items():
state_field = _state_root_field(state_path)
projected_state_schema = project_output_property_to_state_schema(
state_schema=projected_state_schema,
output_schema=output_schema,
output_field=output_field,
state_field=state_field,
)
patch: list[dict[str, Any]] = [
{
"op": "add",
"path": f"/steps/{_escape_json_pointer(step_id)}",
"value": {
"use": capability_name,
"input": _draft_input_bindings_payload(input_map, {}),
"output": _draft_output_bindings_payload(bind_outputs),
},
},
{
"op": "add",
"path": f"/routes/{_escape_json_pointer(step_id)}",
"value": {route_outcome: route_to},
},
]
if projected_state_schema != state_schema:
patch.insert(
0,
{
"op": "replace",
"path": "/state_schema",
"value": projected_state_schema,
},
)
if route_from_step is not None:
patch.append(
{
"op": "add",
"path": (
f"/routes/{_escape_json_pointer(route_from_step)}/"
f"{_escape_json_pointer(route_from_outcome)}"
),
"value": step_id,
}
)
return await self.patch_draft_workspace(
workspace_id=workspace_id,
revision=revision,
patch=patch,
)
def _step_input_maps( def _step_input_maps(
self, self,
*, *,
@@ -494,76 +314,6 @@ class WorkflowDraftApi:
step = _draft_step(workspace.draft, step_id) step = _draft_step(workspace.draft, step_id)
return _output_map_from_payload(step.get("output", [])) return _output_map_from_payload(step.get("output", []))
async def create_minimal_draft_workspace(
self,
*,
workspace_id: str,
name: str,
capability_name: str,
input_schema: dict[str, Any],
state_schema: dict[str, Any],
output_schema: dict[str, Any],
input: Sequence[InputBinding] | None = None,
output: Sequence[OutputBinding] | None = None,
input_map: dict[str, str] | None = None,
output_map: dict[str, str] | None = None,
error_message_source: str | GraphSourcePath | None = None,
title: str | None = None,
) -> dict[str, Any]:
"""Bootstrap the smallest patchable draft around one workflow capability."""
draft_input, draft_with = _draft_input_maps(
input=input,
input_map=input_map,
)
draft_output = _draft_output_map(output=output, output_map=output_map)
outcomes = self._outcomes_for_capability(capability_name) or (
DEFAULT_OK_OUTCOME,
)
steps: dict[str, Any] = {
DEFAULT_CALL_STEP_ID: {
"use": capability_name,
"input": _draft_input_bindings_payload(draft_input, draft_with),
"output": _draft_output_bindings_payload(draft_output),
}
}
routes: dict[str, dict[str, str]] = {
DEFAULT_CALL_STEP_ID: {DEFAULT_OK_OUTCOME: "__end__"}
}
if DEFAULT_ERROR_OUTCOME in outcomes:
# The bootstrapper cannot infer provider-specific error envelopes.
# Use a static default unless the caller explicitly supplies the
# state path containing a better provider error message.
error_input: dict[str, Any] = {
"target": {"root": "local", "parts": ["message"]},
"value": "Capability call failed",
}
if error_message_source is not None:
error_input = {
"target": {"root": "local", "parts": ["message"]},
"path": _graph_path_payload(error_message_source),
}
steps[DEFAULT_ERROR_STEP_ID] = {
"use": RUNTIME_ERROR_CAPABILITY,
"input": [error_input],
"output": [],
}
routes[DEFAULT_CALL_STEP_ID][DEFAULT_ERROR_OUTCOME] = DEFAULT_ERROR_STEP_ID
routes[DEFAULT_ERROR_STEP_ID] = {DEFAULT_OK_OUTCOME: "__end__"}
draft = {
"name": name,
"input_schema": input_schema,
"state_schema": state_schema,
"output_schema": output_schema,
"start": DEFAULT_CALL_STEP_ID,
"steps": steps,
"routes": routes,
}
return await self.create_draft_workspace(
workspace_id=workspace_id,
title=title,
draft=draft,
)
def _draft_input_maps( def _draft_input_maps(
*, *,
@@ -606,38 +356,6 @@ 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 _draft_step(draft: Mapping[str, Any], step_id: str) -> Mapping[str, Any]:
steps = draft.get("steps", {})
if not isinstance(steps, Mapping):
raise KeyError("draft steps are not available")
step = steps[step_id]
if not isinstance(step, Mapping):
raise KeyError(f"draft step {step_id!r} is not an object")
return step
def _input_maps_from_payload( def _input_maps_from_payload(
payload: Any, payload: Any,
) -> tuple[dict[str, str], dict[str, Any]]: ) -> tuple[dict[str, str], dict[str, Any]]:
@@ -694,24 +412,6 @@ def _path_text(value: Any, *, expected_root: str | None = None) -> str:
return root if not raw_parts else f"{root}.{'.'.join(raw_parts)}" return root if not raw_parts else f"{root}.{'.'.join(raw_parts)}"
def _graph_path_payload(value: str | GraphSourcePath) -> str:
path = value if isinstance(value, GraphSourcePath) else GraphSourcePath.parse(value)
return GraphSourcePath._serialize(path)
def _local_path_payload(value: str) -> str:
return LocalPath._serialize(LocalPath.parse(value))
def _state_path_payload(value: str) -> str:
return StatePath._serialize(StatePath.parse(value))
def _escape_json_pointer(value: str) -> str:
"""Escape one JSON Pointer path segment for generated JSON Patch helpers."""
return value.replace("~", "~0").replace("/", "~1")
def _with_workspace_repair_hints( def _with_workspace_repair_hints(
payload: dict[str, Any], payload: dict[str, Any],
*, *,
@@ -762,10 +462,3 @@ def _draft_repair_hint(
f"wf draft bind-output-to-state {workspace_id} --revision {revision} " f"wf draft bind-output-to-state {workspace_id} --revision {revision} "
f"--step {step_id} --output {output_field} --state {state_path}" f"--step {step_id} --output {output_field} --state {state_path}"
) )
def _state_root_field(value: str) -> str:
path = StatePath.parse(value)
if len(path.parts) != 1:
raise ValueError("state_path must name one root field, such as state.after")
return path.parts[0]
+50 -24
View File
@@ -8,6 +8,7 @@ from wf_artifacts import ArtifactKind
from .artifacts import WorkflowArtifactApi from .artifacts import WorkflowArtifactApi
from .capabilities import WorkflowCapabilityApi from .capabilities import WorkflowCapabilityApi
from .deployments import WorkflowDeploymentApi from .deployments import WorkflowDeploymentApi
from .draft_authoring import WorkflowDraftAuthoringApi
from .drafts import WorkflowDraftApi from .drafts import WorkflowDraftApi
from .models import RawWorkflowPlan from .models import RawWorkflowPlan
from .operation_context import WorkflowOperationContext from .operation_context import WorkflowOperationContext
@@ -26,6 +27,7 @@ class WorkflowApi:
self.context = context self.context = context
self.capabilities = WorkflowCapabilityApi(context) self.capabilities = WorkflowCapabilityApi(context)
self.drafts = WorkflowDraftApi(context) self.drafts = WorkflowDraftApi(context)
self.draft_authoring = WorkflowDraftAuthoringApi(context, self.drafts)
self.artifacts = WorkflowArtifactApi(context) self.artifacts = WorkflowArtifactApi(context)
self.deployments = WorkflowDeploymentApi(context) self.deployments = WorkflowDeploymentApi(context)
self.runs = WorkflowRunApi(context) self.runs = WorkflowRunApi(context)
@@ -285,6 +287,13 @@ class WorkflowApi:
) -> dict[str, Any]: ) -> dict[str, Any]:
return await self.drafts.validate_draft_workspace(workspace_id=workspace_id) return await self.drafts.validate_draft_workspace(workspace_id=workspace_id)
async def compile_draft_workspace(
self,
*,
workspace_id: str,
) -> dict[str, Any]:
return await self.drafts.compile_draft_workspace(workspace_id=workspace_id)
async def patch_draft_workspace( async def patch_draft_workspace(
self, self,
*, *,
@@ -362,23 +371,6 @@ class WorkflowApi:
merge=merge, merge=merge,
) )
async def add_state_schema_from_output(
self,
*,
workspace_id: str,
revision: int,
step_id: str,
output_field: str,
state_path: str,
) -> dict[str, Any]:
return await self.drafts.add_state_schema_from_output(
workspace_id=workspace_id,
revision=revision,
step_id=step_id,
output_field=output_field,
state_path=state_path,
)
async def bind_output_to_state( async def bind_output_to_state(
self, self,
*, *,
@@ -388,7 +380,7 @@ class WorkflowApi:
output_field: str, output_field: str,
state_path: str, state_path: str,
) -> dict[str, Any]: ) -> dict[str, Any]:
return await self.drafts.bind_output_to_state( return await self.draft_authoring.bind_output_to_state(
workspace_id=workspace_id, workspace_id=workspace_id,
revision=revision, revision=revision,
step_id=step_id, step_id=step_id,
@@ -405,24 +397,58 @@ class WorkflowApi:
capability_name: str, capability_name: str,
route_from_step: str | None = None, route_from_step: str | None = None,
route_from_outcome: str = "ok", route_from_outcome: str = "ok",
route_outcome: str = "ok", routes: dict[str, str] | None = None,
route_to: str = "__end__",
input_map: dict[str, str] | None = None, input_map: dict[str, str] | None = None,
bind_outputs: dict[str, str] | None = None, bind_outputs: dict[str, str] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
return await self.drafts.add_step_from_capability( return await self.draft_authoring.add_step_from_capability(
workspace_id=workspace_id, workspace_id=workspace_id,
revision=revision, revision=revision,
step_id=step_id, step_id=step_id,
capability_name=capability_name, capability_name=capability_name,
route_from_step=route_from_step, route_from_step=route_from_step,
route_from_outcome=route_from_outcome, route_from_outcome=route_from_outcome,
route_outcome=route_outcome, routes=routes,
route_to=route_to,
input_map=input_map, input_map=input_map,
bind_outputs=bind_outputs, bind_outputs=bind_outputs,
) )
async def branch_draft(
self,
*,
workspace_id: str,
revision: int,
step_id: str,
routes: dict[str, str],
) -> dict[str, Any]:
return await self.draft_authoring.branch_draft(
workspace_id=workspace_id,
revision=revision,
step_id=step_id,
routes=routes,
)
async def handle_draft(
self,
*,
workspace_id: str,
revision: int,
branches: list[dict[str, str]],
target: str,
) -> dict[str, Any]:
from .draft_authoring import DraftOutcomeRef
refs = [
DraftOutcomeRef(step_id=b["step_id"], outcome=b["outcome"])
for b in branches
]
return await self.draft_authoring.handle_draft(
workspace_id=workspace_id,
revision=revision,
branches=refs,
target=target,
)
async def create_minimal_draft_workspace( async def create_minimal_draft_workspace(
self, self,
*, *,
@@ -439,7 +465,7 @@ class WorkflowApi:
error_message_source: Any | None = None, error_message_source: Any | None = None,
title: str | None = None, title: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
return await self.drafts.create_minimal_draft_workspace( return await self.draft_authoring.create_minimal_draft_workspace(
workspace_id=workspace_id, workspace_id=workspace_id,
name=name, name=name,
capability_name=capability_name, capability_name=capability_name,
+25 -12
View File
@@ -114,16 +114,6 @@ class WorkflowDraftSurface(Protocol):
merge: bool = False, merge: bool = False,
) -> dict[str, Any]: ... ) -> dict[str, Any]: ...
async def add_state_schema_from_output(
self,
*,
workspace_id: str,
revision: int,
step_id: str,
output_field: str,
state_path: str,
) -> dict[str, Any]: ...
async def bind_output_to_state( async def bind_output_to_state(
self, self,
*, *,
@@ -143,18 +133,41 @@ class WorkflowDraftSurface(Protocol):
capability_name: str, capability_name: str,
route_from_step: str | None = None, route_from_step: str | None = None,
route_from_outcome: str = "ok", route_from_outcome: str = "ok",
route_outcome: str = "ok", routes: dict[str, str] | None = None,
route_to: str = "__end__",
input_map: dict[str, str] | None = None, input_map: dict[str, str] | None = None,
bind_outputs: dict[str, str] | None = None, bind_outputs: dict[str, str] | None = None,
) -> dict[str, Any]: ... ) -> dict[str, Any]: ...
async def branch_draft(
self,
*,
workspace_id: str,
revision: int,
step_id: str,
routes: dict[str, str],
) -> dict[str, Any]: ...
async def handle_draft(
self,
*,
workspace_id: str,
revision: int,
branches: list[dict[str, str]],
target: str,
) -> dict[str, Any]: ...
async def validate_draft_workspace( async def validate_draft_workspace(
self, self,
*, *,
workspace_id: str, workspace_id: str,
) -> dict[str, Any]: ... ) -> dict[str, Any]: ...
async def compile_draft_workspace(
self,
*,
workspace_id: str,
) -> dict[str, Any]: ...
async def delete_draft_workspace( async def delete_draft_workspace(
self, self,
*, *,
+116 -51
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import json
from pathlib import Path from pathlib import Path
from typing import Annotated, Literal from typing import Annotated, Literal
@@ -277,47 +278,6 @@ def set_step_output_map(
) )
@app.command("add-state-from-output")
def add_state_from_output(
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.")],
output_field: Annotated[
str,
typer.Option("--output", help="Top-level capability output field."),
],
state_path: Annotated[
str,
typer.Option("--state", help="Root state path, for example state.after."),
],
) -> None:
"""Copy one capability output field schema into draft state_schema.
Use this before mapping a step output into a new state field. The command
reads the selected draft step's capability output schema, copies the
requested output property schema, and preserves local $defs/definitions so
JSON Schema refs remain valid.
Run `wf draft validate <workspace_id>` after adding state schema fields.
"""
context = load_cli_context(ctx)
emit_json(
run_cli_operation(
context,
context.handlers.add_state_schema_from_output(
workspace_id=workspace_id,
revision=revision,
step_id=step_id,
output_field=output_field,
state_path=state_path,
),
)
)
@app.command("bind-output-to-state") @app.command("bind-output-to-state")
def bind_output_to_state( def bind_output_to_state(
ctx: typer.Context, ctx: typer.Context,
@@ -381,14 +341,13 @@ def add_step_from_capability(
str, str,
typer.Option("--from-outcome", help="Outcome on --from-step."), typer.Option("--from-outcome", help="Outcome on --from-step."),
] = "ok", ] = "ok",
route_outcome: Annotated[ route: Annotated[
str, list[str] | None,
typer.Option("--outcome", help="Outcome emitted by the new step."), typer.Option(
] = "ok", "--route",
route_to: Annotated[ help="Route mapping OUTCOME=TARGET. Repeat for multiple outcomes.",
str, ),
typer.Option("--to", help="Target step id or __end__ for the new step."), ] = None,
] = "__end__",
input_mapping: Annotated[ input_mapping: Annotated[
list[str] | None, list[str] | None,
typer.Option( typer.Option(
@@ -414,6 +373,15 @@ def add_step_from_capability(
""" """
input_map = _parse_map_flags(input_mapping) input_map = _parse_map_flags(input_mapping)
bind_outputs = _parse_map_flags(output_mapping) bind_outputs = _parse_map_flags(output_mapping)
routes: dict[str, str] = {}
if route:
for r in route:
key, _, value = r.partition("=")
if not key or not value:
raise typer.BadParameter(
f"invalid route: {r!r} (expected OUTCOME=TARGET)"
)
routes[key] = value
context = load_cli_context(ctx) context = load_cli_context(ctx)
emit_json( emit_json(
run_cli_operation( run_cli_operation(
@@ -425,8 +393,7 @@ def add_step_from_capability(
capability_name=capability_name, capability_name=capability_name,
route_from_step=route_from_step, route_from_step=route_from_step,
route_from_outcome=route_from_outcome, route_from_outcome=route_from_outcome,
route_outcome=route_outcome, routes=routes or None,
route_to=route_to,
input_map=input_map, input_map=input_map,
bind_outputs=bind_outputs, bind_outputs=bind_outputs,
), ),
@@ -434,6 +401,86 @@ def add_step_from_capability(
) )
@app.command("branch")
def branch_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: Annotated[str, typer.Option("--step", help="Draft step id.")],
route: Annotated[
list[str] | None,
typer.Option(
"--route",
help="Route mapping OUTCOME=TARGET. Repeat for multiple outcomes.",
),
] = None,
) -> None:
"""Branch multiple outcome routes on a single step atomically."""
routes: dict[str, str] = {}
if route:
for r in route:
key, _, value = r.partition("=")
if not key or not value:
raise typer.BadParameter(
f"invalid route: {r!r} (expected OUTCOME=TARGET)"
)
routes[key] = value
context = load_cli_context(ctx)
emit_json(
run_cli_operation(
context,
context.handlers.branch_draft(
workspace_id=workspace_id,
revision=revision,
step_id=step,
routes=routes,
),
)
)
@app.command("handle")
def handle_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.")
],
to: Annotated[str, typer.Option("--to", help="Target step id or __end__.")],
branch: Annotated[
list[str] | None,
typer.Option(
"--branch",
help="Branch mapping STEP:OUTCOME. Repeat for multiple branches.",
),
] = None,
) -> None:
"""Set a common target for multiple step/outcome pairs atomically."""
branches: list[dict[str, str]] = []
if branch:
for b in branch:
parts = b.rsplit(":", 1)
if len(parts) != 2 or not parts[0] or not parts[1]:
raise typer.BadParameter(
f"invalid branch: {b!r} (expected STEP:OUTCOME)"
)
branches.append({"step_id": parts[0], "outcome": parts[1]})
context = load_cli_context(ctx)
emit_json(
run_cli_operation(
context,
context.handlers.handle_draft(
workspace_id=workspace_id,
revision=revision,
branches=branches,
target=to,
),
)
)
@app.command("validate") @app.command("validate")
def validate_draft( def validate_draft(
ctx: typer.Context, ctx: typer.Context,
@@ -449,6 +496,24 @@ def validate_draft(
) )
@app.command("compile")
def compile_draft(
ctx: typer.Context,
workspace_id: Annotated[str, typer.Argument(help="Draft workspace id.")],
) -> None:
"""Compile a stored draft workspace without mutating it."""
context = load_cli_context(ctx)
result = run_cli_operation(
context,
context.handlers.compile_draft_workspace(workspace_id=workspace_id),
)
if "compiled_plan" in result:
emit_json(result["compiled_plan"])
return
typer.echo(json.dumps(result, indent=2, sort_keys=True), err=True)
raise typer.Exit(1)
@app.command("delete") @app.command("delete")
def delete_draft( def delete_draft(
ctx: typer.Context, ctx: typer.Context,
+38 -21
View File
@@ -187,6 +187,12 @@ class ValidateDraftWorkspaceRequest(BaseModel):
workspace_id: WorkspaceId workspace_id: WorkspaceId
class CompileDraftWorkspaceRequest(BaseModel):
"""Typed MCP request for compiling one workspace draft without mutation."""
workspace_id: WorkspaceId
class SetDraftNameRequest(BaseModel): class SetDraftNameRequest(BaseModel):
"""Typed MCP request for changing the workflow draft name.""" """Typed MCP request for changing the workflow draft name."""
@@ -237,20 +243,6 @@ class SetStepOutputMapRequest(BaseModel):
) )
class AddStateFromOutputRequest(BaseModel):
"""Typed MCP request for declaring a state field from a step output schema."""
workspace_id: WorkspaceId
revision: int = Field(ge=1, description="Expected current workspace revision.")
step_id: str = Field(description="Draft step id whose capability output is used.")
output_field: str = Field(
description="Top-level output field to copy, for example after."
)
state_path: str = Field(
description="Root state path to declare, for example state.after."
)
class BindOutputToStateRequest(BaseModel): class BindOutputToStateRequest(BaseModel):
"""Typed MCP request for binding one step output to one root state field.""" """Typed MCP request for binding one step output to one root state field."""
@@ -280,13 +272,13 @@ class AddStepFromCapabilityRequest(BaseModel):
default="ok", default="ok",
description="Outcome on route_from_step that should route to the new step.", description="Outcome on route_from_step that should route to the new step.",
) )
route_outcome: str = Field( routes: dict[str, str] | None = Field(
default="ok", default=None,
description="Outcome emitted by the new step.", description=(
) "Outcome to target route map for the new step. If omitted, a single "
route_to: str = Field( "declared outcome routes to __end__; multiple declared outcomes "
default="__end__", "require explicit routes."
description="Target step id or __end__ for the new step outcome.", ),
) )
input_map: dict[str, str] = Field( input_map: dict[str, str] = Field(
default_factory=dict, default_factory=dict,
@@ -298,6 +290,31 @@ class AddStepFromCapabilityRequest(BaseModel):
) )
class BranchDraftRequest(BaseModel):
"""Typed MCP request for branching routes on a draft step."""
workspace_id: WorkspaceId
revision: int = Field(ge=1, description="Expected workspace revision.")
step_id: str = Field(description="Draft step id whose routes should be branched.")
routes: dict[str, str] = Field(description="Outcome to target step map.")
class HandleDraftBranchItem(BaseModel):
"""Single branch item for handle_draft."""
step_id: str = Field(description="Draft step id.")
outcome: str = Field(description="Outcome label.")
class HandleDraftRequest(BaseModel):
"""Typed MCP request for handling draft branches."""
workspace_id: WorkspaceId
revision: int = Field(ge=1, description="Expected workspace revision.")
branches: list[HandleDraftBranchItem] = Field(description="Branch items to handle.")
target: str = Field(description="Target step id or __end__.")
class DeleteDraftWorkspaceRequest(BaseModel): class DeleteDraftWorkspaceRequest(BaseModel):
"""Typed MCP request payload for deleting one draft workspace.""" """Typed MCP request payload for deleting one draft workspace."""
+56 -24
View File
@@ -12,10 +12,11 @@ from wf_mcp.broker.service import WfMcpService
from wf_mcp.broker.service.workflow_operation_context import context_from_service from wf_mcp.broker.service.workflow_operation_context import context_from_service
from .models import ( from .models import (
AddStateFromOutputRequest,
AddStepFromCapabilityRequest, AddStepFromCapabilityRequest,
BindOutputToStateRequest, BindOutputToStateRequest,
BranchDraftRequest,
CallCapabilityResult, CallCapabilityResult,
CompileDraftWorkspaceRequest,
CreateArtifactFromWorkspaceRequest, CreateArtifactFromWorkspaceRequest,
CreateDraftWorkspaceFromCapabilityRequest, CreateDraftWorkspaceFromCapabilityRequest,
CreateDraftWorkspaceFromCapabilityResult, CreateDraftWorkspaceFromCapabilityResult,
@@ -26,6 +27,7 @@ from .models import (
DeleteDraftWorkspaceResult, DeleteDraftWorkspaceResult,
DraftWorkspaceListResult, DraftWorkspaceListResult,
DraftWorkspaceResult, DraftWorkspaceResult,
HandleDraftRequest,
PatchDraftWorkspaceRequest, PatchDraftWorkspaceRequest,
RunDeploymentResult, RunDeploymentResult,
SetDraftNameRequest, SetDraftNameRequest,
@@ -368,6 +370,22 @@ def register_workflow_tools(server: FastMCP[Any], service: WfMcpService) -> None
await handlers.validate_draft_workspace(workspace_id=request.workspace_id) await handlers.validate_draft_workspace(workspace_id=request.workspace_id)
) )
@server.tool(
name="wf.workflow.compile_draft_workspace",
title="Compile Draft Workspace",
description=(
"Compile a stored draft workspace without mutating it. Returns "
"compiled_plan and required_capabilities when valid, or diagnostics "
"when invalid."
),
)
async def compile_draft_workspace(
request: CompileDraftWorkspaceRequest,
) -> dict[str, Any]:
return await handlers.compile_draft_workspace(
workspace_id=request.workspace_id,
)
@server.tool( @server.tool(
name="wf.workflow.set_draft_name", name="wf.workflow.set_draft_name",
title="Set Draft Name", title="Set Draft Name",
@@ -442,27 +460,6 @@ def register_workflow_tools(server: FastMCP[Any], service: WfMcpService) -> None
) )
) )
@server.tool(
name="wf.workflow.add_state_from_output",
title="Add State From Output",
description=(
"Declare one root state field by copying a draft step capability output "
"field schema, including local $defs/definitions when present."
),
)
async def add_state_from_output(
request: AddStateFromOutputRequest,
) -> DraftWorkspaceResult:
return DraftWorkspaceResult.model_validate(
await handlers.add_state_schema_from_output(
workspace_id=request.workspace_id,
revision=request.revision,
step_id=request.step_id,
output_field=request.output_field,
state_path=request.state_path,
)
)
@server.tool( @server.tool(
name="wf.workflow.bind_output_to_state", name="wf.workflow.bind_output_to_state",
title="Bind Output To State", title="Bind Output To State",
@@ -503,13 +500,48 @@ def register_workflow_tools(server: FastMCP[Any], service: WfMcpService) -> None
capability_name=request.capability_name, capability_name=request.capability_name,
route_from_step=request.route_from_step, route_from_step=request.route_from_step,
route_from_outcome=request.route_from_outcome, route_from_outcome=request.route_from_outcome,
route_outcome=request.route_outcome, routes=request.routes,
route_to=request.route_to,
input_map=request.input_map, input_map=request.input_map,
bind_outputs=request.bind_outputs, bind_outputs=request.bind_outputs,
) )
) )
@server.tool(
name="wf.workflow.branch_draft",
title="Branch Draft",
description=(
"Branch multiple outcome routes on a single draft step atomically. "
"Provided outcomes are updated while unspecified outcomes are preserved."
),
)
async def branch_draft(request: BranchDraftRequest) -> DraftWorkspaceResult:
return DraftWorkspaceResult.model_validate(
await handlers.branch_draft(
workspace_id=request.workspace_id,
revision=request.revision,
step_id=request.step_id,
routes=request.routes,
)
)
@server.tool(
name="wf.workflow.handle_draft",
title="Handle Draft",
description=("Set a common target for multiple step/outcome pairs atomically."),
)
async def handle_draft(request: HandleDraftRequest) -> DraftWorkspaceResult:
return DraftWorkspaceResult.model_validate(
await handlers.handle_draft(
workspace_id=request.workspace_id,
revision=request.revision,
branches=[
{"step_id": b.step_id, "outcome": b.outcome}
for b in request.branches
],
target=request.target,
)
)
@server.tool( @server.tool(
name="wf.workflow.create_minimal_draft_workspace", name="wf.workflow.create_minimal_draft_workspace",
title="Create Minimal Draft Workspace", title="Create Minimal Draft Workspace",
+10 -2
View File
@@ -6,13 +6,17 @@ from .errors import WorkflowRpcError
from .models import ( from .models import (
AddStepFromCapabilityParams, AddStepFromCapabilityParams,
AdminEmptyParams, AdminEmptyParams,
BranchDraftParams,
CallCapabilityParams, CallCapabilityParams,
CompileDraftWorkspaceParams,
CreateArtifactFromPlanParams, CreateArtifactFromPlanParams,
CreateArtifactFromWorkspaceParams, CreateArtifactFromWorkspaceParams,
CreateDraftFromCapabilityParams, CreateDraftFromCapabilityParams,
CreateWrapperFromWorkspaceParams, CreateWrapperFromWorkspaceParams,
DeleteDeploymentParams, DeleteDeploymentParams,
GetDraftWorkspaceParams, GetDraftWorkspaceParams,
HandleDraftBranch,
HandleDraftParams,
HealthParams, HealthParams,
InspectArtifactParams, InspectArtifactParams,
InspectCapabilityParams, InspectCapabilityParams,
@@ -42,15 +46,19 @@ from .models import (
) )
__all__ = [ __all__ = [
"CreateArtifactFromPlanParams",
"CreateArtifactFromWorkspaceParams",
"AddStepFromCapabilityParams", "AddStepFromCapabilityParams",
"AdminEmptyParams", "AdminEmptyParams",
"BranchDraftParams",
"CallCapabilityParams", "CallCapabilityParams",
"CompileDraftWorkspaceParams",
"CreateArtifactFromPlanParams",
"CreateArtifactFromWorkspaceParams",
"CreateDraftFromCapabilityParams", "CreateDraftFromCapabilityParams",
"CreateWrapperFromWorkspaceParams", "CreateWrapperFromWorkspaceParams",
"DeleteDeploymentParams", "DeleteDeploymentParams",
"GetDraftWorkspaceParams", "GetDraftWorkspaceParams",
"HandleDraftBranch",
"HandleDraftParams",
"HealthParams", "HealthParams",
"InspectArtifactParams", "InspectArtifactParams",
"InspectCapabilityParams", "InspectCapabilityParams",
+48 -24
View File
@@ -141,26 +141,6 @@ class RpcDraftClientMixin:
}, },
) )
async def add_state_schema_from_output(
self: RpcCaller,
*,
workspace_id: str,
revision: int,
step_id: str,
output_field: str,
state_path: str,
) -> dict[str, Any]:
return await self._call(
"workflow.draft_workspaces.add_state_from_output",
{
"workspace_id": workspace_id,
"revision": revision,
"step_id": step_id,
"output_field": output_field,
"state_path": state_path,
},
)
async def bind_output_to_state( async def bind_output_to_state(
self: RpcCaller, self: RpcCaller,
*, *,
@@ -190,8 +170,7 @@ class RpcDraftClientMixin:
capability_name: str, capability_name: str,
route_from_step: str | None = None, route_from_step: str | None = None,
route_from_outcome: str = "ok", route_from_outcome: str = "ok",
route_outcome: str = "ok", routes: dict[str, str] | None = None,
route_to: str = "__end__",
input_map: dict[str, str] | None = None, input_map: dict[str, str] | None = None,
bind_outputs: dict[str, str] | None = None, bind_outputs: dict[str, str] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
@@ -204,13 +183,48 @@ class RpcDraftClientMixin:
"capability_name": capability_name, "capability_name": capability_name,
"route_from_step": route_from_step, "route_from_step": route_from_step,
"route_from_outcome": route_from_outcome, "route_from_outcome": route_from_outcome,
"route_outcome": route_outcome, "routes": routes,
"route_to": route_to,
"input_map": input_map or {}, "input_map": input_map or {},
"bind_outputs": bind_outputs or {}, "bind_outputs": bind_outputs or {},
}, },
) )
async def branch_draft(
self: RpcCaller,
*,
workspace_id: str,
revision: int,
step_id: str,
routes: dict[str, str],
) -> dict[str, Any]:
return await self._call(
"workflow.draft_workspaces.branch",
{
"workspace_id": workspace_id,
"revision": revision,
"step_id": step_id,
"routes": routes,
},
)
async def handle_draft(
self: RpcCaller,
*,
workspace_id: str,
revision: int,
branches: list[dict[str, str]],
target: str,
) -> dict[str, Any]:
return await self._call(
"workflow.draft_workspaces.handle",
{
"workspace_id": workspace_id,
"revision": revision,
"branches": branches,
"target": target,
},
)
async def validate_draft_workspace( async def validate_draft_workspace(
self: RpcCaller, self: RpcCaller,
*, *,
@@ -221,6 +235,16 @@ class RpcDraftClientMixin:
{"workspace_id": workspace_id}, {"workspace_id": workspace_id},
) )
async def compile_draft_workspace(
self: RpcCaller,
*,
workspace_id: str,
) -> dict[str, Any]:
return await self._call(
"workflow.draft_workspaces.compile",
{"workspace_id": workspace_id},
)
async def delete_draft_workspace( async def delete_draft_workspace(
self: RpcCaller, self: RpcCaller,
*, *,
+52 -21
View File
@@ -8,14 +8,16 @@ 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 (
AddStateFromOutputParams,
AddStepFromCapabilityParams, AddStepFromCapabilityParams,
BindOutputToStateParams, BindOutputToStateParams,
BranchDraftParams,
CompileDraftWorkspaceParams,
CreateArtifactFromWorkspaceParams, CreateArtifactFromWorkspaceParams,
CreateDraftFromCapabilityParams, CreateDraftFromCapabilityParams,
CreateWrapperFromWorkspaceParams, CreateWrapperFromWorkspaceParams,
DeleteDraftWorkspaceParams, DeleteDraftWorkspaceParams,
GetDraftWorkspaceParams, GetDraftWorkspaceParams,
HandleDraftParams,
ListDraftWorkspacesParams, ListDraftWorkspacesParams,
PatchDraftParams, PatchDraftParams,
PatchDraftWorkspaceParams, PatchDraftWorkspaceParams,
@@ -180,24 +182,6 @@ def register_methods(
except (ValueError, KeyError, LookupError, FileNotFoundError) as exc: except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
raise_workflow_rpc_error(exc) raise_workflow_rpc_error(exc)
@entrypoint.method(
name="workflow.draft_workspaces.add_state_from_output",
errors=[WorkflowRpcError],
)
async def workflow_draft_workspaces_add_state_from_output(
params: AddStateFromOutputParams = RpcParams(),
) -> dict[str, Any]:
try:
return await server.api.add_state_schema_from_output(
workspace_id=params.workspace_id,
revision=params.revision,
step_id=params.step_id,
output_field=params.output_field,
state_path=params.state_path,
)
except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
raise_workflow_rpc_error(exc)
@entrypoint.method( @entrypoint.method(
name="workflow.draft_workspaces.bind_output_to_state", name="workflow.draft_workspaces.bind_output_to_state",
errors=[WorkflowRpcError], errors=[WorkflowRpcError],
@@ -231,14 +215,48 @@ def register_methods(
capability_name=params.capability_name, capability_name=params.capability_name,
route_from_step=params.route_from_step, route_from_step=params.route_from_step,
route_from_outcome=params.route_from_outcome, route_from_outcome=params.route_from_outcome,
route_outcome=params.route_outcome, routes=params.routes,
route_to=params.route_to,
input_map=params.input_map, input_map=params.input_map,
bind_outputs=params.bind_outputs, bind_outputs=params.bind_outputs,
) )
except (ValueError, KeyError, LookupError, FileNotFoundError) as exc: except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
raise_workflow_rpc_error(exc) raise_workflow_rpc_error(exc)
@entrypoint.method(
name="workflow.draft_workspaces.branch", errors=[WorkflowRpcError]
)
async def workflow_draft_workspaces_branch(
params: BranchDraftParams = RpcParams(),
) -> dict[str, Any]:
try:
return await server.api.branch_draft(
workspace_id=params.workspace_id,
revision=params.revision,
step_id=params.step_id,
routes=params.routes,
)
except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
raise_workflow_rpc_error(exc)
@entrypoint.method(
name="workflow.draft_workspaces.handle", errors=[WorkflowRpcError]
)
async def workflow_draft_workspaces_handle(
params: HandleDraftParams = RpcParams(),
) -> dict[str, Any]:
try:
return await server.api.handle_draft(
workspace_id=params.workspace_id,
revision=params.revision,
branches=[
{"step_id": b.step_id, "outcome": b.outcome}
for b in params.branches
],
target=params.target,
)
except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
raise_workflow_rpc_error(exc)
@entrypoint.method( @entrypoint.method(
name="workflow.draft_workspaces.validate", errors=[WorkflowRpcError] name="workflow.draft_workspaces.validate", errors=[WorkflowRpcError]
) )
@@ -252,6 +270,19 @@ def register_methods(
except (ValueError, KeyError, LookupError, FileNotFoundError) as exc: except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
raise_workflow_rpc_error(exc) raise_workflow_rpc_error(exc)
@entrypoint.method(
name="workflow.draft_workspaces.compile", errors=[WorkflowRpcError]
)
async def workflow_draft_workspaces_compile(
params: CompileDraftWorkspaceParams = RpcParams(),
) -> dict[str, Any]:
try:
return await server.api.compile_draft_workspace(
workspace_id=params.workspace_id,
)
except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
raise_workflow_rpc_error(exc)
@entrypoint.method( @entrypoint.method(
name="workflow.draft_workspaces.delete", errors=[WorkflowRpcError] name="workflow.draft_workspaces.delete", errors=[WorkflowRpcError]
) )
+24 -10
View File
@@ -141,14 +141,6 @@ class SetStepOutputMapParams(RpcParamsModel):
merge: bool = False merge: bool = False
class AddStateFromOutputParams(RpcParamsModel):
workspace_id: str = Field(min_length=1)
revision: int = Field(ge=1)
step_id: str = Field(min_length=1)
output_field: str = Field(min_length=1)
state_path: str = Field(min_length=1)
class BindOutputToStateParams(RpcParamsModel): class BindOutputToStateParams(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)
@@ -164,16 +156,38 @@ class AddStepFromCapabilityParams(RpcParamsModel):
capability_name: str = Field(min_length=1) capability_name: str = Field(min_length=1)
route_from_step: str | None = None route_from_step: str | None = None
route_from_outcome: str = Field(default="ok", min_length=1) route_from_outcome: str = Field(default="ok", min_length=1)
route_outcome: str = Field(default="ok", min_length=1) routes: dict[str, str] | None = None
route_to: str = Field(default="__end__", min_length=1)
input_map: dict[str, str] = Field(default_factory=dict) input_map: dict[str, str] = Field(default_factory=dict)
bind_outputs: dict[str, str] = Field(default_factory=dict) bind_outputs: dict[str, str] = Field(default_factory=dict)
class BranchDraftParams(RpcParamsModel):
workspace_id: str = Field(min_length=1)
revision: int = Field(ge=1)
step_id: str = Field(min_length=1)
routes: dict[str, str]
class HandleDraftBranch(RpcParamsModel):
step_id: str = Field(min_length=1)
outcome: str = Field(min_length=1)
class HandleDraftParams(RpcParamsModel):
workspace_id: str = Field(min_length=1)
revision: int = Field(ge=1)
branches: list[HandleDraftBranch]
target: str = Field(min_length=1)
class ValidateDraftWorkspaceParams(RpcParamsModel): class ValidateDraftWorkspaceParams(RpcParamsModel):
workspace_id: str = Field(min_length=1) workspace_id: str = Field(min_length=1)
class CompileDraftWorkspaceParams(RpcParamsModel):
workspace_id: str = Field(min_length=1)
class DeleteDraftWorkspaceParams(RpcParamsModel): class DeleteDraftWorkspaceParams(RpcParamsModel):
workspace_id: str = Field(min_length=1) workspace_id: str = Field(min_length=1)
+306 -109
View File
@@ -7,7 +7,9 @@ import pytest
from pydantic import BaseModel from pydantic import BaseModel
from tests.wf_mcp.test_support import echo_tool from tests.wf_mcp.test_support import echo_tool
from wf_api.draft_authoring import DraftOutcomeRef, WorkflowDraftAuthoringApi
from wf_api.drafts import WorkflowDraftApi from wf_api.drafts import WorkflowDraftApi
from wf_api.service import WorkflowApi
from wf_artifacts import FileDraftWorkspaceStore, FileWorkflowArtifactStore from wf_artifacts import FileDraftWorkspaceStore, FileWorkflowArtifactStore
from wf_authoring import node from wf_authoring import node
from wf_mcp.broker import WfMcpService from wf_mcp.broker import WfMcpService
@@ -65,7 +67,7 @@ class _SnapshotInput(BaseModel):
pass pass
@node(name="snapshot_tool") @node(name="snapshot_tool", outcomes=("ok", "skipped"))
def _snapshot_tool(payload: _SnapshotInput) -> _SnapshotOutput: def _snapshot_tool(payload: _SnapshotInput) -> _SnapshotOutput:
return _SnapshotOutput(after=_Snapshot(clicked=True)) return _SnapshotOutput(after=_Snapshot(clicked=True))
@@ -74,7 +76,7 @@ def _draft_api(
artifact_store: FileWorkflowArtifactStore, artifact_store: FileWorkflowArtifactStore,
*, *,
register_echo: bool = False, register_echo: bool = False,
) -> tuple[WorkflowDraftApi, WfMcpService]: ) -> tuple[WorkflowDraftApi, WfMcpService, WorkflowDraftAuthoringApi]:
mcp_root = artifact_store.root / "drafts_mcp" / str(id(artifact_store)) mcp_root = artifact_store.root / "drafts_mcp" / str(id(artifact_store))
service = WfMcpService( service = WfMcpService(
store=FileStore(mcp_root), store=FileStore(mcp_root),
@@ -87,13 +89,17 @@ def _draft_api(
) )
service.register_specs("demo.personal", echo_tool) service.register_specs("demo.personal", echo_tool)
context = context_from_service(service) context = context_from_service(service)
return WorkflowDraftApi(context), service return (
WorkflowDraftApi(context),
service,
WorkflowDraftAuthoringApi(context, WorkflowDraftApi(context)),
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_patch_draft_applies_json_patch(tmp_path: Path) -> None: async def test_patch_draft_applies_json_patch(tmp_path: Path) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_patch") artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_patch")
api, _service = _draft_api(artifact_store, register_echo=True) api, _service, _authoring = _draft_api(artifact_store, register_echo=True)
result = await api.patch_draft( result = await api.patch_draft(
draft=_echo_draft(), draft=_echo_draft(),
@@ -116,7 +122,7 @@ async def test_patch_draft_applies_json_patch(tmp_path: Path) -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_draft_workspace_creates_workspace(tmp_path: Path) -> None: async def test_create_draft_workspace_creates_workspace(tmp_path: Path) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_create_workspace") artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_create_workspace")
api, _service = _draft_api(artifact_store) api, _service, _authoring = _draft_api(artifact_store)
result = await api.create_draft_workspace( result = await api.create_draft_workspace(
workspace_id="echo_ws", workspace_id="echo_ws",
@@ -138,7 +144,7 @@ async def test_list_draft_workspaces_returns_sorted_summaries_without_drafts(
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_list_workspaces") artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_list_workspaces")
api, _service = _draft_api(artifact_store) api, _service, _authoring = _draft_api(artifact_store)
await api.create_draft_workspace( await api.create_draft_workspace(
workspace_id="b_draft", workspace_id="b_draft",
title="B Draft", title="B Draft",
@@ -164,7 +170,7 @@ async def test_list_draft_workspaces_returns_sorted_summaries_without_drafts(
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delete_draft_workspace_is_idempotent(tmp_path: Path) -> None: async def test_delete_draft_workspace_is_idempotent(tmp_path: Path) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_delete_workspace") artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_delete_workspace")
api, _service = _draft_api(artifact_store) api, _service, _authoring = _draft_api(artifact_store)
await api.create_draft_workspace( await api.create_draft_workspace(
workspace_id="echo_ws", workspace_id="echo_ws",
draft=_echo_draft(), draft=_echo_draft(),
@@ -186,7 +192,7 @@ async def test_delete_draft_workspace_is_idempotent(tmp_path: Path) -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_patch_draft_workspace_updates_revision(tmp_path: Path) -> None: async def test_patch_draft_workspace_updates_revision(tmp_path: Path) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_patch_workspace") artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_patch_workspace")
api, _service = _draft_api(artifact_store, register_echo=True) api, _service, _authoring = _draft_api(artifact_store, register_echo=True)
await api.create_draft_workspace( await api.create_draft_workspace(
workspace_id="echo_ws", workspace_id="echo_ws",
draft=_echo_draft(), draft=_echo_draft(),
@@ -207,7 +213,7 @@ async def test_draft_workspace_patch_helpers_update_revision_and_bindings(
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_patch_helpers") artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_patch_helpers")
api, _service = _draft_api(artifact_store) api, _service, _authoring = _draft_api(artifact_store)
await api.create_draft_workspace( await api.create_draft_workspace(
workspace_id="echo_ws", workspace_id="echo_ws",
draft=_echo_draft(), draft=_echo_draft(),
@@ -263,7 +269,7 @@ async def test_draft_workspace_patch_helpers_update_revision_and_bindings(
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_step_map_helpers_merge_with_existing_bindings(tmp_path: Path) -> None: async def test_step_map_helpers_merge_with_existing_bindings(tmp_path: Path) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_patch_helper_merge") artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_patch_helper_merge")
api, _service = _draft_api(artifact_store) api, _service, _authoring = _draft_api(artifact_store)
await api.create_draft_workspace( await api.create_draft_workspace(
workspace_id="echo_ws", workspace_id="echo_ws",
draft=_echo_draft(), draft=_echo_draft(),
@@ -315,7 +321,7 @@ async def test_step_map_helpers_merge_with_existing_bindings(tmp_path: Path) ->
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_validate_draft_workspace_refreshes_status(tmp_path: Path) -> None: async def test_validate_draft_workspace_refreshes_status(tmp_path: Path) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_validate_workspace") artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_validate_workspace")
api, service = _draft_api(artifact_store, register_echo=True) api, service, authoring = _draft_api(artifact_store, register_echo=True)
draft = _echo_draft() draft = _echo_draft()
draft["routes"]["echo"] = {"typo": "__end__"} draft["routes"]["echo"] = {"typo": "__end__"}
await api.create_draft_workspace( await api.create_draft_workspace(
@@ -340,7 +346,7 @@ async def test_validate_draft_workspace_suggests_bind_output_to_state(
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_repair_hint") artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_repair_hint")
api, service = _draft_api(artifact_store) api, service, authoring = _draft_api(artifact_store)
service.register_connection( service.register_connection(
ConnectionConfig(id="demo.personal", server="demo", account="personal") ConnectionConfig(id="demo.personal", server="demo", account="personal")
) )
@@ -385,7 +391,7 @@ async def test_patch_draft_workspace_validates_new_use_step_with_context_specs(
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_patch_new_use") artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_patch_new_use")
api, service = _draft_api(artifact_store, register_echo=True) api, service, authoring = _draft_api(artifact_store, register_echo=True)
service.register_specs("demo.personal", echo_tool, _snapshot_tool) service.register_specs("demo.personal", echo_tool, _snapshot_tool)
await api.create_draft_workspace( await api.create_draft_workspace(
workspace_id="echo_ws", workspace_id="echo_ws",
@@ -438,9 +444,9 @@ async def test_create_minimal_draft_workspace_minimal_success_path(
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_minimal_workspace") artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_minimal_workspace")
api, _service = _draft_api(artifact_store, register_echo=True) api, _service, authoring = _draft_api(artifact_store, register_echo=True)
result = await api.create_minimal_draft_workspace( result = await authoring.create_minimal_draft_workspace(
workspace_id="echo_minimal", workspace_id="echo_minimal",
name="echo", name="echo",
capability_name="demo.personal.echo_tool", capability_name="demo.personal.echo_tool",
@@ -498,92 +504,40 @@ async def test_delegation_smoke_validate_draft_equivalence(tmp_path: Path) -> No
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_state_schema_from_output_copies_output_property_defs( async def test_facade_delegates_semantic_authoring_to_authoring_service(
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_state_from_output") """WorkflowApi constructs a sibling WorkflowDraftAuthoringApi."""
api, service = _draft_api(artifact_store) artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_facade_delegation")
mcp_root = artifact_store.root / "facade_mcp"
service = WfMcpService(
store=FileStore(mcp_root),
artifact_store=artifact_store,
draft_workspace_store=FileDraftWorkspaceStore(mcp_root),
)
service.register_connection( service.register_connection(
ConnectionConfig(id="demo.personal", server="demo", account="personal") ConnectionConfig(id="demo.personal", server="demo", account="personal")
) )
service.register_specs("demo.personal", _snapshot_tool) service.register_specs("demo.personal", echo_tool, _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__"}},
},
)
updated = await api.add_state_schema_from_output( context = context_from_service(service)
workspace_id="snapshot_ws", facade = WorkflowApi(context)
assert facade.draft_authoring is not None
assert isinstance(facade.draft_authoring, WorkflowDraftAuthoringApi)
await facade.create_draft_workspace(
workspace_id="ws1",
draft=_echo_draft(),
)
result = await facade.bind_output_to_state(
workspace_id="ws1",
revision=1, revision=1,
step_id="snap", step_id="echo",
output_field="after", output_field="echoed",
state_path="state.after", state_path="state.echoed",
)
fetched = await api.get_draft_workspace(
workspace_id="snapshot_ws",
include_draft=True,
)
state_schema = fetched["draft"]["state_schema"]
assert updated["revision"] == 2
assert state_schema["properties"]["after"]["$ref"] == "#/$defs/_Snapshot"
assert state_schema["$defs"]["_Snapshot"]["properties"]["clicked"] == {
"title": "Clicked",
"type": "boolean",
}
@pytest.mark.asyncio
async def test_add_state_schema_from_output_rejects_nested_state_path(
tmp_path: Path,
) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_nested_state_output")
api, service = _draft_api(artifact_store)
service.register_connection(
ConnectionConfig(id="demo.personal", server="demo", account="personal")
)
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__"}},
},
)
with pytest.raises(ValueError, match="state_path must name one root field"):
await api.add_state_schema_from_output(
workspace_id="snapshot_ws",
revision=1,
step_id="snap",
output_field="after",
state_path="state.after.clicked",
) )
assert result["revision"] == 2
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -591,7 +545,7 @@ async def test_bind_output_to_state_projects_schema_and_merges_output_map(
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_bind_output_state") artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_bind_output_state")
api, service = _draft_api(artifact_store) api, service, authoring = _draft_api(artifact_store)
service.register_connection( service.register_connection(
ConnectionConfig(id="demo.personal", server="demo", account="personal") ConnectionConfig(id="demo.personal", server="demo", account="personal")
) )
@@ -623,7 +577,7 @@ async def test_bind_output_to_state_projects_schema_and_merges_output_map(
}, },
) )
updated = await api.bind_output_to_state( updated = await authoring.bind_output_to_state(
workspace_id="snapshot_ws", workspace_id="snapshot_ws",
revision=1, revision=1,
step_id="snap", step_id="snap",
@@ -657,7 +611,7 @@ async def test_bind_output_to_state_projects_schema_and_merges_output_map(
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_bind_output_to_state_rejects_nested_state_path(tmp_path: Path) -> None: async def test_bind_output_to_state_rejects_nested_state_path(tmp_path: Path) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_bind_nested_state") artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_bind_nested_state")
api, service = _draft_api(artifact_store) api, service, authoring = _draft_api(artifact_store)
service.register_connection( service.register_connection(
ConnectionConfig(id="demo.personal", server="demo", account="personal") ConnectionConfig(id="demo.personal", server="demo", account="personal")
) )
@@ -682,7 +636,7 @@ async def test_bind_output_to_state_rejects_nested_state_path(tmp_path: Path) ->
) )
with pytest.raises(ValueError, match="state_path must name one root field"): with pytest.raises(ValueError, match="state_path must name one root field"):
await api.bind_output_to_state( await authoring.bind_output_to_state(
workspace_id="snapshot_ws", workspace_id="snapshot_ws",
revision=1, revision=1,
step_id="snap", step_id="snap",
@@ -696,7 +650,7 @@ async def test_bind_output_to_state_rejects_missing_output_field(
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_bind_missing_output") artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_bind_missing_output")
api, service = _draft_api(artifact_store) api, service, authoring = _draft_api(artifact_store)
service.register_connection( service.register_connection(
ConnectionConfig(id="demo.personal", server="demo", account="personal") ConnectionConfig(id="demo.personal", server="demo", account="personal")
) )
@@ -721,7 +675,7 @@ async def test_bind_output_to_state_rejects_missing_output_field(
) )
with pytest.raises(ValueError, match="output field 'missing'"): with pytest.raises(ValueError, match="output field 'missing'"):
await api.bind_output_to_state( await authoring.bind_output_to_state(
workspace_id="snapshot_ws", workspace_id="snapshot_ws",
revision=1, revision=1,
step_id="snap", step_id="snap",
@@ -735,7 +689,7 @@ async def test_bind_output_to_state_rejects_step_without_capability_use(
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_bind_no_use") artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_bind_no_use")
api, _service = _draft_api(artifact_store) api, _service, authoring = _draft_api(artifact_store)
await api.create_draft_workspace( await api.create_draft_workspace(
workspace_id="snapshot_ws", workspace_id="snapshot_ws",
draft={ draft={
@@ -755,7 +709,7 @@ async def test_bind_output_to_state_rejects_step_without_capability_use(
) )
with pytest.raises(ValueError, match="does not declare a capability use"): with pytest.raises(ValueError, match="does not declare a capability use"):
await api.bind_output_to_state( await authoring.bind_output_to_state(
workspace_id="snapshot_ws", workspace_id="snapshot_ws",
revision=1, revision=1,
step_id="snap", step_id="snap",
@@ -769,22 +723,21 @@ async def test_add_step_from_capability_wires_route_inputs_and_state_outputs(
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_add_step") artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_add_step")
api, service = _draft_api(artifact_store, register_echo=True) api, service, authoring = _draft_api(artifact_store, register_echo=True)
service.register_specs("demo.personal", echo_tool, _snapshot_tool) service.register_specs("demo.personal", echo_tool, _snapshot_tool)
await api.create_draft_workspace( await api.create_draft_workspace(
workspace_id="echo_ws", workspace_id="echo_ws",
draft=_echo_draft(), draft=_echo_draft(),
) )
result = await api.add_step_from_capability( result = await authoring.add_step_from_capability(
workspace_id="echo_ws", workspace_id="echo_ws",
revision=1, revision=1,
step_id="snap", step_id="snap",
capability_name="demo.personal.snapshot_tool", capability_name="demo.personal.snapshot_tool",
route_from_step="echo", route_from_step="echo",
route_from_outcome="ok", route_from_outcome="ok",
route_outcome="ok", routes={"ok": "__end__", "skipped": "__end__"},
route_to="__end__",
input_map={}, input_map={},
bind_outputs={"after": "state.after"}, bind_outputs={"after": "state.after"},
) )
@@ -814,22 +767,266 @@ async def test_add_step_from_capability_rejects_existing_step_id(
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_add_step_duplicate") artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_add_step_duplicate")
api, _service = _draft_api(artifact_store, register_echo=True) api, _service, authoring = _draft_api(artifact_store, register_echo=True)
await api.create_draft_workspace( await api.create_draft_workspace(
workspace_id="echo_ws", workspace_id="echo_ws",
draft=_echo_draft(), draft=_echo_draft(),
) )
with pytest.raises(ValueError, match="draft step 'echo' already exists"): with pytest.raises(ValueError, match="draft step 'echo' already exists"):
await api.add_step_from_capability( await authoring.add_step_from_capability(
workspace_id="echo_ws", workspace_id="echo_ws",
revision=1, revision=1,
step_id="echo", step_id="echo",
capability_name="demo.personal.echo_tool", capability_name="demo.personal.echo_tool",
route_from_step=None, route_from_step=None,
route_from_outcome="ok", route_from_outcome="ok",
route_outcome="ok", routes={"ok": "__end__"},
route_to="__end__",
input_map={}, input_map={},
bind_outputs={}, bind_outputs={},
) )
@pytest.mark.asyncio
async def test_branch_draft_updates_routes_atomically(tmp_path: Path) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_branch")
api, _service, authoring = _draft_api(artifact_store, register_echo=True)
await api.create_draft_workspace(
workspace_id="branching",
draft={
"name": "branching",
"input_schema": {"type": "object", "properties": {}},
"state_schema": {"type": "object", "properties": {}},
"output_schema": {"type": "object", "properties": {}},
"start": "classify",
"steps": {
"classify": {
"use": "demo.personal.echo_tool",
"input": [],
"output": [],
},
"tool_error": {
"use": "demo.personal.echo_tool",
"input": [],
"output": [],
},
},
"routes": {
"classify": {"ok": "classify"},
"tool_error": {"ok": "__end__"},
},
},
)
result = await authoring.branch_draft(
workspace_id="branching",
revision=1,
step_id="classify",
routes={"ok": "classify", "error": "tool_error"},
)
assert result["revision"] == 2
workspace = await api.get_draft_workspace(
workspace_id="branching", include_draft=True
)
assert workspace["draft"]["routes"]["classify"] == {
"ok": "classify",
"error": "tool_error",
}
assert "tool_error" in workspace["draft"]["steps"]
@pytest.mark.asyncio
async def test_handle_draft_updates_multiple_source_outcomes(tmp_path: Path) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_handle")
api, _service, authoring = _draft_api(artifact_store, register_echo=True)
await api.create_draft_workspace(
workspace_id="handling",
draft={
"name": "handling",
"input_schema": {"type": "object", "properties": {}},
"state_schema": {"type": "object", "properties": {}},
"output_schema": {"type": "object", "properties": {}},
"start": "lookup",
"steps": {
"lookup": {
"use": "demo.personal.echo_tool",
"input": [],
"output": [],
},
"transform": {
"use": "demo.personal.echo_tool",
"input": [],
"output": [],
},
},
"routes": {
"lookup": {"ok": "transform", "error": "lookup"},
"transform": {"ok": "__end__", "error": "transform"},
},
},
)
result = await authoring.handle_draft(
workspace_id="handling",
revision=1,
branches=[
DraftOutcomeRef(step_id="lookup", outcome="error"),
DraftOutcomeRef(step_id="transform", outcome="error"),
],
target="__end__",
)
assert result["revision"] == 2
workspace = await api.get_draft_workspace(
workspace_id="handling", include_draft=True
)
assert workspace["draft"]["routes"]["lookup"]["error"] == "__end__"
assert workspace["draft"]["routes"]["transform"]["error"] == "__end__"
assert workspace["draft"]["routes"]["lookup"]["ok"] == "transform"
assert workspace["draft"]["routes"]["transform"]["ok"] == "__end__"
@pytest.mark.asyncio
async def test_branch_draft_no_change_when_routes_unchanged(tmp_path: Path) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_branch_noop")
api, _service, authoring = _draft_api(artifact_store, register_echo=True)
await api.create_draft_workspace(
workspace_id="noop_ws",
draft=_echo_draft(),
)
before = await api.get_draft_workspace(workspace_id="noop_ws", include_draft=True)
result = await authoring.branch_draft(
workspace_id="noop_ws",
revision=1,
step_id="echo",
routes={"ok": "__end__"},
)
after = await api.get_draft_workspace(workspace_id="noop_ws", include_draft=True)
assert result["revision"] == 1
assert after == before
@pytest.mark.asyncio
async def test_handle_draft_empty_branches_noop(tmp_path: Path) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_handle_noop")
api, _service, authoring = _draft_api(artifact_store, register_echo=True)
await api.create_draft_workspace(
workspace_id="noop_ws",
draft=_echo_draft(),
)
before = await api.get_draft_workspace(workspace_id="noop_ws", include_draft=True)
result = await authoring.handle_draft(
workspace_id="noop_ws",
revision=1,
branches=[],
target="fail",
)
after = await api.get_draft_workspace(workspace_id="noop_ws", include_draft=True)
assert result["revision"] == 1
assert after == before
@pytest.mark.asyncio
async def test_add_step_from_capability_infers_single_outcome_route(
tmp_path: Path,
) -> None:
"""One declared outcome named 'done', no routes supplied -> routes to __end__."""
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_single_outcome")
api, service, authoring = _draft_api(artifact_store, register_echo=True)
service.register_specs("demo.personal", echo_tool)
await api.create_draft_workspace(
workspace_id="single",
draft=_echo_draft(),
)
result = await authoring.add_step_from_capability(
workspace_id="single",
revision=1,
step_id="done_step",
capability_name="demo.personal.echo_tool",
)
assert result["revision"] == 2
workspace = await api.get_draft_workspace(workspace_id="single", include_draft=True)
assert workspace["draft"]["routes"]["done_step"] == {"ok": "__end__"}
@pytest.mark.asyncio
async def test_add_step_from_capability_requires_complete_routes_for_multi_outcome(
tmp_path: Path,
) -> None:
"""Multiple declared outcomes with incomplete explicit routes raises ValueError."""
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_multi_outcome")
api, service, authoring = _draft_api(artifact_store, register_echo=True)
service.register_specs("demo.personal", echo_tool, _snapshot_tool)
await api.create_draft_workspace(
workspace_id="multi",
draft=_echo_draft(),
)
with pytest.raises(ValueError, match="missing routes"):
await authoring.add_step_from_capability(
workspace_id="multi",
revision=1,
step_id="snap",
capability_name="demo.personal.snapshot_tool",
routes={"ok": "__end__"},
)
@pytest.mark.asyncio
async def test_add_step_from_capability_rejects_unknown_routes_for_multi_outcome(
tmp_path: Path,
) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_unknown_outcome")
api, service, authoring = _draft_api(artifact_store, register_echo=True)
service.register_specs("demo.personal", _snapshot_tool)
await api.create_draft_workspace(
workspace_id="unknown_multi",
draft=_echo_draft(),
)
with pytest.raises(ValueError, match="unknown routes"):
await authoring.add_step_from_capability(
workspace_id="unknown_multi",
revision=1,
step_id="snap",
capability_name="demo.personal.snapshot_tool",
routes={"ok": "__end__", "skipped": "__end__", "typo": "__end__"},
)
@pytest.mark.asyncio
async def test_compile_draft_workspace_returns_compiled_plan(tmp_path: Path) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_compile")
api, _service, _authoring = _draft_api(artifact_store, register_echo=True)
await api.create_draft_workspace(
workspace_id="compile_me",
draft=_echo_draft(),
)
before = await api.get_draft_workspace(
workspace_id="compile_me", include_draft=True
)
result = await api.compile_draft_workspace(workspace_id="compile_me")
after = await api.get_draft_workspace(workspace_id="compile_me", include_draft=True)
assert result["compiled_plan"]["name"] == "echo"
assert result["required_capabilities"]
assert after == before
@pytest.mark.asyncio
async def test_compile_draft_workspace_invalid_returns_diagnostics(
tmp_path: Path,
) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_compile_invalid")
api, _service, _authoring = _draft_api(artifact_store, register_echo=True)
draft = _echo_draft()
draft["routes"]["echo"] = {"typo": "__end__"}
await api.create_draft_workspace(
workspace_id="invalid_ws",
draft=draft,
)
result = await api.compile_draft_workspace(workspace_id="invalid_ws")
assert result["status"] == "invalid"
assert "compiled_plan" not in result
assert result["diagnostics"]
-10
View File
@@ -144,16 +144,6 @@ 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_add_state_from_output_help_explains_schema_copy() -> None:
result = runner.invoke(app, ["draft", "add-state-from-output", "--help"])
assert result.exit_code == 0
help_text = " ".join(result.output.split())
assert "capability output field schema" in help_text
assert "$defs" in help_text
assert "draft validate" in help_text
def test_wf_draft_bind_output_to_state_help_explains_composed_edit() -> None: def test_wf_draft_bind_output_to_state_help_explains_composed_edit() -> None:
result = runner.invoke(app, ["draft", "bind-output-to-state", "--help"]) result = runner.invoke(app, ["draft", "bind-output-to-state", "--help"])
+89 -31
View File
@@ -1101,23 +1101,6 @@ def test_wf_draft_focused_edit_commands_use_rpc_target(monkeypatch, tmp_path) ->
"--merge", "--merge",
], ],
) )
state_added = runner.invoke(
app,
[
*base_args,
"draft",
"add-state-from-output",
"focused_ws",
"--revision",
"7",
"--step",
"call",
"--output",
"value",
"--state",
"state.extra_value",
],
)
inspected = runner.invoke( inspected = runner.invoke(
app, app,
[*base_args, "draft", "inspect", "focused_ws", "--include-draft"], [*base_args, "draft", "inspect", "focused_ws", "--include-draft"],
@@ -1129,7 +1112,6 @@ def test_wf_draft_focused_edit_commands_use_rpc_target(monkeypatch, tmp_path) ->
assert output_mapped.exit_code == 0, output_mapped.output assert output_mapped.exit_code == 0, output_mapped.output
assert input_merged.exit_code == 0, input_merged.output assert input_merged.exit_code == 0, input_merged.output
assert output_merged.exit_code == 0, output_merged.output assert output_merged.exit_code == 0, output_merged.output
assert state_added.exit_code == 0, state_added.output
assert inspected.exit_code == 0, inspected.output assert inspected.exit_code == 0, inspected.output
payload = json.loads(inspected.output) payload = json.loads(inspected.output)
draft = payload["draft"] draft = payload["draft"]
@@ -1137,25 +1119,24 @@ def test_wf_draft_focused_edit_commands_use_rpc_target(monkeypatch, tmp_path) ->
assert draft["routes"]["call"]["ok"] == "__end__" assert draft["routes"]["call"]["ok"] == "__end__"
assert draft["steps"]["call"]["input"] == [ assert draft["steps"]["call"]["input"] == [
{ {
"target": {"root": "local", "parts": ["value"]}, "target": "value",
"path": {"root": "input", "parts": ["value"]}, "path": "input.value",
}, },
{ {
"target": {"root": "local", "parts": ["extra"]}, "target": "extra",
"path": {"root": "input", "parts": ["extra"]}, "path": "input.extra",
}, },
] ]
assert draft["steps"]["call"]["output"] == [ assert draft["steps"]["call"]["output"] == [
{ {
"source": {"root": "local", "parts": ["value"]}, "source": "value",
"target": {"root": "state", "parts": ["value"]}, "target": "state.value",
}, },
{ {
"source": {"root": "local", "parts": ["extra"]}, "source": "extra",
"target": {"root": "state", "parts": ["extra"]}, "target": "state.extra",
}, },
] ]
assert "extra_value" in draft["state_schema"]["properties"]
def test_wf_draft_bind_output_to_state_uses_rpc_target(monkeypatch, tmp_path) -> None: def test_wf_draft_bind_output_to_state_uses_rpc_target(monkeypatch, tmp_path) -> None:
@@ -1244,10 +1225,8 @@ def test_wf_draft_add_step_from_capability_uses_rpc_target(
"call", "call",
"--from-outcome", "--from-outcome",
"ok", "ok",
"--outcome", "--route",
"ok", "ok=__end__",
"--to",
"__end__",
"--input", "--input",
"input.value=value", "input.value=value",
"--bind-output", "--bind-output",
@@ -1261,6 +1240,85 @@ def test_wf_draft_add_step_from_capability_uses_rpc_target(
assert payload["status"] == "valid" assert payload["status"] == "valid"
def test_wf_draft_compile_prints_compiled_plan(monkeypatch, tmp_path) -> None:
server = build_local_static_workflow_server(tmp_path / "store")
_patch_rpc_client_to_server(monkeypatch, server)
config_path = tmp_path / "wf.json"
config_path.write_text('{"version": 1}', encoding="utf-8")
runner = CliRunner()
base_args = ["--config", str(config_path), "--url", "http://test/rpc"]
created = runner.invoke(
app,
[
*base_args,
"draft",
"create-from-capability",
"compile_ws",
"wf.std.constant",
"--name",
"compile_me",
],
)
assert created.exit_code == 0, created.output
result = runner.invoke(app, [*base_args, "draft", "compile", "compile_ws"])
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
assert payload["name"] == "compile_me"
assert "compiled_plan" not in payload
def test_wf_draft_compile_invalid_prints_diagnostics_to_stderr(
monkeypatch, tmp_path
) -> None:
server = build_local_static_workflow_server(tmp_path / "store")
asyncio.run(
server.api.create_draft_workspace(
workspace_id="invalid_compile_ws",
draft={
"name": "invalid_compile",
"input_schema": {"type": "object"},
"state_schema": {"type": "object", "properties": {}},
"output_schema": {"type": "object", "properties": {}},
"start": "call",
"steps": {
"call": {
"use": "wf.std.constant",
"input": [],
"output": [],
}
},
"routes": {"call": {"typo": "__end__"}},
},
)
)
_patch_rpc_client_to_server(monkeypatch, server)
config_path = tmp_path / "wf.json"
config_path.write_text('{"version": 1}', encoding="utf-8")
runner = CliRunner()
result = runner.invoke(
app,
[
"--config",
str(config_path),
"--url",
"http://test/rpc",
"draft",
"compile",
"invalid_compile_ws",
],
)
assert result.exit_code == 1
# This Typer test runner mixes stderr into output; the command implementation
# writes invalid compile diagnostics with err=True for real terminals.
assert '"status": "invalid"' in result.output
assert "compiled_plan" not in result.output
def test_wf_deploy_create_alias_saves_deployment(monkeypatch, tmp_path) -> None: def test_wf_deploy_create_alias_saves_deployment(monkeypatch, tmp_path) -> None:
server = build_local_static_workflow_server(tmp_path / "store") server = build_local_static_workflow_server(tmp_path / "store")
asyncio.run( asyncio.run(
-7
View File
@@ -56,7 +56,6 @@ 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.add_state_from_output" in names
assert "wf.workflow.bind_output_to_state" in names assert "wf.workflow.bind_output_to_state" 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
@@ -115,12 +114,6 @@ def test_server_exposes_upstream_admin_and_workflow_tools() -> None:
].inputSchema ].inputSchema
set_input_request = set_input_schema["properties"]["request"] set_input_request = set_input_schema["properties"]["request"]
assert "merge" in set_input_request["properties"] assert "merge" in set_input_request["properties"]
state_from_output_schema = tools_by_name[
"wf.workflow.add_state_from_output"
].inputSchema
state_request = state_from_output_schema["properties"]["request"]
assert "output_field" in state_request["properties"]
assert "state_path" in state_request["properties"]
add_step_schema = tools_by_name[ add_step_schema = tools_by_name[
"wf.workflow.add_step_from_capability" "wf.workflow.add_step_from_capability"
].inputSchema ].inputSchema
+2 -2
View File
@@ -44,7 +44,7 @@ def test_workflow_surface_rejects_unknown_draft_route_outcome_when_spec_is_known
payload = asyncio.run(h.validate_draft(draft=draft)) payload = asyncio.run(h.validate_draft(draft=draft))
assert payload["status"] == "invalid" assert payload["status"] == "invalid"
assert payload["diagnostics"][0]["path"] == "routes.echo.typo" assert payload["diagnostics"][0]["path"] == "edges[0].outcome"
def test_workflow_surface_creates_artifact_from_draft_with_binding_suggestions( def test_workflow_surface_creates_artifact_from_draft_with_binding_suggestions(
@@ -150,7 +150,7 @@ def test_workflow_surface_validates_draft_workspace_with_live_outcomes(
assert payload["revision"] == 1 assert payload["revision"] == 1
assert payload["status"] == "invalid" assert payload["status"] == "invalid"
assert payload["diagnostics"][0]["code"] == "unknown_outcome" assert payload["diagnostics"][0]["code"] == "undeclared_edge_outcome"
assert fetched["status"] == "invalid" assert fetched["status"] == "invalid"
+4 -17
View File
@@ -266,7 +266,7 @@ async def test_rpc_draft_workspace_methods(tmp_path) -> None:
"name": "remote_constant", "name": "remote_constant",
"title": "Remote Constant", "title": "Remote Constant",
"input_map": {}, "input_map": {},
"output_map": {"value": "state.result"}, "output_map": {},
}, },
) )
listed = await _rpc(client, "workflow.draft_workspaces.list", {}) listed = await _rpc(client, "workflow.draft_workspaces.list", {})
@@ -722,23 +722,12 @@ async def test_rpc_draft_workspace_focused_edit_methods(tmp_path) -> None:
"merge": True, "merge": True,
}, },
) )
state_added = await _rpc(
client,
"workflow.draft_workspaces.add_state_from_output",
{
"workspace_id": "focused_ws",
"revision": 7,
"step_id": "call",
"output_field": "value",
"state_path": "state.extra_value",
},
)
state_bound = await _rpc( state_bound = await _rpc(
client, client,
"workflow.draft_workspaces.bind_output_to_state", "workflow.draft_workspaces.bind_output_to_state",
{ {
"workspace_id": "focused_ws", "workspace_id": "focused_ws",
"revision": state_added["result"]["revision"], "revision": 7,
"step_id": "call", "step_id": "call",
"output_field": "value", "output_field": "value",
"state_path": "state.extra_value", "state_path": "state.extra_value",
@@ -756,8 +745,7 @@ async def test_rpc_draft_workspace_focused_edit_methods(tmp_path) -> None:
assert output_mapped["result"]["revision"] == 5 assert output_mapped["result"]["revision"] == 5
assert input_merged["result"]["revision"] == 6 assert input_merged["result"]["revision"] == 6
assert output_merged["result"]["revision"] == 7 assert output_merged["result"]["revision"] == 7
assert state_added["result"]["revision"] == 8 assert state_bound["result"]["revision"] == 8
assert state_bound["result"]["revision"] == 9
draft = fetched["result"]["draft"] draft = fetched["result"]["draft"]
assert draft["name"] == "focused_renamed" assert draft["name"] == "focused_renamed"
assert draft["routes"]["call"]["ok"] == "__end__" assert draft["routes"]["call"]["ok"] == "__end__"
@@ -812,8 +800,7 @@ async def test_rpc_draft_workspace_add_step_from_capability(tmp_path) -> None:
"capability_name": "wf.std.constant", "capability_name": "wf.std.constant",
"route_from_step": "call", "route_from_step": "call",
"route_from_outcome": "ok", "route_from_outcome": "ok",
"route_outcome": "ok", "routes": {"ok": "__end__"},
"route_to": "__end__",
"input_map": {"input.value": "value"}, "input_map": {"input.value": "value"},
"bind_outputs": {"value": "state.second_value"}, "bind_outputs": {"value": "state.second_value"},
}, },
+4 -13
View File
@@ -293,7 +293,7 @@ async def test_rpc_workflow_client_draft_workspace_lifecycle(tmp_path) -> None:
name="client_constant", name="client_constant",
title="Client Constant", title="Client Constant",
input_map={}, input_map={},
output_map={"value": "state.result"}, output_map={},
) )
listed = await client.list_draft_workspaces() listed = await client.list_draft_workspaces()
fetched = await client.get_draft_workspace(workspace_id="client_ws") fetched = await client.get_draft_workspace(workspace_id="client_ws")
@@ -501,16 +501,9 @@ 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_added = await client.add_state_schema_from_output(
workspace_id="client_focused_ws",
revision=7,
step_id="call",
output_field="value",
state_path="state.extra_value",
)
state_bound = await client.bind_output_to_state( state_bound = await client.bind_output_to_state(
workspace_id="client_focused_ws", workspace_id="client_focused_ws",
revision=state_added["revision"], revision=7,
step_id="call", step_id="call",
output_field="value", output_field="value",
state_path="state.extra_value", state_path="state.extra_value",
@@ -522,8 +515,7 @@ async def test_rpc_client_draft_workspace_focused_edit_methods(tmp_path) -> None
assert output_mapped["revision"] == 5 assert output_mapped["revision"] == 5
assert input_merged["revision"] == 6 assert input_merged["revision"] == 6
assert output_merged["revision"] == 7 assert output_merged["revision"] == 7
assert state_added["revision"] == 8 assert state_bound["revision"] == 8
assert state_bound["revision"] == 9
async def test_rpc_client_draft_workspace_add_step_from_capability(tmp_path) -> None: async def test_rpc_client_draft_workspace_add_step_from_capability(tmp_path) -> None:
@@ -550,8 +542,7 @@ async def test_rpc_client_draft_workspace_add_step_from_capability(tmp_path) ->
capability_name="wf.std.constant", capability_name="wf.std.constant",
route_from_step="call", route_from_step="call",
route_from_outcome="ok", route_from_outcome="ok",
route_outcome="ok", routes={"ok": "__end__"},
route_to="__end__",
input_map={"input.value": "value"}, input_map={"input.value": "value"},
bind_outputs={"value": "state.second_value"}, bind_outputs={"value": "state.second_value"},
) )