feat: discover existing workflow objects

This commit is contained in:
lda
2026-09-02 05:21:48 +07:00 Verified
parent ef79eae6bf
commit 143e70bcf7
13 changed files with 345 additions and 2 deletions
+5
View File
@@ -189,6 +189,11 @@ server state is needed.
Draft workspaces are intentionally not part of `wf_client`. They are a separate 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. 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 - `examples/agent_challenges/` contains reusable opencode challenge harnesses
for evaluating whether agents can use the public workflow CLI/server path. for evaluating whether agents can use the public workflow CLI/server path.
+6
View File
@@ -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 JSON-RPC registration opt-in so artifact, deployment, and run durability do not
depend on a draft store. 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 And Domain Services
`WorkflowApiSurface` is the public application contract shared by local and `WorkflowApiSurface` is the public application contract shared by local and
+19
View File
@@ -25,6 +25,25 @@ Choose the object that matches the operation:
- `Deployment`: bind one artifact version to concrete sources and validate it. - `Deployment`: bind one artifact version to concrete sources and validate it.
- `Run`: inspect, refresh, resume, or trace one durable execution. - `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 Do not collapse artifact saving, deployment configuration, and execution into
one invented "publish" operation. one invented "publish" operation.
+4
View File
@@ -6,6 +6,7 @@ from .app import App
from .authoring import EditableWorkflow from .authoring import EditableWorkflow
from .capabilities import CapabilityResult, CapabilitySummary, RemoteCapability from .capabilities import CapabilityResult, CapabilitySummary, RemoteCapability
from .deployments import Deployment, DeploymentValidation from .deployments import Deployment, DeploymentValidation
from .discovery import ArtifactSummary, DeploymentSummary, RunSummary
from .errors import ( from .errors import (
ArtifactNotFound, ArtifactNotFound,
ArtifactVersionConflict, ArtifactVersionConflict,
@@ -33,6 +34,7 @@ __all__ = [
"ArtifactVersionConflict", "ArtifactVersionConflict",
"App", "App",
"ArtifactRef", "ArtifactRef",
"ArtifactSummary",
"CapabilityNotFound", "CapabilityNotFound",
"CapabilityRef", "CapabilityRef",
"CapabilityResult", "CapabilityResult",
@@ -42,11 +44,13 @@ __all__ = [
"DeploymentRequired", "DeploymentRequired",
"Deployment", "Deployment",
"DeploymentValidation", "DeploymentValidation",
"DeploymentSummary",
"InvalidResponse", "InvalidResponse",
"Page", "Page",
"ProtocolError", "ProtocolError",
"RemoteCapability", "RemoteCapability",
"Run", "Run",
"RunSummary",
"RevisionConflict", "RevisionConflict",
"TransportError", "TransportError",
"ValidationFailed", "ValidationFailed",
+34
View File
@@ -12,8 +12,10 @@ import httpx
from wf_api.models import ( from wf_api.models import (
CapabilityCallResult, CapabilityCallResult,
InspectCapabilityResult, InspectCapabilityResult,
ListArtifactsResult,
ListCapabilitiesResult, ListCapabilitiesResult,
ListDeploymentsResult, ListDeploymentsResult,
ListRunsResult,
RunResult, RunResult,
RunTraceResult, RunTraceResult,
SaveArtifactResult, SaveArtifactResult,
@@ -156,6 +158,23 @@ class PublicErrorWorkflowClientPort:
version=version, 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( async def create_artifact_from_plan(
self, self,
*, *,
@@ -256,6 +275,21 @@ class PublicErrorWorkflowClientPort:
run_id=run_id, 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( async def resume_run(
self, self,
*, *,
+80 -1
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from collections.abc import Sequence from collections.abc import Sequence
from dataclasses import dataclass, field 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_platform import CapabilityRef, Page, SourceRef
from wf_transport_rpc_http import RpcWorkflowApiClient from wf_transport_rpc_http import RpcWorkflowApiClient
@@ -14,10 +14,14 @@ from ._identity import require_response_identity
from .authoring import EditableWorkflow from .authoring import EditableWorkflow
from .capabilities import CapabilitySummary, RemoteCapability from .capabilities import CapabilitySummary, RemoteCapability
from .codec import ( from .codec import (
decode_artifacts_page,
decode_capabilities_page, decode_capabilities_page,
decode_capability_inspect, decode_capability_inspect,
decode_deployments,
decode_runs_page,
decode_workflow_artifact, decode_workflow_artifact,
) )
from .discovery import ArtifactSummary, DeploymentSummary, RunSummary
from .errors import InvalidResponse from .errors import InvalidResponse
from .protocols import WorkflowClientPort from .protocols import WorkflowClientPort
from .workflows import WorkflowArtifact from .workflows import WorkflowArtifact
@@ -148,6 +152,81 @@ class App:
total=wire["total"], 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( def new_workflow(
self, self,
name: str, name: str,
+12
View File
@@ -12,8 +12,10 @@ from wf_api.models import (
CapabilityCallResult, CapabilityCallResult,
DependencyDiagnosticPayload, DependencyDiagnosticPayload,
InspectCapabilityResult, InspectCapabilityResult,
ListArtifactsResult,
ListCapabilitiesResult, ListCapabilitiesResult,
ListDeploymentsResult, ListDeploymentsResult,
ListRunsResult,
RawWorkflowPlan, RawWorkflowPlan,
RunResult, RunResult,
RunTraceResult, 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: def decode_validate_artifact_plan(payload: object) -> ValidateArtifactPlanResult:
"""Validate a non-persisting artifact-plan response at the client boundary.""" """Validate a non-persisting artifact-plan response at the client boundary."""
return _validate( return _validate(
+44
View File
@@ -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
+19
View File
@@ -8,8 +8,10 @@ from typing import Any, Literal, Protocol
from wf_api.models import ( from wf_api.models import (
CapabilityCallResult, CapabilityCallResult,
InspectCapabilityResult, InspectCapabilityResult,
ListArtifactsResult,
ListCapabilitiesResult, ListCapabilitiesResult,
ListDeploymentsResult, ListDeploymentsResult,
ListRunsResult,
RunResult, RunResult,
RunTraceResult, RunTraceResult,
SaveArtifactResult, SaveArtifactResult,
@@ -60,6 +62,15 @@ class WorkflowClientPort(Protocol):
version: int, version: int,
) -> WorkflowArtifactPayload: ... ) -> 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( async def create_artifact_from_plan(
self, self,
*, *,
@@ -112,6 +123,14 @@ class WorkflowClientPort(Protocol):
trace_range: TraceRangeLike | None = None, trace_range: TraceRangeLike | None = None,
) -> RunResult: ... ) -> 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 inspect_run(self, *, run_id: str) -> RunResult: ...
async def resume_run( async def resume_run(
+6
View File
@@ -31,6 +31,9 @@ class FakeWorkflowClient:
async def inspect_artifact(self, **params: Any) -> object: async def inspect_artifact(self, **params: Any) -> object:
return self._response("workflow.artifacts.inspect", params) 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: async def save_artifact(self, artifact: dict[str, Any]) -> object:
return self._response("workflow.artifacts.save", {"artifact": artifact}) return self._response("workflow.artifacts.save", {"artifact": artifact})
@@ -55,6 +58,9 @@ class FakeWorkflowClient:
async def run_deployment(self, **params: Any) -> object: async def run_deployment(self, **params: Any) -> object:
return self._response("workflow.runs.start", params) 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: async def inspect_run(self, **params: Any) -> object:
return self._response("workflow.runs.inspect", params) return self._response("workflow.runs.inspect", params)
+104
View File
@@ -273,6 +273,110 @@ async def test_capability_discovery_returns_rich_page() -> None:
assert page.items[0].outcomes == ("ok",) 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 @pytest.mark.asyncio
async def test_capability_reconstructs_structural_reference() -> None: async def test_capability_reconstructs_structural_reference() -> None:
capability = await _app().capability("app.default.search") capability = await _app().capability("app.default.search")
+8
View File
@@ -54,8 +54,16 @@ async def test_http_app_calls_authors_saves_deploys_and_runs(tmp_path) -> None:
validation = await graph.validate() validation = await graph.validate()
artifact = await graph.save(version=1, title="HTTP client proof") artifact = await graph.save(version=1, title="HTTP client proof")
run = await artifact.run({}) 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 validation.ok is True
assert artifact.ref == ArtifactRef("http_client_proof", 1) assert artifact.ref == ArtifactRef("http_client_proof", 1)
assert run.status == "completed" assert run.status == "completed"
assert run.output == {"value": "hello"} 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
+4 -1
View File
@@ -61,8 +61,11 @@ 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", 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) 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"]}