Files
lda-wf/src/wf_api/draft_authoring.py
T

1689 lines
64 KiB
Python

from __future__ import annotations
from collections.abc import Mapping, Sequence
from copy import deepcopy
from dataclasses import dataclass
from typing import Any
from pydantic import TypeAdapter
from wf_artifacts.draft_workspaces.models import (
WorkflowDraftWorkspace,
summarize_draft_workspace,
)
from wf_artifacts.drafts.models import (
DraftChooseStep,
DraftEndStep,
DraftForeachStep,
DraftInterruptStep,
DraftMatchStep,
DraftStep,
DraftSubgraphStep,
DraftUseStep,
DraftWhenStep,
)
from wf_core.local_paths import has_overlapping_paths, paths_overlap
from wf_core.models.steps import (
InputBinding,
InputExpressionBinding,
InputPathBinding,
InputValueBinding,
OutputBinding,
StepInputBinding,
)
from wf_core.paths import (
GraphSourcePath,
LocalPath,
format_toml_path_segments,
parse_toml_path_segments,
)
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 .draft_updates import CapabilityStepUpdate
from .drafts import WorkflowDraftApi, _draft_output_map
from .input_expressions import (
validate_and_project_input_expression,
validate_schema_references,
)
from .models import DraftWorkspaceResult, JsonProjector
from .operation_context import WorkflowOperationContext
from .schema_projection import (
project_output_property_to_state_schema,
project_schema_path_to_schema_path,
schema_fragment_at_location,
schema_location_is_explicit,
validate_json_value_at_schema_location,
)
_PROJECT_DRAFT_WORKSPACE = JsonProjector(DraftWorkspaceResult)
def _graph_parts(path: str) -> tuple[str, tuple[str, ...]]:
parsed = GraphSourcePath.parse(path)
return parsed.root, parsed.parts
def _local_parts(path: str) -> tuple[str, ...]:
"""Parse a CLI local-root path as the rootless core LocalPath value."""
return LocalPath.parse(path.removeprefix("local.")).parts
def _draft_schema(draft: Mapping[str, Any], key: str) -> dict[str, Any]:
"""Return an isolated mutable copy of one draft schema document."""
value = draft.get(key, {})
if not isinstance(value, dict):
raise ValueError(f"draft {key} must be an object")
return deepcopy(value)
def _upsert_input_path_binding(
payload: object,
*,
binding: InputPathBinding,
step_id: str,
) -> list[dict[str, Any]]:
"""Update one graph source without lowering unrelated canonical bindings."""
bindings = TypeAdapter(list[StepInputBinding]).validate_python(payload)
matching = [
index
for index, existing in enumerate(bindings)
if isinstance(existing, InputPathBinding) and existing.path == binding.path
]
if len(matching) > 1:
raise ValueError(
f"step {step_id!r} source {str(binding.path)!r} has multiple input "
"bindings; replace the complete canonical binding list instead"
)
if matching:
bindings[matching[0]] = binding
else:
bindings.append(binding)
return [existing.model_dump(mode="json") for existing in bindings]
def _upsert_step_output_binding(
payload: object,
*,
binding: OutputBinding,
step_id: str,
) -> tuple[list[dict[str, Any]], str | None]:
"""Return updated canonical outputs and the source's previous state target."""
bindings = TypeAdapter(list[OutputBinding]).validate_python(payload)
matching = [
index
for index, existing in enumerate(bindings)
if existing.source == binding.source
]
if len(matching) > 1:
raise ValueError(
f"step {step_id!r} source {str(binding.source)!r} has multiple output "
"bindings; replace the complete canonical binding list instead"
)
previous_target = str(bindings[matching[0]].target) if matching else None
if matching:
bindings[matching[0]] = binding
else:
bindings.append(binding)
return (
[existing.model_dump(mode="json") for existing in bindings],
previous_target,
)
def _rebind_workflow_output(
payload: object,
*,
previous_state_path: str | None,
state_path: str,
output_target: str,
) -> list[dict[str, Any]]:
"""Rebind one public target without guessing across source fan-out.
A previous state source and the requested public target can identify two
different records. Both are replaced because the bind operation transfers
ownership of that local output to the new state-to-public projection. The
replacement occupies the earliest removed position so unrelated canonical
bindings retain their relative order.
"""
bindings = TypeAdapter(list[InputBinding]).validate_python(payload)
previous_matches = [
index
for index, binding in enumerate(bindings)
if (
previous_state_path is not None
and isinstance(binding, InputPathBinding)
and str(binding.path) == previous_state_path
)
]
if len(previous_matches) > 1:
raise ValueError(
f"state source {previous_state_path!r} has multiple public output "
"bindings; replace the complete canonical binding list instead"
)
target_matches = [
index
for index, binding in enumerate(bindings)
if str(binding.target) == output_target
]
if len(target_matches) > 1:
raise ValueError(
f"public output target {output_target!r} has multiple bindings; replace "
"the complete canonical binding list instead"
)
replaced_indices = set(previous_matches) | set(target_matches)
replacement = InputPathBinding(
path=GraphSourcePath.parse(state_path),
target=LocalPath.parse(output_target),
)
if not replaced_indices:
bindings.append(replacement)
else:
insert_at = min(replaced_indices)
bindings = [
binding
for index, binding in enumerate(bindings)
if index not in replaced_indices
]
bindings.insert(insert_at, replacement)
return [binding.model_dump(mode="json") for binding in bindings]
def _overlapping_input_binding_targets_error(
bindings: Sequence[StepInputBinding],
) -> ValueError:
"""Describe the first overlapping input-shaped target pair."""
for left_index, left in enumerate(bindings):
for right_index in range(left_index + 1, len(bindings)):
right = bindings[right_index]
if paths_overlap(left.target, right.target):
return ValueError(
f"bindings[{left_index}].target {str(left.target)!r} "
f"overlaps bindings[{right_index}].target "
f"{str(right.target)!r}"
)
raise AssertionError("overlap error requested without overlapping targets")
def _overlapping_output_targets_error(
bindings: Sequence[OutputBinding],
) -> ValueError:
"""Describe the first overlapping state-target pair with stable indexes."""
for left_index, left in enumerate(bindings):
for right_index in range(left_index + 1, len(bindings)):
right = bindings[right_index]
# StatePath is a separate typed path, so serialized state.* values
# provide the shared synthetic root expected by paths_overlap.
if paths_overlap(str(left.target), str(right.target)):
return ValueError(
f"bindings[{left_index}].target {str(left.target)!r} "
f"overlaps bindings[{right_index}].target "
f"{str(right.target)!r}"
)
raise AssertionError("overlap error requested without overlapping targets")
def _workflow_source_schema(
draft: Mapping[str, Any],
path: GraphSourcePath,
) -> dict[str, Any] | None:
"""Return the declared graph-source schema, or ``None`` for context paths."""
if path.root == "input":
key = "input_schema"
elif path.root == "state":
key = "state_schema"
else:
return None
value = draft.get(key, {})
if not isinstance(value, dict):
raise ValueError(f"draft {key} must be an object")
return value
def _step_input_bindings_patch(
*,
workspace: WorkflowDraftWorkspace,
step_id: str,
bindings: list[dict[str, Any]],
input_schema: dict[str, Any],
state_schema: dict[str, Any],
) -> list[dict[str, Any]]:
"""Build one atomic patch for schemas and canonical step input bindings."""
patch: list[dict[str, Any]] = []
for key, value in (
("input_schema", input_schema),
("state_schema", state_schema),
):
if workspace.draft.get(key, {}) != value:
patch.append({"op": "replace", "path": f"/{key}", "value": value})
patch.append(
{
"op": "replace",
"path": f"/steps/{escape_json_pointer(step_id)}/input",
"value": bindings,
}
)
return patch
def _step_output_bindings_patch(
*,
workspace: WorkflowDraftWorkspace,
step_id: str,
bindings: list[dict[str, Any]],
state_schema: dict[str, Any],
) -> list[dict[str, Any]]:
"""Build one atomic patch for state schema and canonical step outputs."""
patch: list[dict[str, Any]] = []
if workspace.draft.get("state_schema", {}) != state_schema:
patch.append({"op": "replace", "path": "/state_schema", "value": state_schema})
patch.append(
{
"op": "replace",
"path": f"/steps/{escape_json_pointer(step_id)}/output",
"value": bindings,
}
)
return patch
@dataclass(frozen=True)
class _ProjectedStepInputBindings:
"""Canonical step inputs plus workflow schemas projected from their sources."""
payload: list[dict[str, Any]]
input_schema: dict[str, Any]
state_schema: dict[str, Any]
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 _workspace_if_revision_matches(
self,
*,
workspace_id: str,
revision: int,
) -> WorkflowDraftWorkspace | DraftWorkspaceResult:
"""Load a workspace and enforce optimistic locking before semantic preflight."""
return self.drafts._workspace_if_revision_matches(
workspace_id=workspace_id,
revision=revision,
)
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
def _draft_step_route_outcomes(self, step: DraftStep) -> set[str] | None:
"""Return top-level route outcomes, or ``None`` for non-routable steps."""
if isinstance(step, DraftUseStep):
return set(self._outcomes_for_capability(step.use) or (DEFAULT_OK_OUTCOME,))
if isinstance(step, DraftForeachStep):
outcomes = {"loop", "done"}
if step.foreach.item_error.action in {"skip", "collect"}:
outcomes.add("completed_with_errors")
return outcomes
if isinstance(step, DraftInterruptStep):
return set(step.interrupt.outcomes)
if isinstance(step, DraftSubgraphStep):
return set(step.subgraph.outcomes)
if isinstance(
step, (DraftEndStep, DraftWhenStep, DraftChooseStep, DraftMatchStep)
):
return None
raise TypeError(f"unsupported draft step {type(step)!r}")
async def add_step(
self,
*,
workspace_id: str,
revision: int,
step_id: str,
step: DraftStep,
incoming: RouteSource | None = None,
routes: dict[str, str] | None = None,
) -> DraftWorkspaceResult:
"""Add one typed draft step and optional route edits in one revision."""
checked = self._workspace_if_revision_matches(
workspace_id=workspace_id,
revision=revision,
)
if isinstance(checked, dict):
return checked
workspace = checked
steps = workspace.draft.get("steps")
if not isinstance(steps, dict):
raise ValueError("draft steps must be an object")
draft_routes = workspace.draft.get("routes")
if not isinstance(draft_routes, dict):
raise ValueError("draft routes must be an object")
if step_id in steps:
raise ValueError(f"draft step {step_id!r} already exists")
route_outcomes = self._draft_step_route_outcomes(step)
if routes is not None:
if route_outcomes is None:
raise ValueError(f"routes are not allowed for draft step {step_id!r}")
unknown_outcomes = set(routes) - route_outcomes
if unknown_outcomes:
raise ValueError(
f"unknown route outcome(s) for draft step {step_id!r}: "
f"{sorted(unknown_outcomes)!r}"
)
if incoming is not None:
if incoming.step_id not in steps:
raise ValueError(f"unknown incoming source step {incoming.step_id!r}")
source_step = TypeAdapter(DraftStep).validate_python(
steps[incoming.step_id]
)
source_outcomes = self._draft_step_route_outcomes(source_step)
if source_outcomes is None or incoming.outcome not in source_outcomes:
raise ValueError(
f"unknown incoming route outcome {incoming.outcome!r} for "
f"source step {incoming.step_id!r}"
)
patch: list[dict[str, Any]] = [
{
"op": "add",
"path": f"/steps/{escape_json_pointer(step_id)}",
"value": step.model_dump(mode="json", by_alias=True),
}
]
if routes is not None:
patch.append(
{
"op": "add",
"path": f"/routes/{escape_json_pointer(step_id)}",
"value": routes,
}
)
if incoming is not None:
source_routes = draft_routes.get(incoming.step_id)
if source_routes is None:
# JSON Patch cannot add a nested outcome until its parent exists.
patch.append(
{
"op": "add",
"path": f"/routes/{escape_json_pointer(incoming.step_id)}",
"value": {incoming.outcome: step_id},
}
)
else:
if not isinstance(source_routes, dict):
raise ValueError(
f"routes for step {incoming.step_id!r} must be an object"
)
patch.append(
{
"op": "add",
"path": (
f"/routes/{escape_json_pointer(incoming.step_id)}/"
f"{escape_json_pointer(incoming.outcome)}"
),
"value": step_id,
}
)
return await self.drafts.patch_draft_workspace(
workspace_id=workspace_id,
revision=revision,
patch=patch,
)
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[StepInputBinding] | 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,
) -> DraftWorkspaceResult:
"""Bootstrap the smallest patchable draft around one workflow capability."""
if input is not None and input_map is not None:
raise ValueError("cannot mix canonical input bindings with input_map")
input_payload = (
[binding.model_dump(mode="json") for binding in input]
if input is not None
else input_bindings_payload(input_map or {}, {})
)
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_payload,
"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 set_step_input_bindings(
self,
*,
workspace_id: str,
revision: int,
step_id: str,
bindings: Sequence[StepInputBinding],
) -> DraftWorkspaceResult:
"""Replace one capability step's canonical input bindings atomically."""
checked = self._workspace_if_revision_matches(
workspace_id=workspace_id,
revision=revision,
)
if isinstance(checked, dict):
return checked
workspace = checked
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"
)
projected = self._project_step_input_bindings(
workspace=workspace,
capability_name=capability_name,
bindings=bindings,
)
if (
step.get("input", []) == projected.payload
and workspace.draft.get("input_schema", {}) == projected.input_schema
and workspace.draft.get("state_schema", {}) == projected.state_schema
):
return _PROJECT_DRAFT_WORKSPACE(
summarize_draft_workspace(workspace, include_draft=True)
)
patch = _step_input_bindings_patch(
workspace=workspace,
step_id=step_id,
bindings=projected.payload,
input_schema=projected.input_schema,
state_schema=projected.state_schema,
)
if any(isinstance(binding, InputExpressionBinding) for binding in bindings):
# Compact input maps cannot represent recursive expressions. Persist
# the validated canonical bindings structurally to avoid lossy lowering.
next_draft = deepcopy(workspace.draft)
next_steps = next_draft.get("steps")
if not isinstance(next_steps, dict):
raise ValueError("draft steps must be an object")
next_steps[step_id] = dict(next_steps[step_id])
next_steps[step_id]["input"] = projected.payload
next_draft["input_schema"] = projected.input_schema
next_draft["state_schema"] = projected.state_schema
return await self.drafts.replace_validated_draft_document(
workspace_id=workspace_id,
revision=revision,
draft=next_draft,
)
return await self.drafts.patch_draft_workspace(
workspace_id=workspace_id,
revision=revision,
patch=patch,
)
def _project_step_input_bindings(
self,
*,
workspace: WorkflowDraftWorkspace,
capability_name: str,
bindings: Sequence[StepInputBinding],
) -> _ProjectedStepInputBindings:
"""Validate canonical inputs and project missing workflow source schemas."""
spec = self.context.specs.get_qualified_spec(capability_name)
capability_schema = (
spec.input_schema_contract or spec.input_model.model_json_schema()
)
targets = [binding.target for binding in bindings]
if has_overlapping_paths(targets):
raise _overlapping_input_binding_targets_error(bindings)
projected_input = _draft_schema(workspace.draft, "input_schema")
projected_state = _draft_schema(workspace.draft, "state_schema")
for index, binding in enumerate(bindings):
target_parts = binding.target.parts
try:
schema_fragment_at_location(
capability_schema,
target_parts,
label="capability input schema",
)
if not schema_location_is_explicit(
capability_schema,
target_parts,
label="capability input schema",
):
raise ValueError(
f"capability input schema path {'.'.join(target_parts)!r} "
"is not declared"
)
except ValueError as exc:
raise ValueError(
f"bindings[{index}].target {str(binding.target)!r} "
f"is not declared by capability {capability_name!r}: {exc}"
) from exc
if isinstance(binding, InputExpressionBinding):
projection = validate_and_project_input_expression(
binding.expression,
target_schema=capability_schema,
input_schema=projected_input,
state_schema=projected_state,
target_location=target_parts,
label=f"bindings[{index}].expression",
)
projected_input = projection.input_schema
projected_state = projection.state_schema
continue
if isinstance(binding, InputValueBinding):
if not target_parts and not isinstance(binding.value, Mapping):
raise ValueError(
f"bindings[{index}].value for target '.' must be a JSON object"
)
validate_json_value_at_schema_location(
schema=capability_schema,
location=target_parts,
value=binding.value,
label=f"bindings[{index}].value",
)
continue
if isinstance(binding, InputPathBinding):
source = binding.path
if source.root == "context":
continue
target_schema = (
projected_input if source.root == "input" else projected_state
)
if schema_location_is_explicit(
target_schema,
source.parts,
label=f"{source.root} source schema",
):
source_fragment = schema_fragment_at_location(
target_schema,
source.parts,
label=f"{source.root} source schema",
)
validate_schema_references(
source_fragment,
label=f"{source.root} source schema",
)
else:
target_schema = project_schema_path_to_schema_path(
target_schema=target_schema,
source_schema=capability_schema,
source_parts=target_parts,
target_parts=source.parts,
allow_existing_equivalent=True,
allow_additional_properties=True,
)
if source.root == "input":
projected_input = target_schema
else:
projected_state = target_schema
payload = [binding.model_dump(mode="json") for binding in bindings]
return _ProjectedStepInputBindings(
payload=payload,
input_schema=projected_input,
state_schema=projected_state,
)
async def update_capability_step(
self,
*,
workspace_id: str,
revision: int,
step_id: str,
update: CapabilityStepUpdate,
) -> DraftWorkspaceResult:
"""Return a workspace summary or conflict after one atomic step patch."""
checked = self._workspace_if_revision_matches(
workspace_id=workspace_id,
revision=revision,
)
if isinstance(checked, dict):
return checked
workspace = checked
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} is not capability-backed")
current = DraftUseStep.model_validate(step)
changes: dict[str, object] = {}
for field in ("desc", "retry", "timeout_seconds"):
if field in update.model_fields_set:
changes[field] = getattr(update, field)
projected: _ProjectedStepInputBindings | None = None
if "input" in update.model_fields_set:
if update.input is None:
raise AssertionError("validated capability update has null input")
projected = self._project_step_input_bindings(
workspace=workspace,
capability_name=capability_name,
bindings=update.input,
)
changes["input"] = update.input
changed = current.model_copy(update=changes)
# Mutate a raw copy so omitted fields preserve their exact stored
# presence, including legacy explicit-null metadata.
step_payload = dict(deepcopy(step))
for field in ("desc", "retry", "timeout_seconds"):
if field not in update.model_fields_set:
continue
value = getattr(update, field)
if value is None:
step_payload.pop(field, None)
else:
step_payload[field] = value
if projected is not None:
step_payload["input"] = projected.payload
input_schema = (
projected.input_schema
if projected is not None
else _draft_schema(workspace.draft, "input_schema")
)
state_schema = (
projected.state_schema
if projected is not None
else _draft_schema(workspace.draft, "state_schema")
)
removed_metadata_key = any(
field in update.model_fields_set
and getattr(update, field) is None
and field in step
for field in ("desc", "retry", "timeout_seconds")
)
if (
current == changed
and not removed_metadata_key
and workspace.draft.get("input_schema", {}) == input_schema
and workspace.draft.get("state_schema", {}) == state_schema
):
return _PROJECT_DRAFT_WORKSPACE(
summarize_draft_workspace(workspace, include_draft=True)
)
next_draft = deepcopy(workspace.draft)
next_steps = next_draft.get("steps")
if not isinstance(next_steps, dict):
raise ValueError("draft steps must be an object")
next_steps[step_id] = step_payload
next_draft["input_schema"] = input_schema
next_draft["state_schema"] = state_schema
return await self.drafts.replace_validated_draft_document(
workspace_id=workspace_id,
revision=revision,
draft=next_draft,
)
async def set_workflow_output_bindings(
self,
*,
workspace_id: str,
revision: int,
bindings: Sequence[InputBinding],
) -> DraftWorkspaceResult:
"""Replace canonical workflow output bindings atomically."""
checked = self._workspace_if_revision_matches(
workspace_id=workspace_id,
revision=revision,
)
if isinstance(checked, dict):
return checked
workspace = checked
output_schema = _draft_schema(workspace.draft, "output_schema")
# Validate declared sources before overlap checks so malformed sources
# remain the primary diagnostic and no semantic error can mutate state.
source_schemas: dict[int, dict[str, Any]] = {}
source_fragments: dict[int, dict[str, Any]] = {}
for index, binding in enumerate(bindings):
if not isinstance(binding, InputPathBinding):
continue
source_schema = _workflow_source_schema(workspace.draft, binding.path)
if source_schema is None:
continue
try:
source_schemas[index] = source_schema
source_fragments[index] = schema_fragment_at_location(
source_schema,
binding.path.parts,
label=f"workflow {binding.path.root} schema",
)
if not schema_location_is_explicit(
source_schema,
binding.path.parts,
label=f"workflow {binding.path.root} schema",
):
raise ValueError(
f"workflow {binding.path.root} schema path "
f"{'.'.join(binding.path.parts)!r} is not declared"
)
except ValueError as exc:
raise ValueError(
f"bindings[{index}].path {str(binding.path)!r} "
f"is not declared: {exc}"
) from exc
if has_overlapping_paths(binding.target for binding in bindings):
raise _overlapping_input_binding_targets_error(bindings)
projected = output_schema
for index, binding in enumerate(bindings):
target_parts = binding.target.parts
if isinstance(binding, InputPathBinding):
if binding.path.root == "context" and not target_parts:
raise ValueError(
f"bindings[{index}].path {str(binding.path)!r} "
"cannot target '.' because context schemas are not declared"
)
source_schema = source_schemas.get(index)
if source_schema is None:
if not schema_location_is_explicit(
projected,
target_parts,
label="workflow output schema",
):
raise ValueError(
f"bindings[{index}].path {str(binding.path)!r} "
"requires a declared output target"
)
continue
if not target_parts:
# Root replacement has no parent where a fragment can be
# inserted, so only exact whole-schema equality is safe.
if projected != source_fragments[index]:
raise ValueError(
f"bindings[{index}].target '.' already has an "
"incompatible schema"
)
continue
try:
# Pass the complete source document so local $ref
# definitions are copied into the output schema root.
projected = project_schema_path_to_schema_path(
target_schema=projected,
source_schema=source_schema,
source_parts=binding.path.parts,
target_parts=target_parts,
allow_existing_equivalent=True,
allow_additional_properties=True,
)
except ValueError as exc:
raise ValueError(
f"bindings[{index}].target {str(binding.target)!r} "
f"cannot receive source {str(binding.path)!r}: {exc}"
) from exc
continue
if not isinstance(binding, InputValueBinding):
raise TypeError(f"unsupported workflow output binding {binding!r}")
if not target_parts and not isinstance(binding.value, Mapping):
raise ValueError(
f"bindings[{index}].value for root target must be an object"
)
if not schema_location_is_explicit(
projected,
target_parts,
label="workflow output schema",
):
raise ValueError(
f"bindings[{index}].target {str(binding.target)!r} is not declared"
)
validate_json_value_at_schema_location(
schema=projected,
location=target_parts,
value=binding.value,
label=f"bindings[{index}].value",
schema_label="workflow output schema",
)
payload = [binding.model_dump(mode="json") for binding in bindings]
if workspace.draft.get("output", []) == payload and projected == output_schema:
return _PROJECT_DRAFT_WORKSPACE(
summarize_draft_workspace(workspace, include_draft=True)
)
patch: list[dict[str, Any]] = []
if projected != output_schema:
patch.append(
{
"op": "replace",
"path": "/output_schema",
"value": projected,
}
)
patch.append({"op": "replace", "path": "/output", "value": payload})
return await self.drafts.patch_draft_workspace(
workspace_id=workspace_id,
revision=revision,
patch=patch,
)
async def set_step_output_bindings(
self,
*,
workspace_id: str,
revision: int,
step_id: str,
bindings: Sequence[OutputBinding],
) -> DraftWorkspaceResult:
"""Replace one capability step's canonical output bindings atomically."""
checked = self._workspace_if_revision_matches(
workspace_id=workspace_id,
revision=revision,
)
if isinstance(checked, dict):
return checked
workspace = checked
step = draft_step(workspace.draft, step_id)
capability_name = step.get("use")
if not isinstance(capability_name, str) or not capability_name:
raise ValueError(
f"draft step {step_id!r} does not declare a capability use"
)
spec = self.context.specs.get_qualified_spec(capability_name)
capability_schema = (
spec.output_schema_contract or spec.output_model.model_json_schema()
)
for index, binding in enumerate(bindings):
try:
schema_fragment_at_location(
capability_schema,
binding.source.parts,
label="capability output schema",
)
if not schema_location_is_explicit(
capability_schema,
binding.source.parts,
label="capability output schema",
):
raise ValueError(
f"capability output schema path "
f"{'.'.join(binding.source.parts)!r} is not declared"
)
except ValueError as exc:
raise ValueError(
f"bindings[{index}].source {str(binding.source)!r} "
f"is not declared by capability {capability_name!r}: {exc}"
) from exc
targets = [str(binding.target) for binding in bindings]
if has_overlapping_paths(targets):
raise _overlapping_output_targets_error(bindings)
projected_state = _draft_schema(workspace.draft, "state_schema")
for index, binding in enumerate(bindings):
source_parts = binding.source.parts
target_parts = binding.target.parts
try:
projected_state = project_schema_path_to_schema_path(
target_schema=projected_state,
source_schema=capability_schema,
source_parts=source_parts,
target_parts=target_parts,
allow_existing_equivalent=True,
allow_additional_properties=True,
)
except ValueError as exc:
raise ValueError(
f"bindings[{index}].target {str(binding.target)!r} "
f"cannot receive source {str(binding.source)!r}: {exc}"
) from exc
payload = [binding.model_dump(mode="json") for binding in bindings]
if (
step.get("output", []) == payload
and workspace.draft.get("state_schema", {}) == projected_state
):
return _PROJECT_DRAFT_WORKSPACE(
summarize_draft_workspace(workspace, include_draft=True)
)
return await self.drafts.patch_draft_workspace(
workspace_id=workspace_id,
revision=revision,
patch=_step_output_bindings_patch(
workspace=workspace,
step_id=step_id,
bindings=payload,
state_schema=projected_state,
),
)
async def bind_draft(
self,
*,
workspace_id: str,
revision: int,
step_id: str,
source_path: str,
target_path: str,
) -> DraftWorkspaceResult:
"""Bind a graph path to or from one capability-local path."""
checked = self._workspace_if_revision_matches(
workspace_id=workspace_id,
revision=revision,
)
if isinstance(checked, dict):
return checked
workspace = checked
step = draft_step(workspace.draft, step_id)
capability_name = step.get("use")
if not isinstance(capability_name, str) or not capability_name:
raise ValueError(
f"draft step {step_id!r} does not declare a capability use"
)
spec = self.context.specs.get_qualified_spec(capability_name)
source_root, source_parts = (
_graph_parts(source_path)
if not source_path.startswith("local.")
else ("local", _local_parts(source_path))
)
if target_path.startswith("output."):
# GraphSourcePath excludes output targets, but output fields still
# use the same canonical TOML-key grammar as other workflow paths.
output_path_parts = parse_toml_path_segments(target_path)
target_root = output_path_parts[0]
target_parts = output_path_parts[1:]
if target_root != "output" or not target_parts:
raise ValueError("output path must name a field, such as output.result")
elif target_path.startswith("local."):
target_root = "local"
target_parts = _local_parts(target_path)
else:
target_root, target_parts = _graph_parts(target_path)
if target_root == "local" and source_root in {"input", "state"}:
input_schema = (
spec.input_schema_contract or spec.input_model.model_json_schema()
)
schema_key = "input_schema" if source_root == "input" else "state_schema"
target_schema = workspace.draft.get(schema_key, {})
if not isinstance(target_schema, dict):
raise ValueError(f"draft {schema_key} must be an object")
if schema_location_is_explicit(
target_schema,
source_parts,
label=f"{source_root} source schema",
):
projected = target_schema
else:
projected = project_schema_path_to_schema_path(
target_schema=target_schema,
source_schema=input_schema,
source_parts=target_parts,
target_parts=source_parts,
allow_additional_properties=True,
)
input_bindings = _upsert_input_path_binding(
step.get("input", []),
binding=InputPathBinding(
path=GraphSourcePath.parse(source_path),
target=LocalPath(target_parts),
),
step_id=step_id,
)
return await self.drafts.patch_draft_workspace(
workspace_id=workspace_id,
revision=revision,
patch=[
{"op": "replace", "path": f"/{schema_key}", "value": projected},
{
"op": "replace",
"path": f"/steps/{escape_json_pointer(step_id)}/input",
"value": input_bindings,
},
],
)
if source_root == "local" and target_root == "output":
local_path = format_toml_path_segments(source_parts)
output_schema_source = (
spec.output_schema_contract or spec.output_model.model_json_schema()
)
if not schema_location_is_explicit(
output_schema_source,
source_parts,
label="output schema",
):
raise ValueError(
f"source schema path {'.'.join(source_parts)!r} is not declared"
)
state_path_str = format_toml_path_segments(("state", *target_parts))
output_target_str = format_toml_path_segments(target_parts)
state_schema = workspace.draft.get("state_schema", {})
if not isinstance(state_schema, dict):
raise ValueError("draft state_schema must be an object")
projected_state = project_schema_path_to_schema_path(
target_schema=state_schema,
source_schema=output_schema_source,
source_parts=source_parts,
target_parts=target_parts,
allow_existing_equivalent=True,
allow_additional_properties=True,
)
output_schema = workspace.draft.get("output_schema", {})
if not isinstance(output_schema, dict):
raise ValueError("draft output_schema must be an object")
projected_output = project_schema_path_to_schema_path(
target_schema=output_schema,
source_schema=output_schema_source,
source_parts=source_parts,
target_parts=target_parts,
allow_existing_equivalent=True,
allow_additional_properties=True,
)
step_output_bindings, previous_state_path = _upsert_step_output_binding(
step.get("output", []),
binding=OutputBinding.model_validate(
{"source": local_path, "target": state_path_str}
),
step_id=step_id,
)
output_bindings = _rebind_workflow_output(
workspace.draft.get("output", []),
previous_state_path=previous_state_path,
state_path=state_path_str,
output_target=output_target_str,
)
return await self.drafts.patch_draft_workspace(
workspace_id=workspace_id,
revision=revision,
patch=[
{
"op": "replace",
"path": "/state_schema",
"value": projected_state,
},
{
"op": "replace",
"path": "/output_schema",
"value": projected_output,
},
{
"op": "replace",
"path": f"/steps/{escape_json_pointer(step_id)}/output",
"value": step_output_bindings,
},
{"op": "replace", "path": "/output", "value": output_bindings},
],
)
if source_root == "local" and target_root == "state":
local_path = format_toml_path_segments(source_parts)
output_schema = (
spec.output_schema_contract or spec.output_model.model_json_schema()
)
if not schema_location_is_explicit(
output_schema,
source_parts,
label="output schema",
):
raise ValueError(
f"source schema path {'.'.join(source_parts)!r} is not declared"
)
target_schema = workspace.draft.get("state_schema", {})
if not isinstance(target_schema, dict):
raise ValueError("draft state_schema must be an object")
projected = project_schema_path_to_schema_path(
target_schema=target_schema,
source_schema=output_schema,
source_parts=source_parts,
target_parts=target_parts,
allow_existing_equivalent=True,
allow_additional_properties=True,
)
output_bindings, _previous_state_path = _upsert_step_output_binding(
step.get("output", []),
binding=OutputBinding.model_validate(
{"source": local_path, "target": target_path}
),
step_id=step_id,
)
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,
},
],
)
raise ValueError(
f"unsupported bind direction: {source_path!r} -> {target_path!r}"
)
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,
input_bindings: Sequence[StepInputBinding] | None = None,
bind_outputs: dict[str, str] | None = None,
desc: str | None = None,
retry: int | None = None,
timeout_seconds: int | None = None,
) -> DraftWorkspaceResult:
"""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.
"""
checked = self._workspace_if_revision_matches(
workspace_id=workspace_id,
revision=revision,
)
if isinstance(checked, dict):
return checked
workspace = checked
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")
if input_map is not None and input_bindings is not None:
raise ValueError("input_map and input_bindings are mutually exclusive")
metadata = {
field: value
for field, value in (
("desc", desc),
("retry", retry),
("timeout_seconds", timeout_seconds),
)
if value is not None
}
if metadata:
_ = CapabilityStepUpdate.model_validate(metadata)
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}",
]
repairs: list[str] = []
if unknown_outcomes:
repairs.append(
f"remove --route entries for {sorted(unknown_outcomes)!r}"
)
if missing_outcomes:
repairs.append(
f"add --route OUTCOME=TARGET for {sorted(missing_outcomes)!r}"
)
raise ValueError(
f"capability {capability_name!r} declares outcomes "
f"{declared_outcomes}, but routes has missing routes "
f"{sorted(missing_outcomes)} and unknown routes "
f"{sorted(unknown_outcomes)}; "
+ ", ".join(details)
+ "; repair: "
+ "; ".join(repairs)
)
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}"
)
if input_bindings is None:
canonical_inputs = TypeAdapter(list[StepInputBinding]).validate_python(
input_bindings_payload(input_map or {}, {})
)
else:
canonical_inputs = list(input_bindings)
bind_outputs = bind_outputs or {}
projected_inputs = self._project_step_input_bindings(
workspace=workspace,
capability_name=capability_name,
bindings=canonical_inputs,
)
projected_input_schema = projected_inputs.input_schema
projected_state_schema = projected_inputs.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,
allow_existing_equivalent=True,
)
step_payload: dict[str, Any] = {
"use": capability_name,
"input": projected_inputs.payload,
"output": output_bindings_payload(bind_outputs),
}
if desc is not None:
step_payload["desc"] = desc
if retry is not None:
step_payload["retry"] = retry
if timeout_seconds is not None:
step_payload["timeout_seconds"] = timeout_seconds
step_payload = DraftUseStep.model_validate(step_payload).model_dump(
mode="json",
by_alias=True,
exclude_none=True,
)
patch: list[dict[str, Any]] = [
{
"op": "add",
"path": f"/steps/{escape_json_pointer(step_id)}",
"value": step_payload,
},
{
"op": "add",
"path": f"/routes/{escape_json_pointer(step_id)}",
"value": step_routes,
},
]
if projected_input_schema != workspace.draft.get("input_schema", {}):
patch.insert(
0,
{
"op": "replace",
"path": "/input_schema",
"value": projected_input_schema,
},
)
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],
) -> DraftWorkspaceResult:
"""Atomically set routes for one step, preserving unspecified outcomes."""
checked = self._workspace_if_revision_matches(
workspace_id=workspace_id,
revision=revision,
)
if isinstance(checked, dict):
return checked
workspace = checked
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 _PROJECT_DRAFT_WORKSPACE(
summarize_draft_workspace(workspace, include_draft=True)
)
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[RouteSource],
target: str,
) -> DraftWorkspaceResult:
"""Update the target for multiple (step, outcome) pairs atomically."""
checked = self._workspace_if_revision_matches(
workspace_id=workspace_id,
revision=revision,
)
if isinstance(checked, dict):
return checked
workspace = checked
if not branches:
return _PROJECT_DRAFT_WORKSPACE(
summarize_draft_workspace(workspace, include_draft=True)
)
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 _PROJECT_DRAFT_WORKSPACE(
summarize_draft_workspace(workspace, include_draft=True)
)
return await self.drafts.patch_draft_workspace(
workspace_id=workspace_id,
revision=revision,
patch=patch,
)
async def remove_draft_route(
self,
*,
workspace_id: str,
revision: int,
step_id: str,
outcome: str,
) -> DraftWorkspaceResult:
"""Remove one route; missing routes are revision-checked no-ops."""
checked = self._workspace_if_revision_matches(
workspace_id=workspace_id,
revision=revision,
)
if isinstance(checked, dict):
return checked
workspace = checked
draft_routes = workspace.draft.get("routes", {})
if not isinstance(draft_routes, dict):
raise ValueError("draft routes must be an object")
step_routes = draft_routes.get(step_id, {})
if not isinstance(step_routes, dict):
raise ValueError(f"routes for step {step_id!r} must be an object")
if outcome not in step_routes:
return _PROJECT_DRAFT_WORKSPACE(
summarize_draft_workspace(workspace, include_draft=True)
)
return await self.drafts.patch_draft_workspace(
workspace_id=workspace_id,
revision=revision,
patch=[
{
"op": "remove",
"path": (
f"/routes/{escape_json_pointer(step_id)}/"
f"{escape_json_pointer(outcome)}"
),
}
],
)
async def remove_draft_step(
self,
*,
workspace_id: str,
revision: int,
step_id: str,
) -> DraftWorkspaceResult:
"""Remove a step and its own route map; inbound routes are left explicit."""
checked = self._workspace_if_revision_matches(
workspace_id=workspace_id,
revision=revision,
)
if isinstance(checked, dict):
return checked
workspace = checked
steps = workspace.draft.get("steps", {})
if not isinstance(steps, dict):
raise ValueError("draft steps must be an object")
if step_id not in steps:
return _PROJECT_DRAFT_WORKSPACE(
summarize_draft_workspace(workspace, include_draft=True)
)
patch = [
{
"op": "remove",
"path": f"/steps/{escape_json_pointer(step_id)}",
}
]
routes = workspace.draft.get("routes", {})
if isinstance(routes, dict) and step_id in routes:
patch.append(
{
"op": "remove",
"path": f"/routes/{escape_json_pointer(step_id)}",
}
)
return await self.drafts.patch_draft_workspace(
workspace_id=workspace_id,
revision=revision,
patch=patch,
)
async def remove_draft_binding(
self,
*,
workspace_id: str,
revision: int,
step_id: str,
inputs: Sequence[str] = (),
outputs: Sequence[str] = (),
) -> DraftWorkspaceResult:
"""Remove selected local input/output bindings from one draft step."""
if not inputs and not outputs:
raise ValueError("pass at least one input or output binding to remove")
checked = self._workspace_if_revision_matches(
workspace_id=workspace_id,
revision=revision,
)
if isinstance(checked, dict):
return checked
workspace = checked
step = draft_step(workspace.draft, step_id)
current_inputs = step.get("input", [])
current_outputs = step.get("output", [])
if not isinstance(current_inputs, list):
raise ValueError(f"input bindings for step {step_id!r} must be a list")
if not isinstance(current_outputs, list):
raise ValueError(f"output bindings for step {step_id!r} must be a list")
if not all(isinstance(item, dict) for item in current_inputs):
raise ValueError(
f"input binding entries for step {step_id!r} must be objects"
)
if not all(isinstance(item, dict) for item in current_outputs):
raise ValueError(
f"output binding entries for step {step_id!r} must be objects"
)
input_targets = set(inputs)
output_sources = set(outputs)
next_inputs = [
item for item in current_inputs if item.get("target") not in input_targets
]
next_outputs = [
item for item in current_outputs if item.get("source") not in output_sources
]
if next_inputs == current_inputs and next_outputs == current_outputs:
return _PROJECT_DRAFT_WORKSPACE(
summarize_draft_workspace(workspace, include_draft=True)
)
patch: list[dict[str, Any]] = []
if next_inputs != current_inputs:
patch.append(
{
"op": "replace",
"path": f"/steps/{escape_json_pointer(step_id)}/input",
"value": next_inputs,
}
)
if next_outputs != current_outputs:
patch.append(
{
"op": "replace",
"path": f"/steps/{escape_json_pointer(step_id)}/output",
"value": next_outputs,
}
)
return await self.drafts.patch_draft_workspace(
workspace_id=workspace_id,
revision=revision,
patch=patch,
)
@dataclass(frozen=True)
class RouteSource:
"""One source step/outcome pair used for atomic route edits."""
step_id: str
outcome: str = DEFAULT_OK_OUTCOME