third slice: artifacts and deployments...

This commit is contained in:
lda
2026-06-02 01:04:05 +07:00 Verified
parent fb8baf02ea
commit c46c636694
11 changed files with 2004 additions and 388 deletions
+4
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
from .artifacts import WorkflowArtifactApi
from .backend import TraceRange, WorkflowApiBackend
from .constants import (
DEFAULT_CALL_STEP_ID,
@@ -8,6 +9,7 @@ from .constants import (
DEFAULT_OK_OUTCOME,
RUNTIME_ERROR_CAPABILITY,
)
from .deployments import WorkflowDeploymentApi
from .drafts import WorkflowDraftApi
from .next_actions import NextActionPatchExample, NextActionTool, NextActions
from .refs import WorkflowSurfaceCapabilityId, parse_workflow_surface_capability_id
@@ -52,7 +54,9 @@ __all__ = [
"TraceRange",
"WorkflowApi",
"WorkflowApiBackend",
"WorkflowArtifactApi",
"WorkflowArtifactCataloger",
"WorkflowDeploymentApi",
"WorkflowDraftApi",
"WorkflowEventRecorder",
"WorkflowLiveSourceChecker",
+352
View File
@@ -0,0 +1,352 @@
"""Saved workflow artifact operations.
Event construction is intentionally delegated through
WorkflowOperationContext so this module stays protocol-neutral.
"""
from __future__ import annotations
from collections.abc import Sequence
from typing import Any, TypeVar
from wf_artifacts import (
ArtifactKind,
RequiredCapability,
WorkflowArtifact,
WorkflowCapabilityRef,
create_workflow_artifact_from_plan as build_workflow_artifact_from_plan,
)
from wf_platform import NodeSpecInventory, page_items
from .drafts import WorkflowDraftApi
from .models import RawWorkflowPlan
from .operation_context import WorkflowOperationContext
T = TypeVar("T")
def _matches_query(*values: object, query: str | None) -> bool:
"""Return whether a compact discovery row matches a human search query."""
if query is None:
return True
needle = query.strip().casefold()
if not needle:
return True
return any(needle in str(value).casefold() for value in values if value is not None)
def _paged_list_payload(
key: str,
items: Sequence[T],
*,
cursor: str | None,
limit: int,
) -> dict[str, Any]:
"""Build the common workflow-surface list response shape."""
page = page_items(items, cursor=cursor, limit=limit)
return {
key: list(page.items),
"next_cursor": page.next_cursor,
"total": page.total,
}
class WorkflowArtifactApi:
"""Saved workflow artifact operations.
Event construction is intentionally delegated through
WorkflowOperationContext so this module stays protocol-neutral.
"""
def __init__(self, context: WorkflowOperationContext) -> None:
self.context = context
self.drafts = WorkflowDraftApi(context)
def _artifact_store(self):
if self.context.artifact_store is None:
raise KeyError("workflow artifact store is not configured")
return self.context.artifact_store
async def list_artifacts(
self,
*,
query: str | None = None,
kind: ArtifactKind | None = None,
cursor: str | None = None,
limit: int = 50,
) -> dict[str, Any]:
"""Return compact paged saved artifact summaries.
Saved artifacts can contain full raw workflow plans, so list results
deliberately stay summary-only. Use inspect/run tools for detail.
"""
if self.context.artifact_store is None:
return _paged_list_payload("nodes", [], cursor=cursor, limit=limit)
entries = [
self.context.artifacts.workflow_artifact_catalog_entry(artifact).model_dump(
mode="json"
)
for artifact in self.context.artifact_store.list_artifacts()
if kind is None or artifact.kind == kind
]
entries = [
entry
for entry in entries
if _matches_query(
entry.get("name"),
entry.get("artifact_id"),
entry.get("display_name"),
entry.get("description"),
entry.get("kind"),
query=query,
)
]
entries.sort(key=lambda entry: str(entry["name"]))
return _paged_list_payload("nodes", entries, cursor=cursor, limit=limit)
async def save_artifact(self, artifact: dict[str, Any]) -> dict[str, Any]:
workflow_artifact = WorkflowArtifact.model_validate(artifact)
self._artifact_store().save_artifact(workflow_artifact)
self.context.events.record_workflow_event(
"workflow_artifact_saved",
capability_id=_artifact_capability_id(workflow_artifact),
payload={
"artifact_id": workflow_artifact.id,
"version": workflow_artifact.version,
},
)
return {
"artifact_id": workflow_artifact.id,
"version": workflow_artifact.version,
"saved": True,
}
async def create_artifact_from_plan(
self,
*,
artifact_id: str,
version: int,
title: str,
plan: RawWorkflowPlan | dict[str, Any],
outcomes: Sequence[str],
kind: ArtifactKind = "workflow",
description: str | None = None,
required_capabilities: dict[str, dict[str, Any]] | None = None,
source_bindings: dict[str, str] | None = None,
created_from_catalog_version: str | None = None,
) -> dict[str, Any]:
typed_plan = (
plan
if isinstance(plan, RawWorkflowPlan)
else RawWorkflowPlan.model_validate(plan)
)
workflow_artifact = build_workflow_artifact_from_plan(
artifact_id=artifact_id,
version=version,
title=title,
kind=kind,
description=description,
plan=typed_plan.model_dump(mode="json", by_alias=True),
outcomes=tuple(outcomes),
required_capabilities={
name: RequiredCapability.model_validate(capability)
for name, capability in (required_capabilities or {}).items()
},
source_bindings=source_bindings,
observed_node_specs=_observed_node_specs(self.context),
created_from_catalog_version=created_from_catalog_version,
)
self._artifact_store().save_artifact(workflow_artifact)
self.context.events.record_workflow_event(
"workflow_artifact_saved",
capability_id=_artifact_capability_id(workflow_artifact),
payload={
"artifact_id": workflow_artifact.id,
"version": workflow_artifact.version,
"created_from_plan": True,
},
)
return {
"artifact_id": workflow_artifact.id,
"version": workflow_artifact.version,
"saved": True,
}
async def create_artifact_from_draft(
self,
*,
artifact_id: str,
version: int,
title: str,
draft: dict[str, Any],
outcomes: Sequence[str],
kind: ArtifactKind = "workflow",
description: str | None = None,
required_capabilities: dict[str, dict[str, Any]] | None = None,
source_bindings: dict[str, str] | None = None,
created_from_catalog_version: str | None = None,
) -> dict[str, Any]:
from wf_artifacts import compile_workflow_draft
plan = compile_workflow_draft(draft)
workflow_artifact = build_workflow_artifact_from_plan(
artifact_id=artifact_id,
version=version,
title=title,
kind=kind,
description=description,
plan=plan,
outcomes=tuple(outcomes),
required_capabilities={
name: RequiredCapability.model_validate(capability)
for name, capability in (required_capabilities or {}).items()
},
source_bindings=source_bindings,
observed_node_specs=_observed_node_specs(self.context),
created_from_catalog_version=created_from_catalog_version,
)
self._artifact_store().save_artifact(workflow_artifact)
self.context.events.record_workflow_event(
"workflow_artifact_saved",
capability_id=_artifact_capability_id(workflow_artifact),
payload={
"artifact_id": workflow_artifact.id,
"version": workflow_artifact.version,
"created_from_draft": True,
},
)
required_sources = sorted(
{
capability.logical_source
for capability in workflow_artifact.required_capability_map().values()
}
)
return {
"artifact_id": workflow_artifact.id,
"version": workflow_artifact.version,
"saved": True,
"required_logical_sources": required_sources,
"suggested_bindings": _suggested_self_bindings(required_sources),
}
async def create_artifact_from_workspace(
self,
*,
workspace_id: str,
artifact_id: str,
version: int,
title: str,
outcomes: Sequence[str],
kind: ArtifactKind = "workflow",
description: str | None = None,
required_capabilities: dict[str, dict[str, Any]] | None = None,
source_bindings: dict[str, str] | None = None,
created_from_catalog_version: str | None = None,
) -> dict[str, Any]:
store = self.context.draft_workspace_store
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)
if validation["status"] != "valid":
return {
"saved": False,
"workspace_id": workspace_id,
"revision": workspace.revision,
"status": validation["status"],
"diagnostics": validation["diagnostics"],
}
return await self.create_artifact_from_draft(
artifact_id=artifact_id,
version=version,
title=title,
kind=kind,
description=description,
draft=workspace.draft,
outcomes=outcomes,
required_capabilities=required_capabilities,
source_bindings=source_bindings,
created_from_catalog_version=created_from_catalog_version,
)
async def create_wrapper_from_workspace(
self,
*,
workspace_id: str,
artifact_id: str,
version: int,
title: str,
outcomes: Sequence[str],
description: str | None = None,
required_capabilities: dict[str, dict[str, Any]] | None = None,
source_bindings: dict[str, str] | None = None,
created_from_catalog_version: str | None = None,
) -> dict[str, Any]:
"""Save the current draft workspace as a callable wrapper artifact."""
return await self.create_artifact_from_workspace(
workspace_id=workspace_id,
artifact_id=artifact_id,
version=version,
title=title,
outcomes=outcomes,
kind="wrapper",
description=description,
required_capabilities=required_capabilities,
source_bindings=source_bindings,
created_from_catalog_version=created_from_catalog_version,
)
async def inspect_artifact(
self, *, artifact_id: str, version: int
) -> dict[str, Any]:
artifact = self._artifact_store().get_artifact(artifact_id, version)
return artifact.model_dump(mode="json")
def _required_capability_payloads(
requirements: dict[str, RequiredCapability],
) -> dict[str, dict[str, Any]]:
return {
name: capability.model_dump(mode="json")
for name, capability in sorted(requirements.items())
}
def _suggested_self_bindings(required_sources: Sequence[str]) -> dict[str, str]:
"""Suggest local bindings for built-in sources that deploy to themselves."""
return {
source: source for source in required_sources if source in {"wf.std", "wf.mcp"}
}
def _observed_node_specs(
context: WorkflowOperationContext,
) -> dict[str, NodeSpecInventory]:
"""Project current executable specs into serializable observed contracts."""
observed: dict[str, NodeSpecInventory] = {}
for source in context.capability_sources.values():
inventory = source.as_inventory()
observed.update(
{detail.name: detail for detail in inventory.capabilities.node_spec_details}
)
return observed
def _plan_nodes(artifact: WorkflowArtifact) -> list[dict[str, Any]]:
nodes = artifact.plan.get("nodes", [])
return [node for node in nodes if isinstance(node, dict)]
def _artifact_capability_id(artifact: WorkflowArtifact) -> str:
"""Use the same stable name shape as workflow artifact catalog entries."""
return str(
WorkflowCapabilityRef(
artifact_id=artifact.id,
version=artifact.version,
)
)
__all__ = [
"WorkflowArtifactApi",
]
+211
View File
@@ -0,0 +1,211 @@
"""Saved deployment operations and dependency validation."""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from wf_artifacts import (
AvailableCapability,
AvailableSource,
DependencyDiagnostic,
WorkflowArtifact,
WorkflowDeployment,
validate_deployment_dependencies,
)
from wf_platform import CapabilitySource, hash_json_schema
from .next_actions import NextActions
from .operation_context import WorkflowOperationContext
from .saved_subgraphs import resolve_saved_subgraph_tree, validate_saved_subgraph_tree
class WorkflowDeploymentApi:
"""Saved deployment operations and dependency validation."""
def __init__(self, context: WorkflowOperationContext) -> None:
self.context = context
def _artifact_store(self):
if self.context.artifact_store is None:
raise KeyError("workflow artifact store is not configured")
return self.context.artifact_store
async def list_deployments(self) -> dict[str, Any]:
if self.context.artifact_store is None:
return {"deployments": []}
return {
"deployments": [
_deployment_summary(deployment)
for deployment in self.context.artifact_store.list_deployments()
]
}
async def inspect_deployment(self, *, deployment_id: str) -> dict[str, Any]:
return self._artifact_store().get_deployment(deployment_id).model_dump(
mode="json"
)
async def save_deployment(self, deployment: dict[str, Any]) -> dict[str, Any]:
workflow_deployment = WorkflowDeployment.model_validate(deployment)
self._artifact_store().save_deployment(workflow_deployment)
self.context.events.record_workflow_event(
"workflow_deployment_saved",
capability_id=f"deployment.{workflow_deployment.id}",
payload={
"deployment_id": workflow_deployment.id,
"artifact_id": workflow_deployment.artifact_id,
"artifact_version": workflow_deployment.artifact_version,
},
)
return {
"deployment_id": workflow_deployment.id,
"artifact_id": workflow_deployment.artifact_id,
"artifact_version": workflow_deployment.artifact_version,
"saved": True,
}
async def delete_deployment(self, *, deployment_id: str) -> dict[str, Any]:
"""Delete one mutable deployment environment binding."""
self._artifact_store().delete_deployment(deployment_id)
self.context.events.record_workflow_event(
"workflow_deployment_deleted",
capability_id=f"deployment.{deployment_id}",
payload={"deployment_id": deployment_id},
)
return {"deployment_id": deployment_id, "deleted": True}
async def validate_deployment(
self,
*,
deployment_id: str,
live_check: bool = False,
) -> dict[str, Any]:
deployment, artifact, diagnostics, tree = self.deployment_validation(
deployment_id
)
if live_check and self.context.live_sources is not None:
diagnostics.extend(
await self.context.live_sources.deployment_diagnostics(
deployment=deployment,
artifacts=[artifact, *tree.artifacts_by_ref.values()],
)
)
return {
"deployment_id": deployment.id,
"artifact_id": artifact.id,
"artifact_version": artifact.version,
"status": "unrunnable" if diagnostics else "runnable",
"diagnostics": [
diagnostic.model_dump(mode="json") for diagnostic in diagnostics
],
"next_actions": NextActions.from_deployment_validation(
deployment_id=deployment.id,
diagnostics=diagnostics,
).model_dump(mode="json"),
}
def deployment_validation(
self,
deployment_id: str,
) -> tuple[
WorkflowDeployment,
WorkflowArtifact,
list[DependencyDiagnostic],
Any, # SavedSubgraphTree
]:
store = self._artifact_store()
deployment = store.get_deployment(deployment_id)
artifact = store.get_artifact(
deployment.artifact_id,
deployment.artifact_version,
)
available_sources = _available_sources(self.context.capability_sources)
diagnostics = validate_deployment_dependencies(
artifact=artifact,
deployment=deployment,
sources=available_sources,
)
tree = resolve_saved_subgraph_tree(
root_artifact=artifact,
artifact_store=store,
)
diagnostics.extend(
validate_saved_subgraph_tree(
tree=tree,
deployment=deployment,
sources=available_sources,
)
)
return deployment, artifact, diagnostics, tree
def _available_sources(
capability_sources: Mapping[str, CapabilitySource],
) -> list[AvailableSource]:
"""Convert broker capability sources into artifact validation snapshots."""
sources: list[AvailableSource] = []
for source in capability_sources.values():
node_spec_details = {
detail.name: detail
for detail in source.as_inventory().capabilities.node_spec_details
}
capabilities = {
capability_name: AvailableCapability(
name=capability_name,
kind="node_spec",
input_schema_hash=hash_json_schema(detail.input_schema),
output_schema_hash=hash_json_schema(detail.output_schema),
)
for spec in source.capabilities.node_specs.values()
if (capability_name := _capability_name(spec.name)) is not None
if (detail := node_spec_details.get(spec.name)) is not None
}
capabilities.update(
{
capability_name: AvailableCapability(
name=capability_name,
kind="reducer",
)
for reducer in source.capabilities.reducers.values()
if (capability_name := _capability_name(reducer.name)) is not None
}
)
sources.append(
AvailableSource(
id=source.id,
enabled=source.enabled,
capabilities=capabilities,
)
)
return sources
def _capability_name(qualified_name: str) -> str | None:
"""Return the local name of one qualified capability ref if it is valid."""
from wf_api.refs import parse_workflow_surface_capability_id
from wf_artifacts import WorkflowCapabilityRef
try:
parsed = parse_workflow_surface_capability_id(qualified_name)
except ValueError:
return None
if isinstance(parsed, WorkflowCapabilityRef):
return None
return parsed.name
def _deployment_summary(deployment: WorkflowDeployment) -> dict[str, Any]:
"""Return compact deployment metadata for progressive list responses."""
return {
"id": deployment.id,
"artifact_id": deployment.artifact_id,
"artifact_version": deployment.artifact_version,
"binding_count": len(deployment.binding_map()),
"drift_policy": deployment.drift_policy.value,
}
__all__ = [
"WorkflowDeploymentApi",
]
+21 -4
View File
@@ -1,15 +1,17 @@
from __future__ import annotations
from collections.abc import Mapping
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from typing import Any, Protocol
from wf_artifacts import (
DependencyDiagnostic,
DraftWorkspaceStore,
RunStore,
WorkflowArtifact,
WorkflowArtifactCatalogEntry,
WorkflowArtifactStore,
WorkflowDeployment,
)
from wf_authoring import AsyncRegistryHandler
from wf_core import RunState
@@ -23,7 +25,17 @@ class WorkflowEventRecorder(Protocol):
"""Records workflow lifecycle events without exposing MCP event types."""
def record_event(self, event: object) -> None:
"""Record one event object supplied by an adapter-owned event factory."""
"""Record one adapter-native event object."""
...
def record_workflow_event(
self,
event_type: str,
*,
capability_id: str,
payload: dict[str, Any],
) -> None:
"""Record one workflow lifecycle event by protocol-neutral fields."""
...
@@ -85,8 +97,13 @@ class WorkflowRuntimeRunner(Protocol):
class WorkflowLiveSourceChecker(Protocol):
"""Optional hook for validating live external source availability."""
async def available_sources(self) -> list[object]:
"""Return source availability records understood by the caller."""
async def deployment_diagnostics(
self,
*,
deployment: WorkflowDeployment,
artifacts: Sequence[WorkflowArtifact],
) -> list[DependencyDiagnostic]:
"""Return opt-in live-source diagnostics for a deployment tree."""
...