durable stopped workflow runs and resume
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user