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
+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