fix: make workflow drafts explicitly opt in

This commit is contained in:
lda
2026-08-31 03:08:47 +07:00 Verified
parent 5315d4b66e
commit ce7e3ed761
15 changed files with 256 additions and 108 deletions
@@ -19,19 +19,31 @@
## Verification ## Verification
- `uv run pytest tests/wf_client -q` — 46 passed. - Initial Task 7 client and cross-layer selection — 288 passed.
- Focused/cross-layer Task 7 selection — 288 passed. - Fix-round focused client/composition selection — 55 passed.
- `uv run pytest tests/wf_api/test_durable_context.py tests/wf_transport_rpc_http/test_app.py::test_rpc_app_can_omit_draft_methods -q` — passed.
- Ruff check and basedpyright for changed client/API/transport surfaces — passed. - Ruff check and basedpyright for changed client/API/transport surfaces — passed.
- `uv run python -m wf_contract_manifest check` — passed. - `uv run python -m wf_contract_manifest check` — passed.
- `pnpm --dir web --filter @lda/workflow-rpc contract:check` — passed. - `pnpm --dir web --filter @lda/workflow-rpc contract:check` — passed.
- `pnpm --dir web --filter @lda/workflow-rpc test` — 151 passed, 3 skipped. - `pnpm --dir web --filter @lda/workflow-rpc test` — 151 passed, 3 skipped.
The broader repository format check still reports pre-existing formatting The full repository suite was not used as the fix-round gate: legacy direct
differences in `src/wf_api/deployments.py`, `src/wf_authoring/builder/core.py`, draft-service tests still construct draft APIs without the now-required
and `tests/wf_client/test_authoring.py`; no formatting errors remain in the explicit `drafts=True` opt-in, and one thesis asset test expects untracked PDF
changed Task 7 files. The full `uv run pytest -q` run reached 2,613 passed, figures absent from the base worktree. No generated `.wf_mcp_store/` or
1 skipped, and 1 xfailed; three failures were external to this change: two `test-artifacts/` files are part of this change.
legacy direct-service/draft tests were fixed by retaining the default-enabled
constructor compatibility, while the remaining thesis asset test expects ## Fix round 1
untracked PDF figures absent from the base worktree.
- Normal `WorkflowApi`, nested capability/artifact services, durable context,
local server construction, and JSON-RPC app composition now default to
`drafts=False`. Draft APIs are stored as `None` when disabled and require
explicit `drafts=True` at composition time; RPC registration rejects an
opt-in against a disabled API.
- The two live walkthroughs now include real schemas, explicit constant input
and output bindings, an `end` step, and the terminal route.
- The repr projector now follows the console evidence policy's exact-key
matching, normalizes camelCase spellings (`apiKey`, `accessToken`, etc.),
avoids false positives (`tokenCount`, `secretary`), and consumes at most a
bounded prefix of mappings/sequences/iterables.
- Fix-round verification: focused client/composition tests `55 passed`; Ruff
and basedpyright passed with zero errors.
+19 -5
View File
@@ -128,23 +128,37 @@ permanent graph node or expose it as a final workflow-output source.
### Python client walkthrough ### Python client walkthrough
The Python client is intended for an application that already has a running The Python client is intended for an application that already has a running
workflow server. This is the complete shape of a real client call; the schema workflow server. Hypothetically, an application that wants to turn a constant
capability into a durable run would use this complete call shape; the schema
arguments may be JSON Schema dictionaries or the application's schema model arguments may be JSON Schema dictionaries or the application's schema model
values: values:
```python ```python
from wf_client import App from wf_client import App
from wf_authoring import input_from, input_value, output_to, state_path
app = App.from_http_jsonrpc("http://localhost:8765/rpc") app = App.from_http_jsonrpc("http://localhost:8765/rpc")
capability = await app.capability("wf.std.constant") capability = await app.capability("wf.std.constant")
graph = app.new_workflow( graph = app.new_workflow(
"example", "example",
input_schema=InputModel, input_schema={"type": "object", "properties": {}},
state_schema=StateModel, state_schema={"type": "object", "properties": {"value": {"type": "string"}}},
output_schema=OutputModel, output_schema={
"type": "object",
"properties": {"value": {"type": "string"}},
"required": ["value"],
},
) )
step = graph.use(capability) step = graph.use(
capability,
id="constant",
input=[input_value("value", "hello")],
output=[output_to("value", state_path("value"))],
)
end = graph.end("ok", id="end_ok")
graph.set_entry_point(step) graph.set_entry_point(step)
graph.connect(step, "ok", end)
graph.set_output([input_from(state_path("value"), "value")])
validation = await graph.validate() validation = await graph.validate()
validation.raise_for_errors() validation.raise_for_errors()
artifact = await graph.save(version=1) artifact = await graph.save(version=1)
+22 -7
View File
@@ -110,19 +110,34 @@ new dependency, add a narrow protocol or explicit field.
## Python client lifecycle ## Python client lifecycle
The Python client makes the intended application flow explicit: Hypothetically, an application that wants to turn a discovered capability into
a durable run would use the following complete flow:
```python ```python
from wf_authoring import input_from, input_value, output_to, state_path
app = App.from_http_jsonrpc("http://localhost:8765/rpc") app = App.from_http_jsonrpc("http://localhost:8765/rpc")
capability = await app.capability("wf.std.constant") capability = await app.capability("wf.std.constant")
graph = app.new_workflow( graph = app.new_workflow(
"example", "example",
input_schema=InputModel, input_schema={"type": "object", "properties": {}},
state_schema=StateModel, state_schema={"type": "object", "properties": {"value": {"type": "string"}}},
output_schema=OutputModel, output_schema={
"type": "object",
"properties": {"value": {"type": "string"}},
"required": ["value"],
},
) )
step = graph.use(capability) step = graph.use(
capability,
id="constant",
input=[input_value("value", "hello")],
output=[output_to("value", state_path("value"))],
)
end = graph.end("ok", id="end_ok")
graph.set_entry_point(step) graph.set_entry_point(step)
graph.connect(step, "ok", end)
graph.set_output([input_from(state_path("value"), "value")])
validation = await graph.validate() validation = await graph.validate()
validation.raise_for_errors() validation.raise_for_errors()
artifact = await graph.save(version=1) artifact = await graph.save(version=1)
@@ -189,8 +204,8 @@ contract itself.
```text ```text
WorkflowApi WorkflowApi
capabilities: WorkflowCapabilityApi capabilities: WorkflowCapabilityApi
drafts: WorkflowDraftApi drafts: WorkflowDraftApi | None # only when drafts=True
draft_authoring: WorkflowDraftAuthoringApi draft_authoring: WorkflowDraftAuthoringApi | None # only when drafts=True
artifacts: WorkflowArtifactApi artifacts: WorkflowArtifactApi
deployments: WorkflowDeploymentApi deployments: WorkflowDeploymentApi
runs: WorkflowRunApi runs: WorkflowRunApi
+13 -3
View File
@@ -123,9 +123,19 @@ class WorkflowArtifactApi:
WorkflowOperationContext so this module stays protocol-neutral. WorkflowOperationContext so this module stays protocol-neutral.
""" """
def __init__(self, context: WorkflowOperationContext) -> None: def __init__(
self, context: WorkflowOperationContext, *, drafts: bool = False
) -> None:
self.context = context self.context = context
self.drafts = WorkflowDraftApi(context) self.drafts: WorkflowDraftApi | None = (
WorkflowDraftApi(context) if drafts else None
)
def _require_drafts(self) -> WorkflowDraftApi:
"""Return draft helpers for the explicitly enabled authoring surface."""
if self.drafts is None:
raise ValueError("workflow draft APIs are disabled; pass drafts=True")
return self.drafts
def _artifact_store(self): def _artifact_store(self):
if self.context.artifact_store is None: if self.context.artifact_store is None:
@@ -369,7 +379,7 @@ class WorkflowArtifactApi:
if store is None: if store is None:
raise KeyError("draft workspace store is not configured") raise KeyError("draft workspace store is not configured")
workspace = store.get_workspace(workspace_id) workspace = store.get_workspace(workspace_id)
validation = await self.drafts.validate_draft(draft=workspace.draft) validation = await self._require_drafts().validate_draft(draft=workspace.draft)
if validation["status"] != "valid": if validation["status"] != "valid":
return _PROJECT_UNSAVED_DRAFT_ARTIFACT( return _PROJECT_UNSAVED_DRAFT_ARTIFACT(
{ {
+18 -4
View File
@@ -98,10 +98,24 @@ class WorkflowCapabilityApi:
tool schemas stay outside wf_api. tool schemas stay outside wf_api.
""" """
def __init__(self, context: WorkflowOperationContext) -> None: def __init__(
self, context: WorkflowOperationContext, *, drafts: bool = False
) -> None:
self.context = context self.context = context
self.drafts = WorkflowDraftApi(context) self.drafts: WorkflowDraftApi | None = (
self.draft_authoring = WorkflowDraftAuthoringApi(context, self.drafts) WorkflowDraftApi(context) if drafts else None
)
self.draft_authoring: WorkflowDraftAuthoringApi | None = (
WorkflowDraftAuthoringApi(context, self.drafts)
if self.drafts is not None
else None
)
def _require_draft_authoring(self) -> WorkflowDraftAuthoringApi:
"""Return draft helpers for the explicitly enabled authoring surface."""
if self.draft_authoring is None:
raise ValueError("workflow draft APIs are disabled; pass drafts=True")
return self.draft_authoring
async def list_capabilities( async def list_capabilities(
self, self,
@@ -441,7 +455,7 @@ class WorkflowCapabilityApi:
# Validate capability-derived guidance before workspace creation. The # Validate capability-derived guidance before workspace creation. The
# workspace result is already projected by the draft-workspace API. # workspace result is already projected by the draft-workspace API.
hints = _PROJECT_WRAPPER_HINTS(capability["wrapper_hints"]) hints = _PROJECT_WRAPPER_HINTS(capability["wrapper_hints"])
result = await self.draft_authoring.create_minimal_draft_workspace( result = await self._require_draft_authoring().create_minimal_draft_workspace(
workspace_id=workspace_id, workspace_id=workspace_id,
name=name or _draft_name_from_capability(capability_name), name=name or _draft_name_from_capability(capability_name),
capability_name=capability_name, capability_name=capability_name,
+2 -2
View File
@@ -8,7 +8,7 @@ from .stores import WorkflowStores
def require_workflow_stores( def require_workflow_stores(
context: WorkflowOperationContext, context: WorkflowOperationContext,
*, *,
drafts: bool = True, drafts: bool = False,
) -> WorkflowStores: ) -> WorkflowStores:
"""Return required stores or fail before constructing durable frontends. """Return required stores or fail before constructing durable frontends.
@@ -37,7 +37,7 @@ def require_workflow_stores(
def durable_workflow_api( def durable_workflow_api(
context: WorkflowOperationContext, context: WorkflowOperationContext,
*, *,
drafts: bool = True, drafts: bool = False,
) -> WorkflowApi: ) -> WorkflowApi:
"""Construct a durable API, optionally omitting the draft product surface.""" """Construct a durable API, optionally omitting the draft product surface."""
require_workflow_stores(context, drafts=drafts) require_workflow_stores(context, drafts=drafts)
+59 -57
View File
@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping, Sequence from collections.abc import Mapping, Sequence
from typing import Any, Literal, cast, overload from typing import Any, Literal, overload
from wf_artifacts import ArtifactKind, compile_workflow_draft from wf_artifacts import ArtifactKind, compile_workflow_draft
from wf_artifacts.drafts.models import DraftStep from wf_artifacts.drafts.models import DraftStep
@@ -56,19 +56,6 @@ from .operation_context import WorkflowOperationContext
from .runs import TraceRangeLike, WorkflowRunApi from .runs import TraceRangeLike, WorkflowRunApi
class _DisabledDraftSurface:
"""Placeholder that fails clearly if disabled draft methods are called.
Keeping this tiny seam avoids constructing draft services while preserving
the existing method layout on ``WorkflowApi`` for explicit draft callers.
"""
def __getattr__(self, name: str) -> Any:
raise RuntimeError(
"workflow draft APIs are disabled; compose WorkflowApi with drafts=True"
)
def _authoring_schema( def _authoring_schema(
value: object, value: object,
*, *,
@@ -117,26 +104,35 @@ class WorkflowApi:
self, self,
context: WorkflowOperationContext, context: WorkflowOperationContext,
*, *,
drafts: bool = True, drafts: bool = False,
) -> None: ) -> None:
self.context = context self.context = context
self.capabilities = WorkflowCapabilityApi(context) self.capabilities = WorkflowCapabilityApi(context, drafts=drafts)
# ``drafts`` keeps server composition explicit: callers that omit draft # ``drafts`` keeps server composition explicit. Disabled APIs are None,
# storage must pass False, while the default preserves legacy direct # so artifact/deployment/run initialization has no draft dependency.
# WorkflowApi callers that use the draft service for validation only.
self.drafts_enabled = drafts self.drafts_enabled = drafts
if self.drafts_enabled: if self.drafts_enabled:
self.drafts = WorkflowDraftApi(context) self.drafts = WorkflowDraftApi(context)
self.draft_authoring = WorkflowDraftAuthoringApi(context, self.drafts) self.draft_authoring = WorkflowDraftAuthoringApi(context, self.drafts)
else: else:
self.drafts = cast(WorkflowDraftApi, _DisabledDraftSurface()) self.drafts = None
self.draft_authoring = cast( self.draft_authoring = None
WorkflowDraftAuthoringApi, _DisabledDraftSurface() self.artifacts = WorkflowArtifactApi(context, drafts=drafts)
)
self.artifacts = WorkflowArtifactApi(context)
self.deployments = WorkflowDeploymentApi(context) self.deployments = WorkflowDeploymentApi(context)
self.runs = WorkflowRunApi(context) self.runs = WorkflowRunApi(context)
def _require_drafts(self) -> WorkflowDraftApi:
"""Return the draft service for an explicitly draft-enabled API."""
if self.drafts is None:
raise ValueError("workflow draft APIs are disabled; pass drafts=True")
return self.drafts
def _require_draft_authoring(self) -> WorkflowDraftAuthoringApi:
"""Return draft authoring for an explicitly draft-enabled API."""
if self.draft_authoring is None:
raise ValueError("workflow draft APIs are disabled; pass drafts=True")
return self.draft_authoring
# -- capabilities -- # -- capabilities --
async def list_capabilities( async def list_capabilities(
@@ -347,14 +343,14 @@ class WorkflowApi:
*, *,
draft: dict[str, Any], draft: dict[str, Any],
) -> ValidateDraftResult: ) -> ValidateDraftResult:
return await self.drafts.validate_draft(draft=draft) return await self._require_drafts().validate_draft(draft=draft)
async def compile_draft( async def compile_draft(
self, self,
*, *,
draft: dict[str, Any], draft: dict[str, Any],
) -> CompileDraftWorkspaceSuccess: ) -> CompileDraftWorkspaceSuccess:
return await self.drafts.compile_draft(draft=draft) return await self._require_drafts().compile_draft(draft=draft)
async def patch_draft( async def patch_draft(
self, self,
@@ -362,12 +358,12 @@ class WorkflowApi:
draft: dict[str, Any], draft: dict[str, Any],
patch: list[dict[str, Any]], patch: list[dict[str, Any]],
) -> PatchDraftResult: ) -> PatchDraftResult:
return await self.drafts.patch_draft(draft=draft, patch=patch) return await self._require_drafts().patch_draft(draft=draft, patch=patch)
# -- draft workspaces -- # -- draft workspaces --
async def list_draft_workspaces(self) -> ListDraftWorkspacesResult: async def list_draft_workspaces(self) -> ListDraftWorkspacesResult:
return await self.drafts.list_draft_workspaces() return await self._require_drafts().list_draft_workspaces()
async def create_draft_workspace( async def create_draft_workspace(
self, self,
@@ -376,7 +372,7 @@ class WorkflowApi:
draft: dict[str, Any], draft: dict[str, Any],
title: str | None = None, title: str | None = None,
) -> DraftWorkspaceResult: ) -> DraftWorkspaceResult:
return await self.drafts.create_draft_workspace( return await self._require_drafts().create_draft_workspace(
workspace_id=workspace_id, workspace_id=workspace_id,
draft=draft, draft=draft,
title=title, title=title,
@@ -393,7 +389,7 @@ class WorkflowApi:
output_schema: dict[str, Any] | None = None, output_schema: dict[str, Any] | None = None,
outcomes: Sequence[str] = ("ok",), outcomes: Sequence[str] = ("ok",),
) -> DraftWorkspaceResult: ) -> DraftWorkspaceResult:
return await self.drafts.create_empty_draft_workspace( return await self._require_drafts().create_empty_draft_workspace(
workspace_id=workspace_id, workspace_id=workspace_id,
name=name, name=name,
title=title, title=title,
@@ -425,7 +421,7 @@ class WorkflowApi:
workspace_id: str, workspace_id: str,
include_draft: bool = False, include_draft: bool = False,
) -> DraftWorkspaceResult | DraftWorkspaceWithDocument: ) -> DraftWorkspaceResult | DraftWorkspaceWithDocument:
return await self.drafts.get_draft_workspace( return await self._require_drafts().get_draft_workspace(
workspace_id=workspace_id, workspace_id=workspace_id,
include_draft=include_draft, include_draft=include_draft,
) )
@@ -443,7 +439,7 @@ class WorkflowApi:
selected step. This preserves the draft APIs' canonical conflict selected step. This preserves the draft APIs' canonical conflict
precedence when an authoring client is holding an old revision. precedence when an authoring client is holding an old revision.
""" """
checked = self.drafts._workspace_if_revision_matches( checked = self._require_drafts()._workspace_if_revision_matches(
workspace_id=workspace_id, workspace_id=workspace_id,
revision=revision, revision=revision,
) )
@@ -567,21 +563,27 @@ class WorkflowApi:
*, *,
workspace_id: str, workspace_id: str,
) -> DeleteDraftWorkspaceResult: ) -> DeleteDraftWorkspaceResult:
return await self.drafts.delete_draft_workspace(workspace_id=workspace_id) return await self._require_drafts().delete_draft_workspace(
workspace_id=workspace_id
)
async def validate_draft_workspace( async def validate_draft_workspace(
self, self,
*, *,
workspace_id: str, workspace_id: str,
) -> DraftWorkspaceResult: ) -> DraftWorkspaceResult:
return await self.drafts.validate_draft_workspace(workspace_id=workspace_id) return await self._require_drafts().validate_draft_workspace(
workspace_id=workspace_id
)
async def compile_draft_workspace( async def compile_draft_workspace(
self, self,
*, *,
workspace_id: str, workspace_id: str,
) -> CompileDraftWorkspaceResult: ) -> CompileDraftWorkspaceResult:
return await self.drafts.compile_draft_workspace(workspace_id=workspace_id) return await self._require_drafts().compile_draft_workspace(
workspace_id=workspace_id
)
async def patch_draft_workspace( async def patch_draft_workspace(
self, self,
@@ -590,7 +592,7 @@ class WorkflowApi:
revision: int, revision: int,
patch: list[dict[str, Any]], patch: list[dict[str, Any]],
) -> DraftWorkspaceResult: ) -> DraftWorkspaceResult:
return await self.drafts.patch_draft_workspace( return await self._require_drafts().patch_draft_workspace(
workspace_id=workspace_id, workspace_id=workspace_id,
revision=revision, revision=revision,
patch=patch, patch=patch,
@@ -604,7 +606,7 @@ class WorkflowApi:
draft: dict[str, Any], draft: dict[str, Any],
) -> DraftWorkspaceResult: ) -> DraftWorkspaceResult:
"""Replace and semantically revalidate one complete workspace draft.""" """Replace and semantically revalidate one complete workspace draft."""
return await self.drafts.replace_draft_workspace_document( return await self._require_drafts().replace_draft_workspace_document(
workspace_id=workspace_id, workspace_id=workspace_id,
revision=revision, revision=revision,
draft=draft, draft=draft,
@@ -617,7 +619,7 @@ class WorkflowApi:
revision: int, revision: int,
name: str, name: str,
) -> DraftWorkspaceResult: ) -> DraftWorkspaceResult:
return await self.drafts.set_draft_name( return await self._require_drafts().set_draft_name(
workspace_id=workspace_id, workspace_id=workspace_id,
revision=revision, revision=revision,
name=name, name=name,
@@ -630,7 +632,7 @@ class WorkflowApi:
revision: int, revision: int,
step_id: str, step_id: str,
) -> DraftWorkspaceResult: ) -> DraftWorkspaceResult:
return await self.drafts.set_draft_start( return await self._require_drafts().set_draft_start(
workspace_id=workspace_id, workspace_id=workspace_id,
revision=revision, revision=revision,
step_id=step_id, step_id=step_id,
@@ -646,7 +648,7 @@ class WorkflowApi:
output_schema: dict[str, Any] | None = None, output_schema: dict[str, Any] | None = None,
outcomes: Sequence[str] | None = None, outcomes: Sequence[str] | None = None,
) -> DraftWorkspaceResult: ) -> DraftWorkspaceResult:
return await self.drafts.set_draft_contract( return await self._require_drafts().set_draft_contract(
workspace_id=workspace_id, workspace_id=workspace_id,
revision=revision, revision=revision,
input_schema=input_schema, input_schema=input_schema,
@@ -664,7 +666,7 @@ class WorkflowApi:
outcome: str, outcome: str,
target: str, target: str,
) -> DraftWorkspaceResult: ) -> DraftWorkspaceResult:
return await self.drafts.set_draft_route( return await self._require_drafts().set_draft_route(
workspace_id=workspace_id, workspace_id=workspace_id,
revision=revision, revision=revision,
step_id=step_id, step_id=step_id,
@@ -681,7 +683,7 @@ class WorkflowApi:
input_map: dict[str, str], input_map: dict[str, str],
merge: bool = False, merge: bool = False,
) -> DraftWorkspaceResult: ) -> DraftWorkspaceResult:
return await self.drafts.set_step_input_map( return await self._require_drafts().set_step_input_map(
workspace_id=workspace_id, workspace_id=workspace_id,
revision=revision, revision=revision,
step_id=step_id, step_id=step_id,
@@ -697,7 +699,7 @@ class WorkflowApi:
step_id: str, step_id: str,
bindings: Sequence[StepInputBinding], bindings: Sequence[StepInputBinding],
) -> DraftWorkspaceResult: ) -> DraftWorkspaceResult:
return await self.draft_authoring.set_step_input_bindings( return await self._require_draft_authoring().set_step_input_bindings(
workspace_id=workspace_id, workspace_id=workspace_id,
revision=revision, revision=revision,
step_id=step_id, step_id=step_id,
@@ -712,7 +714,7 @@ class WorkflowApi:
step_id: str, step_id: str,
bindings: Sequence[OutputBinding], bindings: Sequence[OutputBinding],
) -> DraftWorkspaceResult: ) -> DraftWorkspaceResult:
return await self.draft_authoring.set_step_output_bindings( return await self._require_draft_authoring().set_step_output_bindings(
workspace_id=workspace_id, workspace_id=workspace_id,
revision=revision, revision=revision,
step_id=step_id, step_id=step_id,
@@ -728,7 +730,7 @@ class WorkflowApi:
update: CapabilityStepUpdate, update: CapabilityStepUpdate,
) -> DraftWorkspaceResult: ) -> DraftWorkspaceResult:
"""Return the updated workspace summary or a revision-conflict payload.""" """Return the updated workspace summary or a revision-conflict payload."""
return await self.draft_authoring.update_capability_step( return await self._require_draft_authoring().update_capability_step(
workspace_id=workspace_id, workspace_id=workspace_id,
revision=revision, revision=revision,
step_id=step_id, step_id=step_id,
@@ -744,7 +746,7 @@ class WorkflowApi:
output_map: dict[str, str], output_map: dict[str, str],
merge: bool = False, merge: bool = False,
) -> DraftWorkspaceResult: ) -> DraftWorkspaceResult:
return await self.drafts.set_step_output_map( return await self._require_drafts().set_step_output_map(
workspace_id=workspace_id, workspace_id=workspace_id,
revision=revision, revision=revision,
step_id=step_id, step_id=step_id,
@@ -760,7 +762,7 @@ class WorkflowApi:
output_map: dict[str, str], output_map: dict[str, str],
merge: bool = False, merge: bool = False,
) -> DraftWorkspaceResult: ) -> DraftWorkspaceResult:
return await self.drafts.set_workflow_output_map( return await self._require_drafts().set_workflow_output_map(
workspace_id=workspace_id, workspace_id=workspace_id,
revision=revision, revision=revision,
output_map=output_map, output_map=output_map,
@@ -774,7 +776,7 @@ class WorkflowApi:
revision: int, revision: int,
bindings: Sequence[InputBinding], bindings: Sequence[InputBinding],
) -> DraftWorkspaceResult: ) -> DraftWorkspaceResult:
return await self.draft_authoring.set_workflow_output_bindings( return await self._require_draft_authoring().set_workflow_output_bindings(
workspace_id=workspace_id, workspace_id=workspace_id,
revision=revision, revision=revision,
bindings=bindings, bindings=bindings,
@@ -789,7 +791,7 @@ class WorkflowApi:
source_path: str, source_path: str,
target_path: str, target_path: str,
) -> DraftWorkspaceResult: ) -> DraftWorkspaceResult:
return await self.draft_authoring.bind_draft( return await self._require_draft_authoring().bind_draft(
workspace_id=workspace_id, workspace_id=workspace_id,
revision=revision, revision=revision,
step_id=step_id, step_id=step_id,
@@ -814,7 +816,7 @@ class WorkflowApi:
retry: int | None = None, retry: int | None = None,
timeout_seconds: int | None = None, timeout_seconds: int | None = None,
) -> DraftWorkspaceResult: ) -> DraftWorkspaceResult:
return await self.draft_authoring.add_step_from_capability( return await self._require_draft_authoring().add_step_from_capability(
workspace_id=workspace_id, workspace_id=workspace_id,
revision=revision, revision=revision,
step_id=step_id, step_id=step_id,
@@ -840,7 +842,7 @@ class WorkflowApi:
incoming: RouteSource | None = None, incoming: RouteSource | None = None,
routes: dict[str, str] | None = None, routes: dict[str, str] | None = None,
) -> DraftWorkspaceResult: ) -> DraftWorkspaceResult:
return await self.draft_authoring.add_step( return await self._require_draft_authoring().add_step(
workspace_id=workspace_id, workspace_id=workspace_id,
revision=revision, revision=revision,
step_id=step_id, step_id=step_id,
@@ -857,7 +859,7 @@ class WorkflowApi:
step_id: str, step_id: str,
routes: dict[str, str], routes: dict[str, str],
) -> DraftWorkspaceResult: ) -> DraftWorkspaceResult:
return await self.draft_authoring.branch_draft( return await self._require_draft_authoring().branch_draft(
workspace_id=workspace_id, workspace_id=workspace_id,
revision=revision, revision=revision,
step_id=step_id, step_id=step_id,
@@ -875,7 +877,7 @@ class WorkflowApi:
refs = [ refs = [
RouteSource(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( return await self._require_draft_authoring().handle_draft(
workspace_id=workspace_id, workspace_id=workspace_id,
revision=revision, revision=revision,
branches=refs, branches=refs,
@@ -898,7 +900,7 @@ class WorkflowApi:
error_message_source: Any | None = None, error_message_source: Any | None = None,
title: str | None = None, title: str | None = None,
) -> DraftWorkspaceResult: ) -> DraftWorkspaceResult:
return await self.draft_authoring.create_minimal_draft_workspace( return await self._require_draft_authoring().create_minimal_draft_workspace(
workspace_id=workspace_id, workspace_id=workspace_id,
name=name, name=name,
capability_name=capability_name, capability_name=capability_name,
@@ -921,7 +923,7 @@ class WorkflowApi:
step_id: str, step_id: str,
outcome: str, outcome: str,
) -> DraftWorkspaceResult: ) -> DraftWorkspaceResult:
return await self.draft_authoring.remove_draft_route( return await self._require_draft_authoring().remove_draft_route(
workspace_id=workspace_id, workspace_id=workspace_id,
revision=revision, revision=revision,
step_id=step_id, step_id=step_id,
@@ -935,7 +937,7 @@ class WorkflowApi:
revision: int, revision: int,
step_id: str, step_id: str,
) -> DraftWorkspaceResult: ) -> DraftWorkspaceResult:
return await self.draft_authoring.remove_draft_step( return await self._require_draft_authoring().remove_draft_step(
workspace_id=workspace_id, workspace_id=workspace_id,
revision=revision, revision=revision,
step_id=step_id, step_id=step_id,
@@ -950,7 +952,7 @@ class WorkflowApi:
inputs: Sequence[str] = (), inputs: Sequence[str] = (),
outputs: Sequence[str] = (), outputs: Sequence[str] = (),
) -> DraftWorkspaceResult: ) -> DraftWorkspaceResult:
return await self.draft_authoring.remove_draft_binding( return await self._require_draft_authoring().remove_draft_binding(
workspace_id=workspace_id, workspace_id=workspace_id,
revision=revision, revision=revision,
step_id=step_id, step_id=step_id,
+31 -10
View File
@@ -11,27 +11,37 @@ from __future__ import annotations
import html import html
import json import json
import re
from collections.abc import Mapping, Sequence from collections.abc import Mapping, Sequence
from itertools import islice
_SECRET_KEY_PARTS = ( _SENSITIVE_KEYS = {
"authorization", "authorization",
"cookie", "cookie",
"set-cookie", "set_cookie",
"token", "token",
"access_token",
"refresh_token",
"secret", "secret",
"password", "password",
"api_key", "api_key",
"api-key", }
)
_MAX_DEPTH = 2 _MAX_DEPTH = 2
_MAX_ITEMS = 8 _MAX_ITEMS = 8
_MAX_STRING = 160 _MAX_STRING = 160
_MAX_RENDERED = 1_200 _MAX_RENDERED = 1_200
def _canonical_key(key: object) -> str:
"""Normalize snake/kebab/camel spellings to the shared evidence keys."""
value = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", str(key))
return value.replace(" ", "_").replace("-", "_").lower()
def _secret_key(key: object) -> bool: def _secret_key(key: object) -> bool:
lowered = str(key).lower() # Match the evidence policy's exact key set; substring matching would
return any(part in lowered for part in _SECRET_KEY_PARTS) # incorrectly redact harmless fields such as ``tokenCount`` or ``secretary``.
return _canonical_key(key) in _SENSITIVE_KEYS
def bounded_value(value: object, *, depth: int = 0) -> object: def bounded_value(value: object, *, depth: int = 0) -> object:
@@ -43,7 +53,8 @@ def bounded_value(value: object, *, depth: int = 0) -> object:
if value is None or isinstance(value, bool | int | float): if value is None or isinstance(value, bool | int | float):
return value return value
if isinstance(value, Mapping): if isinstance(value, Mapping):
items = list(value.items()) iterator = iter(value.items())
items = list(islice(iterator, _MAX_ITEMS + 1))
preview = { preview = {
str(key): "[redacted]" str(key): "[redacted]"
if _secret_key(key) if _secret_key(key)
@@ -51,13 +62,23 @@ def bounded_value(value: object, *, depth: int = 0) -> object:
for key, item in items[:_MAX_ITEMS] for key, item in items[:_MAX_ITEMS]
} }
if len(items) > _MAX_ITEMS: if len(items) > _MAX_ITEMS:
preview[""] = f"{len(items) - _MAX_ITEMS} more entries" preview[""] = "more entries"
return preview return preview
if isinstance(value, Sequence) and not isinstance(value, str | bytes | bytearray): if isinstance(value, Sequence) and not isinstance(value, str | bytes | bytearray):
items = list(value) iterator = iter(value)
items = list(islice(iterator, _MAX_ITEMS + 1))
preview = [bounded_value(item, depth=depth + 1) for item in items[:_MAX_ITEMS]] preview = [bounded_value(item, depth=depth + 1) for item in items[:_MAX_ITEMS]]
if len(items) > _MAX_ITEMS: if len(items) > _MAX_ITEMS:
preview.append(f" {len(items) - _MAX_ITEMS} more items") preview.append("… more items")
return preview
if isinstance(value, Sequence):
return "[truncated sequence]"
if hasattr(value, "__iter__"):
iterator = iter(value) # type: ignore[call-overload]
items = list(islice(iterator, _MAX_ITEMS + 1))
preview = [bounded_value(item, depth=depth + 1) for item in items[:_MAX_ITEMS]]
if len(items) > _MAX_ITEMS:
preview.append("… more items")
return preview return preview
rendered = repr(value) rendered = repr(value)
return rendered if len(rendered) <= _MAX_STRING else rendered[:_MAX_STRING] + "" return rendered if len(rendered) <= _MAX_STRING else rendered[:_MAX_STRING] + ""
+9 -2
View File
@@ -14,8 +14,15 @@ from .normalize import manifest_from_openrpc
def generate_manifest() -> ContractManifest: def generate_manifest() -> ContractManifest:
"""Compose the real server against an isolated store and normalize OpenRPC.""" """Compose the real server against an isolated store and normalize OpenRPC."""
with TemporaryDirectory(prefix="wf-contract-manifest-") as directory: with TemporaryDirectory(prefix="wf-contract-manifest-") as directory:
server = build_local_static_workflow_server(Path(directory) / "store") # The checked contract describes the complete opt-in API. Product
document = cast(dict[str, object], create_rpc_app(server).get_openrpc()) # composition remains draft-free by default; contract generation is
# the deliberate compatibility seam that asks for the full surface.
server = build_local_static_workflow_server(
Path(directory) / "store", drafts=True
)
document = cast(
dict[str, object], create_rpc_app(server, drafts=True).get_openrpc()
)
# Normalization deliberately drops framework metadata that could carry # Normalization deliberately drops framework metadata that could carry
# process-local paths or transport details. # process-local paths or transport details.
return manifest_from_openrpc(document) return manifest_from_openrpc(document)
+3 -2
View File
@@ -295,8 +295,9 @@ def build_local_static_workflow_server(
root: str | Path, root: str | Path,
*, *,
extra_sources: Mapping[str, CapabilitySource] | None = None, extra_sources: Mapping[str, CapabilitySource] | None = None,
drafts: bool = False,
) -> WorkflowServer: ) -> WorkflowServer:
"""Build a durable local/static workflow server composition.""" """Build a durable local/static server, with drafts as an explicit opt-in."""
config = WorkflowServerConfig(store_root=Path(root)) config = WorkflowServerConfig(store_root=Path(root))
stores = file_workflow_stores(config.store_root) stores = file_workflow_stores(config.store_root)
events = InMemoryWorkflowEventRecorder() events = InMemoryWorkflowEventRecorder()
@@ -320,7 +321,7 @@ def build_local_static_workflow_server(
runtime=runtime, runtime=runtime,
live_sources=None, live_sources=None,
) )
api = durable_workflow_api(context) api = durable_workflow_api(context, drafts=drafts)
source_admin = WorkflowSourceAdminApi(context) source_admin = WorkflowSourceAdminApi(context)
admin = WorkflowAdminApi( admin = WorkflowAdminApi(
connections=EmptyWorkflowConnectionProvider(), connections=EmptyWorkflowConnectionProvider(),
+4 -3
View File
@@ -28,7 +28,7 @@ def create_rpc_app(
server: WorkflowServer, server: WorkflowServer,
*, *,
rpc_path: str = "/rpc", rpc_path: str = "/rpc",
drafts: bool | None = None, drafts: bool = False,
) -> jsonrpc.API: ) -> jsonrpc.API:
"""Build a JSON-RPC HTTP app over an existing WorkflowServer. """Build a JSON-RPC HTTP app over an existing WorkflowServer.
@@ -54,8 +54,9 @@ def create_rpc_app(
} }
register_capability_methods(entrypoint, server) register_capability_methods(entrypoint, server)
drafts_enabled = server.api.drafts_enabled if drafts is None else drafts if drafts:
if drafts_enabled: if not server.api.drafts_enabled:
raise ValueError("cannot enable draft RPC methods on a draft-disabled API")
register_draft_methods(entrypoint, server) register_draft_methods(entrypoint, server)
register_artifact_methods(entrypoint, server) register_artifact_methods(entrypoint, server)
register_deployment_methods(entrypoint, server) register_deployment_methods(entrypoint, server)
+1 -1
View File
@@ -27,7 +27,7 @@ def _api(root: Path) -> WorkflowApi:
ConnectionConfig(id="demo.personal", server="demo", account="personal") ConnectionConfig(id="demo.personal", server="demo", account="personal")
) )
service.register_specs("demo.personal", echo_tool) service.register_specs("demo.personal", echo_tool)
return WorkflowApi(context_from_service(service)) return WorkflowApi(context_from_service(service), drafts=True)
def test_workflow_api_composes_domain_services(tmp_path: Path) -> None: def test_workflow_api_composes_domain_services(tmp_path: Path) -> None:
+5
View File
@@ -69,3 +69,8 @@ def test_durable_workflow_api_can_opt_out_of_draft_store(tmp_path) -> None:
api = durable_workflow_api(context, drafts=False) api = durable_workflow_api(context, drafts=False)
assert api.drafts_enabled is False assert api.drafts_enabled is False
assert api.drafts is None
assert api.draft_authoring is None
assert api.capabilities.drafts is None
assert api.capabilities.draft_authoring is None
assert api.artifacts.drafts is None
+39
View File
@@ -86,6 +86,45 @@ def test_rich_representations_bound_large_values_and_redact_secret_like_fields()
assert port.calls == [] assert port.calls == []
def test_repr_redacts_only_exact_sensitive_keys_in_snake_and_camel_case() -> None:
result = CapabilityResult(
outcome="ok",
output={
"apiKey": "hide-me",
"accessToken": "hide-me-too",
"setCookie": "hide-me-three",
"tokenCount": 3,
"authorizationStatus": "ok",
"secretary": "safe",
},
diagnostics=(),
)
rendered = repr(result)
assert "hide-me" not in rendered
assert "hide-me-too" not in rendered
assert "hide-me-three" not in rendered
assert '"tokenCount": 3' in rendered
assert '"authorizationStatus": "ok"' in rendered
assert '"secretary": "safe"' in rendered
def test_repr_does_not_materialize_an_unbounded_iterable() -> None:
class ExplodingIterable:
def __iter__(self):
for index in range(10_000):
if index > 8:
raise AssertionError("repr consumed too many values")
yield index
result = CapabilityResult("ok", {"values": ExplodingIterable()}, ())
rendered = repr(result)
assert "more items" in rendered
def test_all_rich_objects_render_without_port_access() -> None: def test_all_rich_objects_render_without_port_access() -> None:
port = cast(WorkflowClientPort, _port()) port = cast(WorkflowClientPort, _port())
diagnostic = WorkflowDiagnostic("error", "bad", "state.x", "broken") diagnostic = WorkflowDiagnostic("error", "bad", "state.x", "broken")
+8 -1
View File
@@ -35,7 +35,6 @@ async def _rpc(
def test_rpc_app_can_omit_draft_methods(tmp_path) -> None: def test_rpc_app_can_omit_draft_methods(tmp_path) -> None:
server = build_local_static_workflow_server(tmp_path / "store") server = build_local_static_workflow_server(tmp_path / "store")
assert server.api.drafts_enabled is True
app = create_rpc_app(server, drafts=False) app = create_rpc_app(server, drafts=False)
methods = {method["name"] for method in app.get_openrpc()["methods"]} methods = {method["name"] for method in app.get_openrpc()["methods"]}
@@ -43,6 +42,14 @@ def test_rpc_app_can_omit_draft_methods(tmp_path) -> None:
assert "workflow.draft_workspaces.list" not in methods assert "workflow.draft_workspaces.list" not in methods
def test_rpc_app_draft_methods_require_explicit_server_opt_in(tmp_path) -> None:
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
app = create_rpc_app(server, drafts=True)
methods = {method["name"] for method in app.get_openrpc()["methods"]}
assert "workflow.draft_workspaces.list" in methods
def _rpc_constant_draft() -> dict[str, Any]: def _rpc_constant_draft() -> dict[str, Any]:
"""Return the canonical keyed draft shared by stateless RPC tests.""" """Return the canonical keyed draft shared by stateless RPC tests."""
return { return {