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
+27
View File
@@ -0,0 +1,27 @@
// Change order words shared by the packet page and the boards.
import type { ChangeOrder, PacketRead } from "../api/types";
export const feedOrigin = (co: ChangeOrder) => co.origin === "visual" || co.origin === "drawing";
/** The old coSummary(): "M00543 Rev 1 -> Rev 2 + 2 other changes", or the first changes. */
export function coSummary(co: ChangeOrder): string {
if (feedOrigin(co) && co.review !== "approved") {
const v = co.vis ?? {};
const ch = v.changes ?? [];
if (v.dwg_change) {
const from = v.from_dwg && v.to_dwg && v.from_dwg.replace(/\s/g, "").toUpperCase() !== v.to_dwg.replace(/\s/g, "").toUpperCase() ? `${v.from_dwg} -> ` : "";
return `${from}${v.to_dwg ?? ""} Rev ${v.from_rev || "?"} -> Rev ${v.to_rev ?? "?"}${ch.length ? ` + ${ch.length} other change${ch.length > 1 ? "s" : ""}` : ""}`;
}
return ch.slice(0, 2).join("; ") + (ch.length > 2 ? ` (+${ch.length - 2} more)` : "") || co.reason || "change from VISUAL";
}
const parts = co.items.filter((i) => !i.added).map((i) => `${i.old_dwg || "sheet"} Rev ${i.old_rev || "?"} -> Rev ${i.new_rev || "?"}`);
const added = co.items.filter((i) => i.added).length;
if (added) parts.push(`${added} sheet${added > 1 ? "s" : ""} added`);
return parts.join(", ") || co.reason || "documents changed";
}
/** The pending feed change order Clerical should review, if any. */
export function coToReview(p: PacketRead): ChangeOrder | undefined {
return (p.change_orders ?? []).find((c) => c.review === "pending" && feedOrigin(c));
}
+90
View File
@@ -0,0 +1,90 @@
// Words for the worker's job runs and the feed status line. Shared by Clerical and Admin.
import type { FeedOverview, JobRun } from "../api/types";
import { fmtAgo, fmtIn } from "./format";
/** One count from a run, as a number. Missing or odd values are 0. */
export function count(run: Pick<JobRun, "counts"> | null | undefined, key: string): number {
const v = run?.counts?.[key];
const n = typeof v === "number" ? v : Number(v);
return Number.isFinite(n) ? n : 0;
}
const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
/** "4 new, 2 updated, 1 change order" or "no changes". */
export function runSummary(run: JobRun): string {
if (run.status === "skipped") {
const why = run.counts?.reason;
return typeof why === "string" && why ? `skipped (${why})` : "skipped";
}
if (run.status === "failed") return run.errors[0] ? `failed: ${run.errors[0]}` : "failed";
if (run.status === "running") return "running";
if (run.status === "abandoned") return "stopped when the worker restarted";
const parts: string[] = [];
const nw = count(run, "new_drafts");
const up = count(run, "drafts_updated");
const co = count(run, "cos_opened") + count(run, "cos_reaimed");
if (nw) parts.push(`${nw} new`);
if (up) parts.push(`${up} updated`);
if (co) parts.push(plural(co, "change order"));
const att = count(run, "attached");
if (att) parts.push(`${att} drawing${att === 1 ? "" : "s"} attached`);
const text = parts.length ? parts.join(", ") : "no changes";
return run.status === "degraded" ? `${text} (some reads failed)` : text;
}
export const RUN_STATUS_LABEL: Record<string, string> = {
running: "Running",
ok: "OK",
degraded: "Degraded",
failed: "Failed",
skipped: "Skipped",
abandoned: "Abandoned",
};
export function runPillClass(status: string): string {
if (status === "ok") return "pill st-ok";
if (status === "failed" || status === "abandoned") return "pill st-bad";
if (status === "degraded") return "pill st-review";
if (status === "running") return "pill st-in_progress";
return "pill st-filed";
}
export const TRIGGER_LABEL: Record<string, string> = {
schedule: "Schedule",
request: "Run now",
chain: "After the feed",
cli: "Command line",
};
/** Is the feed running right now, by the heartbeat or the last run row. */
export function feedRunning(ov: FeedOverview): boolean {
return !!ov.status.worker?.running?.includes("visual-feed") || ov.last_run?.status === "running";
}
/** The status line on the Clerical panel. */
export function statusLine(ov: FeedOverview, now: Date = new Date()): { text: string; warn: boolean } {
const w = ov.status.worker;
if (!w || w.stale) return { text: "Worker not running. VISUAL is not being read. Tell IT.", warn: true };
if (feedRunning(ov)) return { text: "Reading VISUAL now.", warn: false };
const last = ov.last_run;
const lastText = last ? `last run ${fmtAgo(last.finished_at ?? last.started_at, now)}: ${runSummary(last)}` : "no runs yet";
if (ov.status.paused) return { text: `Paused by the admin · ${lastText}`, warn: true };
const next = ov.status.next_run_at ? `next run ${fmtIn(ov.status.next_run_at, now)}` : "next run not scheduled";
return { text: `On · ${next} · ${lastText}`, warn: last?.status === "failed" };
}
/** Drawings attached by the drawing runs that followed each feed run. Runs are newest first. */
export function drawingsAfter(runs: JobRun[], drawingRuns: JobRun[]): Map<number, number> {
const out = new Map<number, number>();
runs.forEach((r, i) => {
const from = r.started_at;
const to = i > 0 ? runs[i - 1]?.started_at : undefined;
let n = 0;
for (const d of drawingRuns) {
if (d.started_at >= from && (to === undefined || d.started_at < to)) n += count(d, "attached");
}
out.set(r.id, n);
});
return out;
}
+91
View File
@@ -0,0 +1,91 @@
// The Admin feed settings form: fields, ranges and the patch of changed fields.
import type { FeedSettings } from "../api/types";
export const DAYS: ReadonlyArray<[number, string]> = [
[0, "Mon"],
[1, "Tue"],
[2, "Wed"],
[3, "Thu"],
[4, "Fri"],
[5, "Sat"],
[6, "Sun"],
];
export type NumKey =
| "interval_shop_minutes"
| "interval_off_minutes"
| "window_back_days"
| "window_ahead_days"
| "drawing_recheck_hours"
| "drawing_budget"
| "drawing_interval_minutes"
| "auto_release_max";
/** Each number field: label, lowest, highest. The server checks the same ranges. */
export const NUMS: ReadonlyArray<[NumKey, string, number, number]> = [
["interval_shop_minutes", "Every (min) in shop hours", 1, 1440],
["interval_off_minutes", "Every (min) outside shop hours", 1, 1440],
["window_back_days", "Released up to (days back)", 0, 120],
["window_ahead_days", "and (days ahead)", 0, 365],
["drawing_interval_minutes", "Drawing lookups every (min)", 1, 1440],
["drawing_recheck_hours", "Recheck a drawing after (hours)", 1, 168],
["drawing_budget", "Drawing lookups per run", 1, 500],
["auto_release_max", "Auto-release at most (per run)", 1, 500],
];
export interface Form {
nums: Record<NumKey, string>;
shop_days: number[];
shop_start: string;
shop_end: string;
golive_date: string;
departments: string[];
labor_tickets: boolean;
auto_release: boolean;
paused: boolean;
}
export function toForm(s: FeedSettings): Form {
const nums = {} as Record<NumKey, string>;
for (const [k] of NUMS) nums[k] = String(s[k] ?? "");
return {
nums,
shop_days: [...(s.shop_days ?? [])].sort(),
shop_start: s.shop_start ?? "",
shop_end: s.shop_end ?? "",
golive_date: s.golive_date ?? "",
departments: [...(s.departments ?? [])].sort(),
labor_tickets: !!s.labor_tickets,
auto_release: !!s.auto_release,
paused: !!s.paused,
};
}
const sameList = <T,>(a: readonly T[], b: readonly T[]) => a.length === b.length && a.every((x, i) => x === b[i]);
/** The changed fields only, or an error text. */
export function feedPatch(was: FeedSettings, f: Form): { patch: Partial<FeedSettings> } | { error: string } {
const patch: Partial<FeedSettings> = {};
for (const [k, label, lo, hi] of NUMS) {
const raw = f.nums[k].trim();
const n = Number(raw);
if (raw === "" || !Number.isInteger(n) || n < lo || n > hi) return { error: `${label}: a whole number from ${lo} to ${hi}.` };
if (n !== was[k]) patch[k] = n;
}
if (!/^\d{2}:\d{2}$/.test(f.shop_start) || !/^\d{2}:\d{2}$/.test(f.shop_end)) return { error: "Shop hours need a start and an end time." };
if (f.shop_start >= f.shop_end) return { error: "Shop hours must end after they start." };
if (!f.shop_days.length) return { error: "Pick at least one shop day." };
const days = [...f.shop_days].sort();
if (!sameList(days, [...(was.shop_days ?? [])].sort())) patch.shop_days = days;
if (f.shop_start !== was.shop_start) patch.shop_start = f.shop_start;
if (f.shop_end !== was.shop_end) patch.shop_end = f.shop_end;
const golive = f.golive_date || null;
if (golive !== (was.golive_date ?? null)) patch.golive_date = golive;
const depts = [...f.departments].sort();
if (!sameList(depts, [...(was.departments ?? [])].sort())) patch.departments = depts;
if (f.labor_tickets !== !!was.labor_tickets) patch.labor_tickets = f.labor_tickets;
if (f.auto_release !== !!was.auto_release) patch.auto_release = f.auto_release;
if (f.paused !== !!was.paused) patch.paused = f.paused;
return { patch };
}
+39
View File
@@ -42,3 +42,42 @@ export function errorText(err: unknown): string {
}
export const DIGITS = /^\d+$/;
/** Whole minutes between two times, rounded, never negative. */
function minutesBetween(a: number, b: number): number {
return Math.max(0, Math.round((b - a) / 60_000));
}
function spanText(mins: number): string {
if (mins < 1) return "under a minute";
if (mins < 90) return `${mins} min`;
const h = Math.round(mins / 60);
if (h < 36) return `${h} h`;
return `${Math.round(h / 24)} d`;
}
/** "2 min ago", "just now", or a dash. */
export function fmtAgo(iso: string | null | undefined, now: Date = new Date()): string {
if (!iso) return "-";
const t = new Date(iso).getTime();
if (Number.isNaN(t)) return iso;
const m = minutesBetween(t, now.getTime());
return m < 1 ? "just now" : `${spanText(m)} ago`;
}
/** "in 3 min", "now", or a dash. */
export function fmtIn(iso: string | null | undefined, now: Date = new Date()): string {
if (!iso) return "-";
const t = new Date(iso).getTime();
if (Number.isNaN(t)) return iso;
const m = minutesBetween(now.getTime(), t);
return m < 1 ? "now" : `in ${spanText(m)}`;
}
/** A date-only ISO string as "Oct 16 2026". */
export function fmtDay(iso: string | null | undefined): string {
if (!iso) return "-";
const d = new Date(`${iso.slice(0, 10)}T12:00:00`);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
}
+38 -1
View File
@@ -39,9 +39,46 @@ export function projectPillClass(s: ProjectStatus): string {
/** A flag text that names a hard problem shows in pink, the rest in amber. */
export function flagClass(flag: string): string {
return /couldn't|not in|mismatch|No approved|Not released/i.test(flag) ? "due over" : "due soon";
return /couldn't|not in|mismatch|No approved|Not released|failed|cancelled|not a number/i.test(flag) ? "due over" : "due soon";
}
/** The flag kinds the find bar filters on. The value goes to GET /api/packets?flag=. "1" is any flag. */
export const FLAG_FILTERS: ReadonlyArray<[string, string]> = [
["", "All packets"],
["1", "All flags"],
["drawing_needed", "Drawing needed"],
["rev_mismatch", "Rev mismatch"],
["not_released", "Not released"],
["not_found", "Not found"],
["lookup_failed", "Lookup failed"],
["wo_gone", "Work order gone"],
["cancelled", "Cancelled"],
["hand", "Hand upload"],
];
/** Short labels for flag kinds on cards and lists. */
export const FLAG_KIND_LABEL: Record<string, string> = {
drawing_needed: "Drawing needed",
rev_mismatch: "Rev mismatch",
not_released: "Not released",
not_found: "Not found",
lookup_failed: "Lookup failed",
bad_rev: "Bad rev",
no_drawing: "No drawing",
wo_gone: "Work order gone",
cancelled: "Cancelled",
hand: "Made by hand",
hand_drawing: "Hand drawing",
hand_bom: "Hand BOM",
};
export function flagKindLabel(k: string): string {
return FLAG_KIND_LABEL[k] ?? k.replace(/_/g, " ");
}
/** The drawing flags a VISUAL draft carries until M-Files gives a usable drawing. */
export const DRAWING_FLAGS = ["drawing_needed", "rev_mismatch", "not_released", "not_found", "lookup_failed", "bad_rev"] as const;
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
/** The old dueInfo(): "Due Oct 20 '26 · 4 d left", with a class for colour. */