Files
cproudlock 13be0ef90f Import the wilm agent store and WINC adoption work, with four fixes
Eleven commits from the sister site: a reversible Maximo/WINC machine-adoption
import, a versioned agent binary store with upload and rollback, remote-BuildKit
CI, tray version reporting, and UI fixes. Applied as a patch again - the
published mirror rewrites hashes, so the branches have unrelated histories and
git refuses to merge them.

Reviewed by three agents before merging. Four things fixed here.

THE PUBLISH WORKFLOW WAS DEAD. The new remote-BuildKit step in
publish-images.yml sits at column 0 instead of indented under steps:, so the
whole file is invalid YAML and GitHub would refuse it. Their entire GHCR
distribution channel - which Start-ShopDBDev.ps1 and the compose files consume -
was broken at the source and clearly never run. Indented; all five workflows now
parse.

THE IMPORT'S APPROVAL CHECK COULD NOT FAIL. commit() built the plan and the
approval packet from the same upload and then asked the writer whether they
matched, so packet_matches_plan compared a hash against itself. The documented
contract - "a human approves a specific, frozen plan, and the writer can prove
nothing drifted" - did not exist in the web path: an admin could preview file A
and commit file B, gated only by the needs-create group NAMES they ticked. The
preview's planhash is now required at commit and checked against a freshly
rebuilt plan. Their tests committed without previewing, which is what let this
through; they go through the real flow now, and three new tests pin it - all
confirmed to fail with the check stubbed out.

endpoints.agentpackage QUIETLY GREW. It used to mean "wrap the staged exe in a
ZIP"; with the upload routes it also means "choose the executable every future
package installs and runs as SYSTEM". Before this, staging needed filesystem
access to the server. The permission description still said only "generate and
download a package", so an admin granting it could not see what they were
granting. Reworded.

NOTHING RECORDED WHAT WAS STAGED, OR BY WHOM. The manifest held version, size
and timestamp; re-uploading a version replaces the bytes in place under the same
label. A sha256 of the stored file is now recorded, and the upload is
audit-logged with it - after an incident there was previously no answer to who
put that binary on the fleet.

Also documented the nine new routes in docs/api-inventory.json (that gate cannot
run on the mirror, so it falls to us at promotion time again), regenerated the
spec and the project map, and kept our own docs where they conflicted: docs/ is
scrubbed from the mirror, so their DEV-BUILD-TEST.md still had the dev-be
channel mapping we corrected, and their PLUGIN-HOOKS.md was a new 45-line file
rather than our 737-line canonical one. Their journal section is appended to
ours.

Contract version comes across as 0.30.0 for a genuinely additive surface.

Suite 2369, frontend 324, gate green.

Scope: product
2026-09-03 07:54:14 -04:00

160 lines
6.6 KiB
Python

