Files
DECNET/decnet/web/db/models/decky_lifecycle.py
anti 05c0721a51 feat(db): add DeckyLifecycle table for async deploy/mutate tracking
One row per (decky, operation) attempt. State machine:
pending -> running -> succeeded | failed (+ error text). Rows are
append-only after terminal; retries write a new row.

Sibling of DeckyShard rather than a rework -- DeckyShard tracks
runtime container state observed via heartbeat, this tracks
operation lifecycle. New table, UUID PK.

Adds BaseRepository abstract methods (create_lifecycle,
update_lifecycle, get_lifecycle_by_ids, find_open_lifecycle,
sweep_stale_lifecycle) with SQLModelRepository mixin impl.
Backbone for the upcoming 202-Accepted async API.
2026-05-22 16:20:00 -04:00

88 lines
2.9 KiB
Python

"""DeckyLifecycle table + DTOs.
Tracks one row per (decky, operation) attempt — `deploy` or `mutate` —
so the API can return 202 Accepted immediately and the wizard can poll
state instead of holding an open HTTP request open for minutes.
State machine: ``pending`` (row created, runner not yet started) →
``running`` (runner picked it up) → terminal ``succeeded`` | ``failed``
(+ ``error`` text). Rows are immutable after terminal status; a retry
writes a new row.
Sibling of DeckyShard rather than a rework — DeckyShard tracks runtime
container state observed via heartbeat, this tracks operation lifecycle.
Per ``feedback_uuid_over_natural_keys``: new use case, new table, UUID PK.
"""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from typing import Literal, Optional
from pydantic import BaseModel, Field as PydanticField
from sqlalchemy import Column, Text
from sqlmodel import Field, SQLModel
LifecycleOperation = Literal["deploy", "mutate"]
LifecycleStatus = Literal["pending", "running", "succeeded", "failed"]
def _now_utc() -> datetime:
return datetime.now(timezone.utc)
class DeckyLifecycle(SQLModel, table=True):
"""One row per (decky, operation) attempt."""
__tablename__ = "decky_lifecycle"
id: str = Field(
primary_key=True,
default_factory=lambda: str(uuid.uuid4()),
)
decky_name: str = Field(index=True)
# None for unihost / master-resident deckies.
host_uuid: Optional[str] = Field(default=None, index=True)
operation: str = Field(index=True) # LifecycleOperation
status: str = Field(default="pending", index=True) # LifecycleStatus
error: Optional[str] = Field(
default=None, sa_column=Column("error", Text, nullable=True),
)
started_at: datetime = Field(default_factory=_now_utc)
updated_at: datetime = Field(default_factory=_now_utc)
completed_at: Optional[datetime] = Field(default=None)
# --- HTTP DTOs ---
class DeckyLifecycleView(BaseModel):
"""One lifecycle row, serialised for the wizard polling loop."""
id: str
decky_name: str
host_uuid: Optional[str] = None
operation: str
status: str
error: Optional[str] = None
started_at: datetime
updated_at: datetime
completed_at: Optional[datetime] = None
class DeckyLifecycleListResponse(BaseModel):
rows: list[DeckyLifecycleView] = PydanticField(default_factory=list)
class LifecycleAcceptedResponse(BaseModel):
"""Returned by 202 deploy/mutate endpoints — lets the client subscribe
to the matching DeckyLifecycle rows via the polling endpoint."""
lifecycle_ids: list[str]
class LifecycleDelta(BaseModel):
"""One per-decky completion record in a worker → master heartbeat."""
decky_name: str
operation: str
status: str # one of LifecycleStatus, typically "succeeded" | "failed"
error: Optional[str] = None
completed_at: Optional[datetime] = None