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

217 lines
7.5 KiB
Python

"""Packet writes by the worker: the pipeline's packet_write for the system principal, with no session.
`system_write` locks the packet row (FOR UPDATE SKIP LOCKED: a row a person is writing right now is skipped and
retried next run, `Busy`), builds the policy's PacketState from the same child rows the API uses, asks the policy
as `Principal(user_id=None, system="visual-feed")` (`Refused` when it says no), yields a `SysWrite`, then bumps
the version, writes the packet_log row (by NULL, by_name 'VISUAL feed', role 'system'), the audit row
(`packet.<action>`, role_snapshot 'system') and the NOTIFY. Everything runs in a savepoint (or a transaction when
none is open), so a refusal or an error inside the body leaves nothing behind.
VIS_AUTO_RELEASE carries the SEND_TO_WAREHOUSE side effects here, locally: status warehouse, status_at,
wh_skipped false, wh_sent_at, a packet_wh incoming row (by NULL, auto true) and a packet_transitions row (by NULL,
action 'vis_auto_release').
"""
from __future__ import annotations
from collections.abc import Iterator, Mapping
from contextlib import contextmanager
from dataclasses import dataclass, field
from typing import Any
from psycopg.rows import dict_row
from .. import audit, events, policy
from ..domain import packets
from ..text import LIMITS, clean_opt
A = policy.Action
SYSTEM = policy.Principal(user_id=None, system="visual-feed")
BY_NAME = "VISUAL feed"
ROLE = "system"
LABELS: dict[policy.Action, str] = {
A.VIS_UPDATE_DRAFT: "Updated from VISUAL",
A.VIS_OPEN_CO: "Change from VISUAL",
A.VIS_AUTO_RELEASE: "Sent to warehouse automatically",
A.VIS_RETIRE_DRAFT: "Retired: work orders moved to another packet",
}
_SELECT = (
"SELECT *, status::text AS status, source_kind::text AS source_kind, routed_to::text AS routed_to,"
" drawing_state::text AS drawing_state FROM packets WHERE id = %s FOR UPDATE SKIP LOCKED"
)
EMPTY: Mapping[str, Any] = {}
class Busy(Exception):
"""Someone else holds the packet row right now. Retry next run."""
class Missing(LookupError):
"""No such packet."""
class Refused(Exception):
"""The policy said no."""
def __init__(self, decision: policy.Decision) -> None:
super().__init__(f"{decision.code}: {decision.why}")
self.decision = decision
@dataclass
class SysWrite:
conn: Any
packet: dict[str, Any] # the packets row, locked
state: policy.PacketState
action: policy.Action
label: str
note: str | None = None
detail: dict[str, Any] = field(default_factory=dict)
fields: list[str] = field(default_factory=list)
noop: bool = False
version: int = 0
transition_id: int | None = None
@property
def id(self) -> int:
return int(self.packet["id"])
def changed(self, *names: str) -> None:
for n in names:
if n not in self.fields:
self.fields.append(n)
def log(self, label: str, note: str | None = None) -> None:
"""Override the packet_log label (and note)."""
self.label = label
if note is not None:
self.note = note
def skip(self) -> None:
"""Nothing changed: no version bump, no log, audit or event."""
self.noop = True
def lock(conn: Any, packet_id: int) -> dict[str, Any]:
"""The packets row locked FOR UPDATE SKIP LOCKED. Busy when another transaction holds it."""
with conn.cursor(row_factory=dict_row) as cur:
row = cur.execute(_SELECT, (packet_id,)).fetchone()
if row is not None:
return dict(row)
if conn.execute("SELECT 1 FROM packets WHERE id = %s", (packet_id,)).fetchone() is None:
raise Missing(packet_id)
raise Busy(packet_id)
@contextmanager
def system_write(
conn: Any,
packet_id: int,
action: policy.Action,
*,
label: str | None = None,
note: str | None = None,
detail: dict[str, Any] | None = None,
extra: Mapping[str, Any] = EMPTY,
) -> Iterator[SysWrite]:
with conn.transaction():
row = lock(conn, packet_id)
ch = packets.load_children(conn, row, full=False)
state = packets.state_from(row, ch)
decision = policy.check(SYSTEM, state, action, extra=extra)
if not decision.ok:
raise Refused(decision)
sw = SysWrite(
conn=conn,
packet=row,
state=state,
action=action,
label=label or LABELS.get(action, str(action)),
note=note,
detail=dict(detail or {}),
version=int(row["version"]),
)
yield sw
_finish(sw)
def _finish(sw: SysWrite) -> None:
if sw.noop:
return
conn = sw.conn
from_status = str(sw.packet["status"])
from_dept = str(sw.packet["dept_code"])
if sw.action is A.VIS_AUTO_RELEASE:
_auto_release(sw)
sw.version = int(
conn.execute("UPDATE packets SET version = version + 1 WHERE id = %s RETURNING version", (sw.id,)).fetchone()[0]
)
after = conn.execute("SELECT status::text, dept_code FROM packets WHERE id = %s", (sw.id,)).fetchone()
status, dept = str(after[0]), str(after[1])
note = clean_opt(sw.note, LIMITS["note"]) if sw.note else None
conn.execute(
"INSERT INTO packet_log (packet_id, by, by_name, role, action, note, transition_id)"
" VALUES (%s, NULL, %s, %s, %s, %s, %s)",
(sw.id, BY_NAME, ROLE, sw.label, note, sw.transition_id),
)
detail: dict[str, Any] = {"from": from_status, "to": status, "dept": dept}
if from_dept != dept:
detail["from_dept"] = from_dept
if sw.fields:
detail["fields"] = sw.fields
if note:
detail["note"] = note
detail.update(sw.detail)
audit.append(
conn,
action=f"packet.{sw.action}",
role_snapshot=ROLE,
subject_type="packet",
subject_id=sw.id,
packet_id=sw.id,
detail=packets_jsonable(detail),
)
events.publish(
conn, {"event": "changed", "packet_id": sw.id, "version": sw.version, "status": status, "dept": dept}
)
def _auto_release(sw: SysWrite) -> None:
conn = sw.conn
conn.execute(
"UPDATE packets SET status = 'warehouse', status_at = now(), wh_skipped = false, wh_sent_at = now(),"
" return_note = NULL WHERE id = %s",
(sw.id,),
)
conn.execute("UPDATE packet_wh SET closed_at = now() WHERE packet_id = %s AND closed_at IS NULL", (sw.id,))
conn.execute(
"INSERT INTO packet_wh (packet_id, stage, by, note, auto) VALUES (%s, 'incoming', NULL, %s, true)",
(sw.id, "Sent automatically: no flags"),
)
dept = str(sw.packet["dept_code"])
tid = conn.execute(
"INSERT INTO packet_transitions (packet_id, by, action, from_status, to_status, from_dept, to_dept, reason)"
" VALUES (%s, NULL, 'vis_auto_release', %s::packet_status, 'warehouse', %s, %s, %s) RETURNING id",
(sw.id, str(sw.packet["status"]), dept, dept, sw.note),
).fetchone()
sw.transition_id = int(tid[0])
sw.changed("status")
def packets_jsonable(value: Any) -> Any:
"""Audit detail as plain JSON (dates and Decimals as text)."""
from fastapi.encoders import jsonable_encoder
return jsonable_encoder(value)
def log_only(conn: Any, packet_id: int, label: str, note: str | None = None) -> None:
"""A packet_log row by the feed with no version bump (for observations that change nothing a user edits)."""
conn.execute(
"INSERT INTO packet_log (packet_id, by, by_name, role, action, note) VALUES (%s, NULL, %s, %s, %s, %s)",
(packet_id, BY_NAME, ROLE, label, clean_opt(note, LIMITS["note"]) if note else None),
)