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