Persist current state to disk. Held under the run lock and written atomically (temp file + ``os.replace``) so a concurrent fan-out can neither mutate ``step_results`` mid-serialization nor leave a reader observing a half-written file. Racing writers only contend to b
(self)
| 457 | self.step_results[step_id]["output"] = output |
| 458 | |
| 459 | def save(self) -> None: |
| 460 | """Persist current state to disk. |
| 461 | |
| 462 | Held under the run lock and written atomically (temp file + ``os.replace``) |
| 463 | so a concurrent fan-out can neither mutate ``step_results`` mid-serialization |
| 464 | nor leave a reader observing a half-written file. Racing writers only |
| 465 | contend to be last; they never corrupt. |
| 466 | """ |
| 467 | runs_dir = self.runs_dir |
| 468 | runs_dir.mkdir(parents=True, exist_ok=True) |
| 469 | |
| 470 | with self._lock: |
| 471 | # Stamp updated_at inside the lock so the timestamp matches the |
| 472 | # snapshot this thread serializes (concurrent savers don't race it). |
| 473 | self.updated_at = datetime.now(timezone.utc).isoformat() |
| 474 | state_data = { |
| 475 | "run_id": self.run_id, |
| 476 | "workflow_id": self.workflow_id, |
| 477 | "status": self.status.value, |
| 478 | "current_step_index": self.current_step_index, |
| 479 | "current_step_id": self.current_step_id, |
| 480 | "step_results": self.step_results, |
| 481 | "created_at": self.created_at, |
| 482 | "updated_at": self.updated_at, |
| 483 | } |
| 484 | self._atomic_write_json(runs_dir / "state.json", state_data) |
| 485 | self._atomic_write_json(runs_dir / "inputs.json", {"inputs": self.inputs}) |
| 486 | |
| 487 | @staticmethod |
| 488 | def _atomic_write_json(path: Path, data: dict[str, Any]) -> None: |