diff --git a/docs/project_map.md b/docs/project_map.md index ff2bc72d..0c4444c3 100644 --- a/docs/project_map.md +++ b/docs/project_map.md @@ -189,6 +189,11 @@ server state is needed. Draft workspaces are intentionally not part of `wf_client`. They are a separate server/admin surface and must be explicitly enabled when composing a server. + +Use `app.artifacts(...)`, `app.deployments()`, and `app.runs(...)` to discover +existing remote objects as lightweight immutable summaries. Exact loaders +(`app.workflow(...)`, `app.deployment(...)`, and `app.run(...)`) reconstruct the +selected rich object without making collection listing eager or trace-heavy. - `examples/agent_challenges/` contains reusable opencode challenge harnesses for evaluating whether agents can use the public workflow CLI/server path. diff --git a/docs/wf_api_architecture.md b/docs/wf_api_architecture.md index c6f21a7c..fbc88fa1 100644 --- a/docs/wf_api_architecture.md +++ b/docs/wf_api_architecture.md @@ -175,6 +175,12 @@ to server/admin and console callers, but normal server composition keeps draft JSON-RPC registration opt-in so artifact, deployment, and run durability do not depend on a draft store. +Existing remote objects can be discovered without eagerly loading their full +plans, bindings, or traces. `app.artifacts(...)` and `app.runs(...)` return +paged immutable summary rows; `app.deployments()` returns an immutable tuple. +Call `app.workflow(id, version=...)`, `app.deployment(id)`, or `app.run(id)` to +reconstruct the selected rich object. + ## WorkflowApiSurface And Domain Services `WorkflowApiSurface` is the public application contract shared by local and diff --git a/skills/wf-python/SKILL.md b/skills/wf-python/SKILL.md index 8d2b47a0..97db0b6b 100644 --- a/skills/wf-python/SKILL.md +++ b/skills/wf-python/SKILL.md @@ -25,6 +25,25 @@ Choose the object that matches the operation: - `Deployment`: bind one artifact version to concrete sources and validate it. - `Run`: inspect, refresh, resume, or trace one durable execution. +Discover existing saved objects as lightweight summaries, then load the exact +object selected by the application: + +```python +artifacts = await app.artifacts(query="report", kind="workflow") +deployments = await app.deployments() +runs = await app.runs(status="interrupted", limit=25) + +artifact = await app.workflow( + artifacts.items[0].artifact_id, + version=artifacts.items[0].version, +) +run = await app.run(runs.items[0].run_id) +``` + +Artifact and run discovery are paged. Deployment discovery returns an immutable +tuple because the server operation is not paged. Listing never reconstructs +full objects or loads run traces. + Do not collapse artifact saving, deployment configuration, and execution into one invented "publish" operation. diff --git a/src/wf_client/__init__.py b/src/wf_client/__init__.py index 91b42b82..04466a15 100644 --- a/src/wf_client/__init__.py +++ b/src/wf_client/__init__.py @@ -6,6 +6,7 @@ from .app import App from .authoring import EditableWorkflow from .capabilities import CapabilityResult, CapabilitySummary, RemoteCapability from .deployments import Deployment, DeploymentValidation +from .discovery import ArtifactSummary, DeploymentSummary, RunSummary from .errors import ( ArtifactNotFound, ArtifactVersionConflict, @@ -33,6 +34,7 @@ __all__ = [ "ArtifactVersionConflict", "App", "ArtifactRef", + "ArtifactSummary", "CapabilityNotFound", "CapabilityRef", "CapabilityResult", @@ -42,11 +44,13 @@ __all__ = [ "DeploymentRequired", "Deployment", "DeploymentValidation", + "DeploymentSummary", "InvalidResponse", "Page", "ProtocolError", "RemoteCapability", "Run", + "RunSummary", "RevisionConflict", "TransportError", "ValidationFailed", diff --git a/src/wf_client/_http_port.py b/src/wf_client/_http_port.py index 48afcb42..678f415b 100644 --- a/src/wf_client/_http_port.py +++ b/src/wf_client/_http_port.py @@ -12,8 +12,10 @@ import httpx from wf_api.models import ( CapabilityCallResult, InspectCapabilityResult, + ListArtifactsResult, ListCapabilitiesResult, ListDeploymentsResult, + ListRunsResult, RunResult, RunTraceResult, SaveArtifactResult, @@ -156,6 +158,23 @@ class PublicErrorWorkflowClientPort: version=version, ) + async def list_artifacts( + self, + *, + query: str | None = None, + kind: Literal["workflow", "wrapper"] | None = None, + cursor: str | None = None, + limit: int = 50, + ) -> ListArtifactsResult: + return await self._invoke( + "workflow.artifacts.list", + self._rpc.list_artifacts, + query=query, + kind=kind, + cursor=cursor, + limit=limit, + ) + async def create_artifact_from_plan( self, *, @@ -256,6 +275,21 @@ class PublicErrorWorkflowClientPort: run_id=run_id, ) + async def list_runs( + self, + *, + status: str | None = None, + cursor: str | None = None, + limit: int = 50, + ) -> ListRunsResult: + return await self._invoke( + "workflow.runs.list", + self._rpc.list_runs, + status=status, + cursor=cursor, + limit=limit, + ) + async def resume_run( self, *, diff --git a/src/wf_client/app.py b/src/wf_client/app.py index 1849efd4..c34d8593 100644 --- a/src/wf_client/app.py +++ b/src/wf_client/app.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Sequence from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Literal from wf_platform import CapabilityRef, Page, SourceRef from wf_transport_rpc_http import RpcWorkflowApiClient @@ -14,10 +14,14 @@ from ._identity import require_response_identity from .authoring import EditableWorkflow from .capabilities import CapabilitySummary, RemoteCapability from .codec import ( + decode_artifacts_page, decode_capabilities_page, decode_capability_inspect, + decode_deployments, + decode_runs_page, decode_workflow_artifact, ) +from .discovery import ArtifactSummary, DeploymentSummary, RunSummary from .errors import InvalidResponse from .protocols import WorkflowClientPort from .workflows import WorkflowArtifact @@ -148,6 +152,81 @@ class App: total=wire["total"], ) + async def artifacts( + self, + *, + query: str | None = None, + kind: Literal["workflow", "wrapper"] | None = None, + cursor: str | None = None, + limit: int = 50, + ) -> Page[ArtifactSummary]: + """List lightweight saved artifact versions without reconstructing them.""" + wire = decode_artifacts_page( + await self._port.list_artifacts( + query=query, kind=kind, cursor=cursor, limit=limit + ) + ) + return Page( + items=tuple( + ArtifactSummary( + artifact_id=row["artifact_id"], + version=row["version"], + kind=row["kind"], + title=row["display_name"], + description=row["description"], + outcomes=tuple(row["outcomes"]), + required_sources=tuple(row["required_sources"]), + ) + for row in wire["nodes"] + ), + next_cursor=wire["next_cursor"], + total=wire["total"], + ) + + async def deployments(self) -> tuple[DeploymentSummary, ...]: + """List lightweight saved deployment rows.""" + wire = decode_deployments(await self._port.list_deployments()) + return tuple( + DeploymentSummary( + deployment_id=row["id"], + artifact_id=row["artifact_id"], + artifact_version=row["artifact_version"], + binding_count=row["binding_count"], + drift_policy=row["drift_policy"], + ) + for row in wire["deployments"] + ) + + async def runs( + self, + *, + status: str | None = None, + cursor: str | None = None, + limit: int = 50, + ) -> Page[RunSummary]: + """List lightweight durable-run rows without loading traces.""" + wire = decode_runs_page( + await self._port.list_runs(status=status, cursor=cursor, limit=limit) + ) + return Page( + items=tuple( + RunSummary( + run_id=row["run_id"], + deployment_id=row["deployment_id"], + artifact_id=row["artifact_id"], + artifact_version=row["artifact_version"], + status=row["status"], + resume_readiness=row["resume_readiness"], + diagnostic_count=row["diagnostic_count"], + created_at=row["created_at"], + updated_at=row["updated_at"], + ) + for row in wire["runs"] + ), + next_cursor=wire["next_cursor"], + total=wire["total"], + ) + def new_workflow( self, name: str, diff --git a/src/wf_client/codec.py b/src/wf_client/codec.py index e9a6edf1..11f2d71a 100644 --- a/src/wf_client/codec.py +++ b/src/wf_client/codec.py @@ -12,8 +12,10 @@ from wf_api.models import ( CapabilityCallResult, DependencyDiagnosticPayload, InspectCapabilityResult, + ListArtifactsResult, ListCapabilitiesResult, ListDeploymentsResult, + ListRunsResult, RawWorkflowPlan, RunResult, RunTraceResult, @@ -114,6 +116,16 @@ def decode_capabilities_page(payload: object) -> ListCapabilitiesResult: ) +def decode_artifacts_page(payload: object) -> ListArtifactsResult: + """Validate one cursor-paged artifact catalog response.""" + return _validate(payload, ListArtifactsResult, "workflow.artifacts.list") + + +def decode_runs_page(payload: object) -> ListRunsResult: + """Validate one cursor-paged durable-run response.""" + return _validate(payload, ListRunsResult, "workflow.runs.list") + + def decode_validate_artifact_plan(payload: object) -> ValidateArtifactPlanResult: """Validate a non-persisting artifact-plan response at the client boundary.""" return _validate( diff --git a/src/wf_client/discovery.py b/src/wf_client/discovery.py new file mode 100644 index 00000000..06b756db --- /dev/null +++ b/src/wf_client/discovery.py @@ -0,0 +1,44 @@ +"""Lightweight immutable rows for discovering existing workflow objects.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class ArtifactSummary: + """Catalog identity and display metadata for one artifact version.""" + + artifact_id: str + version: int + kind: str + title: str + description: str | None + outcomes: tuple[str, ...] + required_sources: tuple[str, ...] + + +@dataclass(frozen=True, slots=True) +class DeploymentSummary: + """Identity and binding metadata for one saved deployment.""" + + deployment_id: str + artifact_id: str + artifact_version: int + binding_count: int + drift_policy: str + + +@dataclass(frozen=True, slots=True) +class RunSummary: + """Identity and lifecycle metadata for one durable run.""" + + run_id: str + deployment_id: str + artifact_id: str + artifact_version: int + status: str + resume_readiness: str + diagnostic_count: int + created_at: str + updated_at: str diff --git a/src/wf_client/protocols.py b/src/wf_client/protocols.py index 795edbea..4cb25610 100644 --- a/src/wf_client/protocols.py +++ b/src/wf_client/protocols.py @@ -8,8 +8,10 @@ from typing import Any, Literal, Protocol from wf_api.models import ( CapabilityCallResult, InspectCapabilityResult, + ListArtifactsResult, ListCapabilitiesResult, ListDeploymentsResult, + ListRunsResult, RunResult, RunTraceResult, SaveArtifactResult, @@ -60,6 +62,15 @@ class WorkflowClientPort(Protocol): version: int, ) -> WorkflowArtifactPayload: ... + async def list_artifacts( + self, + *, + query: str | None = None, + kind: Literal["workflow", "wrapper"] | None = None, + cursor: str | None = None, + limit: int = 50, + ) -> ListArtifactsResult: ... + async def create_artifact_from_plan( self, *, @@ -112,6 +123,14 @@ class WorkflowClientPort(Protocol): trace_range: TraceRangeLike | None = None, ) -> RunResult: ... + async def list_runs( + self, + *, + status: str | None = None, + cursor: str | None = None, + limit: int = 50, + ) -> ListRunsResult: ... + async def inspect_run(self, *, run_id: str) -> RunResult: ... async def resume_run( diff --git a/tests/wf_client/conftest.py b/tests/wf_client/conftest.py index 26492796..ee1de6a4 100644 --- a/tests/wf_client/conftest.py +++ b/tests/wf_client/conftest.py @@ -31,6 +31,9 @@ class FakeWorkflowClient: async def inspect_artifact(self, **params: Any) -> object: return self._response("workflow.artifacts.inspect", params) + async def list_artifacts(self, **params: Any) -> object: + return self._response("workflow.artifacts.list", params) + async def save_artifact(self, artifact: dict[str, Any]) -> object: return self._response("workflow.artifacts.save", {"artifact": artifact}) @@ -55,6 +58,9 @@ class FakeWorkflowClient: async def run_deployment(self, **params: Any) -> object: return self._response("workflow.runs.start", params) + async def list_runs(self, **params: Any) -> object: + return self._response("workflow.runs.list", params) + async def inspect_run(self, **params: Any) -> object: return self._response("workflow.runs.inspect", params) diff --git a/tests/wf_client/test_app.py b/tests/wf_client/test_app.py index 9ed4aea8..8bd916a9 100644 --- a/tests/wf_client/test_app.py +++ b/tests/wf_client/test_app.py @@ -273,6 +273,110 @@ async def test_capability_discovery_returns_rich_page() -> None: assert page.items[0].outcomes == ("ok",) +@pytest.mark.asyncio +async def test_app_discovers_existing_artifacts_deployments_and_runs() -> None: + """Catch collection methods disappearing while exact loaders still work.""" + + class ExistingObjectsPort(_Port): + async def list_artifacts(self, **params: Any) -> object: + self.calls.append(("artifacts", params)) + return { + "nodes": [ + { + "name": "report.v3", + "artifact_id": "report", + "version": 3, + "kind": "workflow", + "display_name": "Report", + "description": None, + "outcomes": ["ok"], + "input_schema": {"type": "object", "properties": {}}, + "output_schema": {"type": "object", "properties": {}}, + "required_sources": ["app.default"], + "diagnostics": [], + } + ], + "cursor": "0", + "next_cursor": None, + "limit": 10, + "total": 1, + } + + async def list_deployments(self) -> object: + self.calls.append(("deployments", {})) + return { + "deployments": [ + { + "id": "report.production", + "artifact_id": "report", + "artifact_version": 3, + "binding_count": 1, + "drift_policy": "block", + } + ] + } + + async def list_runs(self, **params: Any) -> object: + self.calls.append(("runs", params)) + return { + "runs": [ + { + "run_id": "run-123", + "deployment_id": "report.production", + "artifact_id": "report", + "artifact_version": 3, + "status": "interrupted", + "resume_readiness": "ready", + "diagnostic_count": 0, + "created_at": "2026-09-02T00:00:00Z", + "updated_at": "2026-09-02T00:01:00Z", + } + ], + "cursor": None, + "next_cursor": "next", + "limit": 25, + "total": 1, + } + + port = ExistingObjectsPort() + app = App._from_port(cast(WorkflowClientPort, port)) + + artifacts = await app.artifacts(query="report", kind="workflow", limit=10) + deployments = await app.deployments() + runs = await app.runs(status="interrupted", limit=25) + + assert artifacts.items[0].artifact_id == "report" + assert artifacts.items[0].version == 3 + assert artifacts.total == 1 + assert deployments[0].deployment_id == "report.production" + assert deployments[0].artifact_version == 3 + assert runs.items[0].run_id == "run-123" + assert runs.items[0].status == "interrupted" + assert runs.next_cursor == "next" + assert port.calls == [ + ( + "artifacts", + {"query": "report", "kind": "workflow", "cursor": None, "limit": 10}, + ), + ("deployments", {}), + ("runs", {"status": "interrupted", "cursor": None, "limit": 25}), + ] + + +@pytest.mark.asyncio +async def test_app_rejects_malformed_existing_object_discovery() -> None: + """Catch unvalidated wire dictionaries leaking through collection methods.""" + + class MalformedPort(_Port): + async def list_runs(self, **params: Any) -> object: + return {"runs": [{"run_id": "missing-the-rest"}]} + + app = App._from_port(cast(WorkflowClientPort, MalformedPort())) + + with pytest.raises(InvalidResponse, match="workflow.runs.list"): + await app.runs() + + @pytest.mark.asyncio async def test_capability_reconstructs_structural_reference() -> None: capability = await _app().capability("app.default.search") diff --git a/tests/wf_client/test_http_integration.py b/tests/wf_client/test_http_integration.py index 207f6626..1ab7c035 100644 --- a/tests/wf_client/test_http_integration.py +++ b/tests/wf_client/test_http_integration.py @@ -54,8 +54,16 @@ async def test_http_app_calls_authors_saves_deploys_and_runs(tmp_path) -> None: validation = await graph.validate() artifact = await graph.save(version=1, title="HTTP client proof") run = await artifact.run({}) + artifacts = await app.artifacts(query="http_client_proof") + deployments = await app.deployments() + runs = await app.runs(status="completed", limit=25) assert validation.ok is True assert artifact.ref == ArtifactRef("http_client_proof", 1) assert run.status == "completed" assert run.output == {"value": "hello"} + assert [(item.artifact_id, item.version) for item in artifacts.items] == [ + ("http_client_proof", 1) + ] + assert deployments[0].artifact_id == "http_client_proof" + assert runs.items[0].run_id == run.run_id diff --git a/tests/wf_transport_rpc_http/test_app.py b/tests/wf_transport_rpc_http/test_app.py index 9b4c9901..f250a06a 100644 --- a/tests/wf_transport_rpc_http/test_app.py +++ b/tests/wf_transport_rpc_http/test_app.py @@ -61,8 +61,11 @@ async def _rpc( 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", drafts=True) + # Start enabled so this test proves the RPC composition override disables + # the surface instead of merely observing the server's default-off state. + assert server.api.drafts_enabled is True app = create_rpc_app(server, drafts=False) methods = {method["name"] for method in app.get_openrpc()["methods"]}