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
+308
View File
@@ -0,0 +1,308 @@
"""The VISUAL feed for Clerical: the From VISUAL panel (status, runs, ignored work orders, unmapped resources,
projects without names), Run now, pulling in a work order released before go-live, and what VISUAL changed.
Clerical (clerical or clerical_supervisor) or an admin. Pulling in a work order changes what the feed tracks, so an
admin outside Clerical gives an IT reason for it. Screens read PostgreSQL only: nothing here calls VISUAL. Run now
inserts a job request and NOTIFYs the worker, which does the reading.
"""
from __future__ import annotations
import json
from typing import Any, Literal
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from .. import audit
from ..domain import packets
from ..domain.pipeline import jsonable
from ..text import LIMITS, clean_opt, reason_ok
from .deps import Db, Principal, current_principal, get_db, require_password_session
from .errors import ApiError
from .packets import WRITE
router = APIRouter(prefix="/api/feed", tags=["feed"])
CLERICAL = frozenset({"clerical", "clerical_supervisor"})
JOBS = ("visual-feed", "drawing-lookup")
IGNORED_MAX = 200
UNMAPPED_MAX = 30
STALE_DEFAULT = 90
PULL_IN_REASON_MIN = 3
PULL_IN_REASON_MAX = 300
class RunBody(BaseModel):
job: Literal["visual-feed", "drawing-lookup"] = "visual-feed"
note: str | None = Field(default=None, max_length=1000)
class PullInBody(BaseModel):
reason: str | None = Field(default=None, max_length=2000)
it_reason: str | None = Field(default=None, max_length=1000)
# ---------------------------------------------------------------- access
def require_clerical(p: Principal = Depends(current_principal)) -> Principal: # noqa: B008
if CLERICAL & set(p.roles) or p.is_admin:
return p
raise ApiError(403, "forbidden", "Only Clerical can see the VISUAL feed.")
def _it_reason(p: Principal, it_reason: str | None) -> str | None:
"""None for Clerical. An admin outside Clerical needs a password session and an IT reason."""
if CLERICAL & set(p.roles):
return None
require_password_session(p)
why = reason_ok(it_reason)
if why is None:
raise ApiError(400, "it_reason_required", "Give an IT reason of at least 8 characters.")
return why
# ---------------------------------------------------------------- shapes
def _run_out(r: dict[str, Any]) -> dict[str, Any]:
return {
"id": r["id"],
"job": r["job"],
"trigger": r["trigger"],
"requested_by": [str(u) for u in r.get("requested_by") or []],
"started_at": r["started_at"],
"finished_at": r["finished_at"],
"status": r["status"],
"counts": r["counts"] or {},
"errors": r["errors"] or [],
"build_version": r.get("build_version"),
"visual_target": r.get("visual_target"),
"host": r.get("host"),
}
def _runs(conn: Any, job: str, limit: int) -> list[dict[str, Any]]:
return [
_run_out(r)
for r in packets.rows(conn, "SELECT * FROM job_runs WHERE job = %s ORDER BY id DESC LIMIT %s", (job, limit))
]
def _ignored_out(r: dict[str, Any]) -> dict[str, Any]:
keys = ("id", "wo_base", "lot", "split", "sub", "rel", "project", "unit_number", "res", "dwg", "last_seen_at")
out = {k: r[k] for k in keys}
out["pulled_in_at"] = r["pulled_in_at"]
out["pulled_in_by"] = str(r["pulled_in_by"]) if r["pulled_in_by"] else None
out["pulled_in_reason"] = r["pulled_in_reason"]
return out
def _stale_seconds() -> int:
try:
from ..worker import STALE_SECONDS
except ImportError: # the worker package is agent A's; the panel still answers without it
return STALE_DEFAULT
return int(STALE_SECONDS)
def _feed_settings(conn: Any) -> Any:
from ..feed import config
return config.load(conn)
def _request_run(conn: Any, p: Principal, job: str, note: str | None) -> int:
"""Insert a job request and NOTIFY the worker in the same transaction. Returns the pending request count."""
conn.execute(
"INSERT INTO job_requests (job, requested_by, note) VALUES (%s, %s, %s)",
(job, p.user_id, clean_opt(note, LIMITS["job_note"])),
)
conn.execute("SELECT ps_notify('ps_job_request', %s)", (json.dumps({"job": job}),))
return int(
conn.execute("SELECT count(*) FROM job_requests WHERE job = %s AND claimed_at IS NULL", (job,)).fetchone()[0]
)
# ---------------------------------------------------------------- endpoints
@router.get("/overview")
def overview(
p: Principal = Depends(require_clerical), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""Everything the From VISUAL panel shows, from PostgreSQL only."""
conn = d.conn
fs = _feed_settings(conn)
sched = packets.one(conn, "SELECT next_run_at, paused FROM job_schedule WHERE job = 'visual-feed'")
beat = packets.one(
conn,
"SELECT at, build, running, now() - at > make_interval(secs => %s) AS stale FROM worker_heartbeat WHERE id = 1",
(_stale_seconds(),),
)
runs = _runs(conn, "visual-feed", 10)
last_run = runs[0] if runs else None
finished = next((r for r in runs if r["status"] in ("ok", "degraded")), None)
unmapped_raw = (finished or {}).get("counts", {}).get("unmapped") or {}
unmapped: list[dict[str, Any]] = []
if isinstance(unmapped_raw, dict):
for k, v in unmapped_raw.items():
if isinstance(v, int) or (isinstance(v, str) and v.isdigit()):
unmapped.append({"resource": str(k), "rows": int(v)})
unmapped.sort(key=lambda x: (-int(x["rows"]), str(x["resource"])))
unmapped = unmapped[:UNMAPPED_MAX]
ignored = packets.rows(
conn,
"SELECT * FROM visual_ignored_wos WHERE pulled_in_at IS NULL ORDER BY rel DESC NULLS LAST, wo_base, lot, id"
" LIMIT %s",
(IGNORED_MAX,),
)
nameless = packets.rows(
conn,
"SELECT p.id, p.number,"
" (SELECT count(*) FROM units u WHERE u.project_id = p.id AND u.removed_at IS NULL) AS units,"
" (SELECT count(*) FROM packets k JOIN units u ON u.id = k.unit_id"
" WHERE u.project_id = p.id AND k.removed_at IS NULL) AS packets"
" FROM projects p WHERE p.source = 'visual' AND p.needs_details AND p.name IS NULL AND p.removed_at IS NULL"
" ORDER BY p.number LIMIT 200",
)
return {
"status": {
"paused": bool(fs.paused),
"next_run_at": (sched or {}).get("next_run_at"),
"worker": None
if beat is None
else {
"at": beat["at"],
"build": beat["build"],
"running": list(beat["running"] or []),
"stale": bool(beat["stale"]),
},
},
"last_run": last_run,
"runs": runs,
"drawing_runs": _runs(conn, "drawing-lookup", 10),
"pending_requests": int(
conn.execute(
"SELECT count(*) FROM job_requests WHERE job = 'visual-feed' AND claimed_at IS NULL"
).fetchone()[0]
),
"ignored": [_ignored_out(r) for r in ignored],
"unmapped": unmapped,
"projects_without_names": [
{"id": r["id"], "number": r["number"], "units": int(r["units"]), "packets": int(r["packets"])}
for r in nameless
],
"golive_date": fs.golive_date,
"departments": list(fs.departments),
}
@router.post("/run", dependencies=WRITE)
def run_now(
body: RunBody | None = None,
p: Principal = Depends(require_clerical), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""Run now: ask the worker for a feed (or drawing) run. Requests made while one is pending share that run."""
body = body or RunBody()
conn = d.conn
with conn.transaction():
pending = _request_run(conn, p, body.job, body.note)
audit.append(
conn,
action="feed.run_requested",
user_id=p.user_id,
session_id=p.session_id,
role_snapshot=p.role_snapshot,
subject_type="job",
subject_id=body.job,
detail={"job": body.job, "pending": pending},
ip=p.ip,
)
return {"requested": True, "job": body.job, "pending": pending}
@router.post("/ignored/{row_id}/pull-in", dependencies=WRITE)
def pull_in(
row_id: int,
body: PullInBody,
p: Principal = Depends(require_clerical), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""Track a work order released before the go-live date after all. The next feed run makes its packet."""
it_reason = _it_reason(p, body.it_reason)
reason = clean_opt(body.reason, PULL_IN_REASON_MAX)
if reason is None or len(reason) < PULL_IN_REASON_MIN:
raise ApiError(400, "reason_required", "Say why this work order is pulled in (at least 3 characters).")
conn = d.conn
with conn.transaction():
row = packets.one(conn, "SELECT * FROM visual_ignored_wos WHERE id = %s FOR UPDATE", (row_id,))
if row is None:
raise ApiError(404, "not_found", "No such work order on the ignored list.")
if row["pulled_in_at"] is not None:
raise ApiError(
409,
"already_pulled_in",
"This work order was already pulled in.",
extra={"current": jsonable(_ignored_out(row))},
)
row = packets.one(
conn,
"UPDATE visual_ignored_wos SET pulled_in_at = now(), pulled_in_by = %s, pulled_in_reason = %s"
" WHERE id = %s RETURNING *",
(p.user_id, reason, row_id),
)
assert row is not None
key = "/".join(str(row[k]) for k in ("wo_base", "lot", "split", "sub"))
audit.append(
conn,
action="feed.pulled_in",
user_id=p.user_id,
session_id=p.session_id,
role_snapshot=p.role_snapshot,
subject_type="work_order",
subject_id=key,
detail={"id": row_id, "key": key, "rel": str(row["rel"]) if row["rel"] else None, "reason": reason},
it_reason=it_reason,
ip=p.ip,
)
_request_run(conn, p, "visual-feed", f"pull in {key}")
return _ignored_out(row)
@router.get("/batches")
def batches(
limit: int = Query(20, ge=1, le=200),
p: Principal = Depends(require_clerical), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> list[dict[str, Any]]:
return packets.rows(d.conn, "SELECT * FROM visual_batches ORDER BY id DESC LIMIT %s", (limit,))
@router.get("/changes")
def changes(
batch_id: int | None = Query(None),
limit: int = Query(200, ge=1, le=2000),
p: Principal = Depends(require_clerical), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""What VISUAL changed in one batch (the latest when none is given): work order rows and material lines."""
conn = d.conn
if batch_id is None:
batch = packets.one(conn, "SELECT * FROM visual_batches ORDER BY id DESC LIMIT 1")
else:
batch = packets.one(conn, "SELECT * FROM visual_batches WHERE id = %s", (batch_id,))
if batch is None:
raise ApiError(404, "not_found", "No such feed batch.")
if batch is None:
return {"batch": None, "rows": [], "materials": []}
rows = packets.rows(
conn, "SELECT * FROM visual_row_changes WHERE batch_id = %s ORDER BY id LIMIT %s", (batch["id"], limit)
)
mats = packets.rows(
conn, "SELECT * FROM visual_material_changes WHERE batch_id = %s ORDER BY id LIMIT %s", (batch["id"], limit)
)
return {"batch": batch, "rows": rows, "materials": mats}