"""Conservative, explainable match suggestions for UNRESOLVED values.
IMPORT-AGNOSTIC and REPORT-ONLY. This never resolves anything and never writes.
Given a raw value that the normal rules could not map, it proposes zero or more
candidate names that already exist in a lookup table, each with a reason and a
confidence, so a human can accept one into that importer's alias map. Nothing
here is ever applied automatically - a silent wrong match is the worst failure
mode in dirty asset data, so every suggestion has to be acknowledged.
Two tiers, tried in order, stopping at the first that yields anything:
1. Bounded deterministic transforms (confidence 'high'). These are rules that
fit the shapes actually seen in the data - a bay code with a slash is two
bays (T6/U6 -> T6, U6), a parenthetical is a note (LD9W(GE90) -> LD9W), a
decimal is a sub-bay (M6.3 -> M6), a trailing range is its first member
(L1-4 -> L1). Each transform is a named rule, not a fuzzer, so the reason
printed next to a suggestion tells a human exactly why it was proposed.
2. A single edit-distance-1 fallback (confidence 'low'). Only if no transform
matched, and only for a one-character difference against an existing name.
Bounded to distance 1 on purpose: it catches a typo or a dropped letter and
nothing looser, so it cannot quietly invent a plausible-looking wrong bay.
The caller supplies the same normalisation and the lookup it resolves against, so
suggestions are always checked against what is really in the database.
"""
import re
from collections import namedtuple
from scripts.site_imports.resolver import norm
# candidate: the existing name we think the raw value meant.
# reason: which rule proposed it (shown to the human).
# confidence: 'high' for a deterministic transform, 'low' for edit-distance.
Suggestion = namedtuple('Suggestion', ('candidate', 'reason', 'confidence'))
_PAREN = re.compile(r'\s*\([^)]*\)')
def _candidates_from_transforms(stripped):
"""Yield (transformed_name, reason) from the bounded deterministic rules.
Operates on the value AFTER the importer has removed its own prefix (e.g.
WM-). Each rule yields plain names; the caller checks each against the live
lookup, so a rule that produces a non-existent name simply contributes no
suggestion.
"""
# A parenthetical note: LD9W(GE90) -> LD9W
if '(' in stripped:
yield (_PAREN.sub('', stripped).strip(), 'dropped parenthetical note')
# A slash means two bays: T6/U6 -> T6 and U6. A bare second half (9/10)
# inherits the first half's letter prefix (T9/10 -> T9, T10).
if '/' in stripped:
left, right = (part.strip() for part in stripped.split('/', 1))
if left:
yield (left, 'first of a slash-joined pair')
if right:
yield (right, 'second of a slash-joined pair')
prefix = re.match(r'^([A-Za-z]+)', left)
if prefix and re.match(r'^\d', right):
yield (prefix.group(1) + right,
'second of a slash-joined pair, prefix carried')
# A decimal sub-bay: M6.3 -> M6
if '.' in stripped:
yield (stripped.split('.', 1)[0].strip(), 'dropped decimal sub-bay')
# A trailing numeric range: L1-4 -> L1
dash = re.match(r'^([A-Za-z]*\d+)-\d+$', stripped)
if dash:
yield (dash.group(1), 'first of a numeric range')
def _edit_distance_at_most_one(left, right):
"""True if `left` and `right` differ by at most one insert/delete/replace.
Bounded on purpose. A length gap over one is an immediate no; otherwise walk
both once and allow a single mismatch. Cheap and strict - it is a typo
catcher, not a similarity search.
"""
if left == right:
return True
length_left, length_right = len(left), len(right)
if abs(length_left - length_right) > 1:
return False
if length_left == length_right:
return sum(a != b for a, b in zip(left, right)) == 1
# Ensure left is the shorter one, then try to match with one insertion.
if length_left > length_right:
left, right = right, left
length_left, length_right = length_right, length_left
index_left = index_right = 0
skipped = False
while index_left < length_left and index_right < length_right:
if left[index_left] == right[index_right]:
index_left += 1
index_right += 1
elif skipped:
return False
else:
skipped = True
index_right += 1
return True
# A low-confidence edit-distance pass can match many short bay codes at once,
# which is noise, not help. Cap how many it emits so a human sees the few most
# plausible typo-fixes and nothing drowns the high-confidence transforms.
_MAX_LOW_CONFIDENCE = 3
def suggest(raw_value, stripped_value, lookup_by_name, display=None):
"""Suggestions for one unresolved value. Empty list means "no idea".
raw_value the original export string (for reporting only).
stripped_value the value after the importer removed its own prefix; this is
what the transforms and the edit-distance search operate on.
lookup_by_name norm(name) -> id, the live table to match against.
display optional norm(name) -> original-cased name, so a suggestion
echoes the database's spelling instead of the normalised key.
Deterministic transforms first; only if none of them hits an existing name
does the edit-distance-1 fallback run, and that fallback is capped. High-
confidence suggestions are never diluted by low-confidence ones.
"""
display = display or {}
def spell(key):
return display.get(key, key)
results = []
seen = set()
for candidate, reason in _candidates_from_transforms(stripped_value):
candidate = (candidate or '').strip()
if not candidate:
continue
key = norm(candidate)
if key in lookup_by_name and key not in seen:
seen.add(key)
results.append(Suggestion(spell(key), reason, 'high'))
if results:
return results
target = norm(stripped_value)
if not target:
return results
low = []
for existing_key in lookup_by_name:
if existing_key not in seen and _edit_distance_at_most_one(
target, existing_key):
seen.add(existing_key)
low.append(Suggestion(spell(existing_key),
'one-character difference', 'low'))
# Only surface the capped set; too many one-off matches is noise.
if len(low) <= _MAX_LOW_CONFIDENCE:
results.extend(sorted(low))
return results