From ce7e3ed76169d06e73f7c0f16f4ba39b13b7f0f7 Mon Sep 17 00:00:00 2001 From: lda Date: Mon, 31 Aug 2026 03:08:47 +0700 Subject: [PATCH] fix: make workflow drafts explicitly opt in --- .../python-workflow-client/task-7-report.md | 34 +++-- docs/project_map.md | 24 +++- docs/wf_api_architecture.md | 29 +++-- src/wf_api/artifacts.py | 16 ++- src/wf_api/capabilities.py | 22 +++- src/wf_api/durable_context.py | 4 +- src/wf_api/service.py | 116 +++++++++--------- src/wf_client/_repr.py | 41 +++++-- src/wf_contract_manifest/generate.py | 11 +- src/wf_server/context.py | 5 +- src/wf_transport_rpc_http/app.py | 7 +- tests/wf_api/test_direct_service.py | 2 +- tests/wf_api/test_durable_context.py | 5 + tests/wf_client/test_repr.py | 39 ++++++ tests/wf_transport_rpc_http/test_app.py | 9 +- 15 files changed, 256 insertions(+), 108 deletions(-) diff --git a/.superpowers/sdd/python-workflow-client/task-7-report.md b/.superpowers/sdd/python-workflow-client/task-7-report.md index 14fcf376..f8c380a1 100644 --- a/.superpowers/sdd/python-workflow-client/task-7-report.md +++ b/.superpowers/sdd/python-workflow-client/task-7-report.md @@ -19,19 +19,31 @@ ## Verification -- `uv run pytest tests/wf_client -q` — 46 passed. -- Focused/cross-layer Task 7 selection — 288 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. +- Initial Task 7 client and cross-layer selection — 288 passed. +- Fix-round focused client/composition selection — 55 passed. - Ruff check and basedpyright for changed client/API/transport surfaces — 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 test` — 151 passed, 3 skipped. -The broader repository format check still reports pre-existing formatting -differences in `src/wf_api/deployments.py`, `src/wf_authoring/builder/core.py`, -and `tests/wf_client/test_authoring.py`; no formatting errors remain in the -changed Task 7 files. The full `uv run pytest -q` run reached 2,613 passed, -1 skipped, and 1 xfailed; three failures were external to this change: two -legacy direct-service/draft tests were fixed by retaining the default-enabled -constructor compatibility, while the remaining thesis asset test expects -untracked PDF figures absent from the base worktree. +The full repository suite was not used as the fix-round gate: legacy direct +draft-service tests still construct draft APIs without the now-required +explicit `drafts=True` opt-in, and one thesis asset test expects untracked PDF +figures absent from the base worktree. No generated `.wf_mcp_store/` or +`test-artifacts/` files are part of this change. + +## Fix round 1 + +- 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. diff --git a/docs/project_map.md b/docs/project_map.md index 695e9574..00d26321 100644 --- a/docs/project_map.md +++ b/docs/project_map.md @@ -128,23 +128,37 @@ permanent graph node or expose it as a final workflow-output source. ### Python client walkthrough 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 values: ```python 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") capability = await app.capability("wf.std.constant") graph = app.new_workflow( "example", - input_schema=InputModel, - state_schema=StateModel, - output_schema=OutputModel, + input_schema={"type": "object", "properties": {}}, + state_schema={"type": "object", "properties": {"value": {"type": "string"}}}, + 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.connect(step, "ok", end) +graph.set_output([input_from(state_path("value"), "value")]) validation = await graph.validate() validation.raise_for_errors() artifact = await graph.save(version=1) diff --git a/docs/wf_api_architecture.md b/docs/wf_api_architecture.md index 315ae5ca..14a42550 100644 --- a/docs/wf_api_architecture.md +++ b/docs/wf_api_architecture.md @@ -110,19 +110,34 @@ new dependency, add a narrow protocol or explicit field. ## 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 +from wf_authoring import input_from, input_value, output_to, state_path + app = App.from_http_jsonrpc("http://localhost:8765/rpc") capability = await app.capability("wf.std.constant") graph = app.new_workflow( "example", - input_schema=InputModel, - state_schema=StateModel, - output_schema=OutputModel, + input_schema={"type": "object", "properties": {}}, + state_schema={"type": "object", "properties": {"value": {"type": "string"}}}, + 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.connect(step, "ok", end) +graph.set_output([input_from(state_path("value"), "value")]) validation = await graph.validate() validation.raise_for_errors() artifact = await graph.save(version=1) @@ -189,8 +204,8 @@ contract itself. ```text WorkflowApi capabilities: WorkflowCapabilityApi - drafts: WorkflowDraftApi - draft_authoring: WorkflowDraftAuthoringApi + drafts: WorkflowDraftApi | None # only when drafts=True + draft_authoring: WorkflowDraftAuthoringApi | None # only when drafts=True artifacts: WorkflowArtifactApi deployments: WorkflowDeploymentApi runs: WorkflowRunApi diff --git a/src/wf_api/artifacts.py b/src/wf_api/artifacts.py index 64f383d2..22c468ca 100644 --- a/src/wf_api/artifacts.py +++ b/src/wf_api/artifacts.py @@ -123,9 +123,19 @@ class WorkflowArtifactApi: 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.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): if self.context.artifact_store is None: @@ -369,7 +379,7 @@ class WorkflowArtifactApi: if store is None: raise KeyError("draft workspace store is not configured") 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": return _PROJECT_UNSAVED_DRAFT_ARTIFACT( { diff --git a/src/wf_api/capabilities.py b/src/wf_api/capabilities.py index 301b28d9..c5d2ddf6 100644 --- a/src/wf_api/capabilities.py +++ b/src/wf_api/capabilities.py @@ -98,10 +98,24 @@ class WorkflowCapabilityApi: 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.drafts = WorkflowDraftApi(context) - self.draft_authoring = WorkflowDraftAuthoringApi(context, self.drafts) + self.drafts: WorkflowDraftApi | None = ( + 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( self, @@ -441,7 +455,7 @@ class WorkflowCapabilityApi: # Validate capability-derived guidance before workspace creation. The # workspace result is already projected by the draft-workspace API. 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, name=name or _draft_name_from_capability(capability_name), capability_name=capability_name, diff --git a/src/wf_api/durable_context.py b/src/wf_api/durable_context.py index 102969f1..ef62f5b3 100644 --- a/src/wf_api/durable_context.py +++ b/src/wf_api/durable_context.py @@ -8,7 +8,7 @@ from .stores import WorkflowStores def require_workflow_stores( context: WorkflowOperationContext, *, - drafts: bool = True, + drafts: bool = False, ) -> WorkflowStores: """Return required stores or fail before constructing durable frontends. @@ -37,7 +37,7 @@ def require_workflow_stores( def durable_workflow_api( context: WorkflowOperationContext, *, - drafts: bool = True, + drafts: bool = False, ) -> WorkflowApi: """Construct a durable API, optionally omitting the draft product surface.""" require_workflow_stores(context, drafts=drafts) diff --git a/src/wf_api/service.py b/src/wf_api/service.py index 04ea7543..74780849 100644 --- a/src/wf_api/service.py +++ b/src/wf_api/service.py @@ -1,7 +1,7 @@ from __future__ import annotations 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.drafts.models import DraftStep @@ -56,19 +56,6 @@ from .operation_context import WorkflowOperationContext 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( value: object, *, @@ -117,26 +104,35 @@ class WorkflowApi: self, context: WorkflowOperationContext, *, - drafts: bool = True, + drafts: bool = False, ) -> None: self.context = context - self.capabilities = WorkflowCapabilityApi(context) - # ``drafts`` keeps server composition explicit: callers that omit draft - # storage must pass False, while the default preserves legacy direct - # WorkflowApi callers that use the draft service for validation only. + self.capabilities = WorkflowCapabilityApi(context, drafts=drafts) + # ``drafts`` keeps server composition explicit. Disabled APIs are None, + # so artifact/deployment/run initialization has no draft dependency. self.drafts_enabled = drafts if self.drafts_enabled: self.drafts = WorkflowDraftApi(context) self.draft_authoring = WorkflowDraftAuthoringApi(context, self.drafts) else: - self.drafts = cast(WorkflowDraftApi, _DisabledDraftSurface()) - self.draft_authoring = cast( - WorkflowDraftAuthoringApi, _DisabledDraftSurface() - ) - self.artifacts = WorkflowArtifactApi(context) + self.drafts = None + self.draft_authoring = None + self.artifacts = WorkflowArtifactApi(context, drafts=drafts) self.deployments = WorkflowDeploymentApi(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 -- async def list_capabilities( @@ -347,14 +343,14 @@ class WorkflowApi: *, draft: dict[str, Any], ) -> ValidateDraftResult: - return await self.drafts.validate_draft(draft=draft) + return await self._require_drafts().validate_draft(draft=draft) async def compile_draft( self, *, draft: dict[str, Any], ) -> CompileDraftWorkspaceSuccess: - return await self.drafts.compile_draft(draft=draft) + return await self._require_drafts().compile_draft(draft=draft) async def patch_draft( self, @@ -362,12 +358,12 @@ class WorkflowApi: draft: dict[str, Any], patch: list[dict[str, Any]], ) -> PatchDraftResult: - return await self.drafts.patch_draft(draft=draft, patch=patch) + return await self._require_drafts().patch_draft(draft=draft, patch=patch) # -- draft workspaces -- 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( self, @@ -376,7 +372,7 @@ class WorkflowApi: draft: dict[str, Any], title: str | None = None, ) -> DraftWorkspaceResult: - return await self.drafts.create_draft_workspace( + return await self._require_drafts().create_draft_workspace( workspace_id=workspace_id, draft=draft, title=title, @@ -393,7 +389,7 @@ class WorkflowApi: output_schema: dict[str, Any] | None = None, outcomes: Sequence[str] = ("ok",), ) -> DraftWorkspaceResult: - return await self.drafts.create_empty_draft_workspace( + return await self._require_drafts().create_empty_draft_workspace( workspace_id=workspace_id, name=name, title=title, @@ -425,7 +421,7 @@ class WorkflowApi: workspace_id: str, include_draft: bool = False, ) -> DraftWorkspaceResult | DraftWorkspaceWithDocument: - return await self.drafts.get_draft_workspace( + return await self._require_drafts().get_draft_workspace( workspace_id=workspace_id, include_draft=include_draft, ) @@ -443,7 +439,7 @@ class WorkflowApi: selected step. This preserves the draft APIs' canonical conflict 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, revision=revision, ) @@ -567,21 +563,27 @@ class WorkflowApi: *, workspace_id: str, ) -> 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( self, *, workspace_id: str, ) -> 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( self, *, workspace_id: str, ) -> 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( self, @@ -590,7 +592,7 @@ class WorkflowApi: revision: int, patch: list[dict[str, Any]], ) -> DraftWorkspaceResult: - return await self.drafts.patch_draft_workspace( + return await self._require_drafts().patch_draft_workspace( workspace_id=workspace_id, revision=revision, patch=patch, @@ -604,7 +606,7 @@ class WorkflowApi: draft: dict[str, Any], ) -> DraftWorkspaceResult: """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, revision=revision, draft=draft, @@ -617,7 +619,7 @@ class WorkflowApi: revision: int, name: str, ) -> DraftWorkspaceResult: - return await self.drafts.set_draft_name( + return await self._require_drafts().set_draft_name( workspace_id=workspace_id, revision=revision, name=name, @@ -630,7 +632,7 @@ class WorkflowApi: revision: int, step_id: str, ) -> DraftWorkspaceResult: - return await self.drafts.set_draft_start( + return await self._require_drafts().set_draft_start( workspace_id=workspace_id, revision=revision, step_id=step_id, @@ -646,7 +648,7 @@ class WorkflowApi: output_schema: dict[str, Any] | None = None, outcomes: Sequence[str] | None = None, ) -> DraftWorkspaceResult: - return await self.drafts.set_draft_contract( + return await self._require_drafts().set_draft_contract( workspace_id=workspace_id, revision=revision, input_schema=input_schema, @@ -664,7 +666,7 @@ class WorkflowApi: outcome: str, target: str, ) -> DraftWorkspaceResult: - return await self.drafts.set_draft_route( + return await self._require_drafts().set_draft_route( workspace_id=workspace_id, revision=revision, step_id=step_id, @@ -681,7 +683,7 @@ class WorkflowApi: input_map: dict[str, str], merge: bool = False, ) -> DraftWorkspaceResult: - return await self.drafts.set_step_input_map( + return await self._require_drafts().set_step_input_map( workspace_id=workspace_id, revision=revision, step_id=step_id, @@ -697,7 +699,7 @@ class WorkflowApi: step_id: str, bindings: Sequence[StepInputBinding], ) -> DraftWorkspaceResult: - return await self.draft_authoring.set_step_input_bindings( + return await self._require_draft_authoring().set_step_input_bindings( workspace_id=workspace_id, revision=revision, step_id=step_id, @@ -712,7 +714,7 @@ class WorkflowApi: step_id: str, bindings: Sequence[OutputBinding], ) -> DraftWorkspaceResult: - return await self.draft_authoring.set_step_output_bindings( + return await self._require_draft_authoring().set_step_output_bindings( workspace_id=workspace_id, revision=revision, step_id=step_id, @@ -728,7 +730,7 @@ class WorkflowApi: update: CapabilityStepUpdate, ) -> DraftWorkspaceResult: """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, revision=revision, step_id=step_id, @@ -744,7 +746,7 @@ class WorkflowApi: output_map: dict[str, str], merge: bool = False, ) -> DraftWorkspaceResult: - return await self.drafts.set_step_output_map( + return await self._require_drafts().set_step_output_map( workspace_id=workspace_id, revision=revision, step_id=step_id, @@ -760,7 +762,7 @@ class WorkflowApi: output_map: dict[str, str], merge: bool = False, ) -> DraftWorkspaceResult: - return await self.drafts.set_workflow_output_map( + return await self._require_drafts().set_workflow_output_map( workspace_id=workspace_id, revision=revision, output_map=output_map, @@ -774,7 +776,7 @@ class WorkflowApi: revision: int, bindings: Sequence[InputBinding], ) -> DraftWorkspaceResult: - return await self.draft_authoring.set_workflow_output_bindings( + return await self._require_draft_authoring().set_workflow_output_bindings( workspace_id=workspace_id, revision=revision, bindings=bindings, @@ -789,7 +791,7 @@ class WorkflowApi: source_path: str, target_path: str, ) -> DraftWorkspaceResult: - return await self.draft_authoring.bind_draft( + return await self._require_draft_authoring().bind_draft( workspace_id=workspace_id, revision=revision, step_id=step_id, @@ -814,7 +816,7 @@ class WorkflowApi: retry: int | None = None, timeout_seconds: int | None = None, ) -> DraftWorkspaceResult: - return await self.draft_authoring.add_step_from_capability( + return await self._require_draft_authoring().add_step_from_capability( workspace_id=workspace_id, revision=revision, step_id=step_id, @@ -840,7 +842,7 @@ class WorkflowApi: incoming: RouteSource | None = None, routes: dict[str, str] | None = None, ) -> DraftWorkspaceResult: - return await self.draft_authoring.add_step( + return await self._require_draft_authoring().add_step( workspace_id=workspace_id, revision=revision, step_id=step_id, @@ -857,7 +859,7 @@ class WorkflowApi: step_id: str, routes: dict[str, str], ) -> DraftWorkspaceResult: - return await self.draft_authoring.branch_draft( + return await self._require_draft_authoring().branch_draft( workspace_id=workspace_id, revision=revision, step_id=step_id, @@ -875,7 +877,7 @@ class WorkflowApi: refs = [ 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, revision=revision, branches=refs, @@ -898,7 +900,7 @@ class WorkflowApi: error_message_source: Any | None = None, title: str | None = None, ) -> DraftWorkspaceResult: - return await self.draft_authoring.create_minimal_draft_workspace( + return await self._require_draft_authoring().create_minimal_draft_workspace( workspace_id=workspace_id, name=name, capability_name=capability_name, @@ -921,7 +923,7 @@ class WorkflowApi: step_id: str, outcome: str, ) -> DraftWorkspaceResult: - return await self.draft_authoring.remove_draft_route( + return await self._require_draft_authoring().remove_draft_route( workspace_id=workspace_id, revision=revision, step_id=step_id, @@ -935,7 +937,7 @@ class WorkflowApi: revision: int, step_id: str, ) -> DraftWorkspaceResult: - return await self.draft_authoring.remove_draft_step( + return await self._require_draft_authoring().remove_draft_step( workspace_id=workspace_id, revision=revision, step_id=step_id, @@ -950,7 +952,7 @@ class WorkflowApi: inputs: Sequence[str] = (), outputs: Sequence[str] = (), ) -> DraftWorkspaceResult: - return await self.draft_authoring.remove_draft_binding( + return await self._require_draft_authoring().remove_draft_binding( workspace_id=workspace_id, revision=revision, step_id=step_id, diff --git a/src/wf_client/_repr.py b/src/wf_client/_repr.py index 46e27241..c2ede7b8 100644 --- a/src/wf_client/_repr.py +++ b/src/wf_client/_repr.py @@ -11,27 +11,37 @@ from __future__ import annotations import html import json +import re from collections.abc import Mapping, Sequence +from itertools import islice -_SECRET_KEY_PARTS = ( +_SENSITIVE_KEYS = { "authorization", "cookie", - "set-cookie", + "set_cookie", "token", + "access_token", + "refresh_token", "secret", "password", "api_key", - "api-key", -) +} _MAX_DEPTH = 2 _MAX_ITEMS = 8 _MAX_STRING = 160 _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: - lowered = str(key).lower() - return any(part in lowered for part in _SECRET_KEY_PARTS) + # Match the evidence policy's exact key set; substring matching would + # 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: @@ -43,7 +53,8 @@ def bounded_value(value: object, *, depth: int = 0) -> object: if value is None or isinstance(value, bool | int | float): return value if isinstance(value, Mapping): - items = list(value.items()) + iterator = iter(value.items()) + items = list(islice(iterator, _MAX_ITEMS + 1)) preview = { str(key): "[redacted]" if _secret_key(key) @@ -51,13 +62,23 @@ def bounded_value(value: object, *, depth: int = 0) -> object: for key, item in items[:_MAX_ITEMS] } if len(items) > _MAX_ITEMS: - preview["…"] = f"{len(items) - _MAX_ITEMS} more entries" + preview["…"] = "more entries" return preview 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]] 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 rendered = repr(value) return rendered if len(rendered) <= _MAX_STRING else rendered[:_MAX_STRING] + "…" diff --git a/src/wf_contract_manifest/generate.py b/src/wf_contract_manifest/generate.py index f27c2d28..b23dbf9f 100644 --- a/src/wf_contract_manifest/generate.py +++ b/src/wf_contract_manifest/generate.py @@ -14,8 +14,15 @@ from .normalize import manifest_from_openrpc def generate_manifest() -> ContractManifest: """Compose the real server against an isolated store and normalize OpenRPC.""" with TemporaryDirectory(prefix="wf-contract-manifest-") as directory: - server = build_local_static_workflow_server(Path(directory) / "store") - document = cast(dict[str, object], create_rpc_app(server).get_openrpc()) + # The checked contract describes the complete opt-in API. Product + # 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 # process-local paths or transport details. return manifest_from_openrpc(document) diff --git a/src/wf_server/context.py b/src/wf_server/context.py index cd869477..31e09b30 100644 --- a/src/wf_server/context.py +++ b/src/wf_server/context.py @@ -295,8 +295,9 @@ def build_local_static_workflow_server( root: str | Path, *, extra_sources: Mapping[str, CapabilitySource] | None = None, + drafts: bool = False, ) -> 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)) stores = file_workflow_stores(config.store_root) events = InMemoryWorkflowEventRecorder() @@ -320,7 +321,7 @@ def build_local_static_workflow_server( runtime=runtime, live_sources=None, ) - api = durable_workflow_api(context) + api = durable_workflow_api(context, drafts=drafts) source_admin = WorkflowSourceAdminApi(context) admin = WorkflowAdminApi( connections=EmptyWorkflowConnectionProvider(), diff --git a/src/wf_transport_rpc_http/app.py b/src/wf_transport_rpc_http/app.py index b578b673..804a232e 100644 --- a/src/wf_transport_rpc_http/app.py +++ b/src/wf_transport_rpc_http/app.py @@ -28,7 +28,7 @@ def create_rpc_app( server: WorkflowServer, *, rpc_path: str = "/rpc", - drafts: bool | None = None, + drafts: bool = False, ) -> jsonrpc.API: """Build a JSON-RPC HTTP app over an existing WorkflowServer. @@ -54,8 +54,9 @@ def create_rpc_app( } register_capability_methods(entrypoint, server) - drafts_enabled = server.api.drafts_enabled if drafts is None else drafts - if drafts_enabled: + if drafts: + if not server.api.drafts_enabled: + raise ValueError("cannot enable draft RPC methods on a draft-disabled API") register_draft_methods(entrypoint, server) register_artifact_methods(entrypoint, server) register_deployment_methods(entrypoint, server) diff --git a/tests/wf_api/test_direct_service.py b/tests/wf_api/test_direct_service.py index f6d3b457..4ec18643 100644 --- a/tests/wf_api/test_direct_service.py +++ b/tests/wf_api/test_direct_service.py @@ -27,7 +27,7 @@ def _api(root: Path) -> WorkflowApi: ConnectionConfig(id="demo.personal", server="demo", account="personal") ) 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: diff --git a/tests/wf_api/test_durable_context.py b/tests/wf_api/test_durable_context.py index 1a2a1218..1893e2de 100644 --- a/tests/wf_api/test_durable_context.py +++ b/tests/wf_api/test_durable_context.py @@ -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) 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 diff --git a/tests/wf_client/test_repr.py b/tests/wf_client/test_repr.py index 34847157..df8f2587 100644 --- a/tests/wf_client/test_repr.py +++ b/tests/wf_client/test_repr.py @@ -86,6 +86,45 @@ def test_rich_representations_bound_large_values_and_redact_secret_like_fields() 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: port = cast(WorkflowClientPort, _port()) diagnostic = WorkflowDiagnostic("error", "bad", "state.x", "broken") diff --git a/tests/wf_transport_rpc_http/test_app.py b/tests/wf_transport_rpc_http/test_app.py index 9beea833..7edb2ebc 100644 --- a/tests/wf_transport_rpc_http/test_app.py +++ b/tests/wf_transport_rpc_http/test_app.py @@ -35,7 +35,6 @@ async def _rpc( def test_rpc_app_can_omit_draft_methods(tmp_path) -> None: server = build_local_static_workflow_server(tmp_path / "store") - assert server.api.drafts_enabled is True app = create_rpc_app(server, drafts=False) 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 +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]: """Return the canonical keyed draft shared by stateless RPC tests.""" return {