diff --git a/README.md b/README.md index d338cf6..0b4308f 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,6 @@ # deepevents.ai deepevents.ai main codebase + +## Enterprise tooling additions + +- `enterprise-lab-inventory-sync`: institutional lab-inventory and instrument-readiness monitor for ELN/LIMS integration, calibration drift, reagent expiry, webhook events, and export gating. diff --git a/enterprise-lab-inventory-sync/README.md b/enterprise-lab-inventory-sync/README.md new file mode 100644 index 0000000..3ff231a --- /dev/null +++ b/enterprise-lab-inventory-sync/README.md @@ -0,0 +1,24 @@ +# Enterprise Lab Inventory Sync + +Enterprise research offices need to know whether experiments, data exports, and publication packages depend on lab assets that are stale, offline, expired, or out of sync with institutional systems. This module adds a deterministic monitor for lab-inventory and instrument-readiness governance. + +The monitor is intentionally self-contained and credential-free. It consumes synthetic inventory, ELN/LIMS integration, instrument, reagent, and project dependency records, then emits: + +- dashboard metrics for institutional admins and lab operations teams +- project export gates when blocked instruments or expired reagents affect evidence packages +- webhook events for calibration drift, maintenance risk, integration lag, reservation conflicts, and inventory expiry +- prioritized admin actions with deterministic evidence digests for audit trails + +## Run + +```bash +npm run check +npm test +npm run demo +``` + +The demo reads `data/sample-lab-input.json` and prints the current governance status. The visual preview is available at `docs/demo.svg`; `docs/demo.gif` is a short generated walkthrough artifact for the bounty review. + +## Fit For Issue #19 + +This targets the "API & Webhooks" and "Admin Dashboards" enterprise-tooling requirements for integrations with electronic lab notebooks and lab inventory systems. It is distinct from prior slices around broad dashboards, export packages, trust centers, webhook replay, identity drift, retention/legal hold, data residency, grant compliance, and SLA monitoring. diff --git a/enterprise-lab-inventory-sync/data/sample-lab-input.json b/enterprise-lab-inventory-sync/data/sample-lab-input.json new file mode 100644 index 0000000..542e48f --- /dev/null +++ b/enterprise-lab-inventory-sync/data/sample-lab-input.json @@ -0,0 +1,146 @@ +{ + "generatedAt": "2026-05-16T14:00:00Z", + "institution": "Northbridge Translational Research", + "labs": [ + { + "id": "lab-genomics", + "name": "Genomics Core", + "department": "Molecular Biology" + }, + { + "id": "lab-imaging", + "name": "Imaging Core", + "department": "Bioengineering" + } + ], + "integrations": [ + { + "system": "Benchling ELN", + "kind": "eln", + "status": "healthy", + "lastSyncAt": "2026-05-16T13:45:00Z", + "queueDepth": 2 + }, + { + "system": "Quartzy Inventory", + "kind": "inventory", + "status": "degraded", + "lastSyncAt": "2026-05-15T10:30:00Z", + "queueDepth": 97 + }, + { + "system": "Agilent OpenLab", + "kind": "instrument", + "status": "failed", + "lastSyncAt": "2026-05-14T08:00:00Z", + "queueDepth": 211 + } + ], + "instruments": [ + { + "id": "inst-hplc-01", + "name": "HPLC 01", + "labId": "lab-genomics", + "status": "online", + "calibrationDue": "2026-05-10", + "maintenanceDue": "2026-06-02", + "linkedEln": true, + "reservations": [ + { + "projectId": "proj-metabolomics", + "startsAt": "2026-05-17T09:00:00Z", + "endsAt": "2026-05-17T11:00:00Z" + } + ] + }, + { + "id": "inst-confocal-02", + "name": "Confocal Microscope 02", + "labId": "lab-imaging", + "status": "offline", + "calibrationDue": "2026-06-30", + "maintenanceDue": "2026-05-18", + "linkedEln": false, + "reservations": [ + { + "projectId": "proj-organoid-map", + "startsAt": "2026-05-17T10:00:00Z", + "endsAt": "2026-05-17T12:00:00Z" + }, + { + "projectId": "proj-neurochip", + "startsAt": "2026-05-17T11:30:00Z", + "endsAt": "2026-05-17T13:00:00Z" + } + ] + }, + { + "id": "inst-sequencer-03", + "name": "Bench Sequencer 03", + "labId": "lab-genomics", + "status": "online", + "calibrationDue": "2026-06-08", + "maintenanceDue": "2026-08-01", + "linkedEln": true, + "reservations": [] + } + ], + "reagents": [ + { + "id": "rgnt-antibody-a", + "name": "Anti-CD31 antibody", + "lot": "A-114", + "labId": "lab-imaging", + "expiresAt": "2026-05-12", + "quantity": 3, + "unit": "vial", + "projectIds": ["proj-organoid-map"] + }, + { + "id": "rgnt-buffer-b", + "name": "LC-MS Buffer B", + "lot": "B-331", + "labId": "lab-genomics", + "expiresAt": "2026-05-23", + "quantity": 12, + "unit": "bottle", + "projectIds": ["proj-metabolomics"] + }, + { + "id": "rgnt-enzyme-c", + "name": "Library prep enzyme mix", + "lot": "C-515", + "labId": "lab-genomics", + "expiresAt": "2026-07-01", + "quantity": 1, + "unit": "kit", + "projectIds": ["proj-neurochip"] + } + ], + "projects": [ + { + "id": "proj-metabolomics", + "title": "Metabolomics biomarker screen", + "owner": "Dr. Imani Patel", + "exportDue": "2026-05-25", + "requiredInstrumentIds": ["inst-hplc-01"], + "requiredReagentIds": ["rgnt-buffer-b"] + }, + { + "id": "proj-organoid-map", + "title": "Organoid vascular imaging map", + "owner": "Dr. Theo Bennett", + "exportDue": "2026-05-19", + "requiredInstrumentIds": ["inst-confocal-02"], + "requiredReagentIds": ["rgnt-antibody-a"] + }, + { + "id": "proj-neurochip", + "title": "Neurochip electrophysiology pilot", + "owner": "Dr. Hana Okafor", + "exportDue": "2026-06-04", + "requiredInstrumentIds": ["inst-confocal-02", "inst-sequencer-03"], + "requiredReagentIds": ["rgnt-enzyme-c"] + } + ] +} diff --git a/enterprise-lab-inventory-sync/docs/demo.gif b/enterprise-lab-inventory-sync/docs/demo.gif new file mode 100644 index 0000000..f352dae Binary files /dev/null and b/enterprise-lab-inventory-sync/docs/demo.gif differ diff --git a/enterprise-lab-inventory-sync/docs/demo.mp4 b/enterprise-lab-inventory-sync/docs/demo.mp4 new file mode 100644 index 0000000..b0a00ec Binary files /dev/null and b/enterprise-lab-inventory-sync/docs/demo.mp4 differ diff --git a/enterprise-lab-inventory-sync/docs/demo.svg b/enterprise-lab-inventory-sync/docs/demo.svg new file mode 100644 index 0000000..4ca205d --- /dev/null +++ b/enterprise-lab-inventory-sync/docs/demo.svg @@ -0,0 +1,34 @@ + + Enterprise lab inventory sync demo + Dashboard preview showing lab inventory findings, export gates, and webhook events. + + + Lab Inventory Sync + Northbridge Translational Research • evidence digest 0f66ae20eddd2e69 + + + 4 + critical findings + + 3 + exports blocked + + 12 + webhook events + + 3 + integrations + + + Priority queue + lock_instrument_until_calibrated • inst-hplc-01 + quarantine_lot_and_block_exports • rgnt-antibody-a + reroute_or_pause_reservations • inst-confocal-02 + review_sync_backlog • Quartzy Inventory + + Export gates + blocked: proj-organoid-map + blocked: proj-neurochip + blocked: proj-metabolomics + + diff --git a/enterprise-lab-inventory-sync/docs/requirement-map.md b/enterprise-lab-inventory-sync/docs/requirement-map.md new file mode 100644 index 0000000..b24a4fe --- /dev/null +++ b/enterprise-lab-inventory-sync/docs/requirement-map.md @@ -0,0 +1,15 @@ +# Requirement Map + +| Issue #19 capability | Lab inventory sync coverage | +| --- | --- | +| Admin dashboards | Produces lab, integration, finding, export-gate, and action metrics for research-office dashboards. | +| Contributor and usage analytics | Surfaces project owners and affected project IDs for blocked exports and reservation conflicts. | +| Compliance tracking | Blocks export evidence when calibration, maintenance, ELN linkage, or reagent expiry undermines reproducibility. | +| API and webhooks | Emits deterministic `lab_inventory.*` webhook event envelopes with evidence digests. | +| Electronic lab notebooks and lab inventory integrations | Models ELN, inventory, and instrument sync health, queue depth, and stale integration state. | +| Export pipelines | Produces per-project export gates: `ready`, `review_before_export`, or `block_export`. | +| Version and evidence history | Uses stable SHA-256 digests for findings, gates, and full reports. | + +## Distinctness Check + +This slice is about operational readiness of physical lab assets and inventory-system sync before research outputs are exported. It does not duplicate current #19 submissions for enterprise dashboards, export pipeline packaging, trust centers, compliance evidence packets, audit signal routing, webhook replay, identity provisioning drift, retention/legal hold, data residency, grant portfolio compliance, or SLA/uptime monitoring. diff --git a/enterprise-lab-inventory-sync/package.json b/enterprise-lab-inventory-sync/package.json new file mode 100644 index 0000000..0453a23 --- /dev/null +++ b/enterprise-lab-inventory-sync/package.json @@ -0,0 +1,11 @@ +{ + "name": "enterprise-lab-inventory-sync", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "check": "node --check src/lab-inventory-sync.js && node --check scripts/demo.js && node --check test/lab-inventory-sync.test.js", + "test": "node --test test/lab-inventory-sync.test.js", + "demo": "node scripts/demo.js" + } +} diff --git a/enterprise-lab-inventory-sync/scripts/demo.js b/enterprise-lab-inventory-sync/scripts/demo.js new file mode 100644 index 0000000..a7d5215 --- /dev/null +++ b/enterprise-lab-inventory-sync/scripts/demo.js @@ -0,0 +1,17 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { analyzeLabInventorySync } from "../src/lab-inventory-sync.js"; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const input = JSON.parse(readFileSync(join(root, "data", "sample-lab-input.json"), "utf8")); +const report = analyzeLabInventorySync(input); + +console.log(`${report.institution} lab inventory sync`); +console.log(`Evidence digest: ${report.evidenceDigest}`); +console.log(`Findings: ${report.summary.findings} (${report.summary.criticalFindings} critical, ${report.summary.highFindings} high)`); +console.log(`Export gates: ${report.summary.blockedExports} blocked, ${report.summary.reviewExports} review`); +console.log("Top admin actions:"); +for (const action of report.adminActions.slice(0, 5)) { + console.log(`- [${action.severity}] ${action.action}: ${action.subject}`); +} diff --git a/enterprise-lab-inventory-sync/src/lab-inventory-sync.js b/enterprise-lab-inventory-sync/src/lab-inventory-sync.js new file mode 100644 index 0000000..c097576 --- /dev/null +++ b/enterprise-lab-inventory-sync/src/lab-inventory-sync.js @@ -0,0 +1,310 @@ +import { createHash } from "node:crypto"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +function asDate(value) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + throw new Error(`Invalid date: ${value}`); + } + return date; +} + +function daysBetween(later, earlier) { + return Math.ceil((asDate(later).getTime() - asDate(earlier).getTime()) / DAY_MS); +} + +function stableHash(value) { + return createHash("sha256").update(JSON.stringify(value)).digest("hex").slice(0, 16); +} + +function severityRank(severity) { + return { critical: 0, high: 1, medium: 2, low: 3 }[severity] ?? 4; +} + +function sortFindings(findings) { + return findings.sort((a, b) => { + const bySeverity = severityRank(a.severity) - severityRank(b.severity); + if (bySeverity !== 0) return bySeverity; + return a.id.localeCompare(b.id); + }); +} + +function reservationConflicts(instrument) { + const reservations = [...(instrument.reservations ?? [])].sort((a, b) => + asDate(a.startsAt).getTime() - asDate(b.startsAt).getTime() + ); + const conflicts = []; + + for (let i = 1; i < reservations.length; i += 1) { + const previous = reservations[i - 1]; + const current = reservations[i]; + if (asDate(current.startsAt) < asDate(previous.endsAt)) { + conflicts.push({ + instrumentId: instrument.id, + projectIds: [previous.projectId, current.projectId].sort(), + window: `${current.startsAt} overlaps ${previous.endsAt}` + }); + } + } + + return conflicts; +} + +function buildFinding(kind, severity, subject, message, details = {}) { + return { + id: stableHash({ kind, subject, message, details }), + kind, + severity, + subject, + message, + details + }; +} + +function projectMap(projects) { + return new Map((projects ?? []).map((project) => [project.id, project])); +} + +function affectedProjectsForAsset(assetId, projects, field) { + return (projects ?? []) + .filter((project) => (project[field] ?? []).includes(assetId)) + .map((project) => project.id) + .sort(); +} + +function findIntegrationFindings(input, now) { + return (input.integrations ?? []).flatMap((integration) => { + const lagHours = Math.round((asDate(now).getTime() - asDate(integration.lastSyncAt).getTime()) / (60 * 60 * 1000)); + const findings = []; + + if (integration.status === "failed") { + findings.push(buildFinding( + "integration_failed", + "critical", + integration.system, + `${integration.system} has a failed sync state`, + { kind: integration.kind, lagHours, queueDepth: integration.queueDepth } + )); + } else if (integration.status === "degraded" || lagHours >= 24 || integration.queueDepth >= 50) { + findings.push(buildFinding( + "integration_degraded", + "high", + integration.system, + `${integration.system} needs operations review before export evidence is trusted`, + { kind: integration.kind, lagHours, queueDepth: integration.queueDepth } + )); + } + + return findings; + }); +} + +function findInstrumentFindings(input, now) { + return (input.instruments ?? []).flatMap((instrument) => { + const findings = []; + const calibrationDays = daysBetween(instrument.calibrationDue, now); + const maintenanceDays = daysBetween(instrument.maintenanceDue, now); + const affectedProjects = affectedProjectsForAsset(instrument.id, input.projects, "requiredInstrumentIds"); + + if (instrument.status !== "online") { + findings.push(buildFinding( + "instrument_offline", + "critical", + instrument.id, + `${instrument.name} is ${instrument.status}`, + { labId: instrument.labId, affectedProjects } + )); + } + + if (calibrationDays < 0) { + findings.push(buildFinding( + "calibration_overdue", + "critical", + instrument.id, + `${instrument.name} calibration is overdue`, + { overdueDays: Math.abs(calibrationDays), affectedProjects } + )); + } else if (calibrationDays <= 14) { + findings.push(buildFinding( + "calibration_due_soon", + "medium", + instrument.id, + `${instrument.name} calibration is due within ${calibrationDays} days`, + { dueInDays: calibrationDays, affectedProjects } + )); + } + + if (maintenanceDays <= 7) { + findings.push(buildFinding( + "maintenance_due", + maintenanceDays < 0 ? "high" : "medium", + instrument.id, + `${instrument.name} maintenance requires scheduling`, + { dueInDays: maintenanceDays, affectedProjects } + )); + } + + if (!instrument.linkedEln) { + findings.push(buildFinding( + "eln_link_missing", + "medium", + instrument.id, + `${instrument.name} is not linked to the ELN evidence trail`, + { affectedProjects } + )); + } + + for (const conflict of reservationConflicts(instrument)) { + findings.push(buildFinding( + "reservation_conflict", + "high", + instrument.id, + `${instrument.name} has overlapping reservations`, + conflict + )); + } + + return findings; + }); +} + +function findReagentFindings(input, now) { + return (input.reagents ?? []).flatMap((reagent) => { + const daysToExpiry = daysBetween(reagent.expiresAt, now); + const affectedProjects = affectedProjectsForAsset(reagent.id, input.projects, "requiredReagentIds"); + + if (daysToExpiry < 0) { + return [buildFinding( + "reagent_expired", + "critical", + reagent.id, + `${reagent.name} lot ${reagent.lot} is expired`, + { expiredDays: Math.abs(daysToExpiry), quantity: reagent.quantity, unit: reagent.unit, affectedProjects } + )]; + } + + if (daysToExpiry <= 14) { + return [buildFinding( + "reagent_expiring", + "medium", + reagent.id, + `${reagent.name} lot ${reagent.lot} expires soon`, + { expiresInDays: daysToExpiry, quantity: reagent.quantity, unit: reagent.unit, affectedProjects } + )]; + } + + return []; + }); +} + +function projectExportGates(input, findings) { + const projects = projectMap(input.projects); + const projectFindings = new Map(); + + for (const finding of findings) { + const affected = finding.details.affectedProjects ?? finding.details.projectIds ?? []; + for (const projectId of affected) { + if (!projectFindings.has(projectId)) projectFindings.set(projectId, []); + projectFindings.get(projectId).push(finding); + } + } + + return [...projects.values()].map((project) => { + const findingsForProject = projectFindings.get(project.id) ?? []; + const hasCritical = findingsForProject.some((finding) => finding.severity === "critical"); + const hasHigh = findingsForProject.some((finding) => finding.severity === "high"); + const decision = hasCritical ? "block_export" : hasHigh ? "review_before_export" : "ready"; + + return { + projectId: project.id, + title: project.title, + owner: project.owner, + exportDue: project.exportDue, + decision, + findingIds: findingsForProject.map((finding) => finding.id).sort() + }; + }); +} + +function actionForFinding(finding) { + const actionByKind = { + integration_failed: "page_integration_owner", + integration_degraded: "review_sync_backlog", + instrument_offline: "reroute_or_pause_reservations", + calibration_overdue: "lock_instrument_until_calibrated", + calibration_due_soon: "schedule_calibration_window", + maintenance_due: "schedule_maintenance_window", + eln_link_missing: "link_instrument_to_eln", + reservation_conflict: "resolve_reservation_conflict", + reagent_expired: "quarantine_lot_and_block_exports", + reagent_expiring: "reorder_or_validate_lot" + }; + + return { + action: actionByKind[finding.kind] ?? "review", + severity: finding.severity, + subject: finding.subject, + findingId: finding.id + }; +} + +function webhookEvents(input, findings, exportGates) { + return [ + ...findings.map((finding) => ({ + type: `lab_inventory.${finding.kind}`, + subject: finding.subject, + severity: finding.severity, + findingId: finding.id, + digest: stableHash({ institution: input.institution, finding }) + })), + ...exportGates + .filter((gate) => gate.decision !== "ready") + .map((gate) => ({ + type: "lab_inventory.export_gate", + subject: gate.projectId, + severity: gate.decision === "block_export" ? "critical" : "high", + findingIds: gate.findingIds, + digest: stableHash({ institution: input.institution, gate }) + })) + ]; +} + +export function analyzeLabInventorySync(input, options = {}) { + const now = options.now ?? input.generatedAt ?? new Date().toISOString(); + const findings = sortFindings([ + ...findIntegrationFindings(input, now), + ...findInstrumentFindings(input, now), + ...findReagentFindings(input, now) + ]); + const exportGates = projectExportGates(input, findings); + const actions = findings.map(actionForFinding).sort((a, b) => { + const bySeverity = severityRank(a.severity) - severityRank(b.severity); + if (bySeverity !== 0) return bySeverity; + return a.subject.localeCompare(b.subject); + }); + const events = webhookEvents(input, findings, exportGates); + const blockedExports = exportGates.filter((gate) => gate.decision === "block_export").length; + const reviewExports = exportGates.filter((gate) => gate.decision === "review_before_export").length; + + return { + institution: input.institution, + generatedAt: now, + summary: { + labs: input.labs?.length ?? 0, + instruments: input.instruments?.length ?? 0, + reagents: input.reagents?.length ?? 0, + integrations: input.integrations?.length ?? 0, + findings: findings.length, + criticalFindings: findings.filter((finding) => finding.severity === "critical").length, + highFindings: findings.filter((finding) => finding.severity === "high").length, + blockedExports, + reviewExports + }, + findings, + exportGates, + adminActions: actions, + webhookEvents: events, + evidenceDigest: stableHash({ now, findings, exportGates }) + }; +} diff --git a/enterprise-lab-inventory-sync/test/lab-inventory-sync.test.js b/enterprise-lab-inventory-sync/test/lab-inventory-sync.test.js new file mode 100644 index 0000000..fb8b8f6 --- /dev/null +++ b/enterprise-lab-inventory-sync/test/lab-inventory-sync.test.js @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, it } from "node:test"; +import { analyzeLabInventorySync } from "../src/lab-inventory-sync.js"; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const sample = JSON.parse(readFileSync(join(root, "data", "sample-lab-input.json"), "utf8")); + +describe("analyzeLabInventorySync", () => { + it("detects critical integration, calibration, instrument, and reagent findings", () => { + const report = analyzeLabInventorySync(sample); + const kinds = new Set(report.findings.map((finding) => finding.kind)); + + assert.equal(report.summary.criticalFindings, 4); + assert.equal(kinds.has("integration_failed"), true); + assert.equal(kinds.has("calibration_overdue"), true); + assert.equal(kinds.has("instrument_offline"), true); + assert.equal(kinds.has("reagent_expired"), true); + }); + + it("blocks exports for projects that depend on unavailable or expired lab assets", () => { + const report = analyzeLabInventorySync(sample); + const gates = new Map(report.exportGates.map((gate) => [gate.projectId, gate.decision])); + + assert.equal(gates.get("proj-organoid-map"), "block_export"); + assert.equal(gates.get("proj-neurochip"), "block_export"); + assert.equal(gates.get("proj-metabolomics"), "block_export"); + }); + + it("generates webhook events and prioritized admin actions", () => { + const report = analyzeLabInventorySync(sample); + const eventTypes = new Set(report.webhookEvents.map((event) => event.type)); + + assert.equal(eventTypes.has("lab_inventory.integration_failed"), true); + assert.equal(eventTypes.has("lab_inventory.export_gate"), true); + assert.equal(report.adminActions[0].severity, "critical"); + assert.match(report.adminActions[0].action, /page|reroute|lock|quarantine/); + }); + + it("is deterministic for audit evidence", () => { + const first = analyzeLabInventorySync(sample); + const second = analyzeLabInventorySync(sample); + + assert.equal(first.evidenceDigest, second.evidenceDigest); + assert.deepEqual(first.findings.map((finding) => finding.id), second.findings.map((finding) => finding.id)); + }); +});