Manages workflow run state for persistence and resume.
| 365 | |
| 366 | |
| 367 | class RunState: |
| 368 | """Manages workflow run state for persistence and resume.""" |
| 369 | |
| 370 | # ``run_id`` is interpolated into a filesystem path (``runs/<run_id>``) |
| 371 | # by both ``save()`` and ``load()``. Constrain it to a charset that |
| 372 | # cannot contain path separators (``/`` ``\``), parent-directory |
| 373 | # segments (``..``), or NULs — anything that could escape the |
| 374 | # ``.specify/workflows/runs/`` directory or be mis-interpreted by the |
| 375 | # filesystem. The first-character anchor blocks IDs that start with |
| 376 | # ``-`` (which would be mistaken for a CLI flag in error messages |
| 377 | # and shell completions). |
| 378 | _RUN_ID_PATTERN = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$") |
| 379 | |
| 380 | @classmethod |
| 381 | def _validate_run_id(cls, run_id: str) -> None: |
| 382 | """Raise ``ValueError`` if ``run_id`` is not a safe path component. |
| 383 | |
| 384 | This is the single source of truth for what counts as a valid |
| 385 | ``run_id``. ``__init__`` calls it to reject malformed IDs at |
| 386 | construction time; ``load`` calls it *before* interpolating the |
| 387 | ID into a path so a malicious value cannot probe or read files |
| 388 | outside ``.specify/workflows/runs/<run_id>/``. |
| 389 | """ |
| 390 | if not isinstance(run_id, str) or not cls._RUN_ID_PATTERN.match(run_id): |
| 391 | raise ValueError( |
| 392 | f"Invalid run_id {run_id!r}: must be alphanumeric with " |
| 393 | "hyphens/underscores only (and must start with an " |
| 394 | "alphanumeric character)." |
| 395 | ) |
| 396 | |
| 397 | def __init__( |
| 398 | self, |
| 399 | run_id: str | None = None, |
| 400 | workflow_id: str = "", |
| 401 | project_root: Path | None = None, |
| 402 | ) -> None: |
| 403 | # ``run_id is None`` (omitted) → auto-generate. An explicit empty |
| 404 | # string is *not* the same as "omitted" and must be validated like |
| 405 | # any other caller-provided value — otherwise ``__init__("")`` |
| 406 | # would silently substitute a UUID while ``load("")`` rejects, and |
| 407 | # the two entry points would diverge on the empty-string vector. |
| 408 | if run_id is None: |
| 409 | self.run_id = str(uuid.uuid4())[:8] |
| 410 | else: |
| 411 | self.run_id = run_id |
| 412 | self._validate_run_id(self.run_id) |
| 413 | self.workflow_id = workflow_id |
| 414 | self.project_root = project_root or Path(".") |
| 415 | self.status = RunStatus.CREATED |
| 416 | self.current_step_index = 0 |
| 417 | self.current_step_id: str | None = None |
| 418 | self.step_results: dict[str, dict[str, Any]] = {} |
| 419 | # Guards step_results mutation and save() so a concurrent fan-out cannot |
| 420 | # mutate the dict while save() is serializing it (which would raise |
| 421 | # "dictionary changed size during iteration"). |
| 422 | self._lock = threading.Lock() |
| 423 | # Serializes append_log's list append + log.jsonl write so concurrent |
| 424 | # fan-out workers cannot interleave or corrupt log lines. Kept separate |
no outgoing calls