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
|
||||
Reference in New Issue
Block a user