draft workspace: the promised wf_authoring for LLMs
This commit is contained in:
@@ -9,6 +9,17 @@ from .drafts import (
|
||||
patch_workflow_draft,
|
||||
validate_workflow_draft,
|
||||
)
|
||||
from .draft_workspaces import (
|
||||
DraftWorkspaceStore,
|
||||
DraftWorkspaceConflictError,
|
||||
FileDraftWorkspaceStore,
|
||||
WorkflowDraftWorkspace,
|
||||
create_draft_workspace,
|
||||
ensure_workspace_id,
|
||||
get_draft_workspace,
|
||||
patch_draft_workspace,
|
||||
summarize_draft_workspace,
|
||||
)
|
||||
from .models import (
|
||||
ArtifactKind,
|
||||
AvailableCapability,
|
||||
@@ -32,20 +43,29 @@ __all__ = [
|
||||
"DependencyDiagnostic",
|
||||
"DiagnosticSeverity",
|
||||
"DriftPolicy",
|
||||
"DraftWorkspaceConflictError",
|
||||
"DraftWorkspaceStore",
|
||||
"FileDraftWorkspaceStore",
|
||||
"FileWorkflowArtifactStore",
|
||||
"RequiredCapability",
|
||||
"WorkflowArtifact",
|
||||
"WorkflowArtifactCatalogEntry",
|
||||
"WorkflowCapabilityRef",
|
||||
"WorkflowDraftWorkspace",
|
||||
"WorkflowArtifactStore",
|
||||
"WorkflowDeployment",
|
||||
"artifact_catalog_entry",
|
||||
"artifact_node_name",
|
||||
"create_draft_workspace",
|
||||
"create_workflow_artifact_from_plan",
|
||||
"compile_workflow_draft",
|
||||
"ensure_workspace_id",
|
||||
"get_draft_workspace",
|
||||
"logical_ref_for_concrete_ref",
|
||||
"normalize_plan_node_refs",
|
||||
"patch_draft_workspace",
|
||||
"patch_workflow_draft",
|
||||
"summarize_draft_workspace",
|
||||
"validate_deployment_dependencies",
|
||||
"validate_workflow_draft",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
from .api import create_draft_workspace, get_draft_workspace, patch_draft_workspace
|
||||
from .models import (
|
||||
WorkflowDraftWorkspace,
|
||||
ensure_workspace_id,
|
||||
summarize_draft_workspace,
|
||||
)
|
||||
from .store import (
|
||||
DraftWorkspaceConflictError,
|
||||
DraftWorkspaceStore,
|
||||
FileDraftWorkspaceStore,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DraftWorkspaceConflictError",
|
||||
"DraftWorkspaceStore",
|
||||
"FileDraftWorkspaceStore",
|
||||
"WorkflowDraftWorkspace",
|
||||
"create_draft_workspace",
|
||||
"ensure_workspace_id",
|
||||
"get_draft_workspace",
|
||||
"patch_draft_workspace",
|
||||
"summarize_draft_workspace",
|
||||
]
|
||||
@@ -0,0 +1,132 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from wf_artifacts.drafts import patch_workflow_draft, validate_workflow_draft
|
||||
|
||||
from .models import WorkflowDraftWorkspace, summarize_draft_workspace
|
||||
from .store import DraftWorkspaceConflictError, DraftWorkspaceStore
|
||||
|
||||
JsonObject = dict[str, Any]
|
||||
JsonPatch = list[dict[str, Any]]
|
||||
|
||||
|
||||
def create_draft_workspace(
|
||||
store: DraftWorkspaceStore,
|
||||
*,
|
||||
workspace_id: str,
|
||||
draft: JsonObject,
|
||||
title: str | None = None,
|
||||
) -> JsonObject:
|
||||
"""Validate and save a new mutable draft workspace."""
|
||||
now = _now_ms()
|
||||
validation = validate_workflow_draft(draft)
|
||||
workspace = WorkflowDraftWorkspace(
|
||||
id=workspace_id,
|
||||
revision=1,
|
||||
title=title,
|
||||
draft=draft,
|
||||
status=validation["status"],
|
||||
diagnostics=validation["diagnostics"],
|
||||
created_at_epoch_ms=now,
|
||||
updated_at_epoch_ms=now,
|
||||
)
|
||||
try:
|
||||
store.create_workspace(workspace)
|
||||
except DraftWorkspaceConflictError as exc:
|
||||
return _conflict_payload(
|
||||
exc.workspace,
|
||||
code="workspace_exists",
|
||||
message=f"draft workspace {workspace_id!r} already exists",
|
||||
)
|
||||
return summarize_draft_workspace(workspace)
|
||||
|
||||
|
||||
def patch_draft_workspace(
|
||||
store: DraftWorkspaceStore,
|
||||
*,
|
||||
workspace_id: str,
|
||||
revision: int,
|
||||
patch: JsonPatch,
|
||||
) -> JsonObject:
|
||||
"""Apply JSON Patch to a stored workspace when the revision matches."""
|
||||
workspace = store.get_workspace(workspace_id)
|
||||
if workspace.revision != revision:
|
||||
return _revision_conflict_payload(workspace, revision)
|
||||
patched = patch_workflow_draft(workspace.draft, patch)
|
||||
if "draft" not in patched:
|
||||
# A malformed JSON Patch is not a draft revision. Return diagnostics
|
||||
# without mutating the stored workspace or burning a revision number.
|
||||
return {
|
||||
**summarize_draft_workspace(workspace),
|
||||
"status": patched["status"],
|
||||
"diagnostics": patched["diagnostics"],
|
||||
}
|
||||
next_workspace = workspace.model_copy(
|
||||
update={
|
||||
"revision": workspace.revision + 1,
|
||||
"draft": patched["draft"],
|
||||
"status": patched["status"],
|
||||
"diagnostics": patched["diagnostics"],
|
||||
"updated_at_epoch_ms": _now_ms(),
|
||||
}
|
||||
)
|
||||
try:
|
||||
store.replace_workspace(next_workspace, expected_revision=revision)
|
||||
except DraftWorkspaceConflictError as exc:
|
||||
return _revision_conflict_payload(exc.workspace, revision)
|
||||
return summarize_draft_workspace(next_workspace)
|
||||
|
||||
|
||||
def get_draft_workspace(
|
||||
store: DraftWorkspaceStore,
|
||||
*,
|
||||
workspace_id: str,
|
||||
include_draft: bool = False,
|
||||
) -> JsonObject:
|
||||
"""Return one stored workspace, compact by default."""
|
||||
return summarize_draft_workspace(
|
||||
store.get_workspace(workspace_id),
|
||||
include_draft=include_draft,
|
||||
)
|
||||
|
||||
|
||||
def _now_ms() -> int:
|
||||
return int(time.time() * 1000)
|
||||
|
||||
|
||||
def _revision_conflict_payload(
|
||||
workspace: WorkflowDraftWorkspace,
|
||||
expected_revision: int,
|
||||
) -> JsonObject:
|
||||
return _conflict_payload(
|
||||
workspace,
|
||||
code="revision_conflict",
|
||||
message=(
|
||||
f"workspace {workspace.id!r} is at revision "
|
||||
f"{workspace.revision}, not {expected_revision}"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _conflict_payload(
|
||||
workspace: WorkflowDraftWorkspace,
|
||||
*,
|
||||
code: str,
|
||||
message: str,
|
||||
) -> JsonObject:
|
||||
return {
|
||||
"workspace_id": workspace.id,
|
||||
"revision": workspace.revision,
|
||||
"title": workspace.title,
|
||||
"status": "conflict",
|
||||
"diagnostics": [
|
||||
{
|
||||
"code": code,
|
||||
"path": "revision" if code == "revision_conflict" else "workspace_id",
|
||||
"message": message,
|
||||
}
|
||||
],
|
||||
"summary": summarize_draft_workspace(workspace)["summary"],
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
JsonObject = dict[str, Any]
|
||||
WORKSPACE_ID_PATTERN = r"^[A-Za-z0-9_.-]+$"
|
||||
|
||||
|
||||
def ensure_workspace_id(workspace_id: str) -> str:
|
||||
"""Reject ids that cannot be safely used as one local filename stem."""
|
||||
if not re.fullmatch(WORKSPACE_ID_PATTERN, workspace_id):
|
||||
raise ValueError(
|
||||
"workspace_id must match [A-Za-z0-9_.-]+; path separators are not allowed"
|
||||
)
|
||||
return workspace_id
|
||||
|
||||
|
||||
class WorkflowDraftWorkspace(BaseModel):
|
||||
"""Mutable, revisioned authoring workspace for one workflow draft."""
|
||||
|
||||
id: str = Field(pattern=WORKSPACE_ID_PATTERN)
|
||||
revision: int = Field(default=1, ge=1)
|
||||
title: str | None = None
|
||||
draft: JsonObject
|
||||
status: Literal["valid", "invalid"]
|
||||
diagnostics: list[JsonObject] = Field(default_factory=list)
|
||||
created_at_epoch_ms: int
|
||||
updated_at_epoch_ms: int
|
||||
|
||||
|
||||
def summarize_draft_workspace(
|
||||
workspace: WorkflowDraftWorkspace,
|
||||
*,
|
||||
include_draft: bool = False,
|
||||
) -> JsonObject:
|
||||
"""Return the compact workspace payload used by MCP-facing tools."""
|
||||
steps = workspace.draft.get("steps", {})
|
||||
routes = workspace.draft.get("routes", {})
|
||||
summary: JsonObject = {
|
||||
"workspace_id": workspace.id,
|
||||
"revision": workspace.revision,
|
||||
"title": workspace.title,
|
||||
"status": workspace.status,
|
||||
"diagnostics": workspace.diagnostics,
|
||||
"summary": {
|
||||
"name": workspace.draft.get("name"),
|
||||
"start": workspace.draft.get("start"),
|
||||
"step_count": len(steps) if isinstance(steps, dict) else 0,
|
||||
"route_count": len(routes) if isinstance(routes, dict) else 0,
|
||||
"steps": sorted(steps) if isinstance(steps, dict) else [],
|
||||
},
|
||||
}
|
||||
if include_draft:
|
||||
summary["draft"] = workspace.draft
|
||||
return summary
|
||||
@@ -0,0 +1,122 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from threading import RLock
|
||||
|
||||
from .models import WorkflowDraftWorkspace, ensure_workspace_id
|
||||
|
||||
|
||||
class DraftWorkspaceConflictError(RuntimeError):
|
||||
"""Raised when a workspace create/update loses an optimistic-concurrency race."""
|
||||
|
||||
def __init__(self, workspace: WorkflowDraftWorkspace) -> None:
|
||||
self.workspace = workspace
|
||||
super().__init__(
|
||||
f"draft workspace {workspace.id!r} is at revision {workspace.revision}"
|
||||
)
|
||||
|
||||
|
||||
class DraftWorkspaceStore:
|
||||
"""Storage boundary for mutable workflow draft workspaces."""
|
||||
|
||||
def create_workspace(self, workspace: WorkflowDraftWorkspace) -> None:
|
||||
"""Save a new workspace, rejecting duplicate ids."""
|
||||
try:
|
||||
existing = self.get_workspace(workspace.id)
|
||||
except KeyError:
|
||||
self.save_workspace(workspace)
|
||||
return
|
||||
raise DraftWorkspaceConflictError(existing)
|
||||
|
||||
def replace_workspace(
|
||||
self,
|
||||
workspace: WorkflowDraftWorkspace,
|
||||
*,
|
||||
expected_revision: int,
|
||||
) -> None:
|
||||
"""Replace a workspace only if the stored revision still matches."""
|
||||
current = self.get_workspace(workspace.id)
|
||||
if current.revision != expected_revision:
|
||||
raise DraftWorkspaceConflictError(current)
|
||||
self.save_workspace(workspace)
|
||||
|
||||
def save_workspace(self, workspace: WorkflowDraftWorkspace) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
def get_workspace(self, workspace_id: str) -> WorkflowDraftWorkspace:
|
||||
raise NotImplementedError
|
||||
|
||||
def list_workspaces(self) -> list[WorkflowDraftWorkspace]:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class FileDraftWorkspaceStore(DraftWorkspaceStore):
|
||||
"""JSON file-backed draft workspace store for local development and tests.
|
||||
|
||||
The internal lock protects optimistic writes inside one broker process. If
|
||||
multiple broker processes share a root, use a stronger store implementation.
|
||||
"""
|
||||
|
||||
def __init__(self, root: Path) -> None:
|
||||
self.root = root
|
||||
self._lock = RLock()
|
||||
self.workspaces_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@property
|
||||
def workspaces_dir(self) -> Path:
|
||||
return self.root / "draft_workspaces"
|
||||
|
||||
def save_workspace(self, workspace: WorkflowDraftWorkspace) -> None:
|
||||
with self._lock:
|
||||
self._write_workspace(workspace)
|
||||
|
||||
def create_workspace(self, workspace: WorkflowDraftWorkspace) -> None:
|
||||
with self._lock:
|
||||
path = self._workspace_path(workspace.id)
|
||||
if path.exists():
|
||||
raise DraftWorkspaceConflictError(self.get_workspace(workspace.id))
|
||||
self._write_workspace(workspace)
|
||||
|
||||
def replace_workspace(
|
||||
self,
|
||||
workspace: WorkflowDraftWorkspace,
|
||||
*,
|
||||
expected_revision: int,
|
||||
) -> None:
|
||||
with self._lock:
|
||||
current = self.get_workspace(workspace.id)
|
||||
if current.revision != expected_revision:
|
||||
raise DraftWorkspaceConflictError(current)
|
||||
self._write_workspace(workspace)
|
||||
|
||||
def _write_workspace(self, workspace: WorkflowDraftWorkspace) -> None:
|
||||
path = self._workspace_path(workspace.id)
|
||||
temp_path = path.with_suffix(".json.tmp")
|
||||
temp_path.write_text(
|
||||
json.dumps(workspace.model_dump(mode="json"), indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
temp_path.replace(path)
|
||||
|
||||
def get_workspace(self, workspace_id: str) -> WorkflowDraftWorkspace:
|
||||
path = self._workspace_path(workspace_id)
|
||||
if not path.exists():
|
||||
raise KeyError(f"unknown draft workspace {workspace_id!r}")
|
||||
return WorkflowDraftWorkspace.model_validate_json(
|
||||
path.read_text(encoding="utf-8")
|
||||
)
|
||||
|
||||
def list_workspaces(self) -> list[WorkflowDraftWorkspace]:
|
||||
return [
|
||||
WorkflowDraftWorkspace.model_validate_json(path.read_text(encoding="utf-8"))
|
||||
for path in sorted(self.workspaces_dir.glob("*.json"))
|
||||
]
|
||||
|
||||
def _workspace_path(self, workspace_id: str) -> Path:
|
||||
safe_id = ensure_workspace_id(workspace_id)
|
||||
root = self.workspaces_dir.resolve()
|
||||
path = (self.workspaces_dir / f"{safe_id}.json").resolve()
|
||||
if path.parent != root:
|
||||
raise ValueError(f"workspace id escapes workspace store: {workspace_id!r}")
|
||||
return path
|
||||
@@ -3,11 +3,12 @@ from __future__ import annotations
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from wf_artifacts import FileDraftWorkspaceStore, FileWorkflowArtifactStore
|
||||
|
||||
from ..control import BrokerConfigFile
|
||||
from ..models import BrokerConfig
|
||||
from ..sdk import McpSdkAdapter
|
||||
from ..storage import FileStore
|
||||
from wf_artifacts import FileWorkflowArtifactStore
|
||||
from .service import WfMcpService
|
||||
|
||||
|
||||
@@ -23,6 +24,7 @@ def build_service_from_config(config: BrokerConfig) -> WfMcpService:
|
||||
service = WfMcpService(
|
||||
store=FileStore(config.store_root),
|
||||
artifact_store=FileWorkflowArtifactStore(config.store_root),
|
||||
draft_workspace_store=FileDraftWorkspaceStore(config.store_root),
|
||||
)
|
||||
for connection in config.connections:
|
||||
service.register_connection(connection)
|
||||
|
||||
@@ -8,6 +8,8 @@ from typing import Any
|
||||
from pydantic import BaseModel
|
||||
|
||||
from wf_artifacts import (
|
||||
DraftWorkspaceStore,
|
||||
FileDraftWorkspaceStore,
|
||||
FileWorkflowArtifactStore,
|
||||
WorkflowArtifact,
|
||||
WorkflowArtifactCatalogEntry,
|
||||
@@ -67,11 +69,16 @@ class WfMcpService:
|
||||
event_bus: EventBus = field(default_factory=EventBus)
|
||||
include_builtin_specs: bool = True
|
||||
artifact_store: WorkflowArtifactStore | None = None
|
||||
draft_workspace_store: DraftWorkspaceStore | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Install broker-local system specs when enabled."""
|
||||
if self.artifact_store is None:
|
||||
self.artifact_store = FileWorkflowArtifactStore(_store_root(self.store))
|
||||
if self.draft_workspace_store is None:
|
||||
self.draft_workspace_store = FileDraftWorkspaceStore(
|
||||
_store_root(self.store)
|
||||
)
|
||||
if self.include_builtin_specs:
|
||||
for source in builtin_sources(self).values():
|
||||
self.register_capability_source(source)
|
||||
|
||||
@@ -41,6 +41,11 @@ _SEARCH_ALWAYS_VISIBLE_TOOL_NAMES = [
|
||||
"wf.workflow.create_artifact_from_plan",
|
||||
"wf.workflow.create_artifact_from_draft",
|
||||
"wf.workflow.patch_draft",
|
||||
"wf.workflow.create_draft_workspace",
|
||||
"wf.workflow.get_draft_workspace",
|
||||
"wf.workflow.patch_draft_workspace",
|
||||
"wf.workflow.create_minimal_draft_workspace",
|
||||
"wf.workflow.create_artifact_from_workspace",
|
||||
"wf.workflow.call_capability",
|
||||
"wf.workflow.inspect_artifact",
|
||||
"wf.workflow.list_deployments",
|
||||
|
||||
@@ -9,12 +9,16 @@ from wf_artifacts import (
|
||||
AvailableSource,
|
||||
DependencyDiagnostic,
|
||||
DiagnosticSeverity,
|
||||
DraftWorkspaceStore,
|
||||
RequiredCapability,
|
||||
WorkflowArtifact,
|
||||
WorkflowCapabilityRef,
|
||||
WorkflowDeployment,
|
||||
compile_workflow_draft,
|
||||
create_draft_workspace as create_draft_workspace_record,
|
||||
create_workflow_artifact_from_plan as build_workflow_artifact_from_plan,
|
||||
get_draft_workspace as get_draft_workspace_record,
|
||||
patch_draft_workspace as patch_draft_workspace_record,
|
||||
patch_workflow_draft,
|
||||
validate_workflow_draft,
|
||||
validate_deployment_dependencies,
|
||||
@@ -340,7 +344,7 @@ class WorkflowSurfaceHandlers:
|
||||
required_sources = sorted(
|
||||
{
|
||||
capability.logical_source
|
||||
for capability in workflow_artifact.required_capabilities.values()
|
||||
for capability in dict(workflow_artifact.required_capabilities).values()
|
||||
}
|
||||
)
|
||||
return {
|
||||
@@ -359,6 +363,139 @@ class WorkflowSurfaceHandlers:
|
||||
) -> dict[str, Any]:
|
||||
return patch_workflow_draft(draft, patch)
|
||||
|
||||
def _draft_store(self) -> DraftWorkspaceStore:
|
||||
if self.service.draft_workspace_store is None:
|
||||
raise KeyError("draft workspace store is not configured")
|
||||
return self.service.draft_workspace_store
|
||||
|
||||
async def create_draft_workspace(
|
||||
self,
|
||||
*,
|
||||
workspace_id: str,
|
||||
draft: dict[str, Any],
|
||||
title: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return create_draft_workspace_record(
|
||||
self._draft_store(),
|
||||
workspace_id=workspace_id,
|
||||
draft=draft,
|
||||
title=title,
|
||||
)
|
||||
|
||||
async def get_draft_workspace(
|
||||
self,
|
||||
*,
|
||||
workspace_id: str,
|
||||
include_draft: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
return get_draft_workspace_record(
|
||||
self._draft_store(),
|
||||
workspace_id=workspace_id,
|
||||
include_draft=include_draft,
|
||||
)
|
||||
|
||||
async def patch_draft_workspace(
|
||||
self,
|
||||
*,
|
||||
workspace_id: str,
|
||||
revision: int,
|
||||
patch: list[dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
return patch_draft_workspace_record(
|
||||
self._draft_store(),
|
||||
workspace_id=workspace_id,
|
||||
revision=revision,
|
||||
patch=patch,
|
||||
)
|
||||
|
||||
async def create_minimal_draft_workspace(
|
||||
self,
|
||||
*,
|
||||
workspace_id: str,
|
||||
name: str,
|
||||
capability_name: str,
|
||||
input_schema: dict[str, Any],
|
||||
state_schema: dict[str, Any],
|
||||
output_schema: dict[str, Any],
|
||||
input_map: dict[str, str],
|
||||
output_map: dict[str, str],
|
||||
error_message_source: str | None = None,
|
||||
title: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Bootstrap the smallest patchable draft around one workflow capability."""
|
||||
outcomes = self._outcomes_for_capability(capability_name) or ("ok",)
|
||||
steps: dict[str, Any] = {
|
||||
"call": {
|
||||
"use": capability_name,
|
||||
"in": input_map,
|
||||
"out": output_map,
|
||||
}
|
||||
}
|
||||
routes: dict[str, dict[str, str]] = {"call": {"ok": "__end__"}}
|
||||
error_source = error_message_source or _first_state_path(output_map)
|
||||
if "error" in outcomes and error_source is not None:
|
||||
# The bootstrapper cannot infer provider-specific error envelopes.
|
||||
# It only wires an error route when the caller gave, or output_map
|
||||
# exposes, a concrete state path that can become a runtime message.
|
||||
steps["tool_error"] = {
|
||||
"use": "wf.std.runtime_error",
|
||||
"in": {error_source: "message"},
|
||||
"out": {},
|
||||
}
|
||||
routes["call"]["error"] = "tool_error"
|
||||
routes["tool_error"] = {"ok": "__end__"}
|
||||
draft = {
|
||||
"name": name,
|
||||
"input_schema": input_schema,
|
||||
"state_schema": state_schema,
|
||||
"output_schema": output_schema,
|
||||
"start": "call",
|
||||
"steps": steps,
|
||||
"routes": routes,
|
||||
}
|
||||
return await self.create_draft_workspace(
|
||||
workspace_id=workspace_id,
|
||||
title=title,
|
||||
draft=draft,
|
||||
)
|
||||
|
||||
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]:
|
||||
workspace = self._draft_store().get_workspace(workspace_id)
|
||||
validation = await self.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,
|
||||
)
|
||||
|
||||
def _outcomes_for_capability(self, qualified_name: str) -> tuple[str, ...] | None:
|
||||
try:
|
||||
return self.service._get_qualified_spec(qualified_name).outcomes
|
||||
@@ -583,6 +720,14 @@ def _schema_field_names(schema: dict[str, Any]) -> list[str]:
|
||||
return sorted(str(name) for name in properties)
|
||||
|
||||
|
||||
def _first_state_path(output_map: dict[str, str]) -> str | None:
|
||||
"""Return the first mapped state path for minimal error-route bootstraps."""
|
||||
for target in output_map.values():
|
||||
if target.startswith("state."):
|
||||
return target
|
||||
return None
|
||||
|
||||
|
||||
def _source_id_for_capability(
|
||||
sources: dict[str, CapabilitySource],
|
||||
qualified_name: str,
|
||||
|
||||
@@ -32,3 +32,23 @@ class CallCapabilityResult(BaseModel):
|
||||
default_factory=list,
|
||||
description="Structured diagnostics. Empty for successful calls.",
|
||||
)
|
||||
|
||||
|
||||
class DraftWorkspaceResult(BaseModel):
|
||||
"""Inspector-visible response contract for draft workspace operations."""
|
||||
|
||||
workspace_id: str = Field(description="Mutable draft workspace id.")
|
||||
revision: int = Field(description="Current optimistic-concurrency revision.")
|
||||
title: str | None = Field(default=None, description="Optional workspace title.")
|
||||
status: str = Field(description="Workspace validation status.")
|
||||
diagnostics: list[dict[str, Any]] = Field(
|
||||
default_factory=list,
|
||||
description="Structured diagnostics for invalid or conflicted workspaces.",
|
||||
)
|
||||
summary: dict[str, Any] = Field(
|
||||
description="Compact draft summary for progressive MCP clients."
|
||||
)
|
||||
draft: dict[str, Any] | None = Field(
|
||||
default=None,
|
||||
description="Full draft document, only returned when requested.",
|
||||
)
|
||||
|
||||
@@ -9,7 +9,7 @@ from wf_artifacts.models import RequiredCapability
|
||||
from wf_mcp.broker.service import WfMcpService
|
||||
|
||||
from .handlers import WorkflowSurfaceHandlers
|
||||
from .models import CallCapabilityResult
|
||||
from .models import CallCapabilityResult, DraftWorkspaceResult
|
||||
|
||||
|
||||
def register_workflow_tools(server: FastMCP[Any], service: WfMcpService) -> None:
|
||||
@@ -190,6 +190,136 @@ def register_workflow_tools(server: FastMCP[Any], service: WfMcpService) -> None
|
||||
) -> dict[str, Any]:
|
||||
return await handlers.patch_draft(draft=draft, patch=patch)
|
||||
|
||||
@server.tool(
|
||||
name="wf.workflow.create_draft_workspace",
|
||||
title="Create Draft Workspace",
|
||||
description="Store a mutable workflow draft workspace for iterative patching.",
|
||||
)
|
||||
async def create_draft_workspace(
|
||||
workspace_id: str,
|
||||
draft: dict[str, Any],
|
||||
title: str | None = None,
|
||||
) -> DraftWorkspaceResult:
|
||||
return DraftWorkspaceResult.model_validate(
|
||||
await handlers.create_draft_workspace(
|
||||
workspace_id=workspace_id,
|
||||
draft=draft,
|
||||
title=title,
|
||||
)
|
||||
)
|
||||
|
||||
@server.tool(
|
||||
name="wf.workflow.get_draft_workspace",
|
||||
title="Get Draft Workspace",
|
||||
description="Fetch a mutable workflow draft workspace by id.",
|
||||
)
|
||||
async def get_draft_workspace(
|
||||
workspace_id: str,
|
||||
include_draft: bool = False,
|
||||
) -> DraftWorkspaceResult:
|
||||
return DraftWorkspaceResult.model_validate(
|
||||
await handlers.get_draft_workspace(
|
||||
workspace_id=workspace_id,
|
||||
include_draft=include_draft,
|
||||
)
|
||||
)
|
||||
|
||||
@server.tool(
|
||||
name="wf.workflow.patch_draft_workspace",
|
||||
title="Patch Draft Workspace",
|
||||
description=(
|
||||
"Apply an RFC 6902 JSON Patch to a stored workflow draft workspace "
|
||||
"when the expected revision matches."
|
||||
),
|
||||
)
|
||||
async def patch_draft_workspace(
|
||||
workspace_id: str,
|
||||
revision: int,
|
||||
patch: list[dict[str, Any]],
|
||||
) -> DraftWorkspaceResult:
|
||||
return DraftWorkspaceResult.model_validate(
|
||||
await handlers.patch_draft_workspace(
|
||||
workspace_id=workspace_id,
|
||||
revision=revision,
|
||||
patch=patch,
|
||||
)
|
||||
)
|
||||
|
||||
@server.tool(
|
||||
name="wf.workflow.create_minimal_draft_workspace",
|
||||
title="Create Minimal Draft Workspace",
|
||||
description="Bootstrap a patchable draft workspace around one capability.",
|
||||
)
|
||||
async def create_minimal_draft_workspace(
|
||||
workspace_id: str,
|
||||
name: str,
|
||||
capability_name: str,
|
||||
input_schema: dict[str, Any],
|
||||
state_schema: dict[str, Any],
|
||||
output_schema: dict[str, Any],
|
||||
input_map: dict[str, str],
|
||||
output_map: dict[str, str],
|
||||
error_message_source: str | None = None,
|
||||
title: str | None = None,
|
||||
) -> DraftWorkspaceResult:
|
||||
return DraftWorkspaceResult.model_validate(
|
||||
await handlers.create_minimal_draft_workspace(
|
||||
workspace_id=workspace_id,
|
||||
name=name,
|
||||
capability_name=capability_name,
|
||||
input_schema=input_schema,
|
||||
state_schema=state_schema,
|
||||
output_schema=output_schema,
|
||||
input_map=input_map,
|
||||
output_map=output_map,
|
||||
error_message_source=error_message_source,
|
||||
title=title,
|
||||
)
|
||||
)
|
||||
|
||||
@server.tool(
|
||||
name="wf.workflow.create_artifact_from_workspace",
|
||||
title="Create Workflow Artifact From Workspace",
|
||||
description=(
|
||||
"Validate the current draft workspace and save it as a versioned "
|
||||
"workflow artifact."
|
||||
),
|
||||
)
|
||||
async def create_artifact_from_workspace(
|
||||
workspace_id: str,
|
||||
artifact_id: str,
|
||||
version: int,
|
||||
title: str,
|
||||
outcomes: list[str],
|
||||
kind: ArtifactKind = "workflow",
|
||||
description: str | None = None,
|
||||
required_capabilities: (
|
||||
Mapping[str, RequiredCapability | dict[str, Any]] | None
|
||||
) = None,
|
||||
source_bindings: Mapping[str, str] | None = None,
|
||||
created_from_catalog_version: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return await handlers.create_artifact_from_workspace(
|
||||
workspace_id=workspace_id,
|
||||
artifact_id=artifact_id,
|
||||
version=version,
|
||||
title=title,
|
||||
kind=kind,
|
||||
description=description,
|
||||
outcomes=outcomes,
|
||||
required_capabilities={
|
||||
name: (
|
||||
capability.model_dump()
|
||||
if isinstance(capability, RequiredCapability)
|
||||
else capability
|
||||
)
|
||||
for name, capability in (required_capabilities or {}).items()
|
||||
}
|
||||
or None,
|
||||
source_bindings=dict(source_bindings or {}),
|
||||
created_from_catalog_version=created_from_catalog_version,
|
||||
)
|
||||
|
||||
@server.tool(
|
||||
name="wf.workflow.inspect_artifact",
|
||||
title="Inspect Workflow Artifact",
|
||||
|
||||
Reference in New Issue
Block a user