From d986f736b83fa49061574990c6664c59a2c3fda1 Mon Sep 17 00:00:00 2001 From: olyasir Date: Tue, 10 Mar 2026 15:37:25 +0200 Subject: [PATCH] feat: add @qvac/diagnostics package with BaseInference integration and CI - New @qvac/diagnostics package (v0.1.0) with DiagnosticReport schema, addon registration, environment/hardware collection, report generation - Wire BaseInference for optional diagnostics addon registration - Add CI/CD publishing workflow for diagnostics package - 13 unit tests, 44 assertions --- ...gger-reusable-lib-qvac-lib-diagnostics.yml | 185 ++++++++++++ packages/qvac-lib-diagnostics/.gitignore | 8 + packages/qvac-lib-diagnostics/CHANGELOG.md | 17 ++ packages/qvac-lib-diagnostics/LICENSE | 179 +++++++++++ packages/qvac-lib-diagnostics/NOTICE | 4 + packages/qvac-lib-diagnostics/index.d.ts | 159 ++++++++++ packages/qvac-lib-diagnostics/index.js | 285 ++++++++++++++++++ packages/qvac-lib-diagnostics/package.json | 41 +++ .../test/unit/diagnostics.test.js | 155 ++++++++++ packages/qvac-lib-diagnostics/tsconfig.json | 13 + packages/qvac-lib-infer-base/index.js | 15 + packages/qvac-lib-infer-base/package.json | 3 + 12 files changed, 1064 insertions(+) create mode 100644 .github/workflows/trigger-reusable-lib-qvac-lib-diagnostics.yml create mode 100644 packages/qvac-lib-diagnostics/.gitignore create mode 100644 packages/qvac-lib-diagnostics/CHANGELOG.md create mode 100644 packages/qvac-lib-diagnostics/LICENSE create mode 100644 packages/qvac-lib-diagnostics/NOTICE create mode 100644 packages/qvac-lib-diagnostics/index.d.ts create mode 100644 packages/qvac-lib-diagnostics/index.js create mode 100644 packages/qvac-lib-diagnostics/package.json create mode 100644 packages/qvac-lib-diagnostics/test/unit/diagnostics.test.js create mode 100644 packages/qvac-lib-diagnostics/tsconfig.json diff --git a/.github/workflows/trigger-reusable-lib-qvac-lib-diagnostics.yml b/.github/workflows/trigger-reusable-lib-qvac-lib-diagnostics.yml new file mode 100644 index 0000000000..f5ea161cc0 --- /dev/null +++ b/.github/workflows/trigger-reusable-lib-qvac-lib-diagnostics.yml @@ -0,0 +1,185 @@ +name: General CI/CD (Qvac-lib-diagnostics) +# This workflow is used to call the reusable workflows in the qvac-devops repository +# https://github.com/tetherto/qvac-devops/blob/production-workflows-tag/.github/workflows/public-reusable.yml + +permissions: + contents: write + packages: write + pull-requests: write + id-token: write + +on: + push: + branches: + - main + - release-* + - feature-* + - tmp-* + paths: + - "packages/qvac-lib-diagnostics/**" + workflow_dispatch: + +jobs: + release-merge-guard: + name: Release Merge Guard + continue-on-error: true + if: >- + github.event_name == 'push' && + startsWith(github.ref_name, 'release-') && + github.event.before != '0000000000000000000000000000000000000000' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - uses: ./.github/actions/release-merge-guard + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + base-ref: ${{ github.ref_name }} + base-sha: ${{ github.event.before }} + head-sha: ${{ github.sha }} + package-slug: qvac-lib-diagnostics + package-json-path: packages/qvac-lib-diagnostics/package.json + changelog-path: packages/qvac-lib-diagnostics/CHANGELOG.md + + publish-logic: + runs-on: ubuntu-latest + outputs: + publish_main: ${{ steps.logic.outputs.publish_main }} + publish_release: ${{ steps.logic.outputs.publish_release }} + publish_feature: ${{ steps.logic.outputs.publish_feature }} + publish_tmp: ${{ steps.logic.outputs.publish_tmp }} + gpr_tag: ${{ steps.logic.outputs.gpr_tag }} + steps: + - id: logic + shell: bash + run: | + set -euo pipefail + ref_name="${GITHUB_REF_NAME}" + event_name="${GITHUB_EVENT_NAME}" + + publish_main="false" + publish_release="false" + publish_feature="false" + publish_tmp="false" + + if [ "$event_name" = "push" ] || [ "$event_name" = "workflow_dispatch" ]; then + if [ "$ref_name" = "main" ]; then + publish_main="true" + elif [[ "$ref_name" == release-* ]]; then + publish_release="true" + elif [[ "$ref_name" == feature-* ]]; then + publish_feature="true" + elif [[ "$ref_name" == tmp-* ]]; then + publish_tmp="true" + fi + fi + + gpr_tag="dev" + if [ "$ref_name" = "main" ]; then + gpr_tag="dev" + elif [[ "$ref_name" == feature-* ]]; then + gpr_tag="feature" + elif [[ "$ref_name" == tmp-* ]]; then + gpr_tag="temp" + fi + + echo "publish_main=$publish_main" >> "$GITHUB_OUTPUT" + echo "publish_release=$publish_release" >> "$GITHUB_OUTPUT" + echo "publish_feature=$publish_feature" >> "$GITHUB_OUTPUT" + echo "publish_tmp=$publish_tmp" >> "$GITHUB_OUTPUT" + echo "gpr_tag=$gpr_tag" >> "$GITHUB_OUTPUT" + + publish-main-gpr-dev: + needs: publish-logic + if: needs.publish-logic.outputs.publish_main == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Publish to GitHub Packages (dev) + uses: tetherto/qvac-devops/.github/actions/publish-library-to-gpr@monorepo_update + with: + secret-token: ${{ secrets.GITHUB_TOKEN }} + pat-token: ${{ secrets.PAT_TOKEN }} + npm-token: ${{ secrets.NPM_TOKEN }} + tag: ${{ needs.publish-logic.outputs.gpr_tag }} + workdir: packages/qvac-lib-diagnostics + name-suffix: "-mono" + + publish-release-npm: + needs: [publish-logic] + permissions: + contents: write + packages: write + id-token: write + if: needs.publish-logic.outputs.publish_release == 'true' + uses: tetherto/qvac-devops/.github/workflows/public-reusable-npm.yml@monorepo_update + secrets: inherit + with: + workdir: packages/qvac-lib-diagnostics + repo_name: diagnostics + + # Create GitHub release only for actual releases (after NPM publish) + publish-release: + needs: [publish-release-npm] + if: needs.publish-release-npm.result == 'success' && needs.publish-release-npm.outputs.published_version != '' + permissions: + contents: write + uses: ./.github/workflows/create-github-release.yml + secrets: inherit + with: + repo_name: "diagnostics" + release_name: "QVAC diagnostics Lib" + published_version: ${{ needs.publish-release-npm.outputs.published_version }} + prev_sha: ${{ github.event.before }} + workdir: "packages/qvac-lib-diagnostics" + + publish-feature-gpr: + needs: publish-logic + if: needs.publish-logic.outputs.publish_feature == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Publish to GitHub Packages (feature) + uses: tetherto/qvac-devops/.github/actions/publish-library-to-gpr@monorepo_update + with: + secret-token: ${{ secrets.GITHUB_TOKEN }} + pat-token: ${{ secrets.PAT_TOKEN }} + npm-token: ${{ secrets.NPM_TOKEN }} + tag: ${{ needs.publish-logic.outputs.gpr_tag }} + workdir: packages/qvac-lib-diagnostics + name-suffix: "-mono" + + publish-tmp-gpr: + needs: publish-logic + if: needs.publish-logic.outputs.publish_tmp == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Publish to GitHub Packages (tmp) + uses: tetherto/qvac-devops/.github/actions/publish-library-to-gpr@monorepo_update + with: + secret-token: ${{ secrets.GITHUB_TOKEN }} + pat-token: ${{ secrets.PAT_TOKEN }} + npm-token: ${{ secrets.NPM_TOKEN }} + tag: ${{ needs.publish-logic.outputs.gpr_tag }} + workdir: packages/qvac-lib-diagnostics + name-suffix: "-mono" diff --git a/packages/qvac-lib-diagnostics/.gitignore b/packages/qvac-lib-diagnostics/.gitignore new file mode 100644 index 0000000000..fa43f9eb1a --- /dev/null +++ b/packages/qvac-lib-diagnostics/.gitignore @@ -0,0 +1,8 @@ +node_modules +coverage +test/unit/all.js +.npmrc +test/integration/all.js +package-lock.json +.idea +bun.lock diff --git a/packages/qvac-lib-diagnostics/CHANGELOG.md b/packages/qvac-lib-diagnostics/CHANGELOG.md new file mode 100644 index 0000000000..5c007adecb --- /dev/null +++ b/packages/qvac-lib-diagnostics/CHANGELOG.md @@ -0,0 +1,17 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.1.0] - 2026-03-10 + +### Added + +- `DiagnosticReport` schema defining the structure of a diagnostic report +- Contributor pattern API: `registerAddon` function accepting a `getDiagnostics` callback for per-addon diagnostic contributions +- Environment and hardware auto-detection via `bare-os` (platform, architecture, OS release) +- Unit tests covering report schema validation, addon registration, and environment detection diff --git a/packages/qvac-lib-diagnostics/LICENSE b/packages/qvac-lib-diagnostics/LICENSE new file mode 100644 index 0000000000..7d199ae333 --- /dev/null +++ b/packages/qvac-lib-diagnostics/LICENSE @@ -0,0 +1,179 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + +Copyright 2026 Tether Data, S.A. de C.V. diff --git a/packages/qvac-lib-diagnostics/NOTICE b/packages/qvac-lib-diagnostics/NOTICE new file mode 100644 index 0000000000..42e0112d9e --- /dev/null +++ b/packages/qvac-lib-diagnostics/NOTICE @@ -0,0 +1,4 @@ +@qvac/diagnostics +Copyright 2026 Tether Data, S.A. de C.V. + +This product includes software developed by Tether Data, S.A. de C.V. diff --git a/packages/qvac-lib-diagnostics/index.d.ts b/packages/qvac-lib-diagnostics/index.d.ts new file mode 100644 index 0000000000..022829bb99 --- /dev/null +++ b/packages/qvac-lib-diagnostics/index.d.ts @@ -0,0 +1,159 @@ +export type AppInfo = { + /** + * - Application name + */ + name: string; + /** + * - Application version + */ + version: string; +}; +export type EnvironmentInfo = { + /** + * - Operating system platform + */ + os: string; + /** + * - CPU architecture + */ + arch: string; + /** + * - OS version/release string + */ + osVersion: string; + /** + * - Runtime environment (e.g. 'bare', 'node') + */ + runtime: string; +}; +export type HardwareInfo = { + /** + * - CPU model name + */ + cpuModel: string; + /** + * - Number of CPU cores + */ + cpuCores: number; + /** + * - Total system memory in megabytes + */ + totalMemoryMB: number; +}; +export type AddonEntry = { + /** + * - Addon name + */ + name: string; + /** + * - Addon version + */ + version: string; + /** + * - Opaque JSON string returned by getDiagnostics callback + */ + diagnostics: string; +}; +export type ExtensionSection = { + /** + * - Extension name + */ + name: string; + /** + * - Extension data (any JSON-serializable value) + */ + data: any; +}; +export type DiagnosticReport = { + /** + * - Report format version + */ + reportVersion: string; + /** + * - ISO timestamp when report was generated + */ + generatedAt: string; + /** + * - Application information + */ + app: AppInfo; + /** + * - Environment information + */ + environment: EnvironmentInfo; + /** + * - Hardware information + */ + hardware: HardwareInfo; + /** + * - Registered addon diagnostics + */ + addons: AddonEntry[]; + /** + * - Registered extension sections + */ + extensions: ExtensionSection[]; +}; +/** + * Version of the diagnostic report format + * @type {string} + */ +export const REPORT_VERSION: string; +/** + * Registers an addon that can contribute diagnostics to the report. + * The getDiagnostics callback will be called at report generation time + * and must return an opaque JSON string. + * + * @param {{ name: string, version: string, getDiagnostics: () => string }} addon + */ +export function registerAddon(addon: { + name: string; + version: string; + getDiagnostics: () => string; +}): void; +/** + * Unregisters a previously registered addon. + * + * @param {string} name - Addon name to remove + */ +export function unregisterAddon(name: string): void; +/** + * Registers an extension section to be included in the report. + * + * @param {string} name - Extension section name + * @param {*} data - Extension data (any JSON-serializable value) + */ +export function registerExtension(name: string, data: any): void; +/** + * Collects environment information from the current runtime. + * + * @returns {EnvironmentInfo} + */ +export function collectEnvironment(): EnvironmentInfo; +/** + * Collects hardware information from the current system. + * + * @returns {HardwareInfo} + */ +export function collectHardware(): HardwareInfo; +/** + * Generates a full diagnostic report. + * + * @param {{ app: AppInfo }} opts + * @returns {DiagnosticReport} + */ +export function generateReport(opts: { + app: AppInfo; +}): DiagnosticReport; +/** + * Serializes a diagnostic report to a JSON string. + * + * @param {DiagnosticReport} report + * @returns {string} + */ +export function serializeReport(report: DiagnosticReport): string; +/** + * Resets all singleton state (addon registry and extensions). + * Primarily useful for testing. + */ +export function reset(): void; diff --git a/packages/qvac-lib-diagnostics/index.js b/packages/qvac-lib-diagnostics/index.js new file mode 100644 index 0000000000..bb5817dd4b --- /dev/null +++ b/packages/qvac-lib-diagnostics/index.js @@ -0,0 +1,285 @@ +'use strict' + +let _os +try { + _os = require('bare-os') +} catch (e) { + try { + _os = require('os') + } catch (e2) { + _os = null + } +} + +let _process +if (typeof global !== 'undefined' && global.process) { + _process = global.process +} else { + try { + _process = require('process') + } catch (e) { + try { + _process = require('bare-process') + } catch (e2) { + _process = null + } + } +} + +/** + * Version of the diagnostic report format + * @type {string} + */ +const REPORT_VERSION = '1.0.0' + +/** + * @typedef {Object} AppInfo + * @property {string} name - Application name + * @property {string} version - Application version + */ + +/** + * @typedef {Object} EnvironmentInfo + * @property {string} os - Operating system platform + * @property {string} arch - CPU architecture + * @property {string} osVersion - OS version/release string + * @property {string} runtime - Runtime environment (e.g. 'bare', 'node') + */ + +/** + * @typedef {Object} HardwareInfo + * @property {string} cpuModel - CPU model name + * @property {number} cpuCores - Number of CPU cores + * @property {number} totalMemoryMB - Total system memory in megabytes + */ + +/** + * @typedef {Object} AddonEntry + * @property {string} name - Addon name + * @property {string} version - Addon version + * @property {string} diagnostics - Opaque JSON string returned by getDiagnostics callback + */ + +/** + * @typedef {Object} ExtensionSection + * @property {string} name - Extension name + * @property {*} data - Extension data (any JSON-serializable value) + */ + +/** + * @typedef {Object} DiagnosticReport + * @property {string} reportVersion - Report format version + * @property {string} generatedAt - ISO timestamp when report was generated + * @property {AppInfo} app - Application information + * @property {EnvironmentInfo} environment - Environment information + * @property {HardwareInfo} hardware - Hardware information + * @property {AddonEntry[]} addons - Registered addon diagnostics + * @property {ExtensionSection[]} extensions - Registered extension sections + */ + +/** + * Singleton addon registry: name -> { version, getDiagnostics } + * @private + * @type {Map string }>} + */ +const _addonRegistry = new Map() + +/** + * Singleton extension registry: name -> data + * @private + * @type {Map} + */ +const _extensions = new Map() + +/** + * Registers an addon that can contribute diagnostics to the report. + * The getDiagnostics callback will be called at report generation time + * and must return an opaque JSON string. + * + * @param {{ name: string, version: string, getDiagnostics: () => string }} addon + */ +function registerAddon (addon) { + if (!addon || typeof addon.name !== 'string' || !addon.name) { + throw new Error('addon.name must be a non-empty string') + } + if (typeof addon.version !== 'string') { + throw new Error('addon.version must be a string') + } + if (typeof addon.getDiagnostics !== 'function') { + throw new Error('addon.getDiagnostics must be a function') + } + _addonRegistry.set(addon.name, { + version: addon.version, + getDiagnostics: addon.getDiagnostics + }) +} + +/** + * Unregisters a previously registered addon. + * + * @param {string} name - Addon name to remove + */ +function unregisterAddon (name) { + _addonRegistry.delete(name) +} + +/** + * Registers an extension section to be included in the report. + * + * @param {string} name - Extension section name + * @param {*} data - Extension data (any JSON-serializable value) + */ +function registerExtension (name, data) { + if (typeof name !== 'string' || !name) { + throw new Error('extension name must be a non-empty string') + } + _extensions.set(name, data) +} + +/** + * Collects environment information from the current runtime. + * + * @returns {EnvironmentInfo} + */ +function collectEnvironment () { + let os = 'unknown' + let arch = 'unknown' + let osVersion = 'unknown' + let runtime = 'unknown' + + if (_os) { + try { + os = typeof _os.platform === 'function' ? _os.platform() : String(_os.platform || 'unknown') + } catch (e) { + os = 'unknown' + } + try { + arch = typeof _os.arch === 'function' ? _os.arch() : String(_os.arch || 'unknown') + } catch (e) { + arch = 'unknown' + } + try { + osVersion = typeof _os.release === 'function' ? _os.release() : String(_os.release || 'unknown') + } catch (e) { + osVersion = 'unknown' + } + } + + if (_process) { + try { + const ver = _process.version || 'unknown' + const plat = _process.platform || os + runtime = plat === 'android' || plat === 'ios' ? 'bare' : (typeof Bare !== 'undefined' ? 'bare' : 'node') + if (runtime === 'node') { + runtime = 'node ' + ver + } else { + runtime = 'bare ' + ver + } + } catch (e) { + runtime = 'unknown' + } + } + + return { os, arch, osVersion, runtime } +} + +/** + * Collects hardware information from the current system. + * + * @returns {HardwareInfo} + */ +function collectHardware () { + let cpuModel = 'unknown' + let cpuCores = 0 + let totalMemoryMB = 0 + + if (_os) { + try { + const cpuList = typeof _os.cpus === 'function' ? _os.cpus() : [] + if (cpuList && cpuList.length > 0) { + cpuModel = cpuList[0].model || 'unknown' + cpuCores = cpuList.length + } + } catch (e) { + cpuModel = 'unknown' + cpuCores = 0 + } + try { + const totalBytes = typeof _os.totalmem === 'function' ? _os.totalmem() : 0 + totalMemoryMB = Math.floor(totalBytes / (1024 * 1024)) + } catch (e) { + totalMemoryMB = 0 + } + } + + return { cpuModel, cpuCores, totalMemoryMB } +} + +/** + * Generates a full diagnostic report. + * + * @param {{ app: AppInfo }} opts + * @returns {DiagnosticReport} + */ +function generateReport (opts) { + const app = (opts && opts.app) ? opts.app : { name: 'unknown', version: 'unknown' } + const environment = collectEnvironment() + const hardware = collectHardware() + + const addons = [] + for (const [name, entry] of _addonRegistry) { + let diagnostics = '' + try { + diagnostics = entry.getDiagnostics() + } catch (e) { + diagnostics = JSON.stringify({ error: String(e) }) + } + addons.push({ name, version: entry.version, diagnostics }) + } + + const extensions = [] + for (const [name, data] of _extensions) { + extensions.push({ name, data }) + } + + return { + reportVersion: REPORT_VERSION, + generatedAt: new Date().toISOString(), + app, + environment, + hardware, + addons, + extensions + } +} + +/** + * Serializes a diagnostic report to a JSON string. + * + * @param {DiagnosticReport} report + * @returns {string} + */ +function serializeReport (report) { + return JSON.stringify(report, null, 2) +} + +/** + * Resets all singleton state (addon registry and extensions). + * Primarily useful for testing. + */ +function reset () { + _addonRegistry.clear() + _extensions.clear() +} + +module.exports = { + REPORT_VERSION, + registerAddon, + unregisterAddon, + registerExtension, + collectEnvironment, + collectHardware, + generateReport, + serializeReport, + reset +} diff --git a/packages/qvac-lib-diagnostics/package.json b/packages/qvac-lib-diagnostics/package.json new file mode 100644 index 0000000000..d76a2b723c --- /dev/null +++ b/packages/qvac-lib-diagnostics/package.json @@ -0,0 +1,41 @@ +{ + "name": "@qvac/diagnostics", + "version": "0.1.0", + "description": "Diagnostic report generation library for QVAC", + "main": "index.js", + "types": "index.d.ts", + "scripts": { + "lint": "standard", + "lint:fix": "standard --fix", + "build:types": "tsc", + "test:unit": "npm run test:unit:generate && bare test/unit/all.js", + "test:unit:generate": "brittle -r test/unit/all.js test/unit/*.test.js", + "test": "npm run test:unit" + }, + "author": "Tether", + "keywords": [ + "tether", + "diagnostics", + "qvac" + ], + "license": "Apache-2.0", + "exports": { + ".": { + "types": "./index.d.ts", + "default": "./index.js" + } + }, + "devDependencies": { + "bare-os": "^3.4.0", + "bare-process": "^4.2.1", + "brittle": "^3.10.1", + "standard": "17.1.0", + "typescript": "^5.9.3" + }, + "files": [ + "index.js", + "index.d.ts", + "LICENSE", + "NOTICE" + ] +} diff --git a/packages/qvac-lib-diagnostics/test/unit/diagnostics.test.js b/packages/qvac-lib-diagnostics/test/unit/diagnostics.test.js new file mode 100644 index 0000000000..e4c4e5822d --- /dev/null +++ b/packages/qvac-lib-diagnostics/test/unit/diagnostics.test.js @@ -0,0 +1,155 @@ +'use strict' + +const test = require('brittle') +const { + REPORT_VERSION, + registerAddon, + unregisterAddon, + registerExtension, + collectEnvironment, + collectHardware, + generateReport, + serializeReport, + reset +} = require('../..') + +test('registerAddon + unregisterAddon lifecycle', t => { + reset() + registerAddon({ name: 'test-addon', version: '1.0.0', getDiagnostics: () => '{}' }) + const report = generateReport({ app: { name: 'app', version: '1.0.0' } }) + t.is(report.addons.length, 1, 'addon is registered') + t.is(report.addons[0].name, 'test-addon', 'addon has correct name') + + unregisterAddon('test-addon') + const report2 = generateReport({ app: { name: 'app', version: '1.0.0' } }) + t.is(report2.addons.length, 0, 'addon is unregistered') +}) + +test('getDiagnostics callback is called during generateReport()', t => { + reset() + let called = false + registerAddon({ + name: 'callback-addon', + version: '0.1.0', + getDiagnostics: () => { + called = true + return '{"key":"value"}' + } + }) + generateReport({ app: { name: 'app', version: '1.0.0' } }) + t.ok(called, 'getDiagnostics was called during generateReport') +}) + +test('getDiagnostics returning a JSON string appears as-is in report.addons[].diagnostics', t => { + reset() + const diagnosticsStr = '{"model":"llama3","loaded":true,"layers":32}' + registerAddon({ + name: 'json-addon', + version: '2.0.0', + getDiagnostics: () => diagnosticsStr + }) + const report = generateReport({ app: { name: 'app', version: '1.0.0' } }) + t.is(report.addons[0].diagnostics, diagnosticsStr, 'diagnostics string is preserved as-is') +}) + +test('registerExtension appears in report.extensions', t => { + reset() + registerExtension('custom-section', { foo: 'bar', count: 42 }) + const report = generateReport({ app: { name: 'app', version: '1.0.0' } }) + t.is(report.extensions.length, 1, 'extension is present') + t.is(report.extensions[0].name, 'custom-section', 'extension has correct name') + t.alike(report.extensions[0].data, { foo: 'bar', count: 42 }, 'extension has correct data') +}) + +test('generateReport returns valid structure with all expected top-level fields', t => { + reset() + registerAddon({ name: 'struct-addon', version: '1.0.0', getDiagnostics: () => '{}' }) + registerExtension('struct-ext', { x: 1 }) + const report = generateReport({ app: { name: 'myapp', version: '3.2.1' } }) + + t.ok(typeof report.reportVersion === 'string', 'reportVersion is a string') + t.is(report.reportVersion, REPORT_VERSION, 'reportVersion matches REPORT_VERSION constant') + t.ok(typeof report.generatedAt === 'string', 'generatedAt is a string') + t.ok(report.app, 'app field is present') + t.is(report.app.name, 'myapp', 'app.name is correct') + t.is(report.app.version, '3.2.1', 'app.version is correct') + t.ok(report.environment, 'environment field is present') + t.ok(report.hardware, 'hardware field is present') + t.ok(Array.isArray(report.addons), 'addons is an array') + t.ok(Array.isArray(report.extensions), 'extensions is an array') +}) + +test('collectEnvironment returns os/arch/runtime strings', t => { + const env = collectEnvironment() + t.ok(typeof env.os === 'string', 'os is a string') + t.ok(typeof env.arch === 'string', 'arch is a string') + t.ok(typeof env.osVersion === 'string', 'osVersion is a string') + t.ok(typeof env.runtime === 'string', 'runtime is a string') + t.ok(env.os.length > 0, 'os is not empty') + t.ok(env.arch.length > 0, 'arch is not empty') +}) + +test('collectHardware returns object with expected shape', t => { + const hw = collectHardware() + t.ok(typeof hw.cpuModel === 'string', 'cpuModel is a string') + t.ok(typeof hw.cpuCores === 'number', 'cpuCores is a number') + t.ok(typeof hw.totalMemoryMB === 'number', 'totalMemoryMB is a number') +}) + +test('serializeReport produces valid JSON', t => { + reset() + const report = generateReport({ app: { name: 'app', version: '1.0.0' } }) + const json = serializeReport(report) + t.ok(typeof json === 'string', 'serializeReport returns a string') + let parsed + try { + parsed = JSON.parse(json) + } catch (e) { + t.fail('serializeReport output is not valid JSON') + return + } + t.ok(parsed, 'parsed JSON is truthy') + t.is(parsed.reportVersion, REPORT_VERSION, 'serialized report has correct reportVersion') +}) + +test('reset clears all state', t => { + registerAddon({ name: 'reset-addon', version: '1.0.0', getDiagnostics: () => '{}' }) + registerExtension('reset-ext', { a: 1 }) + reset() + const report = generateReport({ app: { name: 'app', version: '1.0.0' } }) + t.is(report.addons.length, 0, 'addons cleared after reset') + t.is(report.extensions.length, 0, 'extensions cleared after reset') +}) + +test('REPORT_VERSION is a string', t => { + t.ok(typeof REPORT_VERSION === 'string', 'REPORT_VERSION is a string') + t.is(REPORT_VERSION, '1.0.0', 'REPORT_VERSION is 1.0.0') +}) + +test('registerAddon throws on invalid inputs', t => { + t.exception(() => registerAddon(null), 'throws when addon is null') + t.exception(() => registerAddon({}), 'throws when name is missing') + t.exception(() => registerAddon({ name: '', version: '1.0.0', getDiagnostics: () => '{}' }), 'throws when name is empty string') + t.exception(() => registerAddon({ name: 'a', version: 1, getDiagnostics: () => '{}' }), 'throws when version is not a string') + t.exception(() => registerAddon({ name: 'a', version: '1.0.0', getDiagnostics: 'not-a-fn' }), 'throws when getDiagnostics is not a function') +}) + +test('registerExtension throws on invalid name', t => { + t.exception(() => registerExtension('', { data: 1 }), 'throws when name is empty string') + t.exception(() => registerExtension(42, { data: 1 }), 'throws when name is not a string') +}) + +test('getDiagnostics throwing is caught and error serialized into diagnostics string', t => { + reset() + registerAddon({ + name: 'throwing-addon', + version: '1.0.0', + getDiagnostics: () => { throw new Error('boom') } + }) + const report = generateReport({ app: { name: 'app', version: '1.0.0' } }) + t.is(report.addons.length, 1, 'addon entry is present') + const diagnostics = report.addons[0].diagnostics + t.ok(typeof diagnostics === 'string', 'diagnostics is a string even when getDiagnostics throws') + const parsed = JSON.parse(diagnostics) + t.ok(parsed.error, 'error field is present in fallback diagnostics') +}) diff --git a/packages/qvac-lib-diagnostics/tsconfig.json b/packages/qvac-lib-diagnostics/tsconfig.json new file mode 100644 index 0000000000..608112ac53 --- /dev/null +++ b/packages/qvac-lib-diagnostics/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "allowJs": true, + "declaration": true, + "emitDeclarationOnly": true, + "strict": true, + "skipLibCheck": true, + "outDir": ".", + "declarationMap": false + }, + "include": ["index.js"], + "exclude": ["node_modules", "test"] +} diff --git a/packages/qvac-lib-infer-base/index.js b/packages/qvac-lib-infer-base/index.js index 0da114649e..f75db414d2 100644 --- a/packages/qvac-lib-infer-base/index.js +++ b/packages/qvac-lib-infer-base/index.js @@ -1,5 +1,8 @@ 'use strict' +let diagnostics +try { diagnostics = require('@qvac/diagnostics') } catch (e) { diagnostics = null } + const QvacLogger = require('@qvac/logging') const { platform } = require('bare-os') const path = require('bare-path') @@ -63,6 +66,14 @@ class BaseInference { await this._load(...args) this.state.configLoaded = true + + if (diagnostics && this._packageName) { + diagnostics.registerAddon({ + name: this._packageName, + version: this._packageVersion || 'unknown', + getDiagnostics: () => this._getDiagnosticsJSON ? this._getDiagnosticsJSON() : '{}' + }) + } } async _load () { @@ -296,6 +307,10 @@ class BaseInference { this.state.configLoaded = false this.state.weightsLoaded = false this.state.destroyed = true + + if (diagnostics && this._packageName) { + diagnostics.unregisterAddon(this._packageName) + } } /** diff --git a/packages/qvac-lib-infer-base/package.json b/packages/qvac-lib-infer-base/package.json index 471b3c4efa..d09e0ec3e5 100644 --- a/packages/qvac-lib-infer-base/package.json +++ b/packages/qvac-lib-infer-base/package.json @@ -28,6 +28,9 @@ "brittle": "^3.15.0", "standard": "^17.1.0" }, + "optionalDependencies": { + "@qvac/diagnostics": "^0.1.0" + }, "peerDependencies": { "@qvac/dl-hyperdrive": "^0.1.0" },