From c380dce8b6fb0ef3fb115fd77150aee06c101f92 Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 20 Oct 2026 19:02:42 -0500 Subject: [PATCH 01/16] ci: adopt verified central QQQ orb 0.7.3 Co-Authored-By: Codex --- .circleci/config.yml | 68 +++++++------------------------------------- .qqq-ci.yml | 16 +++++++++++ 2 files changed, 27 insertions(+), 57 deletions(-) create mode 100644 .qqq-ci.yml diff --git a/.circleci/config.yml b/.circleci/config.yml index aec59b2..113ce37 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,60 +1,14 @@ version: 2.1 - +setup: true orbs: - qqq-orb: kingsrook/qqq-orb@0.6.9 - -commands: - configure-package-install: - steps: - - run: - name: Keep package installation unattended - command: | - # These build machines are discarded after the job. - sudo install -d /etc/needrestart/conf.d - printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null - + qqq-orb: kingsrook/qqq-orb@0.7.3 workflows: - test_only: - jobs: - - qqq-orb/mvn_test_only: - pre-steps: - - configure-package-install - context: [qqq-maven-registry-credentials] - filters: - branches: - ignore: /(develop|release\/.*)/ - - publish_snapshot: - jobs: - - qqq-orb/mvn_publish: - pre-steps: - - configure-package-install - context: [qqq-maven-registry-credentials] - branch_type: snapshot - filters: - branches: - only: [develop] - - publish_release_candidate: - jobs: - - qqq-orb/mvn_publish: - pre-steps: - - configure-package-install - context: [qqq-maven-registry-credentials] - branch_type: release_candidate - filters: - branches: - only: [/release\/.*/] - - publish_release: - jobs: - - qqq-orb/mvn_publish: - pre-steps: - - configure-package-install - context: [qqq-maven-registry-credentials] - branch_type: release - filters: - branches: - ignore: /.*/ - tags: - only: [/^v\d+\.\d+\.\d+$/] + setup: + jobs: + - qqq-orb/generate_pipeline: + orb_version: 0.7.3 + filters: + branches: + ignore: automation/qbit-rc-pending + tags: + only: /^v\d+\.\d+\.\d+$/ diff --git a/.qqq-ci.yml b/.qqq-ci.yml new file mode 100644 index 0000000..3f2b2d1 --- /dev/null +++ b/.qqq-ci.yml @@ -0,0 +1,16 @@ +profile: qbit +orb_version: 0.7.3 +default_branch: develop +java_version: '21' +browsers: true +api_checks: false +docs_only: false +release_on_main: false +release_tag_policy: stable +test_branch_policy: non-release +publish: + snapshot: true + release_candidate: true + release: true + asciidoc: false +release_completion: false -- 2.54.0 From 730bb68428944e597d5b792d9220aed5c6efb899 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 21 Oct 2026 00:56:34 -0500 Subject: [PATCH 02/16] ci: adopt shared orb 0.7.11 (#26) Co-authored-by: Codex --- .circleci/config.yml | 4 ++-- .qqq-ci.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 113ce37..def7f95 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,12 +1,12 @@ version: 2.1 setup: true orbs: - qqq-orb: kingsrook/qqq-orb@0.7.3 + qqq-orb: kingsrook/qqq-orb@0.7.11 workflows: setup: jobs: - qqq-orb/generate_pipeline: - orb_version: 0.7.3 + orb_version: 0.7.11 filters: branches: ignore: automation/qbit-rc-pending diff --git a/.qqq-ci.yml b/.qqq-ci.yml index 3f2b2d1..7b032d6 100644 --- a/.qqq-ci.yml +++ b/.qqq-ci.yml @@ -1,5 +1,5 @@ profile: qbit -orb_version: 0.7.3 +orb_version: 0.7.11 default_branch: develop java_version: '21' browsers: true -- 2.54.0 From 70f841c256a7b8f4d5201c27216dd9e95ab59277 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 21 Oct 2026 02:25:01 -0500 Subject: [PATCH 03/16] ci: centralize rc release completion in qqq orb (#27) Co-authored-by: Codex --- .github/workflows/release-rc.yml | 76 ++++++++++++++++++++++++++------ .qqq-ci.yml | 2 +- 2 files changed, 63 insertions(+), 15 deletions(-) diff --git a/.github/workflows/release-rc.yml b/.github/workflows/release-rc.yml index a342ba8..0158aa2 100644 --- a/.github/workflows/release-rc.yml +++ b/.github/workflows/release-rc.yml @@ -1,26 +1,74 @@ -# Cut the next release candidate (QRun-IO/qqq docs/QRUN-QBIT-REPO-STANDARD.md, C6): -# run this workflow by hand, or merge a develop PR labelled `release:rc`. It merges develop into -# the release line and the release/* push runs CircleCI publish_release_candidate. name: Release candidate - -on: +'on': workflow_dispatch: inputs: dry_run: - description: Print the plan without merging or creating branches + description: Plan a cut without changing release state type: boolean default: false + finalize: + description: Retry finalization of an existing cut; do not cut another RC + type: boolean + default: false + recover: + description: Recover the same published RC after a deployment timeout; never + reupload + type: boolean + default: false + circle_build: + description: Original Circle publisher build number (recovery only) + type: string + default: '' + merge_sha: + description: Existing cut merge_sha (finalize retry only) + type: string + default: '' + bump_sha: + description: Existing cut bump_sha (finalize retry only) + type: string + default: '' + branch: + description: Existing cut branch (finalize retry only) + type: string + default: '' + version: + description: Existing cut version (finalize retry only) + type: string + default: '' pull_request: - types: [closed] - branches: [develop] - + types: + - closed + branches: + - develop + repository_dispatch: + types: + - qbit-rc-published permissions: contents: write - + actions: read + checks: read jobs: release-rc: - if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:rc')) - uses: QRun-IO/.github/.github/workflows/qbit-release-rc.yml@main - secrets: inherit + if: github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' + || (github.event_name == 'pull_request' && github.event.pull_request.merged + == true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name, + 'release:rc')) + uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@7d73ac90b6b5a6acb61b4d3fa56625c5b6dcc652 with: - dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }} + dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false + }} + finalize: ${{ github.event_name == 'repository_dispatch' || (github.event_name + == 'workflow_dispatch' && inputs.finalize) }} + recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false + }} + circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build + || '' }} + merge_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.merge_sha + || inputs.merge_sha || '' }} + bump_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.bump_sha + || inputs.bump_sha || '' }} + branch: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch + || inputs.branch || '' }} + version: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.version + || inputs.version || '' }} + secrets: inherit diff --git a/.qqq-ci.yml b/.qqq-ci.yml index 7b032d6..a8863c3 100644 --- a/.qqq-ci.yml +++ b/.qqq-ci.yml @@ -13,4 +13,4 @@ publish: release_candidate: true release: true asciidoc: false -release_completion: false +release_completion: true -- 2.54.0 From e422092fd40d485d4703b2585b2e66f07f079050 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 21 Oct 2026 08:53:07 -0500 Subject: [PATCH 04/16] ci: adopt central orb 0.7.12 and stable gates (#28) Co-authored-by: Codex --- .circleci/config.yml | 4 ++-- .github/workflows/release-rc.yml | 2 +- .qqq-ci.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index def7f95..c9cb461 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,12 +1,12 @@ version: 2.1 setup: true orbs: - qqq-orb: kingsrook/qqq-orb@0.7.11 + qqq-orb: kingsrook/qqq-orb@0.7.12 workflows: setup: jobs: - qqq-orb/generate_pipeline: - orb_version: 0.7.11 + orb_version: 0.7.12 filters: branches: ignore: automation/qbit-rc-pending diff --git a/.github/workflows/release-rc.yml b/.github/workflows/release-rc.yml index 0158aa2..b48a32a 100644 --- a/.github/workflows/release-rc.yml +++ b/.github/workflows/release-rc.yml @@ -53,7 +53,7 @@ jobs: || (github.event_name == 'pull_request' && github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name, 'release:rc')) - uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@7d73ac90b6b5a6acb61b4d3fa56625c5b6dcc652 + uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@cc30f599e63c8e41629c99d23395bac1adb8bc44 with: dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false }} diff --git a/.qqq-ci.yml b/.qqq-ci.yml index a8863c3..be6666b 100644 --- a/.qqq-ci.yml +++ b/.qqq-ci.yml @@ -1,5 +1,5 @@ profile: qbit -orb_version: 0.7.11 +orb_version: 0.7.12 default_branch: develop java_version: '21' browsers: true -- 2.54.0 From e2990bcd608b6865539374435173e238e120d418 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 21 Oct 2026 10:45:40 -0500 Subject: [PATCH 05/16] ci: centralize QBit source scanning (#29) Co-authored-by: Codex --- .github/workflows/codeql.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .github/workflows/codeql.yml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..bd3ceaf --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,23 @@ +name: CodeQL +'on': + push: + branches: + - main + - develop + - release/** + - hotfix/** + pull_request: {} + schedule: + - cron: 41 16 * * 0 + workflow_dispatch: {} +permissions: + contents: read + actions: read + security-events: write + packages: read +concurrency: + group: codeql-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} +jobs: + analyze: + uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22 -- 2.54.0 From a7b42339b98084d86722148f61a27bd0a9eb540e Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 21 Oct 2026 14:16:25 -0500 Subject: [PATCH 06/16] ci: adopt shared orb 0.7.17 (#33) Co-authored-by: Codex --- .circleci/config.yml | 4 ++-- .qqq-ci.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index c9cb461..1d4fa0d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,12 +1,12 @@ version: 2.1 setup: true orbs: - qqq-orb: kingsrook/qqq-orb@0.7.12 + qqq-orb: kingsrook/qqq-orb@0.7.17 workflows: setup: jobs: - qqq-orb/generate_pipeline: - orb_version: 0.7.12 + orb_version: 0.7.17 filters: branches: ignore: automation/qbit-rc-pending diff --git a/.qqq-ci.yml b/.qqq-ci.yml index be6666b..a9e9f43 100644 --- a/.qqq-ci.yml +++ b/.qqq-ci.yml @@ -1,5 +1,5 @@ profile: qbit -orb_version: 0.7.12 +orb_version: 0.7.17 default_branch: develop java_version: '21' browsers: true -- 2.54.0 From 00f798668a75d59fa3dd762c6b2a93cae02a36cb Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 21 Oct 2026 14:46:42 -0500 Subject: [PATCH 07/16] ci: adopt shared orb 0.7.18 (#34) Co-authored-by: Codex --- .circleci/config.yml | 4 ++-- .qqq-ci.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 1d4fa0d..42b73df 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,12 +1,12 @@ version: 2.1 setup: true orbs: - qqq-orb: kingsrook/qqq-orb@0.7.17 + qqq-orb: kingsrook/qqq-orb@0.7.18 workflows: setup: jobs: - qqq-orb/generate_pipeline: - orb_version: 0.7.17 + orb_version: 0.7.18 filters: branches: ignore: automation/qbit-rc-pending diff --git a/.qqq-ci.yml b/.qqq-ci.yml index a9e9f43..967a18e 100644 --- a/.qqq-ci.yml +++ b/.qqq-ci.yml @@ -1,5 +1,5 @@ profile: qbit -orb_version: 0.7.17 +orb_version: 0.7.18 default_branch: develop java_version: '21' browsers: true -- 2.54.0 From d6f21f6594e82c0400d71d880a689dd7114b3a91 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 21 Oct 2026 15:28:47 -0500 Subject: [PATCH 08/16] ci: adopt managed orb 0.8 patch track (#35) Co-authored-by: Codex --- .circleci/config.yml | 4 ++-- .qqq-ci.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 42b73df..f890c28 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,12 +1,12 @@ version: 2.1 setup: true orbs: - qqq-orb: kingsrook/qqq-orb@0.7.18 + qqq-orb: kingsrook/qqq-orb@0.8 workflows: setup: jobs: - qqq-orb/generate_pipeline: - orb_version: 0.7.18 + orb_version: '0.8' filters: branches: ignore: automation/qbit-rc-pending diff --git a/.qqq-ci.yml b/.qqq-ci.yml index 967a18e..6777ede 100644 --- a/.qqq-ci.yml +++ b/.qqq-ci.yml @@ -1,5 +1,5 @@ profile: qbit -orb_version: 0.7.18 +orb_version: '0.8' default_branch: develop java_version: '21' browsers: true -- 2.54.0 From 72bc4267e664acca7f8915454a07f8e514073877 Mon Sep 17 00:00:00 2001 From: Forge Recovery Date: Mon, 2 Nov 2026 04:09:26 +0000 Subject: [PATCH 09/16] ci: add the identical QRun release caller Co-Authored-By: Codex --- .gitea/workflows/qrun-release.yaml | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .gitea/workflows/qrun-release.yaml diff --git a/.gitea/workflows/qrun-release.yaml b/.gitea/workflows/qrun-release.yaml new file mode 100644 index 0000000..122f7e9 --- /dev/null +++ b/.gitea/workflows/qrun-release.yaml @@ -0,0 +1,12 @@ +# Identical in every QRun Maven application: do not edit. It only delegates to the protected qrun-release-v1 channel. +name: qrun-release +on: + push: + branches: [main, develop, 'release/**', 'hotfix/**'] +permissions: + contents: read +jobs: + release: + uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/java-maven.yaml@qrun-release-v1 + with: + runner: linux-small -- 2.54.0 From 9094e9a76099dc28c3bec1025479f1b067834b1f Mon Sep 17 00:00:00 2001 From: Forge Recovery Date: Mon, 2 Nov 2026 06:17:14 +0000 Subject: [PATCH 10/16] ci: move the QRun release caller to the thin v2 contract Co-Authored-By: Claude Sonnet 5.5 Co-Authored-By: Codex --- .gitea/workflows/qrun-release.yaml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.gitea/workflows/qrun-release.yaml b/.gitea/workflows/qrun-release.yaml index 122f7e9..711441c 100644 --- a/.gitea/workflows/qrun-release.yaml +++ b/.gitea/workflows/qrun-release.yaml @@ -1,12 +1,15 @@ -# Identical in every QRun Maven application: do not edit. It only delegates to the protected qrun-release-v1 channel. +# Standard QRun release caller (v2, thin contract): every push and manual dispatch delegates to the protected qrun-release-v1 channel. Do not edit per repository. name: qrun-release on: - push: - branches: [main, develop, 'release/**', 'hotfix/**'] + push: {} + workflow_dispatch: {} permissions: contents: read + actions: read jobs: release: uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/java-maven.yaml@qrun-release-v1 with: runner: linux-small + browsers: true + browser-runner: browser-build -- 2.54.0 From 409e900f10c583719b1022e126d21b00a36689f4 Mon Sep 17 00:00:00 2001 From: Forge Recovery Date: Mon, 2 Nov 2026 15:48:04 +0000 Subject: [PATCH 11/16] ci: add the isolated postpublish job to the QRun release caller Co-Authored-By: Claude Sonnet 5.5 Co-Authored-By: Codex --- .gitea/workflows/qrun-release.yaml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.gitea/workflows/qrun-release.yaml b/.gitea/workflows/qrun-release.yaml index 711441c..cb745b2 100644 --- a/.gitea/workflows/qrun-release.yaml +++ b/.gitea/workflows/qrun-release.yaml @@ -13,3 +13,12 @@ jobs: runner: linux-small browsers: true browser-runner: browser-build + postpublish: + if: ${{ needs.release.outputs.capture == 'true' }} + needs: release + permissions: + contents: write + actions: write + uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/qrun-source-version.yaml@qrun-release-v1 + with: + runner: linux-small -- 2.54.0 From ee6ee59793bccd7f5ea5f2e9c1a86cce59bbe00f Mon Sep 17 00:00:00 2001 From: Forge Recovery Date: Mon, 2 Nov 2026 20:14:46 +0000 Subject: [PATCH 12/16] ci: enroll shared Gitea release-candidate lifecycle Co-Authored-By: Codex --- .gitea/workflows/release-rc.yaml | 72 ++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 .gitea/workflows/release-rc.yaml diff --git a/.gitea/workflows/release-rc.yaml b/.gitea/workflows/release-rc.yaml new file mode 100644 index 0000000..12ec9fc --- /dev/null +++ b/.gitea/workflows/release-rc.yaml @@ -0,0 +1,72 @@ +name: Release candidate +'on': + workflow_dispatch: + inputs: + dry_run: + description: Plan a cut without changing release state + type: boolean + default: false + finalize: + description: Retry finalization of an existing cut; do not cut another RC + type: boolean + default: false + recover: + description: Recover the same published RC after a deployment timeout; never + reupload + type: boolean + default: false + circle_build: + description: Original release run id (recovery only) + type: string + default: '' + merge_sha: + description: Existing cut merge_sha (finalize retry only) + type: string + default: '' + bump_sha: + description: Existing cut bump_sha (finalize retry only) + type: string + default: '' + branch: + description: Existing cut branch (finalize retry only) + type: string + default: '' + version: + description: Existing cut version (finalize retry only) + type: string + default: '' + pull_request: + types: + - closed + branches: + - develop +permissions: + contents: read +jobs: + release-rc: + # The old qbit-rc-published repository_dispatch is replaced by the already-proven workflow_dispatch + # completion: the publisher's own ActionsWRITE job dispatches this workflow with finalize=true plus + # merge_sha/bump_sha/branch/version (same inputs as the manual finalize retry). + permissions: + contents: write + actions: read + pull-requests: write + if: github.event_name == 'workflow_dispatch' + || (github.event_name == 'pull_request' && github.event.pull_request.merged + == true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name, + 'release:rc')) + uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/qbit-release-rc.yaml@qrun-release-v1 + with: + dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false + }} + finalize: ${{ github.event_name == 'workflow_dispatch' && inputs.finalize }} + recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false + }} + circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build + || '' }} + merge_sha: ${{ inputs.merge_sha || '' }} + bump_sha: ${{ inputs.bump_sha || '' }} + branch: ${{ inputs.branch || '' }} + version: ${{ inputs.version || '' }} + secrets: + QRUN_TAG_SIGNING_KEY: ${{ secrets.QRUN_TAG_SIGNING_KEY }} -- 2.54.0 From c9acd0921cea4bb52600f53e7980648c1b3e2925 Mon Sep 17 00:00:00 2001 From: James Maes Date: Thu, 5 Nov 2026 22:03:32 +0000 Subject: [PATCH 13/16] ci: use shared ci-workflows Maven workflow Replace the platform-ci release callers with one caller of KofTwentyTwo/ci-workflows maven.yaml (qqq-orb port). Co-Authored-By: Claude Opus 5.5 --- .gitea/workflows/build.yaml | 7 +++ .gitea/workflows/qrun-release.yaml | 24 ---------- .gitea/workflows/release-rc.yaml | 72 ------------------------------ 3 files changed, 7 insertions(+), 96 deletions(-) create mode 100644 .gitea/workflows/build.yaml delete mode 100644 .gitea/workflows/qrun-release.yaml delete mode 100644 .gitea/workflows/release-rc.yaml diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml new file mode 100644 index 0000000..ed42608 --- /dev/null +++ b/.gitea/workflows/build.yaml @@ -0,0 +1,7 @@ +name: build +on: + push: +jobs: + maven: + uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@main + secrets: inherit diff --git a/.gitea/workflows/qrun-release.yaml b/.gitea/workflows/qrun-release.yaml deleted file mode 100644 index cb745b2..0000000 --- a/.gitea/workflows/qrun-release.yaml +++ /dev/null @@ -1,24 +0,0 @@ -# Standard QRun release caller (v2, thin contract): every push and manual dispatch delegates to the protected qrun-release-v1 channel. Do not edit per repository. -name: qrun-release -on: - push: {} - workflow_dispatch: {} -permissions: - contents: read - actions: read -jobs: - release: - uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/java-maven.yaml@qrun-release-v1 - with: - runner: linux-small - browsers: true - browser-runner: browser-build - postpublish: - if: ${{ needs.release.outputs.capture == 'true' }} - needs: release - permissions: - contents: write - actions: write - uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/qrun-source-version.yaml@qrun-release-v1 - with: - runner: linux-small diff --git a/.gitea/workflows/release-rc.yaml b/.gitea/workflows/release-rc.yaml deleted file mode 100644 index 12ec9fc..0000000 --- a/.gitea/workflows/release-rc.yaml +++ /dev/null @@ -1,72 +0,0 @@ -name: Release candidate -'on': - workflow_dispatch: - inputs: - dry_run: - description: Plan a cut without changing release state - type: boolean - default: false - finalize: - description: Retry finalization of an existing cut; do not cut another RC - type: boolean - default: false - recover: - description: Recover the same published RC after a deployment timeout; never - reupload - type: boolean - default: false - circle_build: - description: Original release run id (recovery only) - type: string - default: '' - merge_sha: - description: Existing cut merge_sha (finalize retry only) - type: string - default: '' - bump_sha: - description: Existing cut bump_sha (finalize retry only) - type: string - default: '' - branch: - description: Existing cut branch (finalize retry only) - type: string - default: '' - version: - description: Existing cut version (finalize retry only) - type: string - default: '' - pull_request: - types: - - closed - branches: - - develop -permissions: - contents: read -jobs: - release-rc: - # The old qbit-rc-published repository_dispatch is replaced by the already-proven workflow_dispatch - # completion: the publisher's own ActionsWRITE job dispatches this workflow with finalize=true plus - # merge_sha/bump_sha/branch/version (same inputs as the manual finalize retry). - permissions: - contents: write - actions: read - pull-requests: write - if: github.event_name == 'workflow_dispatch' - || (github.event_name == 'pull_request' && github.event.pull_request.merged - == true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name, - 'release:rc')) - uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/qbit-release-rc.yaml@qrun-release-v1 - with: - dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false - }} - finalize: ${{ github.event_name == 'workflow_dispatch' && inputs.finalize }} - recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false - }} - circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build - || '' }} - merge_sha: ${{ inputs.merge_sha || '' }} - bump_sha: ${{ inputs.bump_sha || '' }} - branch: ${{ inputs.branch || '' }} - version: ${{ inputs.version || '' }} - secrets: - QRUN_TAG_SIGNING_KEY: ${{ secrets.QRUN_TAG_SIGNING_KEY }} -- 2.54.0 From 021e86c202acf6c0e7011b2a81b26e8f0030e71b Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 6 Nov 2026 14:07:01 +0000 Subject: [PATCH 14/16] ci: pin shared workflow to v1 Co-Authored-By: Claude Opus 5.5 --- .gitea/workflows/build.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index ed42608..ce00351 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -3,5 +3,5 @@ on: push: jobs: maven: - uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@main + uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@v1 secrets: inherit -- 2.54.0 From d0f558dc60614011769a7438001e33fbcf09f786 Mon Sep 17 00:00:00 2001 From: James Maes Date: Sat, 7 Nov 2026 17:56:32 +0000 Subject: [PATCH 15/16] build(deps): align with Gitea registry (parent 2.2.1, QQQ 4.1.0-RC.26) pom.xml: parent -> 2.2.1 pom.xml: qqq.snapshot.version: 4.1.0-RC.7 -> 4.1.0-RC.26 pom.xml: qbit-email: 0.1.0-RC.3 -> 0.1.0-RC.11 pom.xml: added qqq.version=4.1.0-RC.26 (parent BOM import) Co-Authored-By: Claude Opus 5.5 --- pom.xml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pom.xml b/pom.xml index 0300453..4f5607e 100644 --- a/pom.xml +++ b/pom.xml @@ -22,6 +22,7 @@ + 4.1.0-RC.26 0.2.0-SNAPSHOT 21 21 @@ -33,7 +34,7 @@ com.kingsrook qbit-build-parent - 2.1.2 + 2.2.1 com.kingsrook.qbits @@ -87,7 +88,7 @@ com.kingsrook.qbits qbit-email - 0.1.0-RC.3 + 0.1.0-RC.11 true @@ -239,7 +240,7 @@ true - 4.1.0-RC.7 + 4.1.0-RC.26 -- 2.54.0 From 51ca0bb920a8952637efffc4d44b7760ed19438f Mon Sep 17 00:00:00 2001 From: James Maes Date: Sat, 7 Nov 2026 13:40:26 -0600 Subject: [PATCH 16/16] refactor(health): register CRM checks with the core health registry Replace the CrmHealthChecks STAND-IN (qqq#1199) adapter onto the qqq-middleware-health HealthIndicator with checks registered in the QQQ 4.1 core HealthChecksMetaData registry (crm.email, crm.sequence_lag, crm.lead_score). The core HealthRunner schedules them and runs each as the core system user. Drop the optional qqq-middleware-health dependency. Also keep the lead-score incremental watermark at millisecond precision to match modifyDate, fixing PlatformReuseTest skipping a same-millisecond change on a warm JVM. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 + README.md | 4 +- docs/DESIGN.md | 22 +-- pom.xml | 5 - .../kingsrook/qbits/crm/CrmQBitProducer.java | 2 + .../qbits/crm/health/CrmHealthChecks.java | 179 ++++++++++-------- .../processes/CalculateLeadScoresProcess.java | 6 +- .../qbits/crm/CrmHealthBulkLoadTest.java | 106 ++++++----- 8 files changed, 185 insertions(+), 143 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa157a9..1bd0a35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased / 0.2.0 +- Register CRM health checks (`crm.email`, `crm.sequence_lag`, `crm.lead_score`) with the QQQ 4.1 core health registry (`HealthChecksMetaData`, qqq#1199), replacing the `qqq-middleware-health` indicator stand-in and its optional dependency. + +- Lead scoring keeps its incremental watermark at millisecond precision, matching `modifyDate`, so a contact changed in the same millisecond a pass starts is not skipped. + - Read the CRM QBit metadata version from the Maven-filtered artifact version so release metadata matches the build. - Adopt QQQ 4.1.0-RC.7 native help bundles, replacing the RC.6 stand-in loader with host-neutral CRM help, live references and the core coverage gate. diff --git a/README.md b/README.md index 6c272b1..a075854 100644 --- a/README.md +++ b/README.md @@ -62,9 +62,9 @@ qInstance.setEnforcePermissionsInCoreActions(true); - `withApiName(...)` and `withApiVersion(...)`: publish main and settings tables into an existing host API definition. The host supplies `ApiInstanceMetaData` and includes optional `qqq-middleware-api` at runtime. Main tables declare core search fields. - `withEsbDestinationName(...)`: reserved but rejected on RC.6. Its ESB event factory copies all values and has no classified-field exclusion; CRM does not publish those payloads. - `CrmBulkLoadProfiles.ensure()`: call once in host startup after registering core's `SavedBulkLoadProfileMetaDataProvider`. It seeds three CSV mappings by label without replacing edited profiles. CRM does not register the core profile tables. -- `CrmHealthChecks.indicators(monitoringSession)`: register the returned indicators with the host health middleware. Include optional `qqq-middleware-health`; the supplied session determines tenant scope and is restored on worker threads. `run()` uses the caller's context. No check adds permissions or tenant keys. +- Health: the producer registers `crm.email`, `crm.sequence_lag` and `crm.lead_score` with the core health registry (`HealthChecksMetaData`). Hosts that enable core health monitoring (`HealthMetaDataProvider`) get them scheduled by the core `HealthRunner`, which runs each check as the core system user. `CrmHealthChecks.run()` evaluates them once in the caller's context. No check adds permissions or tenant keys or returns personal values. -The shipped help bundle and health adapter are temporary core integration adapters; their removal target is RC.9. See [P4B implementation notes](docs/P4B-IMPLEMENTATION.md) for RC.6 limits. +Help and health both use the core registries (`HelpBundles`, `HealthChecksMetaData`). See [P4B implementation notes](docs/P4B-IMPLEMENTATION.md) for RC.6 limits. | Scope | Permissions | |---|---| diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 6189ed5..01b2654 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -89,8 +89,8 @@ These parts are real and are kept, adapted to 0.2.0: | Imports | `importContacts` | core bulk load + saved bulk-load profiles | | PII | none | `DataClassification.PII` on every personal field; secured-data tiers apply | | Reporting | 14 bespoke widgets | home widgets for the daily view; trend reports via data analysis (`crm_*.analyze`) | -| Health | none | `crm` health checks (stand-in until core #1199) | -| Help | none | `help/qbit-crm.yaml` bundle (stand-in loader until core #1198) | +| Health | none | `crm.*` checks in the core health registry (`HealthChecksMetaData`) | +| Help | none | `help/qbit-crm.yaml` bundle registered with core `HelpBundles` | | Tenancy | clientId lock on 3 tables | configurable tenant key type on every table; the host maps Authentik claims to keys | ## Data model (33 tables: 6 main, 15 settings, 12 sub-tables) @@ -268,13 +268,14 @@ signals are logged. ### Health -`CrmHealthChecks` reports three checks: -- email transport readiness -- sequence lag (the oldest due enrollment more than 30 min late) -- the scoring job's last success time +`CrmHealthChecks` registers three checks with the core health registry +(`HealthChecksMetaData`, component `crm`): +- `crm.email`: email transport readiness +- `crm.sequence_lag`: the oldest due enrollment more than 30 min late +- `crm.lead_score`: the scoring job's last success time -It is a `STAND-IN(core=QRun-IO/qqq#1199, removeBy=4.1.0-RC.9)` adapter onto the -`qqq-middleware-health` `HealthIndicator` until the central `HealthCheck` SPI ships. +The core `HealthRunner` schedules them on hosts that enable health monitoring and runs each one +as the core system user. A failing check reports UNKNOWN with a fixed message. ### Help @@ -282,9 +283,8 @@ It is a `STAND-IN(core=QRun-IO/qqq#1199, removeBy=4.1.0-RC.9)` adapter onto the - the app - every table, field, action and possible-value source -`HelpBundleAssertions` checks that it is complete. Until `HelpBundles.register` is in a core RC, -`CrmHelpBundleStandIn` (`STAND-IN(core=QRun-IO/qqq#1198, removeBy=4.1.0-RC.9)`) loads the same -file into `QHelpContent`. +The producer registers it with core `HelpBundles.register`; `HelpBundleAssertions` checks that it +is complete. ### Contingent dropdowns diff --git a/pom.xml b/pom.xml index 4f5607e..20203a4 100644 --- a/pom.xml +++ b/pom.xml @@ -79,11 +79,6 @@ qqq-middleware-api true - - com.kingsrook.qqq - qqq-middleware-health - true - com.kingsrook.qbits diff --git a/src/main/java/com/kingsrook/qbits/crm/CrmQBitProducer.java b/src/main/java/com/kingsrook/qbits/crm/CrmQBitProducer.java index 0d07c9c..6697b18 100644 --- a/src/main/java/com/kingsrook/qbits/crm/CrmQBitProducer.java +++ b/src/main/java/com/kingsrook/qbits/crm/CrmQBitProducer.java @@ -46,6 +46,7 @@ import com.kingsrook.qbits.crm.deals.customizers.DealStageChangeCustomizer; import com.kingsrook.qbits.crm.deals.model.Deal; import com.kingsrook.qbits.crm.deals.model.DealProduct; import com.kingsrook.qbits.crm.email.CrmEmailProcessIdentity; +import com.kingsrook.qbits.crm.health.CrmHealthChecks; import com.kingsrook.qbits.crm.scoring.AutoAssignCustomizer; import com.kingsrook.qbits.crm.security.CrmDataPolicy; import com.kingsrook.qbits.crm.security.CrmPermissions; @@ -151,6 +152,7 @@ public class CrmQBitProducer implements QBitMetaDataProducer } CrmDataPolicy.apply(metaDataProducerMultiOutput, qInstance, qBitConfig); CrmIntegrations.apply(metaDataProducerMultiOutput, qBitConfig); + CrmHealthChecks.register(qInstance, qBitConfig); for(QWidgetMetaData widget : metaDataProducerMultiOutput.getEach(QWidgetMetaData.class)) { widget.setName(snakeCase(widget.getName())); diff --git a/src/main/java/com/kingsrook/qbits/crm/health/CrmHealthChecks.java b/src/main/java/com/kingsrook/qbits/crm/health/CrmHealthChecks.java index 16bf3b6..0569e3d 100644 --- a/src/main/java/com/kingsrook/qbits/crm/health/CrmHealthChecks.java +++ b/src/main/java/com/kingsrook/qbits/crm/health/CrmHealthChecks.java @@ -23,6 +23,7 @@ package com.kingsrook.qbits.crm.health; import java.time.Instant; import java.time.temporal.ChronoUnit; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import com.kingsrook.qbits.crm.CrmQBitConfig; @@ -32,9 +33,15 @@ import com.kingsrook.qbits.crm.email.CrmEmailTransport; import com.kingsrook.qbits.crm.scoring.JobState; import com.kingsrook.qbits.crm.scoring.processes.CalculateLeadScoresProcess; import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; -import com.kingsrook.qqq.backend.core.context.CapturedContext; -import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.alerts.AlertSeverity; import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.health.HealthCategory; +import com.kingsrook.qqq.backend.core.health.HealthCheck; +import com.kingsrook.qqq.backend.core.health.HealthCheckInput; +import com.kingsrook.qqq.backend.core.health.HealthCheckKind; +import com.kingsrook.qqq.backend.core.health.HealthCheckMetaData; +import com.kingsrook.qqq.backend.core.health.HealthCheckOutcome; +import com.kingsrook.qqq.backend.core.health.HealthChecksMetaData; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy; @@ -42,24 +49,25 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; -import com.kingsrook.qqq.backend.core.model.session.QSession; -import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession; -import com.kingsrook.qqq.middleware.health.HealthIndicator; -import com.kingsrook.qqq.middleware.health.model.metadata.HealthCheckResult; -import com.kingsrook.qqq.middleware.health.model.metadata.HealthStatus; /******************************************************************************* - ** STAND-IN(core=QRun-IO/qqq#1199, removeBy=4.1.0-RC.9) - ** CRM checks for hosts to register with their health middleware. Run in the - ** caller's tenant context when one is supplied, else as the core system user; - ** no check returns personal values. + ** CRM checks registered with the core health registry (HealthChecksMetaData). + ** The core HealthRunner schedules them and runs each one in its own automated + ** system-user context; no check returns personal values. *******************************************************************************/ public class CrmHealthChecks { + public static final String EMAIL = "crm.email"; + public static final String SEQUENCE_LAG = "crm.sequence_lag"; + public static final String LEAD_SCORE = "crm.lead_score"; + + private static final String COMPONENT = "crm"; + private final CrmEmailTransport transport; + /******************************************************************************* ** Use the same readiness decision as actual email delivery. *******************************************************************************/ @@ -69,6 +77,7 @@ public class CrmHealthChecks } + /******************************************************************************* ** Allow a host transport adapter. *******************************************************************************/ @@ -78,121 +87,141 @@ public class CrmHealthChecks } + /******************************************************************************* - ** Run all checks without registering tables or starting background workers. + ** Register the CRM checks with the instance's core health registry. + ** Production may run repeatedly; an already registered name is kept. *******************************************************************************/ - public Map run() throws QException + public static void register(QInstance instance, CrmQBitConfig config) { - return (Map.of("crm.email", email(), "crm.sequenceLag", sequenceLag(), "crm.leadScore", leadScore())); + new CrmHealthChecks(config).registerWith(instance); } + /******************************************************************************* - ** Expose individual checks through the RC.6 health SPI. With no caller - ** session (registration at startup), each check runs as the core system - ** user: token-based authentication modules (OAuth2, Auth0) reject any other - ** tokenless session, and the checks report only UP/DEGRADED, never values. + ** Register these checks (with this transport) with the core health registry. *******************************************************************************/ - public List indicators() + public void registerWith(QInstance instance) { - return (indicators(QContext.getQSession())); + HealthChecksMetaData registry = HealthChecksMetaData.ofOrWithNew(instance); + add(registry, EMAIL, "CRM email transport", HealthCategory.EMAIL, 300); + add(registry, SEQUENCE_LAG, "CRM sequence lag", HealthCategory.JOBS, 300); + add(registry, LEAD_SCORE, "CRM lead scoring", HealthCategory.JOBS, 900); } - /******************************************************************************* - ** Supply the host's monitoring session explicitly for middleware worker threads. - ** Its permissions and tenant keys are preserved; this adapter never grants keys. - ** A null session runs each check as the core system user. - *******************************************************************************/ - public List indicators(QSession session) - { - return (List.of(new Indicator("crm.email", session), new Indicator("crm.sequenceLag", session), new Indicator("crm.leadScore", session))); - } - /******************************************************************************* - ** One independently evaluated middleware indicator. + ** Run all checks once in the caller's context, without registering anything. *******************************************************************************/ - private class Indicator implements HealthIndicator + public Map run() throws QException { - private final String name; - private final QSession session; - - - /******************************************************************************* - ** Select a check. - *******************************************************************************/ - private Indicator(String name, QSession session) + Map outcomes = new LinkedHashMap<>(); + for(String name : List.of(EMAIL, SEQUENCE_LAG, LEAD_SCORE)) { - this.name = name; - this.session = session; + outcomes.put(name, check(name).run(new HealthCheckInput())); } + return (outcomes); + } - @Override - public String getName() + + /******************************************************************************* + ** The core SPI body for one named check. Failures are reported as UNKNOWN + ** with a fixed message, so no exception text (or value) leaves the check. + *******************************************************************************/ + public HealthCheck check(String name) + { + return (new HealthCheck() { - return (name); - } - - - @Override - public HealthCheckResult check(QInstance instance) - { - CapturedContext original = QContext.capture(); - try + /******************************************************************* + ** Evaluate the named check in the current QContext. + *******************************************************************/ + @Override + public HealthCheckOutcome run(HealthCheckInput input) { - QContext.init(instance, session != null ? session : new QSystemUserSession(instance)); - return (switch(name) + try { - case "crm.email" -> email(); - case "crm.sequenceLag" -> sequenceLag(); - default -> leadScore(); - }); + return (switch(name) + { + case EMAIL -> email(); + case SEQUENCE_LAG -> sequenceLag(); + case LEAD_SCORE -> leadScore(); + default -> throw (new IllegalArgumentException("Unknown CRM health check")); + }); + } + catch(QException | RuntimeException e) + { + return (HealthCheckOutcome.unknown("CRM check could not be completed")); + } } - catch(QException | RuntimeException e) - { - return (new HealthCheckResult().withStatus(HealthStatus.UNKNOWN).withDetail("reason", "CRM check could not be completed")); - } - finally - { - QContext.init(original); - } - } + }); } + + /******************************************************************************* + ** Add one check definition unless the name is already registered. + *******************************************************************************/ + private void add(HealthChecksMetaData registry, String name, String label, HealthCategory category, int intervalSeconds) + { + if(registry.getChecks().stream().anyMatch(existing -> name.equals(existing.getName()))) + { + return; + } + registry.withCheck(new HealthCheckMetaData() + .withName(name) + .withLabel(label) + .withComponent(COMPONENT) + .withCategory(category) + .withKind(HealthCheckKind.DEPENDENCY) + .withSeverity(AlertSeverity.WARNING) + .withIntervalSeconds(intervalSeconds) + .withTimeoutMs(5000) + .withCheckInstance(check(name))); + } + + + /******************************************************************************* ** An unconfigured transport is degraded, not a claim that email can be sent. *******************************************************************************/ - private HealthCheckResult email() throws QException + private HealthCheckOutcome email() throws QException { - return (new HealthCheckResult().withStatus(transport.readiness() == CrmReadiness.NOT_CONFIGURED ? HealthStatus.DEGRADED : HealthStatus.UP)); + return (transport.readiness() == CrmReadiness.NOT_CONFIGURED + ? HealthCheckOutcome.degraded("Email transport not configured") + : HealthCheckOutcome.up("Email transport ready")); } + /******************************************************************************* ** Query just the oldest active due enrollment. *******************************************************************************/ - private HealthCheckResult sequenceLag() throws QException + private HealthCheckOutcome sequenceLag() throws QException { List rows = new QueryAction().execute(new QueryInput("crm_sequence_enrollment").withFilter(new QQueryFilter() .withCriteria(new QFilterCriteria("status", QCriteriaOperator.EQUALS, CrmEnrollmentStatus.ACTIVE.getId())) .withCriteria(new QFilterCriteria("nextStepDate", QCriteriaOperator.LESS_THAN, Instant.now().minus(30, ChronoUnit.MINUTES))) .withOrderBy(new QFilterOrderBy("nextStepDate")).withLimit(1))).getRecords(); - return (new HealthCheckResult().withStatus(rows.isEmpty() ? HealthStatus.UP : HealthStatus.DEGRADED)); + return (rows.isEmpty() + ? HealthCheckOutcome.up("No sequence step is more than 30 minutes late") + : HealthCheckOutcome.degraded("A sequence step is more than 30 minutes late")); } + /******************************************************************************* ** No successful score pass, or one more than three hours old, needs attention. *******************************************************************************/ - private HealthCheckResult leadScore() throws QException + private HealthCheckOutcome leadScore() throws QException { List rows = new QueryAction().execute(new QueryInput(JobState.TABLE_NAME).withFilter(new QQueryFilter( new QFilterCriteria("name", QCriteriaOperator.EQUALS, CalculateLeadScoresProcess.NAME)).withLimit(1))).getRecords(); Instant last = rows.isEmpty() ? null : rows.getFirst().getValueInstant("lastSuccess"); - return (new HealthCheckResult().withStatus(last == null || last.isBefore(Instant.now().minus(3, ChronoUnit.HOURS)) - ? HealthStatus.DEGRADED : HealthStatus.UP)); + return (last == null || last.isBefore(Instant.now().minus(3, ChronoUnit.HOURS)) + ? HealthCheckOutcome.degraded("No lead score pass succeeded in the last 3 hours") + : HealthCheckOutcome.up("Lead scores are current")); } } diff --git a/src/main/java/com/kingsrook/qbits/crm/scoring/processes/CalculateLeadScoresProcess.java b/src/main/java/com/kingsrook/qbits/crm/scoring/processes/CalculateLeadScoresProcess.java index b601485..08bac1e 100644 --- a/src/main/java/com/kingsrook/qbits/crm/scoring/processes/CalculateLeadScoresProcess.java +++ b/src/main/java/com/kingsrook/qbits/crm/scoring/processes/CalculateLeadScoresProcess.java @@ -105,7 +105,11 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer @Override public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException { - Instant startedAt = Instant.now(); + //////////////////////////////////////////////////////////////////////// + // modifyDate is kept at millisecond precision; a microsecond watermark // + // would skip a change made in the same millisecond the pass started. // + //////////////////////////////////////////////////////////////////////// + Instant startedAt = Instant.now().truncatedTo(ChronoUnit.MILLIS); List states = new QueryAction().execute(new QueryInput(JobState.TABLE_NAME) .withFilter(new QQueryFilter(new QFilterCriteria("name", QCriteriaOperator.EQUALS, NAME)))).getRecords(); QRecord state = states.isEmpty() ? new QRecord().withValue("name", NAME) : states.getFirst(); diff --git a/src/test/java/com/kingsrook/qbits/crm/CrmHealthBulkLoadTest.java b/src/test/java/com/kingsrook/qbits/crm/CrmHealthBulkLoadTest.java index 91819fb..77b730c 100644 --- a/src/test/java/com/kingsrook/qbits/crm/CrmHealthBulkLoadTest.java +++ b/src/test/java/com/kingsrook/qbits/crm/CrmHealthBulkLoadTest.java @@ -33,14 +33,19 @@ import com.kingsrook.qbits.crm.health.CrmHealthChecks; import com.kingsrook.qbits.crm.scoring.processes.CalculateLeadScoresProcess; import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.health.HealthCheckInput; +import com.kingsrook.qqq.backend.core.health.HealthCheckMetaData; +import com.kingsrook.qqq.backend.core.health.HealthChecksMetaData; +import com.kingsrook.qqq.backend.core.health.HealthStatus; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData; import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; import com.kingsrook.qqq.backend.core.model.savedbulkloadprofiles.SavedBulkLoadProfileMetaDataProvider; +import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession; import com.kingsrook.qqq.backend.core.processes.implementations.bulk.insert.model.BulkLoadProfile; -import com.kingsrook.qqq.middleware.health.model.metadata.HealthStatus; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; @@ -51,27 +56,41 @@ import static org.assertj.core.api.Assertions.assertThat; class CrmHealthBulkLoadTest extends BaseTest { /******************************************************************************* - ** No transport or score run needs attention. + ** No transport or score run needs attention; the producer registered the + ** same checks with the core health registry. *******************************************************************************/ @Test void noTransportOrScoreRunNeedsAttention() throws Exception { var checks = new CrmHealthChecks(new CrmQBitConfig()); var results = checks.run(); - assertThat(results.get("crm.email").getStatus()).isEqualTo(HealthStatus.DEGRADED); - assertThat(results.get("crm.leadScore").getStatus()).isEqualTo(HealthStatus.DEGRADED); - assertThat(results.get("crm.sequenceLag").getStatus()).isEqualTo(HealthStatus.UP); - checks.indicators().forEach(indicator -> + assertThat(results.get(CrmHealthChecks.EMAIL).getStatus()).isEqualTo(HealthStatus.DEGRADED); + assertThat(results.get(CrmHealthChecks.LEAD_SCORE).getStatus()).isEqualTo(HealthStatus.DEGRADED); + assertThat(results.get(CrmHealthChecks.SEQUENCE_LAG).getStatus()).isEqualTo(HealthStatus.UP); + for(HealthCheckMetaData registered : crmChecks(QContext.getQInstance())) { - try - { - assertThat(indicator.check(QContext.getQInstance()).getStatus()).isEqualTo(results.get(indicator.getName()).getStatus()); - } - catch(Exception e) - { - throw (new AssertionError(e)); - } + assertThat(registered.getCheckInstance().run(new HealthCheckInput()).getStatus()).isEqualTo(results.get(registered.getName()).getStatus()); + } + } + + + /******************************************************************************* + ** The producer registers exactly the three CRM checks as valid core + ** definitions, and re-registration does not duplicate them. + *******************************************************************************/ + @Test + void producerRegistersCoreHealthChecks() + { + QInstance instance = QContext.getQInstance(); + assertThat(crmChecks(instance)).extracting(HealthCheckMetaData::getName) + .containsExactlyInAnyOrder(CrmHealthChecks.EMAIL, CrmHealthChecks.SEQUENCE_LAG, CrmHealthChecks.LEAD_SCORE); + assertThat(crmChecks(instance)).allSatisfy(check -> + { + assertThat(check.getCheckInstance()).isNotNull(); + assertThat(check.getTimeoutMs()).isLessThan(check.getIntervalSeconds() * 1000); }); + CrmHealthChecks.register(instance, new CrmQBitConfig()); + assertThat(crmChecks(instance)).hasSize(3); } @@ -101,8 +120,8 @@ class CrmHealthBulkLoadTest extends BaseTest .withValue("lastSuccess", Instant.now().minus(181, ChronoUnit.MINUTES))); com.kingsrook.qbits.crm.core.CrmRecords.update("crm_sequence_enrollment", new QRecord().withValue("id", 1) .withValue("nextStepDate", Instant.now().minus(31, ChronoUnit.MINUTES))); - assertThat(checks.run().get("crm.sequenceLag").getStatus()).isEqualTo(HealthStatus.DEGRADED); - assertThat(checks.run().get("crm.leadScore").getStatus()).isEqualTo(HealthStatus.DEGRADED); + assertThat(checks.run().get(CrmHealthChecks.SEQUENCE_LAG).getStatus()).isEqualTo(HealthStatus.DEGRADED); + assertThat(checks.run().get(CrmHealthChecks.LEAD_SCORE).getStatus()).isEqualTo(HealthStatus.DEGRADED); } @@ -120,7 +139,7 @@ class CrmHealthBulkLoadTest extends BaseTest throw (new IllegalStateException("unavailable")); } }); - assertThat(checks.indicators().getFirst().check(QContext.getQInstance()).getStatus()).isEqualTo(HealthStatus.UNKNOWN); + assertThat(checks.check(CrmHealthChecks.EMAIL).run(new HealthCheckInput()).getStatus()).isEqualTo(HealthStatus.UNKNOWN); } @@ -163,27 +182,36 @@ class CrmHealthBulkLoadTest extends BaseTest } /******************************************************************************* - ** Health middleware invokes checks in worker threads without a caller QContext. + ** The core HealthRunner runs each check on a worker thread with no caller + ** QContext, as a QSystemUserSession. A registered CRM check must succeed that + ** way on a host whose authentication rejects tokenless sessions (OAuth2, Auth0). *******************************************************************************/ @Test - void indicatorsCarryMonitoringSessionAndRestoreWorkerContext() throws Exception + void registeredChecksRunAsCoreSystemUserOnTokenRequiredHost() throws Exception { var instance = QContext.getQInstance(); - var indicator = new CrmHealthChecks(new CrmQBitConfig()).indicators(QContext.getQSession()).get(1); + instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData() + .withName("token-required") + .withType(QAuthenticationType.MOCK) + .withModuleCodeReference(new QCodeReference(TokenRequiredAuthenticationModule.class))); + HealthCheckMetaData sequenceLag = crmChecks(instance).stream() + .filter(check -> CrmHealthChecks.SEQUENCE_LAG.equals(check.getName())).findFirst().orElseThrow(); var status = java.util.concurrent.CompletableFuture.supplyAsync(() -> { + assertThat(QContext.getQInstance()).isNull(); + QContext.init(instance, new QSystemUserSession(instance)); try { - assertThat(QContext.getQInstance()).isNull(); - var result = indicator.check(instance); - assertThat(QContext.getQInstance()).isNull(); - assertThat(QContext.getQSession()).isNull(); - return (result.getStatus()); + return (sequenceLag.getCheckInstance().run(new HealthCheckInput().withQInstance(instance)).getStatus()); } catch(Exception e) { throw (new AssertionError(e)); } + finally + { + QContext.clear(); + } }).get(); assertThat(status).isEqualTo(HealthStatus.UP); assertThat(QContext.getQInstance()).isSameAs(instance); @@ -192,32 +220,12 @@ class CrmHealthBulkLoadTest extends BaseTest /******************************************************************************* - ** Regression: indicators registered with no caller session (at host startup) - ** must still run on a host whose authentication rejects tokenless sessions - ** (OAuth2, Auth0). They used a plain new QSession(), so every check failed - ** session validation and reported UNKNOWN. + ** The CRM definitions in the core health registry. *******************************************************************************/ - @Test - void contextlessIndicatorsRunOnTokenRequiredHost() throws Exception + private static List crmChecks(QInstance instance) { - var instance = QContext.getQInstance(); - instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData() - .withName("token-required") - .withType(QAuthenticationType.MOCK) - .withModuleCodeReference(new QCodeReference(TokenRequiredAuthenticationModule.class))); - var status = java.util.concurrent.CompletableFuture.supplyAsync(() -> - { - try - { - var indicator = new CrmHealthChecks(new CrmQBitConfig()).indicators().get(1); - return (indicator.check(instance).getStatus()); - } - catch(Exception e) - { - throw (new AssertionError(e)); - } - }).get(); - assertThat(status).isEqualTo(HealthStatus.UP); + return (HealthChecksMetaData.ofOrWithNew(instance).getChecks().stream() + .filter(check -> "crm".equals(check.getComponent())).toList()); } } -- 2.54.0