feat: add atomic generic draft step insertion

This commit is contained in:
lda
2026-07-20 08:49:21 +07:00 Verified
parent 7e8ce5330f
commit 3119276014
5 changed files with 460 additions and 11 deletions
+2
View File
@@ -17,6 +17,7 @@ from .constants import (
RUNTIME_ERROR_CAPABILITY,
)
from .deployments import WorkflowDeploymentApi
from .draft_authoring import RouteSource
from .drafts import WorkflowDraftApi
from .durable_context import durable_workflow_api, require_workflow_stores
from .listing import matches_query, paged_list_payload
@@ -88,6 +89,7 @@ __all__ = [
"OutcomeCandidateKind",
"RUNTIME_ERROR_CAPABILITY",
"RawWorkflowPlan",
"RouteSource",
"RuntimeDependencies",
"TraceRange",
"WorkflowAdminApi",
+119 -4
View File
@@ -8,6 +8,18 @@ from wf_artifacts.draft_workspaces.models import (
WorkflowDraftWorkspace,
summarize_draft_workspace,
)
from wf_artifacts.drafts.models import (
DraftChooseStep,
DraftEndStep,
DraftForeachStep,
DraftInterruptStep,
DraftJoinStep,
DraftMatchStep,
DraftStep,
DraftSubgraphStep,
DraftUseStep,
DraftWhenStep,
)
from wf_core.models.steps import (
InputBinding,
OutputBinding,
@@ -120,6 +132,109 @@ class WorkflowDraftAuthoringApi:
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, DraftJoinStep):
return {"done"}
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,
) -> dict[str, Any]:
"""Add one typed draft step and optional route edits in one revision."""
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")
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 and incoming.step_id not in steps:
raise ValueError(f"unknown incoming 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,
*,
@@ -600,7 +715,7 @@ class WorkflowDraftAuthoringApi:
*,
workspace_id: str,
revision: int,
branches: Sequence[DraftOutcomeRef],
branches: Sequence[RouteSource],
target: str,
) -> dict[str, Any]:
"""Update the target for multiple (step, outcome) pairs atomically."""
@@ -801,8 +916,8 @@ class WorkflowDraftAuthoringApi:
@dataclass(frozen=True)
class DraftOutcomeRef:
"""A reference to a specific outcome of a draft step."""
class RouteSource:
"""One source step/outcome pair used for atomic route edits."""
step_id: str
outcome: str
outcome: str = DEFAULT_OK_OUTCOME
+22 -3
View File
@@ -4,11 +4,12 @@ from collections.abc import Sequence
from typing import Any
from wf_artifacts import ArtifactKind
from wf_artifacts.drafts.models import DraftStep
from .artifacts import WorkflowArtifactApi
from .capabilities import WorkflowCapabilityApi
from .deployments import WorkflowDeploymentApi
from .draft_authoring import DraftOutcomeRef, WorkflowDraftAuthoringApi
from .draft_authoring import RouteSource, WorkflowDraftAuthoringApi
from .drafts import WorkflowDraftApi
from .models import RawWorkflowPlan
from .operation_context import WorkflowOperationContext
@@ -428,6 +429,25 @@ class WorkflowApi:
bind_outputs=bind_outputs,
)
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,
) -> dict[str, Any]:
return await self.draft_authoring.add_step(
workspace_id=workspace_id,
revision=revision,
step_id=step_id,
step=step,
incoming=incoming,
routes=routes,
)
async def branch_draft(
self,
*,
@@ -452,8 +472,7 @@ class WorkflowApi:
target: str,
) -> dict[str, Any]:
refs = [
DraftOutcomeRef(step_id=b["step_id"], outcome=b["outcome"])
for b in branches
RouteSource(step_id=b["step_id"], outcome=b["outcome"]) for b in branches
]
return await self.draft_authoring.handle_draft(
workspace_id=workspace_id,
+13
View File
@@ -4,7 +4,9 @@ from collections.abc import Mapping, Sequence
from typing import Any, Protocol
from wf_artifacts import ArtifactKind
from wf_artifacts.drafts.models import DraftStep
from .draft_authoring import RouteSource
from .runs import TraceRangeLike
@@ -147,6 +149,17 @@ class WorkflowDraftSurface(Protocol):
bind_outputs: dict[str, str] | None = None,
) -> dict[str, Any]: ...
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,
) -> dict[str, Any]: ...
async def branch_draft(
self,
*,