durable stopped workflow runs and resume

This commit is contained in:
lda
2026-05-26 12:20:40 +07:00 Verified
parent d37077397a
commit ba8fd2b614
30 changed files with 1216 additions and 146 deletions
+20
View File
@@ -39,6 +39,17 @@ from .refs import (
workflow_ref_from_capability,
)
from .store import FileWorkflowArtifactStore, WorkflowArtifactStore
from .runs import (
CheckpointReason,
FileRunStore,
PinnedRunEnvironment,
ResumeReadiness,
RunCheckpoint,
RunStore,
StoredRunStatus,
WorkflowRunRecord,
ensure_run_id,
)
from .validation import validate_deployment_dependencies
from .references import logical_ref_for_concrete_ref, normalize_plan_node_refs
@@ -53,6 +64,7 @@ __all__ = [
"DraftWorkspaceStore",
"FileDraftWorkspaceStore",
"FileWorkflowArtifactStore",
"FileRunStore",
"RequiredCapability",
"SourceBinding",
"WorkflowArtifact",
@@ -60,7 +72,15 @@ __all__ = [
"WorkflowCapabilityRef",
"WorkflowDraftWorkspace",
"WorkflowArtifactStore",
"WorkflowRunRecord",
"WorkflowDeployment",
"RunStore",
"RunCheckpoint",
"CheckpointReason",
"PinnedRunEnvironment",
"ResumeReadiness",
"StoredRunStatus",
"ensure_run_id",
"artifact_catalog_entry",
"artifact_node_name",
"create_draft_workspace",
+22
View File
@@ -0,0 +1,22 @@
from .models import (
CheckpointReason,
PinnedRunEnvironment,
ResumeReadiness,
RunCheckpoint,
StoredRunStatus,
WorkflowRunRecord,
ensure_run_id,
)
from .store import FileRunStore, RunStore
__all__ = [
"CheckpointReason",
"FileRunStore",
"PinnedRunEnvironment",
"ResumeReadiness",
"RunCheckpoint",
"RunStore",
"StoredRunStatus",
"WorkflowRunRecord",
"ensure_run_id",
]
+84
View File
@@ -0,0 +1,84 @@
from __future__ import annotations
import re
from datetime import datetime
from enum import StrEnum
from pydantic import BaseModel, ConfigDict, Field
from wf_core import PersistedRunState
from ..models import DependencyDiagnostic, WorkflowArtifact, WorkflowDeployment
RUN_ID_PATTERN = r"^[A-Za-z0-9_.-]+$"
def ensure_run_id(run_id: str) -> str:
"""Reject ids that cannot safely identify one local run directory."""
if not re.fullmatch(RUN_ID_PATTERN, run_id):
raise ValueError(
"run_id must match [A-Za-z0-9_.-]+; path separators are not allowed"
)
return run_id
class StoredRunStatus(StrEnum):
"""Stopped runtime statuses supported by durable run persistence."""
INTERRUPTED = "interrupted"
COMPLETED = "completed"
FAILED = "failed"
class ResumeReadiness(StrEnum):
"""Whether an interrupted stored run may currently continue."""
READY = "ready"
BLOCKED = "blocked"
NOT_APPLICABLE = "not_applicable"
class CheckpointReason(StrEnum):
"""Why a stopped-state checkpoint was written."""
INTERRUPTED = "interrupted"
COMPLETED = "completed"
FAILED = "failed"
class PinnedRunEnvironment(BaseModel):
"""Exact execution definitions captured when a run starts."""
model_config = ConfigDict(extra="forbid")
deployment: WorkflowDeployment
root_artifact: WorkflowArtifact
child_artifacts: list[WorkflowArtifact] = Field(default_factory=list)
class WorkflowRunRecord(BaseModel):
"""Durable summary and pinned environment for one started workflow run."""
model_config = ConfigDict(extra="forbid")
id: str = Field(pattern=RUN_ID_PATTERN)
status: StoredRunStatus
resume_readiness: ResumeReadiness
environment: PinnedRunEnvironment
latest_checkpoint_id: str
diagnostics: list[DependencyDiagnostic] = Field(default_factory=list)
created_at: datetime
updated_at: datetime
class RunCheckpoint(BaseModel):
"""One stopped-state snapshot persisted at an external run boundary."""
model_config = ConfigDict(extra="forbid")
id: str = Field(pattern=RUN_ID_PATTERN)
run_id: str = Field(pattern=RUN_ID_PATTERN)
sequence: int = Field(ge=1)
reason: CheckpointReason
state: PersistedRunState
created_at: datetime
+103
View File
@@ -0,0 +1,103 @@
from __future__ import annotations
import json
from pathlib import Path
from threading import RLock
from .models import RunCheckpoint, WorkflowRunRecord, ensure_run_id
class RunStore:
"""Persistence boundary for stopped run summaries and checkpoints."""
def save_run(self, run: WorkflowRunRecord) -> None:
raise NotImplementedError
def get_run(self, run_id: str) -> WorkflowRunRecord:
raise NotImplementedError
def list_runs(self) -> list[WorkflowRunRecord]:
raise NotImplementedError
def save_checkpoint(self, checkpoint: RunCheckpoint) -> None:
raise NotImplementedError
def get_latest_checkpoint(self, run_id: str) -> RunCheckpoint:
raise NotImplementedError
def list_checkpoints(self, run_id: str) -> list[RunCheckpoint]:
raise NotImplementedError
class FileRunStore(RunStore):
"""JSON file-backed stopped-run store for local development and tests."""
def __init__(self, root: Path) -> None:
self.root = root
self._lock = RLock()
self.runs_dir.mkdir(parents=True, exist_ok=True)
@property
def runs_dir(self) -> Path:
return self.root / "runs"
def save_run(self, run: WorkflowRunRecord) -> None:
with self._lock:
self._write_json(
self._run_path(run.id),
run.model_dump(mode="json"),
)
def get_run(self, run_id: str) -> WorkflowRunRecord:
path = self._run_path(run_id)
if not path.exists():
raise KeyError(f"unknown workflow run {run_id!r}")
return WorkflowRunRecord.model_validate_json(path.read_text(encoding="utf-8"))
def list_runs(self) -> list[WorkflowRunRecord]:
return [
WorkflowRunRecord.model_validate_json(path.read_text(encoding="utf-8"))
for path in sorted(self.runs_dir.glob("*/run.json"))
]
def save_checkpoint(self, checkpoint: RunCheckpoint) -> None:
with self._lock:
self._write_json(
self._checkpoint_path(checkpoint.run_id, checkpoint.sequence),
checkpoint.model_dump(mode="json"),
)
def get_latest_checkpoint(self, run_id: str) -> RunCheckpoint:
checkpoints = self.list_checkpoints(run_id)
if not checkpoints:
raise KeyError(f"workflow run {run_id!r} has no checkpoints")
return checkpoints[-1]
def list_checkpoints(self, run_id: str) -> list[RunCheckpoint]:
directory = self._run_directory(run_id) / "checkpoints"
if not directory.exists():
return []
return [
RunCheckpoint.model_validate_json(path.read_text(encoding="utf-8"))
for path in sorted(directory.glob("*.json"))
]
def _write_json(self, path: Path, payload: object) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temp_path = path.with_suffix(".json.tmp")
temp_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
temp_path.replace(path)
def _run_directory(self, run_id: str) -> Path:
safe_id = ensure_run_id(run_id)
root = self.runs_dir.resolve()
path = (self.runs_dir / safe_id).resolve()
if path.parent != root:
raise ValueError(f"run id escapes run store: {run_id!r}")
return path
def _run_path(self, run_id: str) -> Path:
return self._run_directory(run_id) / "run.json"
def _checkpoint_path(self, run_id: str, sequence: int) -> Path:
return self._run_directory(run_id) / "checkpoints" / f"{sequence:06d}.json"