fix: make workflow drafts explicitly opt in
This commit is contained in:
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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] + "…"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user