draft workspace: the promised wf_authoring for LLMs

This commit is contained in:
lda
2026-05-19 05:13:06 +07:00 Verified
parent edbe8c4d84
commit dd577d9731
19 changed files with 2618 additions and 4 deletions
+20
View File
@@ -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",
]
+132
View File
@@ -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
+122
View File
@@ -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 -1
View File
@@ -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)
+7
View File
@@ -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)
+5
View File
@@ -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",
+146 -1
View File
@@ -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,
+20
View File
@@ -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.",
)
+131 -1
View File
@@ -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",