diff --git a/docker-compose.yml b/docker-compose.yml
index 05e3df7..d4fd4f2 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -35,12 +35,22 @@ services:
# default and enabled from the Admin console; this is the only email
# secret and it is never stored in the DB. Leave unset until configured.
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
+ # Optional — read-only SQL Server connection to the Micron asset catalog,
+ # which backs the asset picker in the work package creator. Leave unset and
+ # the picker cleanly falls back to manual entry (see server/assets_db.py).
+ # Use a db_datareader login: the app only ever SELECTs.
+ MICRON_DB_URL: ${MICRON_DB_URL:-}
restart: unless-stopped
depends_on:
db:
condition: service_healthy # waits for postgres to accept connections
networks:
- internal
+ # Reaching the Micron database means leaving this compose project, and
+ # `internal` is deliberately egress-free. `outbound` is attached to the api
+ # container ONLY — the database and backup containers stay sealed. Detach it
+ # again if you are not using the Micron asset picker.
+ - outbound
db:
image: postgres:16-alpine
@@ -98,4 +108,12 @@ networks:
name: proxy
external: true
internal:
- internal: true # no outbound internet access from api/db
\ No newline at end of file
+ internal: true # no route off the host for anything on this network alone
+ outbound:
+ # An ordinary bridge network, i.e. one that HAS a default gateway. `internal`
+ # above removes the gateway entirely, which blocks not just the internet but
+ # the LAN and the VPN too — so the api container needs this second network to
+ # reach the Micron asset database. Attached to `api` alone: `db` and `backup`
+ # remain on `internal` only and still have no way off the host.
+ # Detach it from api if you are not using the Micron asset picker.
+ driver: bridge
\ No newline at end of file
diff --git a/docs/reference/completion.md b/docs/reference/completion.md
index 91b92da..51a0c7c 100644
--- a/docs/reference/completion.md
+++ b/docs/reference/completion.md
@@ -67,6 +67,7 @@ name the probe that re-checks the item on every run.
| D8 | built | T7.7 (5MB / PDF+image / 2GB, 80% warning) |
| D9 | built | T7.6 (Field View text pill at 390px) |
| D10 | built | T7.6 / reused T8.3 (stored setting, admin-only, audited, sink-verified) |
+| D11 | built | merge of `origin/Micron-Assets` + integration, Aug 20 (`assets_check`); see `decisions-2026-08-20.md` |
## Out of scope, confirmed unbuilt
@@ -74,8 +75,10 @@ name the probe that re-checks the item on every run.
`materials_check` grep the model and the diff for stock/inventory/price
fields on every run; none exist. D6's uploaded list is project-scoped data
entry, not a catalog.
-- **Asset database integration** — the assets section stays a CR-006 toggle
- (off on the Micron sample); no integration code exists.
+- **Asset database integration** — SUPERSEDED by D11 on Aug 20: Cody Schaefer's
+ Micron asset picker (read-only catalog lookup, `origin/Micron-Assets`) merged
+ and adapted to the R2 creator. The assets section stays a CR-006 toggle
+ (off on the Micron sample). This line was true when written.
- **CxAlloy integration** — CR-014 is notification-only, as the task footnote
ordered; the platforms block stores names and URLs, nothing calls them.
- **P6 activity import** — CR-001 renders the two fields; nothing imports.
diff --git a/docs/reference/file-map.md b/docs/reference/file-map.md
index ad0250d..59c9e5f 100644
--- a/docs/reference/file-map.md
+++ b/docs/reference/file-map.md
@@ -305,7 +305,13 @@ python tests/icon_check.py # S6 - one icon system, no emoji, mapped
python tests/helptip_check.py # C1/S8 - tips by keyboard+touch, audit greps 13 checks
python tests/mobile_check.py # C2 - all 7 pages at 390px, targets + fit 24 checks
python tests/archived_check.py # D7 - archived projects, admins only, frozen 15 checks
-python tests/color_check.py # C4 - zero literals outside theme-light 4 checks
+python tests/color_check.py # C4 - zero literals outside theme-light 5 checks
+```
+
+The August 20 integration adds:
+
+```bash
+python tests/assets_check.py # D11 - Micron picker: read-only, degrades 27 checks
```
**Three probes were re-pointed at `T7.1`.** `sections_check.py` 5b drove the live
diff --git a/docs/waves/decisions-2026-08-20.md b/docs/waves/decisions-2026-08-20.md
new file mode 100644
index 0000000..3351819
--- /dev/null
+++ b/docs/waves/decisions-2026-08-20.md
@@ -0,0 +1,55 @@
+# Decisions — August 20, 2026
+
+One item. Like the August 18 set, it is a **new item** with its own `D` id, not a
+reinterpretation of an existing one.
+
+---
+
+## D11 — The Micron asset picker merges into the R2 creator
+
+- **Arrived as:** `origin/Micron-Assets` (`7ef1fcd`, Cody Schaefer, Aug 18) — written
+ against pre-R2 `main`, integrated here by Nick's instruction on Aug 20.
+- **Amends:** the R2 completion record's "Asset database integration — out of scope,
+ confirmed unbuilt" line, which was true when written and stops being true here.
+- **Surface:** `html/` (creator), `server/` (`assets_db.py`, `/api/assets`),
+ `docker-compose.yml`, `requirements.txt`.
+
+### What the branch brought
+
+A read-only lookup onto the Micron asset catalog (a SQL Server instance outside this
+repo): the whole catalog is fetched once per creator page load through `/api/assets`
+and searched in memory; picked assets are stored on the package tagged
+`source:'catalog'` with the DB's own casing; anything not in the catalog is added by
+hand and visibly tagged manual. CSV import and Excel column paste bulk-add with the
+same matching. Unconfigured (`MICRON_DB_URL` unset) and unreachable are first-class
+states that degrade to manual entry — the suite runs without Micron wired up.
+
+### What integration changed (and why)
+
+The branch predates waves 5–9, so it used surfaces R2 replaced. Each adaptation keeps
+Cody's behaviour and moves it onto the R2 idiom:
+
+1. **Six `alert()` calls → the T7.9 dialog kit and toast.** The creator ships zero
+ native dialogs (`creator_dialogs_check` pins the count). File-handling errors use
+ `toast(msg,'alert')` exactly as the drawings uploader and comment import do;
+ the instructional message and the import summary use the kit, which gained the
+ one-button `wpAlertDialog` shape it was always going to need (BL-024 wants it too).
+2. **The export block** moved inside T9.1's sectioned `add('assets', …)` frame, so the
+ CR-006 assets toggle keeps governing it. Content is Cody's: two columns, Asset ID +
+ Note, no controls.dev link column.
+3. **`initAssetPicker()`** joined the R2 `bootData()` loads rather than replacing them.
+4. **`role="status"`** on the picker's source note, so loading → ready/absent/error
+ announces (C1, the login.html pattern).
+5. Everything else landed as written: his `⤒` import glyph is already the S6-mapped
+ U+2912, `.material-actions` is the creator's own class, and the styles block
+ declares no colour literal (`color_check` re-verifies).
+
+### Recorded properties, restated as constraints
+
+- **Read-only, structurally.** `assets_db.py` contains one SELECT and no other
+ statement; there is no POST route. `assets_check` greps this on every run.
+- **Credentials are env-only** (`MICRON_DB_URL`), matching the SMTP password rule.
+ Driver errors are logged server-side and never propagated to the browser, because
+ a malformed URL's error text can quote password fragments.
+- **Unconfigured is not an error.** Local dev and the demo DB run with the picker in
+ manual mode; nothing in the suite requires the catalog to exist.
diff --git a/html/help.js b/html/help.js
index 1a67e50..0fbaf4d 100644
--- a/html/help.js
+++ b/html/help.js
@@ -244,7 +244,7 @@
Key fields
Subject / Title (required) and WP Type (required, from the SOP).
-
Assets — link each controls.dev asset the package covers.
+
Assets — search the Micron DB by asset ID and add each asset the package covers. Anything not in the Micron DB can still be typed in by hand.
Scope & Work — the sequenced steps the crew performs (per-discipline in multi-discipline mode).
Labor – Est. Hrs. — drives the sizing check (see Sizing).
@@ -384,7 +384,7 @@
Sequence
SOP-defined construction phases; a WP can name a predecessor step.
Bagged & tagged
Materials on site, kitted, and labelled — part of the Materials constraint.
MIMO
Material In / Material Out — kitting and staging logistics.
-
Asset
A controls.dev record (equipment/system) a package is built around.
+
Asset
An asset ID from the Micron DB that a package is built around. The Micron DB is read-only here — picking an asset never changes it.
Hold / Witness point
Hold = work stops until inspection sign-off; Witness = inspection offered but work may proceed.
Active project
The currently selected project; all data is scoped to it.
` },
diff --git a/html/wp-creation-app.js b/html/wp-creation-app.js
index 187db00..7c3d354 100644
--- a/html/wp-creation-app.js
+++ b/html/wp-creation-app.js
@@ -285,6 +285,7 @@ function _openDialog(opts){
document.getElementById('wp-dialog-err').textContent='';
document.getElementById('wp-dialog-ok').textContent=opts.okLabel||'OK';
document.getElementById('wp-dialog-cancel').textContent=opts.cancelLabel||'Cancel';
+ document.getElementById('wp-dialog-cancel').style.display=opts.okOnly?'none':'';
ov.classList.add('open');
setTimeout(()=>{ (opts.input?inp:document.getElementById('wp-dialog-ok')).focus(); },0);
});
@@ -314,6 +315,10 @@ function wpConfirmDialog(opts){ return _openDialog({...opts, input:false}); }
// prompt() said string or null; so does this - and a validate() answer renders
// AT the input instead of round-tripping through another dialog.
function wpPromptDialog(opts){ return _openDialog({...opts, input:true}); }
+// alert() said one thing and offered one button; so does this (D11 - the asset
+// importer arrived using alert(), and the kit had no one-button shape).
+// Escape still closes it; the resolved value is not meaningful for alerts.
+function wpAlertDialog(opts){ return _openDialog({...opts, input:false, okOnly:true}); }
document.addEventListener('keydown', e=>{
const ov=document.getElementById('wp-dialog');
if(e.key==='Escape' && ov && ov.classList.contains('open')) wpDialogCancel();
@@ -435,7 +440,7 @@ function applySOP(){
applyKind();
if(!pkgMaterials.length){ pkgMaterials=[{qty:'',unit:'',desc:''}]; buildMaterials(); }
if(!pkgAttach.length){ pkgAttach=[{doc:'',rev:'',link:''}]; buildAttach(); }
- if(!pkgAssets.length){ pkgAssets=[{tag:'',desc:'',link:''}]; buildAssets(); }
+ if(!pkgAssets.length){ buildAssets(); } // renders the "no assets yet" empty state
if(!pkgWorkSteps.length){ pkgWorkSteps=['']; buildWorkSteps(); }
updateNumber(); updateReleaseBanner();
// CR-006. Last, after every builder has rendered its card — applying it earlier
@@ -467,7 +472,7 @@ function applyKindVisibility(){
const show = (id, on) => { const el = document.getElementById(id); if(el) el.style.display = on ? '' : 'none'; };
show('kind-row', bimProj);
show('bim-card', ewp); // model area / clash + IFF # / scan
- show('asset-card', !ewp); // controls.dev assets
+ show('asset-card', !ewp); // Micron DB assets
show('material-card', !ewp); // bill of materials
show('mimo-card', !ewp); // kitting / MIMO
show('bimlink-wrap', bimProj && !ewp); // an IWP references the BIM package that enabled it
@@ -1157,20 +1162,344 @@ async function splitByDiscipline(){
if(untagged) toast(untagged+' material line'+(untagged===1?'':'s')+' had no discipline tag and stayed on the master only.', 'alert');
}
-// ── ASSETS (controls.dev) ────────────────────────────────────────────────────
-// Interim: assets are linked manually back to controls.dev. A future direct
-// integration will let the user pick them from a list instead of pasting links.
+// ── ASSETS (Micron asset catalog) ────────────────────────────────────────────
+// Assets are picked from the Micron asset catalog, a SQL Server database this app
+// reads through /api/assets. The lookup is strictly read-only — picking an asset
+// never writes to the catalog, and there is no endpoint that could.
+//
+// The catalog can be absent (not configured) or unreachable (VPN/host down), and
+// neither may block someone from writing a work package: in both cases the picker
+// says so and manual entry carries on. Manually entered assets are marked
+// source:'manual' so it stays visible which rows the catalog vouches for.
+// The whole catalog is fetched once when the page loads and searched in memory —
+// it is slow-moving reference data, so a request per keystroke would buy nothing
+// and cost latency on every one.
+let assetCatalog = []; // the full catalog, loaded once
+let assetCatalogIndex = new Map(); // lowercased id -> the Micron DB's own casing
+let assetCatalogState = 'loading'; // loading | ready | absent | error
+let assetResults = []; // current matches; the result list indexes into this
+const ASSET_RESULT_MAX = 500; // results shown at once — the box scrolls, not the search
+const ASSET_IMPORT_MAX = 1000; // rows accepted from one CSV — see importAssets()
+
+function assetKey(a){ return String((a && a.tag) || '').trim().toLowerCase(); }
+function assetAlreadyAdded(tag){
+ const k = String(tag||'').trim().toLowerCase();
+ return pkgAssets.some(a => assetKey(a) === k && k);
+}
+
function buildAssets(){
const tb=document.getElementById('asset-body'); if(!tb) return; tb.innerHTML='';
- pkgAssets.forEach((a,i)=>{ const tr=document.createElement('tr');
- tr.innerHTML=`
No assets yet — search the Micron DB above to add the assets this package covers.
`;
+ return;
+ }
+ pkgAssets.forEach((a,i)=>{
+ const tr=document.createElement('tr');
+ // The asset ID on a catalog row is shown as text, not an input: the catalog
+ // is the source of truth for it and a locally edited copy would silently
+ // disagree. The note is always the user's own, so it stays editable either
+ // way. Every interpolation below is esc()'d text content or a numeric index
+ // — never a raw string inside an inline handler, which is the bug pattern
+ // recorded in KNOWN-ISSUES.md §1.
+ const idCell = a.source === 'catalog'
+ ? `
${esc(a.tag)}Micron DB
`
+ : `
`;
+ tr.innerHTML = idCell +
+ `
+
`;
+ tb.appendChild(tr);
+ });
+}
+
+// Kept for saved packages written before the picker existed: their rows have no
+// `source`, so they would render as read-only catalog rows with no way to fix a
+// typo. Anything that didn't come from the catalog is treated as manual.
+function normaliseAsset(a){
+ const o = Object.assign({ tag:'', desc:'', link:'', source:'manual' }, a||{});
+ if(o.source !== 'catalog') o.source = 'manual';
+ return o;
+}
+
+function addManualAsset(){
+ pkgAssets.push(normaliseAsset({}));
+ buildAssets();
+ track('asset_added',{source:'manual'});
+}
+
+// ── CSV IMPORT ───────────────────────────────────────────────────────────────
+// Bulk-add a list of asset ids. Each imported id is checked against the loaded
+// Micron DB: a hit is added as a catalog row (badge, id locked, stored with the
+// DB's own casing); a miss is added as a manual row so it is visibly NOT vouched
+// for rather than silently dropped. Nothing is ever written back to Micron.
+const ASSET_ID_HEADERS = ['asset id','assetid','asset_id','asset','asset tag','assettag','tag','id'];
+
+function importAssets(ev){
+ const f = ev.target.files && ev.target.files[0];
+ if(!f){ return; }
+ const clear = () => { ev.target.value = ''; };
+ if(/\.xlsx?$/i.test(f.name)){
+ wpAlertDialog({title:'Load from CSV', message:'Please save the workbook as CSV first (File → Save As → CSV), then load it here.'});
+ clear(); return;
+ }
+ const r = new FileReader();
+ r.onload = () => {
+ let rows;
+ try { rows = parseCSV(r.result); }
+ catch(e){ toast('Could not parse that CSV.', 'alert'); clear(); return; }
+ if(!rows.length){ toast('That file has no rows.', 'alert'); clear(); return; }
+ applyImportedAssets(rows);
+ clear();
+ };
+ r.onerror = () => { toast('Could not read that file.', 'alert'); clear(); };
+ r.readAsText(f);
+}
+
+// Which column holds the ids, and whether row 0 is a header.
+// - a recognised header name wins outright;
+// - otherwise pick the column with the most Micron DB hits, so an export with
+// the ids in column D works without the user rearranging it;
+// - failing both (nothing matches — e.g. the DB is offline), use column 0.
+function pickAssetColumn(rows){
+ const head = (rows[0] || []).map(c => String(c || '').trim().toLowerCase());
+ const named = head.findIndex(h => ASSET_ID_HEADERS.includes(h));
+ if(named >= 0) return { col: named, start: 1 };
+
+ const width = rows.slice(0, 200).reduce((w, r) => Math.max(w, r.length), 1);
+ let best = 0, bestHits = 0;
+ for(let c = 0; c < width; c++){
+ let hits = 0;
+ for(let i = 0; i < Math.min(rows.length, 200); i++){
+ const v = String((rows[i] || [])[c] || '').trim();
+ if(v && assetCatalogIndex.has(v.toLowerCase())) hits++;
+ }
+ if(hits > bestHits){ bestHits = hits; best = c; }
+ }
+ return { col: best, start: 0 };
+}
+
+function applyImportedAssets(rows){
+ const { col, start } = pickAssetColumn(rows);
+
+ // Collect, trimmed and de-duplicated within the file itself.
+ const seen = new Set(), ids = [];
+ for(let i = start; i < rows.length; i++){
+ const v = String((rows[i] || [])[col] || '').trim();
+ if(!v) continue;
+ const k = v.toLowerCase();
+ if(seen.has(k)) continue;
+ seen.add(k); ids.push(v);
+ }
+ if(!ids.length){ toast('No asset ids found in that file.', 'alert'); return; }
+
+ // Cap the import rather than building a table with thousands of rows. Reported,
+ // never silent — a truncated import that looked complete would be worse.
+ const capped = ids.length > ASSET_IMPORT_MAX;
+ const take = capped ? ids.slice(0, ASSET_IMPORT_MAX) : ids;
+
+ let matched = 0, unmatched = 0, dupes = 0;
+ take.forEach(id => {
+ if(assetAlreadyAdded(id)){ dupes++; return; }
+ const canonical = assetCatalogIndex.get(id.toLowerCase());
+ if(canonical){
+ pkgAssets.push({ tag: canonical, desc: '', link: '', source: 'catalog' });
+ matched++;
+ } else {
+ pkgAssets.push({ tag: id, desc: '', link: '', source: 'manual' });
+ unmatched++;
+ }
+ });
+
+ buildAssets();
+ renderAssetResults(); // rows just added should now read "added"
+ track('asset_imported', { matched: matched, unmatched: unmatched });
+
+ // Every id matched, nothing skipped, nothing truncated: a toast is enough.
+ // Anything the user needs to act on — unmatched ids, a silent-looking
+ // truncation, an unchecked import — interrupts with the detail instead.
+ const offline = assetCatalogState !== 'ready';
+ if(matched && !unmatched && !dupes && !capped && !offline){
+ toast('Added ' + matched + ' asset' + (matched === 1 ? '' : 's') + ' from the Micron DB');
+ return;
+ }
+
+ const parts = [];
+ if(matched) parts.push(matched + ' found in the Micron DB');
+ if(unmatched) parts.push(unmatched + ' not in the Micron DB (added as manual rows)');
+ if(dupes) parts.push(dupes + ' already on this package (skipped)');
+ let msg = 'Imported ' + (matched + unmatched) + ' asset' + ((matched + unmatched) === 1 ? '' : 's') +
+ (parts.length ? ':\n\n• ' + parts.join('\n• ') : '');
+ if(capped) msg += '\n\nThe list held ' + ids.length.toLocaleString() + ' ids — only the first ' +
+ ASSET_IMPORT_MAX.toLocaleString() + ' were added.';
+ if(offline) msg += '\n\nNote: the Micron DB was not loaded, so nothing could be ' +
+ 'checked against it — every row was added as manual.';
+ wpAlertDialog({title:'Asset import', message:msg});
+}
+
+function removeAsset(i){
+ pkgAssets.splice(i,1);
+ buildAssets();
+ renderAssetResults(); // a removed asset becomes addable again
+}
+
+// ── Catalog lookup ───────────────────────────────────────────────────────────
+function assetSourceNote(msg, tone){
+ const el = document.getElementById('asset-source-note'); if(!el) return;
+ el.textContent = msg || '';
+ el.style.color = tone === 'warn' ? 'var(--red)' : '';
+}
+
+function initAssetPicker(){
+ const box = document.getElementById('asset-search'); if(!box) return;
+ box.addEventListener('input', () => runAssetSearch(box.value));
+ // Re-open on focus only when the list is actually closed. Adding an asset
+ // returns focus to this box, and re-running the search there would rebuild the
+ // list under the cursor and throw away the scroll position mid-multi-add.
+ box.addEventListener('focus', () => {
+ const results = document.getElementById('asset-results');
+ if(results && results.hidden && box.value.trim()) runAssetSearch(box.value);
+ });
+ // Pasting a column of ids straight out of Excel adds them all, rather than
+ // dropping a multi-line blob into a search box that can only match one thing.
+ // Excel gives \r\n between rows and \t between columns — i.e. exactly the CSV
+ // importer's row/cell shape, so it goes through the same matching path.
+ // A single value is left alone: that is an ordinary search, not a bulk add.
+ box.addEventListener('paste', e => {
+ const cb = e.clipboardData || window.clipboardData;
+ const text = cb ? cb.getData('text') : '';
+ if(!text) return;
+ const lines = text.replace(/\r\n?/g, '\n').split('\n').filter(l => l.trim());
+ if(lines.length < 2) return; // one id — paste it and search as normal
+ e.preventDefault();
+ applyImportedAssets(lines.map(l => l.split('\t')));
+ box.value = '';
+ assetResults = [];
+ openAssetResults(false);
+ });
+ box.addEventListener('keydown', e => {
+ if(e.key === 'Escape'){ openAssetResults(false); box.blur(); }
+ // Enter adds the first result that isn't already on the package — the common
+ // case of typing an exact tag and taking it without reaching for the mouse.
+ if(e.key === 'Enter'){
+ e.preventDefault();
+ const ix = assetResults.findIndex(tag => !assetAlreadyAdded(tag));
+ if(ix >= 0) addCatalogAsset(ix);
+ }
+ });
+ // Click-away closes, matching the .pp-menu pickers elsewhere on this form.
+ // Tested against composedPath() rather than e.target: adding an asset can
+ // re-render the row that was clicked, and a detached target reports itself as
+ // outside every container, which would close the list on every add.
+ document.addEventListener('click', e => {
+ const path = typeof e.composedPath === 'function' ? e.composedPath() : null;
+ const inside = path && path.length
+ ? path.some(n => n && n.id === 'asset-pick')
+ : !!(e.target.closest && e.target.closest('#asset-pick'));
+ if(!inside) openAssetResults(false);
+ });
+ // Delegated so result rows never need an inline handler carrying catalog text.
+ const results = document.getElementById('asset-results');
+ if(results) results.addEventListener('click', e => {
+ const row = e.target.closest('[data-asset-ix]'); if(!row) return;
+ addCatalogAsset(parseInt(row.getAttribute('data-asset-ix'), 10));
+ });
+
+ box.disabled = true;
+ box.placeholder = 'Loading asset IDs from the Micron DB…';
+ assetSourceNote('Loading asset IDs from the Micron DB…');
+
+ fetch('/api/assets', { headers:{ 'Accept':'application/json' } })
+ .then(r => r.ok ? r.json()
+ : r.json().catch(() => ({})).then(b => Promise.reject(b.detail || 'The Micron DB could not be read.')))
+ .then(body => {
+ if(!body.configured){
+ assetCatalogState = 'absent';
+ box.placeholder = 'Micron DB not configured — add assets manually below';
+ assetSourceNote('The Micron DB is not connected, so assets are entered by hand. Use “+ Add asset not in the Micron DB”.');
+ return;
+ }
+ assetCatalog = (body.assets || []).map(a => String(a.tag || ''));
+ // Lowercased lookup for the CSV importer: it decides whether an imported id
+ // is a real Micron asset, and maps it back to the DB's own casing so an
+ // id typed as "ahu-2p-014" is stored exactly as Micron spells it.
+ assetCatalogIndex = new Map(assetCatalog.map(t => [t.toLowerCase(), t]));
+ assetCatalogState = 'ready';
+ box.disabled = false;
+ box.placeholder = 'Search asset IDs, or paste a column from Excel…';
+ assetSourceNote(assetCatalog.length.toLocaleString() + ' asset IDs loaded from the Micron DB (read-only).');
+ })
+ .catch(err => {
+ assetCatalogState = 'error';
+ box.placeholder = 'Micron DB unavailable — add assets manually below';
+ assetSourceNote(typeof err === 'string' ? err + ' You can still add assets manually.'
+ : 'The Micron DB could not be reached. You can still add assets manually.', 'warn');
+ });
+}
+
+// Filters the loaded catalog in memory. Exact match, then prefix, then contains —
+// so typing a full asset ID puts that asset first rather than whichever ID
+// happens to sort first.
+function runAssetSearch(q){
+ q = String(q||'').trim().toLowerCase();
+ if(!q || assetCatalogState !== 'ready'){
+ assetResults = []; renderAssetResults(); openAssetResults(false); return;
+ }
+ const exact=[], prefix=[], other=[];
+ for(const tag of assetCatalog){
+ const t = tag.toLowerCase();
+ if(t === q) exact.push(tag);
+ else if(t.startsWith(q)) prefix.push(tag);
+ else if(t.includes(q)) other.push(tag);
+ if(exact.length + prefix.length + other.length >= ASSET_RESULT_MAX) break;
+ }
+ assetResults = exact.concat(prefix, other).slice(0, ASSET_RESULT_MAX);
+ renderAssetResults();
+ openAssetResults(true);
+}
+
+function renderAssetResults(){
+ const box = document.getElementById('asset-results'); if(!box) return;
+ if(!assetResults.length){
+ box.innerHTML = `
No matching asset IDs. Add it manually if it isn’t in the Micron DB yet.
`;
+ return;
+ }
+ box.innerHTML = assetResults.map((tag,ix) => {
+ const on = assetAlreadyAdded(tag);
+ return ``;
+ }).join('');
+}
+
+function openAssetResults(open){
+ const box = document.getElementById('asset-results');
+ const inp = document.getElementById('asset-search');
+ if(box) box.hidden = !open;
+ if(inp) inp.setAttribute('aria-expanded', open ? 'true' : 'false');
+}
+
+function addCatalogAsset(ix){
+ const tag = assetResults[ix]; if(!tag) return;
+ if(assetAlreadyAdded(tag)){ toast('That asset is already on this package'); return; }
+ pkgAssets.push({ tag: tag, desc: '', link: '', source: 'catalog' });
+ buildAssets();
+ // Mark just this row instead of re-rendering the list: the results stay open
+ // for the next pick, the scroll position holds, and the clicked element is
+ // never detached mid-click (see the composedPath note in initAssetPicker).
+ markAssetResultAdded(ix);
+ const box = document.getElementById('asset-search');
+ if(box) box.focus(); // keep typing straight into the next search
+ track('asset_added',{source:'catalog'});
+}
+
+function markAssetResultAdded(ix){
+ const row = document.querySelector('#asset-results [data-asset-ix="' + ix + '"]');
+ if(!row) return;
+ row.classList.add('is-added');
+ row.disabled = true;
+ const label = row.querySelector('.asset-result-add');
+ if(label) label.textContent = 'added';
}
-function addAsset(){ pkgAssets.push({tag:'',desc:'',link:''}); buildAssets(); track('asset_added'); }
-function removeAsset(i){ pkgAssets.splice(i,1); if(!pkgAssets.length)pkgAssets=[{tag:'',desc:'',link:''}]; buildAssets(); }
// ── ATTACHMENTS ──────────────────────────────────────────────────────────────
function buildAttach(){
@@ -1848,10 +2177,12 @@ function renderPackage(pkg){
// because "the toggle does nothing" and "the toggle governs one row" look the
// same from outside.
+ // D11: two columns now. The controls.dev link column died with the link field;
+ // a catalog row's identity is its ID, and the note is the user's own text.
if(pkg.assets&&pkg.assets.length){
- let t=`
Asset Tag / ID
Description
controls.dev Link
`;
- pkg.assets.forEach(a=>t+=`
${cell(a.tag)}
${cell(a.desc)}
${a.link?linkify(a.link):ns()}
`);
- add('assets', 'Assets (controls.dev)', t+`
`);
+ let t=`
Asset ID
Note
`;
+ pkg.assets.forEach(a=>t+=`
${cell(a.tag)}
${cell(a.desc)}
`);
+ add('assets', 'Assets', t+`
`);
}
let scopeHtml;
@@ -2968,7 +3299,7 @@ function loadPackageIntoForm(p){
set('wp_hold', (p.hold&&p.hold.trim())?p.hold:sopValueFor('wp_hold'));
lockQuality('wp_qc'); lockQuality('wp_photo'); lockQuality('wp_hold');
// collections
- pkgAssets=(p.assets&&p.assets.length)?p.assets.map(a=>({...a})):[{tag:'',desc:'',link:''}]; buildAssets();
+ pkgAssets=(p.assets||[]).map(normaliseAsset); buildAssets();
pkgMaterials=(p.materials&&p.materials.length)?p.materials.map(m=>({...m,unit:(m.unit||'').toUpperCase()})):[{qty:'',unit:'',desc:''}]; buildMaterials();
pkgAttach=(p.attachments&&p.attachments.length)?p.attachments.map(a=>({...a})):[{doc:'',rev:'',link:''}]; buildAttach();
pkgWorkSteps=(p.workSteps&&p.workSteps.length)?p.workSteps.slice():(p.work?String(p.work).split('\n').filter(Boolean):['']); if(!pkgWorkSteps.length)pkgWorkSteps=['']; buildWorkSteps();
@@ -3039,7 +3370,7 @@ function newPackage(){
setRadio('status','Draft');
numberDims={}; buildNumberDims();
pkgDisciplines=[]; pkgScope={}; pkgDiscStatus={}; buildDisciplinePicker(); renderScope(); onHoursChange();
- pkgAssets=[{tag:'',desc:'',link:''}]; buildAssets();
+ pkgAssets=[]; buildAssets();
pkgMaterials=[{qty:'',unit:'',desc:''}]; buildMaterials();
pkgAttach=[{doc:'',rev:'',link:''}]; buildAttach();
pkgWorkSteps=['']; buildWorkSteps();
@@ -3868,6 +4199,7 @@ function bootData(){
loadLocations(); // CR-004: the option lists come from the server
wpFilesRefreshUsage(); // CR-007: the storage meter is live before the first upload
mreqLoadMaterials(); // CR-013/D6: the request datalist comes from the project list
+ initAssetPicker(); // D11: the Micron catalog, fetched once per page load
// D7: the read-only courtesy needs the SERVER's answer, not a stale local
// summary - the page's project comes from the URL, which may not be the one
// last stored. The chip and the save guard both read this flag.
diff --git a/html/wp-creation-index.html b/html/wp-creation-index.html
index 791f85e..6483cbf 100644
--- a/html/wp-creation-index.html
+++ b/html/wp-creation-index.html
@@ -303,12 +303,24 @@
-
+
-
Assets
-
Every work package is based on one or more assets managed in controls.dev. Paste the controls.dev link for each asset this package covers. A direct integration to pick assets from a list is planned — for now, link them manually.
-
Asset Tag / ID
Description
controls.dev Link *
-
+
Assetsi
+
Every work package is based on one or more assets from the Micron DB. Search by asset ID, or paste a column of IDs straight from Excel, to add each asset this package covers. The Micron DB is read-only — nothing you do here changes it.
+
+
+
+
+
+
+
Asset ID
Note (what this asset is / why it's in scope)
+
+
+
+
+
diff --git a/html/wp-creation-styles.css b/html/wp-creation-styles.css
index 6993e00..a593b06 100644
--- a/html/wp-creation-styles.css
+++ b/html/wp-creation-styles.css
@@ -908,6 +908,38 @@
border:1px solid var(--border); border-radius:3px; }
.pp-free .field-hint { margin-top:4px; }
+ /* ── asset picker (Micron asset catalog) ────────────────────────────────────
+ A search box over a read-only catalog. Results drop below the input and are
+ added to the table as rows; the catalog itself is never written to. */
+ .asset-pick { position:relative; margin-bottom:10px; }
+ .asset-search { width:100%; padding:8px 10px; font:inherit; font-size:13px;
+ border:1px solid var(--border-strong); border-radius:4px; background:var(--surface);
+ box-sizing:border-box; }
+ .asset-search:focus { outline:2px solid var(--accent); outline-offset:-2px; }
+ .asset-search:disabled { background:var(--surface2); color:var(--text-dim); cursor:not-allowed; }
+ .asset-results { position:absolute; top:calc(100% + 4px); left:0; right:0; z-index:60;
+ max-height:320px; overflow-y:auto; background:var(--surface);
+ border:1px solid var(--border-strong); border-radius:4px; padding:4px 0;
+ box-shadow:0 8px 24px rgba(20,30,50,.18); }
+ .asset-results[hidden] { display:none; }
+ .asset-result { display:flex; align-items:baseline; justify-content:space-between; gap:10px;
+ width:100%; text-align:left; background:none; border:0;
+ font:inherit; font-size:13px; padding:7px 12px; cursor:pointer; color:var(--text); }
+ .asset-result:hover:not(:disabled) { background:var(--surface2); }
+ .asset-result:disabled { cursor:default; opacity:.55; }
+ .asset-result-tag { font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
+ .asset-result-add { color:var(--accent); font-size:11.5px; font-weight:700; white-space:nowrap; }
+ .asset-result.is-added .asset-result-add { color:var(--text-dim); font-weight:400; }
+ .asset-result-note { padding:9px 12px; font-size:12.5px; color:var(--text-muted); }
+ /* Marks rows the catalog vouches for, so a manually typed asset is never
+ mistaken for a looked-up one. */
+ .asset-badge { display:inline-block; margin-left:6px; padding:1px 7px; border-radius:10px;
+ font-size:10px; font-weight:700; letter-spacing:.02em; text-transform:uppercase;
+ color:var(--accent); background:var(--accent-dim); vertical-align:middle;
+ white-space:nowrap; } /* two words now — must not wrap under the asset ID */
+ .asset-tag { font-weight:600; }
+ .asset-empty { color:var(--text-dim); font-size:12.5px; font-style:italic; }
+
/* Critical constraint marker (from the SOP) */
.crit-tag { display:inline-block; margin-left:6px; padding:1px 7px; border-radius:10px; font-size:10px;
font-weight:700; letter-spacing:.02em; color:var(--red); background:var(--red-dim);
diff --git a/server/.env.example b/server/.env.example
index fa15079..6087901 100644
--- a/server/.env.example
+++ b/server/.env.example
@@ -30,3 +30,25 @@ AUTH_SECRET_KEY=CHANGE_ME_run_the_command_above
# notifications are marked "skipped", nothing is sent) until both the toggle is
# on and SMTP is configured.
# SMTP_PASSWORD=your-smtp-app-password
+
+# ── Micron asset catalog (optional) ───────────────────────────────────────────
+# Backs the searchable asset picker in the work package creator. READ-ONLY: the
+# app only ever runs the single SELECT in server/assets_db.py, so give it a
+# db_datareader login and nothing more.
+#
+# Leave this unset and the suite works normally — the picker reports that no
+# catalog is configured and people type asset tags in by hand.
+#
+# URL-encode special characters in the password (@ = %40, # = %23, / = %2F …).
+# MICRON_DB_URL=mssql+pymssql://readonly_user:PASSWORD@sqlhost.example.com:1433/MicronDB
+#
+# To use pyodbc instead of pymssql you must also add pyodbc to requirements.txt
+# and install the Microsoft ODBC driver in the image:
+# MICRON_DB_URL=mssql+pyodbc://readonly_user:PASSWORD@sqlhost.example.com/MicronDB?driver=ODBC+Driver+18+for+SQL+Server
+#
+# Two things to check when the picker says the catalog is unreachable:
+# 1. The table/column names in ASSET_QUERY (server/assets_db.py) match the real
+# Micron schema — that one constant is the whole schema contract.
+# 2. The api container is on the `outbound` network in docker-compose.yml. The
+# `internal` network has no default gateway, which blocks the VPN as well as
+# the internet.
diff --git a/server/app.py b/server/app.py
index 34cc6df..b8b6fe7 100644
--- a/server/app.py
+++ b/server/app.py
@@ -26,7 +26,7 @@ from sqlalchemy import select, delete, func
from sqlalchemy.orm import Session
from .db import Base, engine, get_db
-from . import models, auth, notify
+from . import models, auth, notify, assets_db
# Schema management:
# • Local dev (SQLite) auto-creates tables for a zero-config run.
@@ -3392,6 +3392,29 @@ def list_comments(
return [c.to_dict() for c in rows]
+# ── Micron asset catalog (read-only lookup) ────────────────────────────────────
+# Backs the asset picker in the work package creator. This is a *lookup*, not a
+# resource this app owns: there is no POST, and nothing here ever writes to the
+# Micron database. It is deliberately not project-scoped by the app's own access
+# rules — the catalog is reference data, and any signed-in user who can build a
+# work package needs to be able to name the assets it covers. Authentication is
+# still required (the auth_gate middleware covers every /api/ path).
+@app.get("/api/assets")
+def list_assets(_user: models.User = Depends(auth.get_current_user)):
+ """The whole catalog, fetched once when the creator loads. Searching happens
+ in the browser — there is no per-keystroke endpoint by design."""
+ if not assets_db.configured():
+ # Not an error — the suite is designed to run without Micron wired up.
+ # The picker reads this and switches to manual entry.
+ return {"configured": False, "assets": [], "detail": assets_db.status()["detail"]}
+ try:
+ return {"configured": True, "assets": assets_db.load()}
+ except assets_db.AssetSourceError as exc:
+ # 503, not 500: the suite is healthy, its upstream lookup is not. The
+ # picker degrades to manual entry rather than blocking the package.
+ raise HTTPException(status_code=503, detail=str(exc))
+
+
# ── Local dev convenience: serve the static site from this app ──────────────────
# In production NGINX serves html/ and only proxies /api/ here, so this app never
# receives "/" requests, and the api Docker image doesn't even include html/ — so
diff --git a/server/assets_db.py b/server/assets_db.py
new file mode 100644
index 0000000..ca4266c
--- /dev/null
+++ b/server/assets_db.py
@@ -0,0 +1,199 @@
+"""Read-only reader for the Micron asset catalog.
+
+The work package creator used to ask people to paste a controls.dev link for
+every asset. Assets actually live in the Micron database — a SQL Server instance
+that is NOT part of this repo and whose schema is not managed here. This module
+gives the API a *read-only* window onto it so the creator can offer a searchable
+picker instead of free-text links.
+
+How it works: the whole catalog is fetched in one query and handed to the browser
+when the creator loads. Searching then happens in the browser with no round trip
+at all. The catalog is a list of asset IDs — about 9k of them today and not
+expected past 100k — so it is small enough to send whole, and it is slow-moving
+reference data, so there is nothing to gain from querying it per keystroke and a
+lot of latency to lose. A short server-side cache keeps a room full of people
+opening the page from turning into a query each.
+
+Other deliberate constraints:
+
+ * **Read-only, always.** The only statement in this file is the SELECT below.
+ Point it at a login with `db_datareader` and nothing else.
+ * **No prime_db dependency.** A plain SQLAlchemy connection built from a
+ connection string, kept separate from the app's own engine in `db.py`, so a
+ Micron outage can never affect the suite's own database.
+
+Unconfigured is a first-class state: with no `MICRON_DB_URL` set, `configured()`
+returns False, the API says so, and the UI falls back to manual entry. The suite
+boots and runs fine without the Micron database being reachable.
+"""
+import os
+import time
+import logging
+import threading
+
+from sqlalchemy import create_engine, text
+from sqlalchemy.exc import SQLAlchemyError
+
+log = logging.getLogger(__name__)
+
+try:
+ from dotenv import load_dotenv
+ load_dotenv()
+except Exception:
+ pass
+
+
+# ── The query ─────────────────────────────────────────────────────────────────
+# The only place the Micron schema appears; everything else here is plumbing.
+# Returns one row per asset, aliased `tag`. No row cap: the catalog is small
+# enough to hand over whole, and a partial list would silently hide assets.
+#
+# Add a WHERE clause here if some rows should never be offered at all
+# (decommissioned assets, other sites, …). Filtering at the source keeps the
+# payload small, which matters more than anything else here.
+ASSET_QUERY = """
+ SELECT a.AssetID AS tag
+ FROM Asset.Asset AS a
+ ORDER BY a.AssetID
+"""
+
+# How long a fetched catalog is reused before the next page load re-queries.
+CACHE_SECONDS = int(os.getenv("MICRON_ASSETS_CACHE_SECONDS", "300")) # 5 min
+CONNECT_TIMEOUT = 8
+
+
+class AssetSourceError(RuntimeError):
+ """The catalog is configured but could not be read."""
+
+
+# ── Engine (lazy, process-wide) ───────────────────────────────────────────────
+# A full SQLAlchemy URL, e.g.
+# mssql+pymssql://user:pass@host:1433/MicronDB
+# mssql+pyodbc://user:pass@host/MicronDB?driver=ODBC+Driver+18+for+SQL+Server
+# URL-encode any special characters in the password.
+_engine = None
+_engine_lock = threading.Lock()
+
+
+def _db_url() -> str:
+ return os.getenv("MICRON_DB_URL", "").strip()
+
+
+def configured() -> bool:
+ return bool(_db_url())
+
+
+def _validate_url(url: str) -> None:
+ """Catch the one URL mistake that produces a baffling error message.
+
+ A password containing an unencoded '@' makes the URL ambiguous: the parser
+ splits on the first '@', so part of the password ends up parsed as the host.
+ The driver then reports a connection failure against a nonsense hostname that
+ happens to contain a fragment of the password — confusing to read and unsafe
+ to display. Detect it here and say plainly what is wrong.
+
+ Nothing from the URL is included in the message; it never leaves this process.
+ """
+ authority = url.split("://", 1)[-1].split("/", 1)[0]
+ if authority.count("@") > 1:
+ raise AssetSourceError(
+ "MICRON_DB_URL is ambiguous: the username or password contains an "
+ "unencoded '@'. Percent-encode the special characters — @ = %40, "
+ ": = %3A, / = %2F, # = %23, ? = %3F, % = %25."
+ )
+
+
+def _connect_args(url: str) -> dict:
+ """Per-driver connect timeouts, so an unreachable Micron host fails fast
+ instead of tying up a worker until the OS gives up."""
+ if url.startswith("mssql+pymssql"):
+ return {"login_timeout": CONNECT_TIMEOUT, "timeout": CONNECT_TIMEOUT}
+ if url.startswith("mssql+pyodbc"):
+ return {"timeout": CONNECT_TIMEOUT}
+ return {}
+
+
+def _get_engine():
+ global _engine
+ if _engine is not None:
+ return _engine
+ url = _db_url()
+ if not url:
+ raise AssetSourceError("The Micron DB is not configured.")
+ _validate_url(url)
+ with _engine_lock:
+ if _engine is None:
+ try:
+ _engine = create_engine(
+ url,
+ connect_args=_connect_args(url),
+ pool_pre_ping=True, # a recycled dead connection retries instead of erroring
+ pool_recycle=1800,
+ pool_size=1, # one catalog query now and then, not a workload
+ max_overflow=1,
+ future=True,
+ )
+ except Exception as exc: # bad URL, missing driver package, …
+ # See the note on load() — the exception text can echo the
+ # connection string, so it is logged and not propagated.
+ log.error("Micron asset catalog: could not open the connection: %s", exc)
+ raise AssetSourceError(
+ "Could not open a connection to the Micron DB. "
+ "Check MICRON_DB_URL and the API log for the driver error."
+ ) from exc
+ return _engine
+
+
+# ── Cache ─────────────────────────────────────────────────────────────────────
+# Every page load asks for the whole catalog, so without this a shift change
+# would be one full-table query per person. Held per worker process.
+_cache: list[dict] | None = None
+_cached_at = 0.0
+_cache_lock = threading.Lock()
+
+
+def load(force: bool = False) -> list[dict]:
+ """Return the whole catalog as [{'tag': …}, …]. Never writes."""
+ global _cache, _cached_at
+ with _cache_lock:
+ if _cache is not None and not force and (time.monotonic() - _cached_at) < CACHE_SECONDS:
+ return _cache
+
+ engine = _get_engine()
+ try:
+ with engine.connect() as conn:
+ result = conn.execute(text(ASSET_QUERY)).mappings().all()
+ except SQLAlchemyError as exc:
+ # The driver's message is NOT propagated. AssetSourceError text reaches the
+ # browser, and connection errors quote the host, the login, and — when the
+ # URL is malformed — fragments of the password. Operators get the detail
+ # from the API log, where it belongs; users get a message they can act on.
+ log.error("Micron asset catalog query failed: %s", exc)
+ raise AssetSourceError(
+ "The Micron DB could not be read. Check that the host is "
+ "reachable, that the login has SELECT on the asset table, and that "
+ "ASSET_QUERY matches the real schema — the API log has the driver error."
+ ) from exc
+
+ # Drop rows with no identifier — an asset with no tag is not selectable and
+ # would render as a blank line in the picker.
+ rows = [{"tag": str(r["tag"])} for r in result if r.get("tag") not in (None, "")]
+ with _cache_lock:
+ _cache, _cached_at = rows, time.monotonic()
+ return rows
+
+
+def status() -> dict:
+ """Describe the source for the UI, so it can explain itself rather than just
+ showing an empty dropdown."""
+ if not configured():
+ return {
+ "configured": False, "ok": False, "count": 0,
+ "detail": "The Micron DB is not configured — enter assets manually.",
+ }
+ try:
+ rows = load()
+ except AssetSourceError as exc:
+ return {"configured": True, "ok": False, "count": 0, "detail": str(exc)}
+ return {"configured": True, "ok": True, "count": len(rows),
+ "detail": f"{len(rows):,} asset IDs from the Micron DB."}
diff --git a/server/requirements.txt b/server/requirements.txt
index 20f9b31..28ac028 100644
--- a/server/requirements.txt
+++ b/server/requirements.txt
@@ -9,6 +9,12 @@ gunicorn==26.0.0
sqlalchemy==2.0.51
alembic==1.18.5 # database migrations
psycopg[binary]==3.3.4
+pymssql==2.3.13 # read-only lookups against the Micron asset DB (SQL Server).
+ # Chosen over pyodbc because it ships self-contained wheels —
+ # pyodbc would also need msodbcsql18 + unixODBC installed in
+ # the image. To use pyodbc instead, add it here, install the
+ # Microsoft ODBC driver in the Dockerfile, and switch
+ # MICRON_DB_URL to mssql+pyodbc://…?driver=ODBC+Driver+18+for+SQL+Server
pydantic==2.13.4
python-dotenv==1.2.2
bcrypt==5.0.0 # password hashing
diff --git a/tests/assets_check.py b/tests/assets_check.py
new file mode 100644
index 0000000..2058dd2
--- /dev/null
+++ b/tests/assets_check.py
@@ -0,0 +1,232 @@
+#!/usr/bin/env python3
+"""Is the Micron asset picker read-only, and does it degrade to manual entry? — D11.
+
+Cody Schaefer's `origin/Micron-Assets` branch, merged Aug 20 2026 and adapted to
+the R2 creator (decisions-2026-08-20.md). The properties this pins:
+
+ * **Read-only, structurally.** assets_db.py holds one SELECT and nothing else;
+ /api/assets has no writing verb. Picking an asset can never change Micron.
+ * **Unconfigured is a first-class state.** No MICRON_DB_URL -> configured:false,
+ the picker says so, and manual entry carries the package. The suite must run
+ without Micron existing at all — every other probe implicitly relies on that.
+ * **Broken is not a leak.** A configured-but-unusable URL 503s with a message
+ that never echoes the connection string (whose parse errors can quote
+ password fragments).
+ * **The client honours the catalog.** Search ranks exact matches first, a
+ picked row is locked to the DB's own casing and badged, imports canonicalise
+ casing / fall back to manual / skip duplicates, and the import summary goes
+ through the T7.9 dialog kit, not a native alert().
+
+Boots its own throwaway SQLite + uvicorn + headless browser; run it alone, not
+back to back with other probes. Exit 0 all passed, 1 a failure, 2 could not run.
+"""
+import io
+import json
+import os
+import re
+import sys
+import tempfile
+import time
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+
+import cdp # noqa: E402
+from browser_check import seed, start_server, chk, _PASS, _FAIL # noqa: E402
+from sections_check import set_sop # noqa: E402
+from stepper_check import dismiss_dialogs # noqa: E402
+from qa_gate_check import api # noqa: E402
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+HTML = os.path.join(ROOT, "html")
+SERVER = os.path.join(ROOT, "server")
+
+
+def ascii_(v, n=240):
+ return re.sub(r"\s+", " ", str(v)).encode("ascii", "replace").decode()[:n]
+
+
+def wait_creator(page, tries=40):
+ for _ in range(tries):
+ if page.eval("!!window.wpCreatorReady"):
+ return True
+ time.sleep(0.3)
+ return False
+
+
+def strip_py(src):
+ src = re.sub(r'""".*?"""', "", src, flags=re.S)
+ return "\n".join(re.sub(r"#.*$", "", ln) for ln in src.split("\n"))
+
+
+def main():
+ exe = cdp.find_browser()
+ if not exe:
+ print("no headless-capable browser found; set WP_BROWSER.")
+ return 2
+
+ # ── 1. read-only, structurally ─────────────────────────────────────────────
+ print("\n1. read-only, structurally")
+ src = strip_py(io.open(os.path.join(SERVER, "assets_db.py"), encoding="utf-8").read())
+ verbs = re.findall(r"\b(INSERT|UPDATE|DELETE|MERGE|EXEC|TRUNCATE|DROP|ALTER)\b",
+ src, re.I)
+ chk("assets_db.py contains no writing SQL verb", not verbs, verbs)
+ chk("...and exactly one SELECT (the whole schema contract)",
+ len(re.findall(r"\bSELECT\b", src, re.I)) == 1)
+ app_src = io.open(os.path.join(SERVER, "app.py"), encoding="utf-8").read()
+ chk("/api/assets is a GET and only a GET",
+ len(re.findall(r'@app\.get\("/api/assets"\)', app_src)) == 1
+ and not re.findall(r'@app\.(post|put|patch|delete)\("/api/assets', app_src))
+ outside = [f for f in ("models.py", "auth.py", "notify.py")
+ if "MICRON_DB_URL" in io.open(os.path.join(SERVER, f), encoding="utf-8").read()]
+ chk("the connection string is env-only plumbing, not model or auth state",
+ not outside, outside)
+
+ tmpdir = tempfile.mkdtemp(prefix="wpsuite-assets-")
+ db_path = os.path.join(tmpdir, "check.db")
+ server = None
+ browser = None
+ try:
+ tok = seed(db_path)
+ set_sop(db_path, {})
+ port = cdp.free_port()
+ base = "http://127.0.0.1:%d" % port
+ server = start_server(port, db_path)
+
+ # ── 2. the API's unconfigured state ────────────────────────────────────
+ print("\n2. unconfigured is a first-class state")
+ st, _ = api(base, "/api/assets", "not-a-session")
+ chk("anonymous gets 401, same as every other /api/ path", st == 401, st)
+ st, body = api(base, "/api/assets", tok["root"])
+ chk("signed in, no MICRON_DB_URL: 200 with configured:false",
+ st == 200 and body and body.get("configured") is False
+ and body.get("assets") == [], ascii_(body))
+ chk("...and the detail tells the user what to do instead",
+ "manual" in (body.get("detail") or "").lower(), ascii_(body))
+
+ # ── 3. the picker, catalog absent ──────────────────────────────────────
+ print("\n3. the picker degrades to manual entry")
+ browser = cdp.Browser(exe)
+ page = browser.page()
+ page.clear_cookies()
+ page.set_cookie("wp_session", tok["root"])
+ page.viewport(1440, 900)
+ page.goto(base + "/wp-creation-index.html?project=projA")
+ dismiss_dialogs(page)
+ chk("the creator boots", wait_creator(page))
+ time.sleep(1.2)
+ chk("the search box is disabled and says the catalog is not configured",
+ page.eval("(() => { const b=document.getElementById('asset-search');"
+ " return b.disabled && /not configured/i.test(b.placeholder); })()"))
+ chk("the source note announces it (role=status, non-empty)",
+ page.eval("(() => { const n=document.getElementById('asset-source-note');"
+ " return n.getAttribute('role')==='status' && n.textContent.length>0; })()"))
+ chk("no assets yet: the empty state renders instead of a blank table",
+ page.eval("/No assets yet/.test(document.getElementById('asset-body').textContent)"))
+ page.eval("addManualAsset()")
+ chk("+ Add asset adds an editable manual row",
+ page.eval("pkgAssets.length") == 1
+ and page.eval("pkgAssets[0].source") == "manual"
+ and page.eval("!!document.querySelector('#asset-body input')"))
+ page.eval("document.querySelector('#asset-body input').value='HAND-01';"
+ "document.querySelector('#asset-body input')"
+ ".dispatchEvent(new Event('input',{bubbles:true}))")
+ chk("...and typing lands in the model", page.eval("pkgAssets[0].tag") == "HAND-01")
+
+ # ── 4. the client honours the catalog (injected; no SQL Server here) ──
+ print("\n4. search, pick, import — against an injected catalog")
+ page.eval("pkgAssets=[]; buildAssets();"
+ "assetCatalog=['AHU-2P-014','AHU-2P-015','PUMP-01','XPUMP-PUMP-011','CT-100'];"
+ "assetCatalogIndex=new Map(assetCatalog.map(t=>[t.toLowerCase(),t]));"
+ "assetCatalogState='ready';"
+ "(() => { const b=document.getElementById('asset-search');"
+ " b.disabled=false; b.placeholder='Search asset IDs'; })()")
+ page.eval("runAssetSearch('pump-01')")
+ chk("an exact match outranks a longer contains-match",
+ page.eval("JSON.stringify(assetResults)") == '["PUMP-01","XPUMP-PUMP-011"]',
+ ascii_(page.eval("JSON.stringify(assetResults)")))
+ chk("results render as real