fix: address semantic draft review nits

This commit is contained in:
lda
2026-06-27 17:59:49 +07:00 Verified
parent 89a9dbfad8
commit c2bb5728b6
14 changed files with 231 additions and 63 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
# Structured Refs # Structural Refs
Qualified names are display strings. They are not authoritative identifiers. Qualified names are display strings. They are not authoritative identifiers.
+17
View File
@@ -0,0 +1,17 @@
from __future__ import annotations
def short_model_name(model: str) -> str:
"""Return compact model labels for dense trial tables and session titles."""
name = model.rsplit("/", 1)[-1]
for suffix in ("-v4-flash-free", "-v2.5-free", "-3-ultra-free"):
name = name.replace(suffix, "")
return name
def short_challenge_name(challenge: str) -> str:
"""Return compact challenge labels without changing stored challenge ids."""
return {
"browser_click": "browser",
"report_workflow": "report",
}.get(challenge, challenge)
+8 -7
View File
@@ -14,6 +14,10 @@ import yaml
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
from examples.agent_challenges.names import ( # noqa: E402
short_challenge_name,
short_model_name,
)
from examples.agent_challenges.opencode_io import ( # noqa: E402 from examples.agent_challenges.opencode_io import ( # noqa: E402
build_opencode_command, build_opencode_command,
opencode_text_results, opencode_text_results,
@@ -44,13 +48,10 @@ def _opencode_trial_title(
*, challenge_id: str, model: str, profile: str, index: int *, challenge_id: str, model: str, profile: str, index: int
) -> str: ) -> str:
"""Build a compact OpenCode session title for crowded trial matrices.""" """Build a compact OpenCode session title for crowded trial matrices."""
challenge_name = { return (
"browser_click": "browser", f"{short_challenge_name(challenge_id)} "
"report_workflow": "report", f"{short_model_name(model)} {profile} {index:03d}"
}.get(challenge_id, challenge_id) )
model_name = model.rsplit("/", 1)[-1].replace("-v4-flash-free", "")
model_name = model_name.replace("-v2.5-free", "").replace("-3-ultra-free", "")
return f"{challenge_name} {model_name} {profile} {index:03d}"
@dataclass(slots=True) @dataclass(slots=True)
+4 -16
View File
@@ -6,6 +6,8 @@ from dataclasses import asdict, dataclass
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from examples.agent_challenges.names import short_challenge_name, short_model_name
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
DEFAULT_CHALLENGES_ROOT = ROOT / "examples" / "agent_challenges" DEFAULT_CHALLENGES_ROOT = ROOT / "examples" / "agent_challenges"
@@ -26,20 +28,6 @@ class TrialSummary:
notes: str notes: str
def _short_model(model: str) -> str:
name = model.rsplit("/", 1)[-1]
for suffix in ("-v4-flash-free", "-v2.5-free", "-3-ultra-free"):
name = name.replace(suffix, "")
return name
def _short_challenge(challenge: str) -> str:
return {
"browser_click": "browser",
"report_workflow": "report",
}.get(challenge, challenge)
def _string(value: object, default: str = "") -> str: def _string(value: object, default: str = "") -> str:
return value if isinstance(value, str) else default return value if isinstance(value, str) else default
@@ -83,8 +71,8 @@ def load_trial_summary(path: Path) -> TrialSummary:
tokens = _dict(evidence.get("tokens")) tokens = _dict(evidence.get("tokens"))
self_report = _dict(data.get("agent_self_report")) self_report = _dict(data.get("agent_self_report"))
challenge = _short_challenge(_string(identity.get("challenge_id"))) challenge = short_challenge_name(_string(identity.get("challenge_id")))
model = _short_model(_string(identity.get("model"))) model = short_model_name(_string(identity.get("model")))
manual = _string(manual_audit.get("official_outcome"), "pending") manual = _string(manual_audit.get("official_outcome"), "pending")
notes = _string(manual_audit.get("notes")) notes = _string(manual_audit.get("notes"))
+44 -3
View File
@@ -4,6 +4,10 @@ from collections.abc import Sequence
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any
from wf_artifacts.draft_workspaces.models import (
WorkflowDraftWorkspace,
summarize_draft_workspace,
)
from wf_core.models.steps import ( from wf_core.models.steps import (
InputBinding, InputBinding,
OutputBinding, OutputBinding,
@@ -45,6 +49,31 @@ class WorkflowDraftAuthoringApi:
self.context = context self.context = context
self.drafts = drafts self.drafts = drafts
def _workspace_if_revision_matches(
self,
*,
workspace_id: str,
revision: int,
) -> WorkflowDraftWorkspace | dict[str, Any]:
"""Load a workspace for no-op edits while still enforcing optimistic locks."""
workspace = self.drafts._draft_store().get_workspace(workspace_id)
if workspace.revision == revision:
return workspace
return {
**summarize_draft_workspace(workspace),
"status": "conflict",
"diagnostics": [
{
"code": "revision_conflict",
"path": "revision",
"message": (
f"workspace {workspace.id!r} is at revision "
f"{workspace.revision}, not {revision}"
),
}
],
}
def _outcomes_for_capability(self, qualified_name: str) -> tuple[str, ...] | None: def _outcomes_for_capability(self, qualified_name: str) -> tuple[str, ...] | None:
try: try:
spec = self.context.specs.get_qualified_spec(qualified_name) spec = self.context.specs.get_qualified_spec(qualified_name)
@@ -317,9 +346,13 @@ class WorkflowDraftAuthoringApi:
raise ValueError(f"routes for step {step_id!r} must be an object") raise ValueError(f"routes for step {step_id!r} must be an object")
merged = {**existing, **routes} merged = {**existing, **routes}
if merged == existing: if merged == existing:
return await self.drafts.get_draft_workspace( checked = self._workspace_if_revision_matches(
workspace_id=workspace_id, workspace_id=workspace_id,
revision=revision,
) )
if isinstance(checked, dict):
return checked
return summarize_draft_workspace(checked)
return await self.drafts.patch_draft_workspace( return await self.drafts.patch_draft_workspace(
workspace_id=workspace_id, workspace_id=workspace_id,
revision=revision, revision=revision,
@@ -342,9 +375,13 @@ class WorkflowDraftAuthoringApi:
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Update the target for multiple (step, outcome) pairs atomically.""" """Update the target for multiple (step, outcome) pairs atomically."""
if not branches: if not branches:
return await self.drafts.get_draft_workspace( checked = self._workspace_if_revision_matches(
workspace_id=workspace_id, workspace_id=workspace_id,
revision=revision,
) )
if isinstance(checked, dict):
return checked
return summarize_draft_workspace(checked)
workspace = self.drafts._draft_store().get_workspace(workspace_id) workspace = self.drafts._draft_store().get_workspace(workspace_id)
draft_routes = workspace.draft.get("routes", {}) draft_routes = workspace.draft.get("routes", {})
if not isinstance(draft_routes, dict): if not isinstance(draft_routes, dict):
@@ -374,9 +411,13 @@ class WorkflowDraftAuthoringApi:
} }
) )
if not patch: if not patch:
return await self.drafts.get_draft_workspace( checked = self._workspace_if_revision_matches(
workspace_id=workspace_id, workspace_id=workspace_id,
revision=revision,
) )
if isinstance(checked, dict):
return checked
return summarize_draft_workspace(checked)
return await self.drafts.patch_draft_workspace( return await self.drafts.patch_draft_workspace(
workspace_id=workspace_id, workspace_id=workspace_id,
revision=revision, revision=revision,
+3 -3
View File
@@ -53,13 +53,13 @@ def state_root_field(value: str) -> str:
def _local_path_payload(value: str) -> str: def _local_path_payload(value: str) -> str:
return LocalPath._serialize(LocalPath.parse(value)) return str(LocalPath.parse(value))
def _graph_path_payload(value: str | GraphSourcePath) -> str: def _graph_path_payload(value: str | GraphSourcePath) -> str:
path = value if isinstance(value, GraphSourcePath) else GraphSourcePath.parse(value) path = value if isinstance(value, GraphSourcePath) else GraphSourcePath.parse(value)
return GraphSourcePath._serialize(path) return str(path)
def _state_path_payload(value: str) -> str: def _state_path_payload(value: str) -> str:
return StatePath._serialize(StatePath.parse(value)) return str(StatePath.parse(value))
+5
View File
@@ -32,10 +32,15 @@ def project_output_property_to_state_schema(
raise ValueError(f"output field {output_field!r} is not a JSON Schema object") raise ValueError(f"output field {output_field!r} is not a JSON Schema object")
projected = deepcopy(state_schema) projected = deepcopy(state_schema)
state_type = projected.get("type")
if state_type is not None and state_type != "object":
raise ValueError("state_schema must be an object schema")
projected.setdefault("type", "object") projected.setdefault("type", "object")
properties = projected.setdefault("properties", {}) properties = projected.setdefault("properties", {})
if not isinstance(properties, dict): if not isinstance(properties, dict):
raise ValueError("state_schema.properties must be an object") raise ValueError("state_schema.properties must be an object")
if state_field in properties:
raise ValueError(f"state field {state_field!r} already exists")
properties[state_field] = deepcopy(output_property) properties[state_field] = deepcopy(output_property)
_merge_definition_block(projected, output_schema, "$defs") _merge_definition_block(projected, output_schema, "$defs")
+1 -3
View File
@@ -8,7 +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 .draft_authoring import DraftOutcomeRef, 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
@@ -436,8 +436,6 @@ class WorkflowApi:
branches: list[dict[str, str]], branches: list[dict[str, str]],
target: str, target: str,
) -> dict[str, Any]: ) -> dict[str, Any]:
from .draft_authoring import DraftOutcomeRef
refs = [ refs = [
DraftOutcomeRef(step_id=b["step_id"], outcome=b["outcome"]) DraftOutcomeRef(step_id=b["step_id"], outcome=b["outcome"])
for b in branches for b in branches
+26 -21
View File
@@ -12,18 +12,39 @@ from wf_cli.io import CliInputError, emit_json, parse_bindings, parse_json_value
from wf_cli.remote_errors import run_cli_operation from wf_cli.remote_errors import run_cli_operation
def _parse_map_flags(values: list[str] | None) -> dict[str, str]: def _parse_assignment_flags(
values: list[str] | None,
*,
option_name: str,
expected: str,
) -> dict[str, str]:
parsed: dict[str, str] = {} parsed: dict[str, str] = {}
for item in values or []: for item in values or []:
source, separator, target = item.partition("=") source, separator, target = item.partition("=")
if separator != "=" or not source or not target: if separator != "=" or not source or not target:
raise typer.BadParameter("--map must use source=target") raise typer.BadParameter(f"{option_name} must use {expected}")
if source in parsed: if source in parsed:
raise typer.BadParameter(f"duplicate --map for {source!r}") raise typer.BadParameter(f"duplicate {option_name} for {source!r}")
parsed[source] = target parsed[source] = target
return parsed return parsed
def _parse_map_flags(values: list[str] | None) -> dict[str, str]:
return _parse_assignment_flags(
values,
option_name="--map",
expected="source=target",
)
def _parse_route_flags(values: list[str] | None) -> dict[str, str]:
return _parse_assignment_flags(
values,
option_name="--route",
expected="OUTCOME=TARGET",
)
app = typer.Typer( app = typer.Typer(
name="draft", name="draft",
help="Create, inspect, patch, validate, and save draft workflows.", help="Create, inspect, patch, validate, and save draft workflows.",
@@ -373,15 +394,7 @@ 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] = {} routes = _parse_route_flags(route)
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(
@@ -418,15 +431,7 @@ def branch_draft(
] = None, ] = None,
) -> None: ) -> None:
"""Branch multiple outcome routes on a single step atomically.""" """Branch multiple outcome routes on a single step atomically."""
routes: dict[str, str] = {} routes = _parse_route_flags(route)
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(
+2 -2
View File
@@ -280,11 +280,11 @@ class AddStepFromCapabilityRequest(BaseModel):
"require explicit routes." "require explicit routes."
), ),
) )
input_map: dict[str, str] = Field( input_map: DraftPathMap = Field(
default_factory=dict, default_factory=dict,
description="Graph source path to node-local target field.", description="Graph source path to node-local target field.",
) )
bind_outputs: dict[str, str] = Field( bind_outputs: DraftPathMap = Field(
default_factory=dict, default_factory=dict,
description="Node-local output field to state path with schema projection.", description="Node-local output field to state path with schema projection.",
) )
+1 -1
View File
@@ -274,7 +274,7 @@ def test_interrupt_node_rejects_mixed_old_and_new_binding_styles():
) )
def test_foreach_node_serializes_over_path_as_structural_json(): def test_foreach_node_serializes_over_path_as_canonical_string():
node = ForeachNode.model_validate( node = ForeachNode.model_validate(
{ {
"id": "each_item", "id": "each_item",
+40
View File
@@ -906,6 +906,26 @@ async def test_branch_draft_no_change_when_routes_unchanged(tmp_path: Path) -> N
assert after == before assert after == before
@pytest.mark.asyncio
async def test_branch_draft_no_change_still_checks_revision(tmp_path: Path) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_branch_noop_stale")
api, _service, authoring = _draft_api(artifact_store, register_echo=True)
await api.create_draft_workspace(
workspace_id="noop_ws",
draft=_echo_draft(),
)
result = await authoring.branch_draft(
workspace_id="noop_ws",
revision=2,
step_id="echo",
routes={"ok": "__end__"},
)
assert result["status"] == "conflict"
assert result["diagnostics"][0]["code"] == "revision_conflict"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_handle_draft_empty_branches_noop(tmp_path: Path) -> None: async def test_handle_draft_empty_branches_noop(tmp_path: Path) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_handle_noop") artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_handle_noop")
@@ -927,6 +947,26 @@ async def test_handle_draft_empty_branches_noop(tmp_path: Path) -> None:
assert after == before assert after == before
@pytest.mark.asyncio
async def test_handle_draft_no_change_still_checks_revision(tmp_path: Path) -> None:
artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_handle_noop_stale")
api, _service, authoring = _draft_api(artifact_store, register_echo=True)
await api.create_draft_workspace(
workspace_id="noop_ws",
draft=_echo_draft(),
)
result = await authoring.handle_draft(
workspace_id="noop_ws",
revision=2,
branches=[],
target="fail",
)
assert result["status"] == "conflict"
assert result["diagnostics"][0]["code"] == "revision_conflict"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_step_from_capability_infers_single_outcome_route( async def test_add_step_from_capability_infers_single_outcome_route(
tmp_path: Path, tmp_path: Path,
+29
View File
@@ -67,6 +67,35 @@ def test_project_output_property_rejects_conflicting_defs() -> None:
) )
def test_project_output_property_rejects_non_object_state_schema() -> None:
with pytest.raises(ValueError, match="state_schema must be an object schema"):
project_output_property_to_state_schema(
state_schema={"type": "array", "items": {"type": "string"}},
output_schema={
"type": "object",
"properties": {"after": {"type": "object"}},
},
output_field="after",
state_field="after",
)
def test_project_output_property_rejects_existing_state_field() -> None:
with pytest.raises(ValueError, match="state field 'after' already exists"):
project_output_property_to_state_schema(
state_schema={
"type": "object",
"properties": {"after": {"type": "string"}},
},
output_schema={
"type": "object",
"properties": {"after": {"type": "object"}},
},
output_field="after",
state_field="after",
)
def test_project_output_property_rejects_invalid_output_schema() -> None: def test_project_output_property_rejects_invalid_output_schema() -> None:
with pytest.raises(ValueError, match="output_schema is not valid JSON Schema"): with pytest.raises(ValueError, match="output_schema is not valid JSON Schema"):
project_output_property_to_state_schema( project_output_property_to_state_schema(
+50 -6
View File
@@ -148,15 +148,59 @@ 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"])
assert result.exit_code == 0 assert result.exit_code == 0
assert "state schema" in result.output output = " ".join(result.output.split())
assert "output binding" in result.output assert "state schema" in output
assert "validate" in result.output assert "output binding" in output
assert "validate" in output
def test_wf_draft_add_step_from_capability_help_explains_explicit_wiring() -> None: def test_wf_draft_add_step_from_capability_help_explains_explicit_wiring() -> None:
result = runner.invoke(app, ["draft", "add-step-from-capability", "--help"]) result = runner.invoke(app, ["draft", "add-step-from-capability", "--help"])
assert result.exit_code == 0 assert result.exit_code == 0
assert "--from-step" in result.output output = " ".join(result.output.split())
assert "--bind-output" in result.output assert "--from-step" in output
assert "does not guess" in result.output assert "--bind-output" in output
assert "does not guess" in output
def test_wf_draft_route_flags_reject_duplicate_outcomes() -> None:
add_result = runner.invoke(
app,
[
"draft",
"add-step-from-capability",
"ws",
"--revision",
"1",
"--step",
"call",
"--capability",
"demo.call",
"--route",
"ok=call",
"--route",
"ok=__end__",
],
)
branch_result = runner.invoke(
app,
[
"draft",
"branch",
"ws",
"--revision",
"1",
"--step",
"call",
"--route",
"ok=call",
"--route",
"ok=__end__",
],
)
assert add_result.exit_code == 2
assert branch_result.exit_code == 2
assert "duplicate --route for 'ok'" in add_result.output
assert "duplicate --route for 'ok'" in branch_result.output