Phase 3: VISUAL feed and M-Files drawings in the worker
ci / frontend (push) Successful in 30s
ci / server (push) Successful in 4m12s
ci / image (push) Successful in 46s
ci / e2e (push) Successful in 1m24s

Migration 0004 (VISUAL and drawing caches, go-live ignored list, resource
map, job tables, order-derived columns with provenance; work order key
includes the operation sequence). The real worker on APScheduler with
settings re-read each fire, pause, run now, advisory locks, job_runs and a
heartbeat. The visual-feed job (five VisualKit reads, COPY and MERGE cache,
suspect-batch guard, the apply in one transaction: drafts, change orders,
retire-moved, work order gone, closed and cancelled observed, projects and
units from order links, auto-release) and the fingerprint. The drawing-lookup
job (queue, budget, download on version change, sha256 into assets, decision
table, staging for rev approval, drawing-origin change orders). Clerical From
VISUAL panel, flags, rev approval, hand uploads, change-order review; Admin
Feed settings and resource map. Synthetic fixture generator and a
fixture-driven worker in CI with a Playwright scenario. Docs updated.

Verified: 3249 server tests and 13 Playwright scenarios on the docker-1 CI
stack, 76 frontend tests, doctor at 0004; one feed and one drawing run
against VISUAL DEV and the vault from the developer machine (counts in the
changelog).

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-10-16 14:45:38 -06:00
co-authored by Claude Fable 5.1
parent 16950055e3
commit 591fe90076
103 changed files with 16063 additions and 151 deletions
@@ -0,0 +1,146 @@
"""The job registry and the shapes every job uses.
Jobs import `from packetstation.worker.jobs import JobContext, JobSpec, register` and expose `SPEC`. The feed and
drawing jobs are imported lazily by `load_jobs`, so the worker runs before they exist.
"""
from __future__ import annotations
import importlib
import logging
import threading
from collections.abc import Callable
from dataclasses import dataclass, field
from datetime import UTC, date, datetime, time, timedelta
from typing import Any
from zoneinfo import ZoneInfo
from ...feed.config import FeedSettings
from ...settings import Settings
from ..redaction import MAX_ERRORS, clip, redact
log = logging.getLogger(__name__)
Counts = dict[str, Any]
Cadence = Callable[[FeedSettings, datetime], int | None]
TRIGGERS = ("schedule", "request", "chain", "cli")
@dataclass
class Stack:
"""Per-process state shared by every run: the startup guard result and long-lived objects (the M-Files vault)."""
guard: Any = None
cache: dict[str, Any] = field(default_factory=dict)
lock: threading.Lock = field(default_factory=threading.Lock)
def cached(self, key: str, factory: Callable[[], Any]) -> Any:
"""The object under `key`, made once by `factory`. A factory that raises leaves nothing cached."""
with self.lock:
if key not in self.cache:
self.cache[key] = factory()
return self.cache[key]
def drop(self, key: str) -> None:
with self.lock:
self.cache.pop(key, None)
@dataclass
class JobContext:
"""What a run gets. `conn` is the run's own autocommit connection as the worker role."""
conn: Any
settings: Settings
feed_settings: FeedSettings
run_id: int
trigger: str
started_at: datetime
log: logging.Logger
stack: Stack
degraded: bool = False
errors: list[str] = field(default_factory=list)
visual_target: str | None = None
def error(self, text: str, *, degrade: bool = True) -> None:
"""Record one error (redacted, clipped). It makes the run degraded unless degrade is False."""
if len(self.errors) < MAX_ERRORS:
self.errors.extend(clip([text]))
if degrade:
self.degraded = True
def degrade(self, reason: str | None = None) -> None:
self.degraded = True
if reason:
self.error(reason)
@property
def tz(self) -> ZoneInfo:
return ZoneInfo(self.settings.site_tz)
def now_site(self) -> datetime:
return datetime.now(self.tz)
def today_site(self) -> date:
return self.now_site().date()
@dataclass(frozen=True)
class JobSpec:
name: str
run: Callable[[JobContext], Counts]
cadence: Cadence # seconds to the next run, or None for "no schedule"
pausable: bool # the feed pause skips scheduled fires (requests still run)
chain: tuple[str, ...] = () # moved to now after a run that did not fail
JOBS: dict[str, JobSpec] = {}
_loaded = False
_load_lock = threading.Lock()
LAZY = ("visual_feed", "drawing_lookup")
def register(spec: JobSpec) -> JobSpec:
JOBS[spec.name] = spec
return spec
def load_jobs() -> dict[str, JobSpec]:
"""Register every job once and return the registry. A job module that cannot import is logged and left out."""
global _loaded
with _load_lock:
if not _loaded:
from . import housekeeping
register(housekeeping.SPEC)
for mod in LAZY:
try:
m = importlib.import_module(f"{__name__}.{mod}")
register(m.SPEC)
except ImportError as e:
log.warning("job module %s not loaded: %s", mod, redact(str(e)))
_loaded = True
return JOBS
def seconds_until(now_site: datetime, at: time) -> int:
"""Seconds from now_site to the next `at` (site wall clock), at least 1."""
target = datetime.combine(now_site.date(), at, tzinfo=now_site.tzinfo)
if target <= now_site:
target = datetime.combine(now_site.date() + timedelta(days=1), at, tzinfo=now_site.tzinfo)
# Aware datetimes sharing a tzinfo subtract as wall clock; through UTC a DST change counts right
return max(1, int((target.astimezone(UTC) - now_site.astimezone(UTC)).total_seconds()))
__all__ = [
"JOBS",
"TRIGGERS",
"Cadence",
"Counts",
"JobContext",
"JobSpec",
"Stack",
"load_jobs",
"register",
"seconds_until",
]