draft: REALLY ergonomic, not full impl

things that stop it from being complete: workflow builder route needs work

is this the call for normal branch/PR styled work?
This commit is contained in:
lda
2026-05-19 00:44:39 +07:00 Verified
parent 3002a893b3
commit 8d9796dd04
18 changed files with 1285 additions and 654 deletions
-337
View File
@@ -1,337 +0,0 @@
from __future__ import annotations
from copy import deepcopy
from typing import Annotated, Any, Literal
import jsonpatch
from pydantic import BaseModel, Field, ValidationError
from wf_core import (
END,
ConditionNode,
ForeachNode,
InterruptNode,
JoinNode,
NodeUse,
Workflow,
)
JsonObject = dict[str, Any]
JsonPatch = list[dict[str, Any]]
class DraftNodeUse(BaseModel):
"""Authoring-friendly use of one workflow capability."""
id: str
kind: Literal["use"]
capability: str
desc: str | None = None
in_: dict[str, str] = Field(default_factory=dict, alias="in")
out: dict[str, str] = Field(default_factory=dict)
retry: int | None = Field(default=None, ge=0)
timeout_seconds: int | None = Field(default=None, gt=0)
class DraftConditionNode(BaseModel):
"""Authoring-friendly condition step."""
id: str
kind: Literal["condition"]
check: JsonObject
class DraftForeachNode(BaseModel):
"""Authoring-friendly foreach step."""
id: str
kind: Literal["foreach"]
over: str
as_: str = Field(alias="as")
mode: Literal["serial", "parallel"] = "serial"
on_item_error: Literal["fail", "collect", "skip"] = "fail"
class DraftInterruptNode(BaseModel):
"""Authoring-friendly interrupt step."""
id: str
kind: Literal["interrupt"]
interrupt_kind: str
request: dict[str, str] = Field(default_factory=dict)
resume: dict[str, str] = Field(default_factory=dict)
outcomes: list[str] = Field(default_factory=lambda: ["submitted"])
class DraftJoinNode(BaseModel):
"""Authoring-friendly join step."""
id: str
kind: Literal["join"]
DraftStep = Annotated[
DraftNodeUse
| DraftConditionNode
| DraftForeachNode
| DraftInterruptNode
| DraftJoinNode,
Field(discriminator="kind"),
]
"""Discriminated union of workflow draft steps."""
class DraftEdge(BaseModel):
"""Outcome-specific transition between draft steps."""
from_: str = Field(alias="from")
outcome: str
to: str
class WorkflowDraft(BaseModel):
"""LLM-friendly authoring shape that compiles into one raw workflow plan."""
name: str
input_schema: JsonObject
state_schema: JsonObject
output_schema: JsonObject
start: str
steps: list[DraftStep]
edges: list[DraftEdge]
class DraftDiagnostic(BaseModel):
"""Machine-readable reason a draft could not be compiled."""
code: str
path: str
step_id: str | None = None
message: str
class DraftReferenceError(ValueError):
"""Reference failure discovered after the draft shape itself is valid."""
def __init__(self, *, path: str, message: str, step_id: str | None = None) -> None:
super().__init__(message)
self.path = path
self.step_id = step_id
class DraftValidationError(ValueError):
"""Typed draft-shape failure with already-normalized authoring diagnostics."""
def __init__(self, diagnostic: DraftDiagnostic) -> None:
super().__init__(f"{diagnostic.path}: {diagnostic.message}")
self.diagnostic = diagnostic
def compile_workflow_draft(draft: JsonObject) -> JsonObject:
"""Compile the authoring draft into the normalized raw workflow plan."""
try:
parsed = WorkflowDraft.model_validate(draft)
except ValidationError as exc:
raise DraftValidationError(
_diagnostic_from_validation_error(exc, draft)
) from exc
workflow = Workflow.model_validate(
{
"name": parsed.name,
"input_schema": deepcopy(parsed.input_schema),
"state_schema": deepcopy(parsed.state_schema),
"output_schema": deepcopy(parsed.output_schema),
"start": parsed.start,
"nodes": [
step.model_dump(mode="json", by_alias=True)
for step in _compile_steps(parsed.steps)
],
"edges": [edge.model_dump(by_alias=True) for edge in parsed.edges],
}
)
_validate_graph_references(parsed)
return workflow.model_dump(mode="json", by_alias=True, exclude={"node_defs"})
def validate_workflow_draft(draft: JsonObject) -> JsonObject:
"""Return structured draft diagnostics instead of raising on bad input."""
try:
compiled_plan = compile_workflow_draft(draft)
except DraftValidationError as exc:
return _invalid_result(exc.diagnostic)
except DraftReferenceError as exc:
return _invalid_result(
DraftDiagnostic(
code="draft_invalid",
path=exc.path,
step_id=exc.step_id,
message=str(exc),
)
)
return {
"status": "valid",
"diagnostics": [],
"compiled_plan": compiled_plan,
}
def patch_workflow_draft(draft: JsonObject, patch: JsonPatch) -> JsonObject:
"""Apply RFC 6902 JSON Patch to a draft, then validate the patched draft.
Patch authoring is intentionally draft-first. Compiled raw plans are compiler
output, so callers should patch the readable source document and recompile it.
"""
try:
patched = jsonpatch.JsonPatch(patch).apply(deepcopy(draft), in_place=False)
except Exception as exc:
return _invalid_result(
DraftDiagnostic(
code="patch_invalid",
path="patch",
message=str(exc),
)
)
if not isinstance(patched, dict):
return _invalid_result(
DraftDiagnostic(
code="draft_not_object",
path="",
message="patched draft must be a JSON object",
)
)
result = validate_workflow_draft(patched)
return {"draft": patched, **result}
def _compile_steps(
steps: list[DraftStep],
) -> list[NodeUse | ConditionNode | ForeachNode | InterruptNode | JoinNode]:
return [_compile_step(step) for step in steps]
def _compile_step(
step: DraftStep,
) -> NodeUse | ConditionNode | ForeachNode | InterruptNode | JoinNode:
if isinstance(step, DraftNodeUse):
return NodeUse.model_validate(
{
"id": step.id,
"type": "node",
"node": step.capability,
"desc": step.desc,
"in_map": deepcopy(step.in_),
"out_map": deepcopy(step.out),
"retry": step.retry,
"timeout_seconds": step.timeout_seconds,
}
)
if isinstance(step, DraftConditionNode):
return ConditionNode.model_validate(
{
"id": step.id,
"type": "condition",
"check": deepcopy(step.check),
}
)
if isinstance(step, DraftForeachNode):
return ForeachNode.model_validate(
{
"id": step.id,
"type": "foreach",
"over": step.over,
"as": step.as_,
"mode": step.mode,
"on_item_error": step.on_item_error,
}
)
if isinstance(step, DraftInterruptNode):
return InterruptNode.model_validate(
{
"id": step.id,
"type": "interrupt",
"kind": step.interrupt_kind,
"request_map": deepcopy(step.request),
"out_map": deepcopy(step.resume),
"outcomes": deepcopy(step.outcomes),
}
)
return JoinNode.model_validate({"id": step.id, "type": "join"})
def _validate_graph_references(draft: WorkflowDraft) -> None:
step_ids = [step.id for step in draft.steps]
step_id_set = set(step_ids)
if len(step_ids) != len(step_id_set):
raise DraftReferenceError(
path="steps",
message="steps contain duplicate ids",
)
if draft.start not in step_id_set:
raise DraftReferenceError(
path="start",
message=f"start references unknown step id {draft.start!r}",
)
for index, edge in enumerate(draft.edges):
if edge.from_ not in step_id_set:
raise DraftReferenceError(
path=f"edges[{index}].from",
message=f"edges[{index}].from references unknown step id {edge.from_!r}",
)
if edge.to != END and edge.to not in step_id_set:
raise DraftReferenceError(
path=f"edges[{index}].to",
message=f"edges[{index}].to references unknown step id {edge.to!r}",
)
def _invalid_result(diagnostic: DraftDiagnostic) -> JsonObject:
return {
"status": "invalid",
"diagnostics": [diagnostic.model_dump(mode="json")],
}
def _diagnostic_from_validation_error(
exc: ValidationError,
draft: JsonObject,
) -> DraftDiagnostic:
first_error = exc.errors()[0]
path = _format_error_path(first_error["loc"])
return DraftDiagnostic(
code="draft_invalid",
path=path,
step_id=_step_id_for_path(draft, path),
message=first_error["msg"],
)
def _format_error_path(location: tuple[object, ...]) -> str:
parts: list[str] = []
for part in location:
if isinstance(part, int):
parts[-1] = f"{parts[-1]}[{part}]"
continue
if part == "in_":
part = "in"
elif part == "as_":
part = "as"
if part in {"use", "condition", "foreach", "interrupt", "join"}:
continue
parts.append(str(part))
return ".".join(parts)
def _step_id_for_path(draft: JsonObject, path: str) -> str | None:
if not path.startswith("steps["):
return None
index_text = path.removeprefix("steps[").split("]", 1)[0]
if not index_text.isdecimal():
return None
steps = draft.get("steps")
if not isinstance(steps, list):
return None
index = int(index_text)
if index >= len(steps) or not isinstance(steps[index], dict):
return None
step_id = steps[index].get("id")
return step_id if isinstance(step_id, str) else None
+27
View File
@@ -0,0 +1,27 @@
from .adapter import build_workflow_from_draft
from .api import (
DraftDiagnostic,
compile_workflow_draft,
patch_workflow_draft,
validate_workflow_draft,
)
from .models import (
DraftForeachStep,
DraftInterruptStep,
DraftJoinStep,
DraftUseStep,
WorkflowDraft,
)
__all__ = [
"DraftDiagnostic",
"DraftForeachStep",
"DraftInterruptStep",
"DraftJoinStep",
"DraftUseStep",
"WorkflowDraft",
"build_workflow_from_draft",
"compile_workflow_draft",
"patch_workflow_draft",
"validate_workflow_draft",
]
+64
View File
@@ -0,0 +1,64 @@
from __future__ import annotations
from wf_authoring import WorkflowBuilder
from wf_core import JoinNode, Workflow
from .models import (
DraftForeachStep,
DraftInterruptStep,
DraftJoinStep,
DraftStep,
DraftUseStep,
WorkflowDraft,
)
def build_workflow_from_draft(draft: WorkflowDraft) -> Workflow:
"""Adapt one typed draft through `WorkflowBuilder` into a core workflow."""
builder = WorkflowBuilder(
name=draft.name,
input_schema=draft.input_schema,
state_schema=draft.state_schema,
output_schema=draft.output_schema,
)
step_refs = {
step_id: _add_step(builder, step_id, step)
for step_id, step in draft.steps.items()
}
builder.set_entry_point(step_refs[draft.start])
for source_id, routes in draft.routes.items():
for outcome, target in routes.items():
builder.connect(step_refs[source_id], outcome, target)
return builder.compile()
def _add_step(builder: WorkflowBuilder, step_id: str, step: DraftStep):
if isinstance(step, DraftUseStep):
return builder.use_ref(
step.use,
id=step_id,
in_map=step.in_,
out_map=step.out,
desc=step.desc,
)
if isinstance(step, DraftForeachStep):
return builder.foreach(
id=step_id,
over=step.foreach.over,
as_=step.foreach.as_,
mode=step.foreach.mode,
on_item_error=step.foreach.on_item_error,
)
if isinstance(step, DraftInterruptStep):
return builder.interrupt(
id=step_id,
kind=step.interrupt.kind,
request_map=step.interrupt.request,
out_map=step.interrupt.resume,
outcomes=step.interrupt.outcomes,
)
if isinstance(step, DraftJoinStep):
node = JoinNode(id=step_id, type="join")
builder.nodes.append(node)
return node
raise TypeError(f"unsupported draft step {type(step)!r}")
+135
View File
@@ -0,0 +1,135 @@
from __future__ import annotations
from copy import deepcopy
from collections.abc import Callable
from typing import Any
import jsonpatch
from pydantic import BaseModel, ValidationError
from .adapter import build_workflow_from_draft
from .models import WorkflowDraft
JsonObject = dict[str, Any]
JsonPatch = list[dict[str, Any]]
OutcomeLookup = Callable[[str], tuple[str, ...] | None]
class DraftDiagnostic(BaseModel):
"""Machine-readable reason a keyed draft could not be compiled."""
code: str
path: str
step_id: str | None = None
message: str
def compile_workflow_draft(draft: JsonObject) -> JsonObject:
"""Compile a keyed draft through `WorkflowBuilder` into raw workflow JSON."""
parsed = WorkflowDraft.model_validate(draft)
workflow = build_workflow_from_draft(parsed)
return workflow.model_dump(mode="json", by_alias=True, exclude={"node_defs"})
def validate_workflow_draft(
draft: JsonObject,
*,
outcome_lookup: OutcomeLookup | None = None,
) -> JsonObject:
"""Return structured diagnostics instead of raising on a bad keyed draft."""
try:
compiled_plan = compile_workflow_draft(draft)
except (ValidationError, KeyError, ValueError) as exc:
return _invalid_result(_diagnostic_from_exception(exc))
if outcome_lookup is not None:
diagnostic = _validate_known_outcomes(draft, outcome_lookup)
if diagnostic is not None:
return _invalid_result(diagnostic)
return {
"status": "valid",
"diagnostics": [],
"compiled_plan": compiled_plan,
}
def patch_workflow_draft(draft: JsonObject, patch: JsonPatch) -> JsonObject:
"""Patch the draft source document, then validate the patched result."""
try:
patched = jsonpatch.JsonPatch(patch).apply(deepcopy(draft), in_place=False)
except Exception as exc:
return _invalid_result(
DraftDiagnostic(
code="patch_invalid",
path="patch",
message=str(exc),
)
)
if not isinstance(patched, dict):
return _invalid_result(
DraftDiagnostic(
code="draft_not_object",
path="",
message="patched draft must be a JSON object",
)
)
result = validate_workflow_draft(patched)
return {"draft": patched, **result}
def _invalid_result(diagnostic: DraftDiagnostic) -> JsonObject:
return {
"status": "invalid",
"diagnostics": [diagnostic.model_dump(mode="json")],
}
def _diagnostic_from_exception(exc: Exception) -> DraftDiagnostic:
if isinstance(exc, ValidationError):
error = exc.errors()[0]
return DraftDiagnostic(
code="draft_invalid",
path=_format_location(error["loc"]),
message=error["msg"],
)
return DraftDiagnostic(
code="draft_invalid",
path="",
message=str(exc),
)
def _format_location(location: tuple[object, ...]) -> str:
return ".".join(str(part) for part in location)
def _validate_known_outcomes(
draft: JsonObject,
outcome_lookup: OutcomeLookup,
) -> DraftDiagnostic | None:
steps = draft.get("steps")
routes = draft.get("routes")
if not isinstance(steps, dict) or not isinstance(routes, dict):
return None
for step_id, route_map in routes.items():
step = steps.get(step_id)
if not isinstance(step, dict) or not isinstance(route_map, dict):
continue
capability = step.get("use")
if not isinstance(capability, str):
continue
outcomes = outcome_lookup(capability)
if outcomes is None:
continue
known_outcomes = set(outcomes)
for outcome in route_map:
if outcome not in known_outcomes:
return DraftDiagnostic(
code="unknown_outcome",
path=f"routes.{step_id}.{outcome}",
step_id=step_id,
message=(
f"step {step_id!r} routes unknown outcome {outcome!r}; "
f"expected one of {sorted(known_outcomes)!r}"
),
)
return None
+88
View File
@@ -0,0 +1,88 @@
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, Field, model_validator
JsonObject = dict[str, Any]
STEP_KIND_KEYS = frozenset({"use", "foreach", "interrupt", "join"})
class DraftUseStep(BaseModel):
"""Draft step that calls one externally resolvable workflow capability."""
use: str
in_: dict[str, str] = Field(default_factory=dict, alias="in")
out: dict[str, str] = Field(default_factory=dict)
desc: str | None = None
retry: int | None = Field(default=None, ge=0)
timeout_seconds: int | None = Field(default=None, gt=0)
class DraftForeachPayload(BaseModel):
"""Payload for one draft foreach step."""
over: str
as_: str = Field(alias="as")
mode: Literal["serial", "parallel"] = "serial"
on_item_error: Literal["fail", "collect", "skip"] = "fail"
class DraftForeachStep(BaseModel):
"""Draft step that delegates foreach construction to `WorkflowBuilder`."""
foreach: DraftForeachPayload
class DraftInterruptPayload(BaseModel):
"""Payload for one draft interrupt step."""
kind: str
request: dict[str, str] = Field(default_factory=dict)
resume: dict[str, str] = Field(default_factory=dict)
outcomes: list[str] = Field(default_factory=lambda: ["submitted"])
class DraftInterruptStep(BaseModel):
"""Draft step that pauses execution and waits for resume input."""
interrupt: DraftInterruptPayload
class DraftJoinStep(BaseModel):
"""Draft step that emits the current core join node."""
join: JsonObject = Field(default_factory=dict)
DraftStep = DraftUseStep | DraftForeachStep | DraftInterruptStep | DraftJoinStep
class WorkflowDraft(BaseModel):
"""Patch-friendly JSON authoring document for one workflow graph."""
name: str
input_schema: JsonObject
state_schema: JsonObject
output_schema: JsonObject
start: str
steps: dict[str, DraftStep]
routes: dict[str, dict[str, str]] = Field(default_factory=dict)
@model_validator(mode="before")
@classmethod
def _validate_step_kinds(cls, value: object) -> object:
if not isinstance(value, dict):
return value
steps = value.get("steps")
if not isinstance(steps, dict):
return value
for step_id, payload in steps.items():
if not isinstance(payload, dict):
continue
present = STEP_KIND_KEYS.intersection(payload)
if len(present) != 1:
raise ValueError(
f"steps.{step_id} must contain exactly one step kind key"
)
return value