Files
spencermandClaude Fable 5.1 591fe90076
ci / frontend (push) Successful in 30s
ci / server (push) Successful in 4m12s
ci / image (push) Successful in 46s
ci / e2e (push) Successful in 1m24s
Phase 3: VISUAL feed and M-Files drawings in the worker
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]>
2026-10-16 14:45:38 -06:00

114 lines
4.7 KiB
Python

"""Which drawings the drawing job looks up this run, in order.
Over live packets made from VISUAL that are not filed, have a drawing number, and sit in a department in scope
(feed setting `departments`; empty = every department). One item per drawing number (trimmed), the best priority
winning:
1. the VISUAL rev changed since the last lookup (`drawing_lookups.visual_rev_seen`), or a pending feed change
order waits for a drawing (the item is the CO's target drawing and rev; at most every 15 minutes);
2. drafts still needing a drawing (`drawing_state = 'needed'`, or never looked up), unless a failed fetch set a
later `next_check_at`;
3. failed lookups whose backoff ran out (`ok = false and next_check_at <= now`);
4. rechecks due (`checked_at` older than `drawing_recheck_hours`).
A packet with a hand drawing joins only through (1) or (4) once the feed has taken the hand drawing off; while
`drawing_state = 'hand'` it is skipped.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Any
BACKOFF_FIRST = timedelta(minutes=15)
BACKOFF_MAX = timedelta(hours=24)
CO_RECHECK = timedelta(minutes=15) # a change order's target drawing is looked up at most this often
QUEUE_SQL = """
WITH own AS (
SELECT p.id AS packet_id, btrim(p.drawing) AS dwg, coalesce(btrim(p.rev), '') AS visual_rev, NULL::bigint AS co_id,
CASE
WHEN d.dwg IS NOT NULL AND d.checked_at IS NOT NULL
AND d.visual_rev_seen IS DISTINCT FROM coalesce(btrim(p.rev), '') THEN 1
WHEN p.status = 'draft' AND (d.dwg IS NULL OR p.drawing_state = 'needed')
AND (d.next_check_at IS NULL OR d.next_check_at <= %(now)s) THEN 2
WHEN d.ok = false AND (d.next_check_at IS NULL OR d.next_check_at <= %(now)s) THEN 3
WHEN d.checked_at IS NULL OR d.checked_at < %(recheck_before)s THEN 4
END AS priority,
d.checked_at
FROM packets p
LEFT JOIN drawing_lookups d ON d.dwg = btrim(p.drawing)
WHERE p.removed_at IS NULL AND p.source_kind = 'visual' AND p.status <> 'filed'
AND coalesce(btrim(p.drawing), '') <> '' AND p.drawing_state <> 'hand'
AND (cardinality(%(depts)s::text[]) = 0 OR p.dept_code = ANY(%(depts)s::text[]))
),
co AS (
SELECT p.id AS packet_id, btrim(c.waiting->>'dwg') AS dwg, coalesce(btrim(c.waiting->>'rev'), '') AS visual_rev,
c.id AS co_id, 1 AS priority, d.checked_at
FROM change_orders c
JOIN packets p ON p.id = c.packet_id
LEFT JOIN drawing_lookups d ON d.dwg = btrim(c.waiting->>'dwg')
WHERE c.review = 'pending' AND c.origin IN ('visual', 'drawing') AND c.waiting IS NOT NULL
AND jsonb_typeof(c.waiting) = 'object' AND coalesce(btrim(c.waiting->>'dwg'), '') <> ''
AND p.removed_at IS NULL AND p.status NOT IN ('draft', 'filed')
AND (d.checked_at IS NULL OR d.checked_at < %(co_before)s)
AND (d.ok IS NOT false OR d.next_check_at IS NULL OR d.next_check_at <= %(now)s)
AND (cardinality(%(depts)s::text[]) = 0 OR p.dept_code = ANY(%(depts)s::text[]))
),
items AS (
SELECT * FROM own WHERE priority IS NOT NULL
UNION ALL
SELECT * FROM co
),
best AS (
SELECT DISTINCT ON (dwg) packet_id, dwg, visual_rev, priority, co_id, checked_at
FROM items
ORDER BY dwg, priority, co_id NULLS LAST, packet_id
)
SELECT packet_id, dwg, visual_rev, priority, co_id
FROM best
ORDER BY priority, checked_at NULLS FIRST, dwg
"""
@dataclass(frozen=True, slots=True)
class QueueItem:
packet_id: int
dwg: str
visual_rev: str
priority: int
co_id: int | None
def backoff(fail_count: int) -> timedelta:
"""15 minutes x 2^(fail_count - 1), at most 24 hours. fail_count counts this failure (1 = first)."""
n = max(0, int(fail_count) - 1)
if n >= 10:
return BACKOFF_MAX
return min(BACKOFF_FIRST * (2**n), BACKOFF_MAX)
def params(feed_settings: Any, now: datetime) -> dict[str, Any]:
hours = int(getattr(feed_settings, "drawing_recheck_hours", 4))
depts = [str(d) for d in (getattr(feed_settings, "departments", None) or [])]
return {
"now": now,
"recheck_before": now - timedelta(hours=hours),
"co_before": now - CO_RECHECK,
"depts": depts,
}
def queue(conn: Any, feed_settings: Any, now: datetime, *, limit: int | None = None) -> list[QueueItem]:
"""The ordered lookup list. `limit` cuts it (the job passes the budget)."""
sql = QUEUE_SQL
p = params(feed_settings, now)
if limit is not None:
sql += " LIMIT %(limit)s"
p["limit"] = max(0, int(limit))
rows = conn.execute(sql, p).fetchall()
return [
QueueItem(int(r[0]), str(r[1]), str(r[2] or ""), int(r[3]), None if r[4] is None else int(r[4])) for r in rows
]