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",
]
@@ -0,0 +1,536 @@
"""The drawing-lookup job: look drawings up in M-Files (read-only), copy released PDFs into assets, and attach,
flag, stage or open a change order on the packets that use them.
Per run: the queue (drawings/queue.py) cut to `drawing_budget`; per drawing one `Lookup.status`, a fetch when a
released file exists (drawings/fetch.py downloads only when its M-Files identity changed), the `drawing_lookups`
row, then `decide` for every live packet that uses the drawing (its own drawing, or the target of its pending feed
change order), applied through `feed.writes.system_write`. A busy packet is left for the next run.
Never writes to M-Files.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any
from psycopg.rows import dict_row
from psycopg.types.json import Jsonb
from ... import audit, policy
from ...domain import pipeline
from ...drawings import DRAWING_FLAGS, decide, queue
from ...drawings import lookup as lookups
from ...drawings.fetch import FetchError, FetchResult, fetch
from ...drawings.lookup import Found, Lookup
from ...feed import changes, writes
from . import Counts, JobContext, JobSpec
log = logging.getLogger(__name__)
A = policy.Action
D = decide
FEED_ORIGINS = ("visual", "drawing")
class _Stale(Exception):
"""The packet changed between the read and the lock; the next run decides again."""
def new_counts() -> Counts:
return {
"checked": 0,
"fetched": 0,
"unchanged": 0,
"attached": 0,
"flagged": {},
"lookup_failed": 0,
"cos_opened": 0,
"cos_reaimed": 0,
"staged": 0,
"budget_left": 0,
"fetch_failed": 0,
"busy": 0,
"refused": 0,
"no_file": 0,
}
# ---------------------------------------------------------------- the run
def run(ctx: JobContext) -> Counts:
counts = new_counts()
fs = ctx.feed_settings
budget = int(getattr(fs, "drawing_budget", 60))
try:
lk = lookups.make(ctx.settings, ctx.stack.cache)
except Exception as e: # any failure to open M-Files skips the run
ctx.error(f"M-Files could not be opened: {type(e).__name__}: {e}")
counts["reason"] = "mfiles unavailable"
counts["budget_left"] = budget
return counts
now = datetime.now(UTC)
items = queue.queue(ctx.conn, fs, now, limit=budget)
counts["budget_left"] = max(0, budget - len(items))
counts["source"] = lk.describe()
for item in items:
try:
_drawing(ctx, lk, item, counts, now)
except Exception as e: # one bad drawing must not stop the others
log.exception("drawing %s failed", item.dwg)
ctx.error(f"drawing {item.dwg}: {type(e).__name__}: {e}")
return counts
def _drawing(ctx: JobContext, lk: Lookup, item: queue.QueueItem, counts: Counts, now: datetime) -> None:
conn = ctx.conn
try:
found = lk.status(item.dwg)
except Exception as e: # a lookup error is a failed lookup, never a crash
found = lookups.failed(item.dwg, f"M-Files lookup failed: {type(e).__name__}: {e}")
counts["checked"] += 1
fetched: FetchResult | None = None
fetch_failed = False
if found.ok and found.rev is not None:
try:
fetched = fetch(conn, ctx.settings, lk, found, dwg=item.dwg)
except FetchError as e:
fetch_failed = True
counts["fetch_failed"] += 1
ctx.error(str(e))
else:
if fetched.downloaded:
counts["fetched"] += 1
audit.append(
conn,
action="drawing.fetched",
role_snapshot="system",
subject_type="asset",
subject_id=str(fetched.asset_id),
detail={
"dwg": item.dwg,
"rev": found.rev,
"sha256": fetched.sha256.hex(),
"reused": fetched.reused,
},
)
if not found.ok:
counts["lookup_failed"] += 1
ctx.error(f"drawing {item.dwg}: {D.failed_note(found)}")
vrev_seen = item.visual_rev if item.co_id is None else own_rev(conn, item.dwg)
save_lookup(conn, item, found, fetched, now, fetch_failed=fetch_failed, visual_rev_seen=vrev_seen)
scope = [str(d) for d in (getattr(ctx.feed_settings, "departments", None) or [])]
for pid, relations in sharing(conn, item.dwg, scope):
_packet(ctx, item.dwg, found, fetched, pid, relations, counts)
# ---------------------------------------------------------------- drawing_lookups
def save_lookup(
conn: Any,
item: queue.QueueItem,
found: Found,
fetched: FetchResult | None,
now: datetime,
*,
fetch_failed: bool,
visual_rev_seen: str | None,
) -> None:
"""Upsert the drawing_lookups row for this lookup.
A failed lookup keeps everything known before (rev, file, asset) and backs off. A good one stores what M-Files
has; the file identity and the asset are stored together, and cleared when there is no released file or the
copy failed (so the next run downloads again).
"""
with conn.transaction():
prev = conn.execute("SELECT fail_count FROM drawing_lookups WHERE dwg = %s FOR UPDATE", (item.dwg,)).fetchone()
if not found.ok:
fails = (int(prev[0]) if prev else 0) + 1
conn.execute(
"INSERT INTO drawing_lookups (dwg, checked_at, ok, note, fail_count, failing_since, next_check_at,"
" visual_rev_seen) VALUES (%(dwg)s, %(now)s, false, %(note)s, %(fails)s, %(now)s, %(next)s, %(vrev)s)"
" ON CONFLICT (dwg) DO UPDATE SET checked_at = EXCLUDED.checked_at, ok = false, note = EXCLUDED.note,"
" fail_count = EXCLUDED.fail_count,"
" failing_since = coalesce(drawing_lookups.failing_since, EXCLUDED.failing_since),"
" next_check_at = EXCLUDED.next_check_at, visual_rev_seen = EXCLUDED.visual_rev_seen",
{
"dwg": item.dwg,
"now": now,
"note": D.failed_note(found),
"fails": fails,
"next": now + queue.backoff(fails),
"vrev": visual_rev_seen,
},
)
return
has_file = fetched is not None
conn.execute(
"INSERT INTO drawing_lookups (dwg, checked_at, ok, rev, name, obj_id, obj_version, file_id, file_version,"
" file_size, base, draft_rev, draft_where, in_drafting, note, fail_count, failing_since, asset_id, sha256,"
" visual_rev_seen, next_check_at) VALUES (%(dwg)s, %(now)s, true, %(rev)s, %(name)s, %(obj_id)s,"
" %(obj_version)s, %(file_id)s, %(file_version)s, %(file_size)s, %(base)s, %(draft_rev)s,"
" %(draft_where)s, %(in_drafting)s, %(note)s, 0, NULL, %(asset_id)s, %(sha)s, %(vrev)s, %(next)s)"
" ON CONFLICT (dwg) DO UPDATE SET checked_at = EXCLUDED.checked_at, ok = true, rev = EXCLUDED.rev,"
" name = EXCLUDED.name, obj_id = EXCLUDED.obj_id, obj_version = EXCLUDED.obj_version,"
" file_id = EXCLUDED.file_id, file_version = EXCLUDED.file_version, file_size = EXCLUDED.file_size,"
" base = EXCLUDED.base, draft_rev = EXCLUDED.draft_rev, draft_where = EXCLUDED.draft_where,"
" in_drafting = EXCLUDED.in_drafting, note = EXCLUDED.note, fail_count = 0, failing_since = NULL,"
" asset_id = EXCLUDED.asset_id, sha256 = EXCLUDED.sha256,"
" visual_rev_seen = coalesce(EXCLUDED.visual_rev_seen, drawing_lookups.visual_rev_seen),"
" next_check_at = EXCLUDED.next_check_at",
{
"dwg": item.dwg,
"now": now,
"rev": found.rev,
"name": found.name,
"obj_id": found.obj_id if has_file else None,
"obj_version": found.obj_version if has_file else None,
"file_id": found.file_id if has_file else None,
"file_version": found.file_version if has_file else None,
"file_size": found.file_size,
"base": found.base,
"draft_rev": found.draft_rev,
"draft_where": found.draft_where or None,
"in_drafting": found.in_drafting,
"note": found.note or None,
"asset_id": fetched.asset_id if fetched else None,
"sha": fetched.sha256 if fetched else None,
"vrev": visual_rev_seen,
"next": now + queue.backoff(1) if fetch_failed else None,
},
)
# ---------------------------------------------------------------- packets
def own_rev(conn: Any, dwg: str) -> str | None:
"""The rev a live VISUAL packet with this drawing carries (for visual_rev_seen when the item came from a CO)."""
row = conn.execute(
"SELECT coalesce(btrim(rev), '') FROM packets WHERE removed_at IS NULL AND source_kind = 'visual'"
" AND status <> 'filed' AND btrim(drawing) = %s ORDER BY id LIMIT 1",
(dwg,),
).fetchone()
return None if row is None else str(row[0])
SHARING_SQL = """
SELECT p.id, 'own' AS rel FROM packets p
WHERE p.removed_at IS NULL AND p.source_kind = 'visual' AND p.status <> 'filed' AND btrim(p.drawing) = %(dwg)s
AND (cardinality(%(depts)s::text[]) = 0 OR p.dept_code = ANY(%(depts)s::text[]))
UNION
SELECT p.id, 'co' AS rel FROM change_orders c JOIN packets p ON p.id = c.packet_id
WHERE c.review = 'pending' AND c.origin IN ('visual', 'drawing') AND jsonb_typeof(c.waiting) = 'object'
AND btrim(c.waiting->>'dwg') = %(dwg)s AND p.removed_at IS NULL AND p.status NOT IN ('draft', 'filed')
AND (cardinality(%(depts)s::text[]) = 0 OR p.dept_code = ANY(%(depts)s::text[]))
ORDER BY 1
"""
def sharing(conn: Any, dwg: str, depts: list[str]) -> list[tuple[int, set[str]]]:
"""Live packets that use the drawing: as their own drawing ('own') or as a pending feed CO's target ('co')."""
out: dict[int, set[str]] = {}
for pid, rel in conn.execute(SHARING_SQL, {"dwg": dwg, "depts": depts}).fetchall():
out.setdefault(int(pid), set()).add(str(rel))
return sorted(out.items())
@dataclass
class PacketFacts:
row: dict[str, Any]
visual: dict[str, Any]
attached_rev: str | None
attached_shas: set[bytes]
co: dict[str, Any] | None
rejected_sha: bytes | None
def _sha(value: Any) -> bytes | None:
if not isinstance(value, str) or not value:
return None
try:
return bytes.fromhex(value)
except ValueError:
return None
def facts(conn: Any, pid: int) -> PacketFacts | None:
with conn.cursor(row_factory=dict_row) as cur:
row = cur.execute(
"SELECT id, status::text AS status, drawing, rev, drawing_state::text AS drawing_state, flags, visual,"
" version FROM packets WHERE id = %s AND removed_at IS NULL",
(pid,),
).fetchone()
if row is None:
return None
pages = cur.execute(
"SELECT pg.rev, a.sha256 FROM packet_pages pg LEFT JOIN assets a ON a.id = pg.asset_id"
" WHERE pg.packet_id = %s AND pg.type = 'drawing' AND pg.void_at IS NULL ORDER BY pg.n",
(pid,),
).fetchall()
co = cur.execute(
"SELECT id, co_no, origin::text AS origin, waiting, staged FROM change_orders"
" WHERE packet_id = %s AND review = 'pending' AND origin IN ('visual', 'drawing') ORDER BY id DESC LIMIT 1",
(pid,),
).fetchone()
rej = cur.execute(
"SELECT staged FROM change_orders WHERE packet_id = %s AND origin = 'drawing' AND review = 'rejected'"
" ORDER BY id DESC LIMIT 1",
(pid,),
).fetchone()
visual = row["visual"] if isinstance(row["visual"], dict) else {}
attached_rev = None
if pages:
attached_rev = str(pages[0]["rev"] or "").strip()
shas = {bytes(p["sha256"]) for p in pages if p["sha256"] is not None}
rejected = None
if rej is not None and isinstance(rej["staged"], dict):
rejected = _sha(rej["staged"].get("sha256"))
return PacketFacts(dict(row), dict(visual), attached_rev, shas, dict(co) if co else None, rejected)
def _dwg_rev(value: Any) -> tuple[str, str] | None:
if isinstance(value, dict) and value.get("dwg") and value.get("rev") is not None:
return (str(value["dwg"]).strip(), str(value["rev"]).strip())
return None
def target_for(dwg: str, pf: PacketFacts, *, own: bool) -> D.Target:
row, visual, co = pf.row, pf.visual, pf.co
waiting = co.get("waiting") if co else None
staged = co.get("staged") if co else None
co_waiting = _dwg_rev(waiting)
co_staged = _dwg_rev(staged)
packet_rev = str(row["rev"] or "").strip()
rev_ok = None
ro = visual.get("rev_ok")
if isinstance(ro, dict) and ro.get("rev") is not None and str(ro.get("vis_rev") or "").strip() == packet_rev:
rev_ok = str(ro["rev"]).strip()
visual_rev = packet_rev if own or co_waiting is None else co_waiting[1]
return D.Target(
dwg=dwg,
visual_rev=visual_rev,
attached_rev=pf.attached_rev,
attached_sha=next(iter(sorted(pf.attached_shas)), None),
status=str(row["status"]),
drawing_state=str(row["drawing_state"]),
has_hand_drawing=row["drawing_state"] == "hand" or bool(visual.get("manual_dwg")),
rev_ok=rev_ok,
co_waiting=co_waiting,
co_staged=co_staged,
co_pending=co is not None,
co_staged_sha=_sha(staged.get("sha256")) if isinstance(staged, dict) else None,
rejected_sha=pf.rejected_sha,
)
def appr_of(t: D.Target, f: Found, now: datetime) -> dict[str, Any]:
return {
"ok": f.ok,
"rev": f.rev,
"vrev": t.visual_rev,
"name": f.name,
"note": f.note or "",
"base": f.base,
"draft_rev": f.draft_rev,
"draft_where": f.draft_where,
"in_drafting": f.in_drafting,
"at": now.isoformat(),
}
def _same_appr(a: Any, b: dict[str, Any]) -> bool:
if not isinstance(a, dict):
return False
return {k: v for k, v in a.items() if k != "at"} == {k: v for k, v in b.items() if k != "at"}
def new_flags(flags: list[str], d: D.Decision) -> list[str]:
"""Every flag but the drawing ones, then the decision's flag."""
out = [f for f in flags if f not in DRAWING_FLAGS]
if d.flag and d.flag not in out:
out.append(d.flag)
return out
def _packet(
ctx: JobContext,
dwg: str,
found: Found,
fetched: FetchResult | None,
pid: int,
relations: set[str],
counts: Counts,
) -> None:
conn = ctx.conn
pf = facts(conn, pid)
if pf is None:
return
own = "own" in relations
t = target_for(dwg, pf, own=own)
same_file = fetched is not None and fetched.sha256 in pf.attached_shas
d = D.decide(t, found, same_file=same_file, fetched_sha=fetched.sha256 if fetched else None)
if not own and d.kind != D.STAGE_FOR_CO:
return # only the CO's target drawing: nothing to do until it can be staged
if d.kind in (D.ATTACH, D.OPEN_CO, D.STAGE_FOR_CO) and fetched is None:
counts["no_file"] += 1 # the copy failed this run; try again next run
return
now = datetime.now(UTC)
row, visual = pf.row, pf.visual
flags = list(row["flags"] or [])
flags_after = new_flags(flags, d) if own else flags
patch: dict[str, Any] = {}
if own:
appr = appr_of(t, found, now)
if not _same_appr(visual.get("appr"), appr):
patch["appr"] = appr
if found.ok:
confirmed = D.rev_confirmed(t, found)
if bool(visual.get("rev_confirmed")) != confirmed:
patch["rev_confirmed"] = confirmed
state_after = d.drawing_state if own else str(row["drawing_state"])
material = (
d.kind in (D.ATTACH, D.OPEN_CO, D.STAGE_FOR_CO)
or flags_after != flags
or state_after != row["drawing_state"]
or bool(patch)
)
if not material:
counts["unchanged"] += 1
return
action = A.VIS_UPDATE_DRAFT if row["status"] == "draft" else A.VIS_OPEN_CO
label, note = "Drawing status from M-Files", d.note or None
if d.kind == D.ATTACH and fetched is not None:
label = "Drawing attached from M-Files"
note = "; ".join(x for x in (fetched.name or found.name or "", d.note) if x) or None
detail = {"dwg": dwg, "rev": found.rev, "kind": d.kind, "asset_id": str(fetched.asset_id) if fetched else None}
try:
with writes.system_write(conn, pid, action, label=label, note=note, detail=detail) as sw:
if int(sw.packet["version"]) != int(row["version"]):
raise _Stale
if d.kind == D.ATTACH and fetched is not None:
_attach(sw, pid, dwg, found, fetched, d, flags_after, patch, now)
counts["attached"] += 1
else:
_status(sw, pid, state_after, flags_after, patch)
if d.flag:
fl = counts["flagged"]
fl[d.kind] = fl.get(d.kind, 0) + 1
if d.kind == D.OPEN_CO and fetched is not None:
_open_co(sw, t, dwg, found, fetched, d, counts)
elif d.kind == D.STAGE_FOR_CO and fetched is not None and pf.co is not None:
_stage(sw, pf.co, dwg, found, fetched)
counts["staged"] += 1
elif d.kind == D.UNCHANGED:
counts["unchanged"] += 1
except (_Stale, writes.Busy, writes.Missing):
counts["busy"] += 1
except writes.Refused as e:
counts["refused"] += 1
ctx.error(f"packet {pid}: {d.kind} refused: {e}", degrade=False)
def _staged(dwg: str, found: Found, fetched: FetchResult) -> dict[str, Any]:
return {
"asset_id": str(fetched.asset_id),
"name": fetched.name or found.name,
"rev": str(found.rev),
"dwg": dwg,
"sha256": fetched.sha256.hex(),
"pages": fetched.pages,
}
def _attach(
sw: Any,
pid: int,
dwg: str,
found: Found,
fetched: FetchResult,
d: D.Decision,
flags: list[str],
patch: dict[str, Any],
now: datetime,
) -> None:
conn = sw.conn
pages = max(1, int(fetched.pages))
conn.execute("DELETE FROM packet_pages WHERE packet_id = %s AND type = 'drawing'", (pid,))
n0 = pipeline.next_counter(conn, pid, "page", pages)
rev = str(found.rev)
with conn.cursor() as cur:
cur.executemany(
"INSERT INTO packet_pages (packet_id, n, type, asset_id, pn, dwg, rev) VALUES (%s, %s, 'drawing', %s, %s,"
" %s, %s)",
[(pid, n0 + i, fetched.asset_id, i + 1, dwg, rev) for i in range(pages)],
)
vault = {
"asset_id": str(fetched.asset_id),
"name": fetched.name or found.name,
"rev": found.rev,
"note": d.note,
"at": now.isoformat(),
}
merged = {**patch, "rev_confirmed": True, "vault": vault}
conn.execute(
"UPDATE packets SET drawing_state = 'attached', flags = %s, visual = (visual - 'manual_dwg') || %s"
" WHERE id = %s",
(flags, Jsonb(merged), pid),
)
sw.changed("pages", "drawing_state", "flags", "visual")
def _status(sw: Any, pid: int, state: str, flags: list[str], patch: dict[str, Any]) -> None:
sw.conn.execute(
"UPDATE packets SET drawing_state = %s::drawing_state, flags = %s, visual = visual || %s WHERE id = %s",
(state, flags, Jsonb(patch), pid),
)
sw.changed("drawing_state", "flags", "visual")
def _open_co(sw: Any, t: D.Target, dwg: str, found: Found, fetched: FetchResult, d: D.Decision, counts: Counts) -> None:
vis = {
"from_dwg": dwg,
"to_dwg": dwg,
"dwg": dwg,
"from_rev": t.attached_rev,
"to_rev": str(found.rev),
"dwg_change": True,
"changes": [d.note],
}
res = changes.open_or_reaim(
sw,
origin="drawing",
reason=d.note,
vis=vis,
waiting=None,
staged=_staged(dwg, found, fetched),
target_fp=None,
minor=False,
)
# open_or_reaim writes the packet_log label itself
counts["cos_opened" if res.fresh else "cos_reaimed"] += 1
def _stage(sw: Any, co: dict[str, Any], dwg: str, found: Found, fetched: FetchResult) -> None:
staged = _staged(dwg, found, fetched)
sw.conn.execute(
"UPDATE change_orders SET staged = %s, waiting = NULL, row_version = row_version + 1"
" WHERE id = %s AND review = 'pending'",
(Jsonb(staged), co["id"]),
)
sw.changed("change_orders")
sw.log(f"Drawing for change order {co['co_no']} ready for Clerical", staged["name"])
def _cadence(fs: Any, now: datetime) -> int | None:
return int(fs.drawing_interval_minutes) * 60
SPEC = JobSpec(name="drawing-lookup", run=run, cadence=_cadence, pausable=True)
__all__ = ["SPEC", "new_flags", "run", "save_lookup", "sharing", "target_for"]
@@ -0,0 +1,60 @@
"""Housekeeping, nightly at 03:30 site time: prune the VISUAL change rows and gone cache rows, old job runs and
old claimed run requests. It never touches packets, the audit log or anything append-only."""
from __future__ import annotations
from datetime import datetime, time
from ...feed.config import FeedSettings
from . import Counts, JobContext, JobSpec, seconds_until
AT = time(3, 30)
CACHE_DAYS = 90
RUNS_DAYS = 180
REQUESTS_DAYS = 30
STEPS: tuple[tuple[str, str, int], ...] = (
("row_changes", "DELETE FROM visual_row_changes WHERE at < now() - make_interval(days => %s)", CACHE_DAYS),
(
"material_changes",
"DELETE FROM visual_material_changes WHERE at < now() - make_interval(days => %s)",
CACHE_DAYS,
),
(
"gone_rows",
"DELETE FROM visual_rows WHERE gone_batch IN"
" (SELECT id FROM visual_batches WHERE started_at < now() - make_interval(days => %s))",
CACHE_DAYS,
),
(
"gone_materials",
"DELETE FROM visual_materials WHERE gone_batch IN"
" (SELECT id FROM visual_batches WHERE started_at < now() - make_interval(days => %s))",
CACHE_DAYS,
),
(
"job_runs",
"DELETE FROM job_runs WHERE started_at < now() - make_interval(days => %s) AND status <> 'running'",
RUNS_DAYS,
),
(
"job_requests",
"DELETE FROM job_requests WHERE claimed_at IS NOT NULL AND claimed_at < now() - make_interval(days => %s)",
REQUESTS_DAYS,
),
)
def run(ctx: JobContext) -> Counts:
counts: Counts = {}
for name, sql, days in STEPS:
with ctx.conn.transaction():
counts[name] = ctx.conn.execute(sql, (days,)).rowcount
return counts
def cadence(_: FeedSettings, now_site: datetime) -> int:
return seconds_until(now_site, AT)
SPEC = JobSpec(name="housekeeping", run=run, cadence=cadence, pausable=False)
@@ -0,0 +1,141 @@
"""The visual-feed job: read VISUAL (or the fixture files), record the read in the cache, build the candidates
from the cache and apply them.
Per run: the feed settings; the tracked keys (every work order of a live VISUAL packet that is not filed, plus the
go-live rows Clerical pulled in); the window from today in site time; one read through the source (labor at most
every 30 minutes); `cache.write_batch`; `cache.rows_for_apply`; `candidates.build`; `apply.apply`. The run is
degraded when a read section failed or the batch is suspect.
VisualKit: one `Visual.from_env()` per run, closed after, and the stack guard checked against its settings before
the first read. Read-only: VisualKit enforces it.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import Any
from ... import guard
from ...feed import apply as feed_apply
from ...feed import cache, candidates, config
from ...feed.config import FeedSettings
from ...feed.source import FeedSource, FixtureSource, VisualKitSource
from ...feed.types import FeedData, WoKey
from . import Counts, JobContext, JobSpec
LABOR_EVERY = timedelta(minutes=30)
def tracked_keys(conn: Any) -> list[WoKey]:
"""Work orders of live VISUAL packets that are not filed, plus pulled-in go-live rows."""
rows = conn.execute(
"SELECT DISTINCT w.wo_base, w.lot, w.split, w.sub FROM packet_work_orders w JOIN packets p"
" ON p.id = w.packet_id WHERE p.removed_at IS NULL AND p.status <> 'filed' AND p.source_kind = 'visual'"
" UNION SELECT wo_base, lot, split, sub FROM visual_ignored_wos WHERE pulled_in_at IS NOT NULL"
).fetchall()
return sorted({WoKey(r[0], r[1], r[2], r[3]) for r in rows}, key=lambda k: k.as_tuple())
def resource_map(conn: Any) -> dict[str, str]:
return {r[0]: r[1] for r in conn.execute("SELECT resource, target FROM visual_resource_map")}
def _labor_due(conn: Any, fs: FeedSettings) -> bool:
if not fs.labor_tickets:
return False
last = cache.last_labor_read(conn)
return last is None or datetime.now(UTC) - last >= LABOR_EVERY
def _read(source: FeedSource, ctx: JobContext, tracked: list[WoKey], window: tuple[Any, Any], labor: bool) -> FeedData:
return source.read(
site=ctx.settings.site_id,
since=window[0],
until=window[1],
tracked=tracked,
with_materials=True,
with_orders=True,
with_labor=labor,
labor_since=None,
)
def run(ctx: JobContext) -> Counts:
conn = ctx.conn
fs = ctx.feed_settings
today = ctx.today_site()
window = config.window(fs, today)
tracked = tracked_keys(conn)
labor_due = _labor_due(conn, fs)
if ctx.settings.feed_source == "fixture":
fixture = FixtureSource(ctx.settings.fixture_dir)
describe = fixture.describe()
data = _read(fixture, ctx, tracked, window, labor_due)
else:
from visualkit import Visual
with Visual.from_env() as v:
# The stack guard against this run's own VisualKit settings, before the first read.
result = guard.check_stack(ctx.settings, v.settings)
ctx.visual_target = ctx.visual_target or result.target
vk = VisualKitSource(v)
describe = vk.describe()
data = _read(vk, ctx, tracked, window, labor_due)
for e in data.errors:
ctx.error(e)
batch = cache.write_batch(conn, ctx.run_id, data, describe, window)
if batch.suspect:
ctx.degrade(f"suspect read: {batch.reason}; nothing marked gone")
in_play = {r.key for r in data.rows} | set(tracked)
rows, mats, mat_legs = cache.rows_for_apply(conn, in_play)
if data.material_legs:
mat_legs |= data.material_legs
built = candidates.build(
rows,
mats,
mat_legs,
resource_map=resource_map(conn),
window=window,
golive=fs.golive_date,
departments=fs.departments,
tracked_keys=set(tracked),
today=today,
)
bases = {r.wo for r in rows}
projects = {candidates.project_of(b) for b in bases} | {c.project for c in built.candidates.values()}
orders = cache.orders(conn, bases=bases | projects, projects=projects)
counts = feed_apply.apply(
conn,
ctx=ctx,
batch=batch,
feed_settings=fs,
built=built,
orders=orders,
labor=cache.labor_hours(conn),
today=today,
seen={r.key: r.st for r in data.rows},
missing=() if batch.suspect else data.tracked_missing,
)
counts["wos_window"] = data.window_count
counts["wos_tracked"] = len(tracked)
counts["rows"] = len(data.rows)
counts["materials"] = len(data.materials) if data.materials is not None else 0
counts["orders"] = int(batch.counts.get("orders", 0))
counts["labor_legs"] = int(batch.counts.get("labor_legs", 0))
counts["batch_id"] = batch.id
if batch.suspect:
counts["suspect_reason"] = batch.reason
cache.finish_batch(conn, batch.id, counts)
return counts
def _cadence(fs: FeedSettings, now_site: datetime) -> int | None:
return config.interval_seconds(fs, now_site)
SPEC = JobSpec(name="visual-feed", run=run, cadence=_cadence, pausable=True, chain=("drawing-lookup",))
__all__ = ["SPEC", "resource_map", "run", "tracked_keys"]