Compare commits

...
Author SHA1 Message Date
james.maes 8425e6c673 Merge pull request 'build(deps): align with Gitea registry (parent 2.2.1, QQQ 4.1.0-RC.26)' (#45) from deps/align-gitea into develop
build / maven (push) Successful in 1m36s
maven / build (push) Successful in 1m36s
2026-11-07 19:50:25 +00:00
James MaesandClaude Opus 5.5 39c86cdcae refactor(help): register the help bundle with core HelpBundles
build / maven (push) Successful in 1m3s
maven / build (push) Successful in 1m3s
Replace the MarketingHelp STAND-IN (qqq#1198), which applied
help/qbit-marketing.yaml straight onto app and table metadata, with
core HelpBundles.register in the producer. Core's startup sync and
HelpTextRenderer now apply and resolve the bundle; the QBIT layer keeps
host help winning.

HelpBundleCoverageTest now runs core HelpBundleAssertions over the
qbit's own objects (QQQ's generated bulk processes stay core's), plus
widget/possible-value-source coverage and non-blank content, which the
core gate does not require. The empty-state help guardrail checks the
registered bundle as rendered by core, and a HOST-layer fixture proves
host help is not overridden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 13:45:24 -06:00
james.maesandClaude Opus 5.5 ec6e382b69 build(deps): align with Gitea registry (parent 2.2.1, QQQ 4.1.0-RC.26)
build / maven (push) Canceled after 1m10s
maven / build (push) Canceled after 1m10s
pom.xml: parent -> 2.2.1
pom.xml: qqq.snapshot.version: 4.1.0-RC.6 -> 4.1.0-RC.26
pom.xml: qbit-assistant.version: 0.1.0-RC.12 -> 0.1.0-RC.25
pom.xml: qbit-email.version: 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 <noreply@anthropic.com>
2026-11-07 17:57:07 +00:00
james.maes f4d3f2c105 Merge pull request 'ci: pin shared workflow to v1' (#44) from ci/pin-v1-develop into develop
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (push) Successful in 13s
QRun-IO/ci-policy: native / native-result (push) Successful in 12s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Successful in 10s
build / maven (push) Successful in 5m7s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
maven / build (push) Successful in 5m7s
2026-11-06 16:08:31 +00:00
james.maesandClaude Opus 5.5 5d687b37a7 ci: pin shared workflow to v1
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (pull_request) Successful in 9s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
build / maven (push) Successful in 3m7s
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Waiting to run
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 7s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
maven / build (push) Successful in 3m7s
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 14:10:55 +00:00
james.maes a369e3c7b0 Merge pull request 'ci: use shared ci-workflows Maven workflow' (#43) from ci/ci-workflows into develop
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 5s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Successful in 6s
build / maven (push) Successful in 2m19s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow d20a578c7d818bc7ca32a2ac2c675da4e4f27385] (push) Successful in 6s
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
maven / build (push) Successful in 2m18s
2026-11-05 23:17:07 +00:00
james.maesandClaude Opus 5.5 9e465d7b7c ci: use shared ci-workflows Maven workflow
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1b3648d9421543350067e95e54959dfb471b26d5] (pull_request) Successful in 6s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 1m41s
build / maven (push) Successful in 2m32s
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Failing after 2s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Failing after 16s
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Failing after 7m14s
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 5s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 6s
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
maven / build (push) Successful in 2m33s
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 <noreply@anthropic.com>
2026-11-05 22:06:02 +00:00
forge-recovery fe4970e10c ci: enroll shared Gitea lifecycle
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
Shared Java Maven CI / secrets (push) Successful in 37s
QRun-IO/ci-policy: Shared Java Maven CI / secrets (push) Successful in 32s
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 9s
Shared Java Maven CI / verify (push) Successful in 1m53s
QRun-IO/ci-policy: Shared Java Maven CI / verify (push) Successful in 1m41s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 4s
qrun-release / postpublish (push) Successful in 4m39s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
Shared Java Maven CI / gate (push) Successful in 12s
qrun-release / release (push) Successful in 48m37s
QRun-IO/ci-policy: Shared Java Maven CI / gate (push) Successful in 10s
QRun-IO/ci-policy: ci / java-maven-6 (push) Successful in 48m54s
Shared QRun source version / version (push) Successful in 4m38s
2026-11-03 18:09:54 +00:00
forge-recovery f90a1192b4 ci: enroll shared Gitea lifecycle
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Successful in 3s
QRun-IO/ci-policy: Shared Java Maven CI / verify (push) Successful in 1m29s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 9s
QRun-IO/ci-policy: Shared Java Maven CI / secrets (push) Successful in 47s
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / gate (push) Successful in 14s
QRun-IO/ci-policy: ci / java-maven-6 (push) Successful in 1h13m54s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
2026-11-02 22:34:49 +00:00
forge-recoveryandCodex 4c79c330b4 ci: enroll shared Gitea release-candidate lifecycle
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Successful in 31s
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 1m48s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Successful in 19s
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Successful in 10m45s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 9s
Release candidate / release-rc (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 8s
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 20:17:48 +00:00
9b01e6087c ci: add the isolated postpublish job to the QRun release caller
Shared Java Maven CI / secrets (push) Failing after 18s
Shared Java Maven CI / verify (push) Canceled after 0s
qrun-release / postpublish (push) Canceled after 0s
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Successful in 21s
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 1m20s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Successful in 12s
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Successful in 21m41s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 4s
Release candidate / release-rc (pull_request) Skipped
Shared Java Maven CI / gate (push) Canceled after 0s
qrun-release / release (push) Canceled after 2h36m23s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 5s
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 15:50:17 +00:00
5eabd24f9b ci: move the QRun release caller to the thin v2 contract
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-2 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Canceled after 0s
Shared Java Maven CI / verify (push) Canceled after 0s
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 4s
Shared Java Maven CI / secrets (push) Failing after 38s
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Failing after 30s
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 2m33s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-4 (pull_request) Canceled after 48m23s
Shared Java Maven CI / gate (push) Canceled after 0s
qrun-release / release (push) Canceled after 48m51s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 6s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 06:18:52 +00:00
forge-recoveryandCodex fd061f6e8c ci: add the identical QRun release caller
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 5s
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-2 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-7 (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Failing after 2s
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Failing after 2s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Failing after 1s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-4 (pull_request) Failing after 2m8s
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 04:12:59 +00:00
james.maesandCodex 4849764e2d ci: adopt managed orb 0.8 patch track (#37)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 15:35:08 -05:00
james.maesandCodex 4761a0242b ci: adopt shared orb 0.7.18 (#36)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 14:46:20 -05:00
james.maesandCodex 1bb2371d75 ci: adopt shared orb 0.7.17 (#35)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 14:15:49 -05:00
james.maesandCodex a01ad9487f ci: centralize QBit source scanning (#32)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 10:41:48 -05:00
james.maesandCodex 95ae9ff23d ci: adopt central orb 0.7.12 and stable gates (#31)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 08:53:54 -05:00
james.maesandCodex eab599edac ci: centralize rc release completion in qqq orb (#30)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 02:25:24 -05:00
james.maesandCodex bd0f76cad5 ci: adopt shared orb 0.7.11 (#29)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 00:59:21 -05:00
james.maes f8d67b28c9 Merge pull request #28 from QRun-IO/feature/central-ci
ci: adopt shared QQQ orb standard
2026-10-20 19:07:02 -05:00
james.maesandCodex e4bb261ead ci: adopt verified central QQQ orb 0.7.3
Co-Authored-By: Codex <noreply@openai.com>
2026-10-20 19:02:59 -05:00
james.maes 6906859648 Merge pull request #27 from QRun-IO/feature/marketing-business-home
feat(widgets): business-only Marketing home; omit widgets without read
2026-10-16 10:19:33 -05:00
james.maesandClaude Sonnet 5.5 1c3d163104 feat(widgets): business-only Marketing home; omit widgets without read
Take Configuration Status and Agent Activity (provider configuration, model/tool
calls and tokens) off the home; expose them as MarketingWidgets.ADMIN_WIDGETS
gated on marketing.admin.read. Needs Attention gains a business-worded row per
active channel that cannot send. Home and record widgets now require
marketing.read via MarketingPermissionChecker (HIDDEN) and Base.render no longer
returns a "You need marketing.read" shell.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 09:12:20 -05:00
james.maes 3803e3e398 Merge pull request #26 from QRun-IO/ci/release-rc-secrets-inherit
ci(release-rc): pass secrets so the shared workflow can sign the RC tag
2026-10-16 08:04:21 -05:00
james.maesandClaude Opus 5.5 cb15fc0c27 ci(release-rc): pass secrets to the shared workflow so it can sign the RC tag
QRun-IO/.github#2 adds a tag-and-release job that signs vX with the
QRUN_TAG_SIGNING_KEY secret after Maven Central confirms the artifact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:21:02 -05:00
james.maes 171112162a Merge pull request #25 from QRun-IO/fix/help-bundle-version
fix(help): help bundle version comes from the build, not a hardcoded SNAPSHOT
2026-10-14 22:37:20 -05:00
james.maesandClaude Opus 5.5 b733cba229 fix(help): the help bundle carries the artifact version (Maven-filtered) instead of a hardcoded SNAPSHOT
On release branches the bundle's version and the producer's differed, so the
bundle test that asserts they match failed, and released bundles recorded
SNAPSHOT as their provenance. help/*.yaml is now filtered:
version: ${project.version}.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:10:27 -05:00
james.maes c232014d28 Merge pull request #24 from QRun-IO/feature/tables-populated-defaults
feat: channel rules staleness, table help applied at run time with how rows get created
2026-10-14 17:38:28 -05:00
james.maesandClaude Opus 5.5 4bd28c5764 feat: channel rules staleness check, table help applied at run time with how rows get created
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:30:22 -05:00
james.maes 89f1c29d49 Merge pull request #23 from QRun-IO/fix/widget-timezone
fix(widgets): widget dates in the user's time zone
2026-10-14 12:37:50 -05:00
22 changed files with 948 additions and 451 deletions

No files matched your search

+11 -57
View File
@@ -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.8
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.8'
filters:
branches:
ignore: automation/qbit-rc-pending
tags:
only: /^v\d+\.\d+\.\d+$/
+7
View File
@@ -0,0 +1,7 @@
name: build
on:
push:
jobs:
maven:
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@v1
secrets: inherit
+23
View File
@@ -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
+62 -13
View File
@@ -1,25 +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
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@cc30f599e63c8e41629c99d23395bac1adb8bc44
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
+16
View File
@@ -0,0 +1,16 @@
profile: qbit
orb_version: '0.8'
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: true
+7
View File
@@ -4,7 +4,14 @@ All notable changes to this project are documented here. The format follows Keep
## [0.1.0] - Unreleased
### Added
- Check Readiness warns when a channel's `rulesVerifiedDate` is older than the new `staleChannelRulesDays` (default 90), on a campaign whose content uses the channel, like `staleSourceDays` does for evidence sources. The warning, and the "rules not verified" one, now quote the channel's `rulesSourceUrl`. The agent's campaign view also shows the lead qualification note.
- The help bundle is registered with the QQQ core help registry (`HelpBundles.register`, qqq#1198), replacing the `MarketingHelp` stand-in, so it shows when a table is empty on hosts with core help content. `HelpBundleCoverageTest` uses core `HelpBundleAssertions`. Every table says "How rows get created" and names **Load Verified Starter Data** where it seeds the table. The bundle version is `1`.
- `MarketingTablesGuardrailTest` and `ChannelRulesStalenessTest`.
### Changed
- The Marketing home is business content only: **Configuration Status** and **Agent Activity** (provider configuration, model/tool calls and tokens) are off the home and exposed as `MarketingWidgets.ADMIN_WIDGETS` for a host to place on its Admin area; they need `marketing.admin.read` (hidden without it). **Needs Attention** gains one row per active channel that cannot send ("Sends on <channel> are paused - ask an admin"), linking to the channel.
- Home and record widgets require `marketing.read` through `MarketingPermissionChecker` (HIDDEN): a user with app access but no read no longer sees a "You need marketing.read ..." shell; the widgets are omitted.
- `QbitCrmGateway` uses qbit-crm 0.2.0's `crm_*` snake_case table names (`crm_contact`, ...). qbit-crm 0.1.0's camelCase tables are no longer supported.
- Make the agent limits distributed (#4): `maxConcurrentAgentRuns` counts Running `marketing_agent_run` rows in the database (a new `heartbeatAt` column, updated on each model call, lets runs whose server stopped be marked Failed first), and `maxModelCallsPerMinute` counts model-call steps in the last 60 seconds and makes a run wait rather than fail. The in-memory semaphore and rate window are gone; the settings and Marketing Policy override keep their names and defaults (the policy `maxConcurrentAgentRuns` override now applies). Hosts add `heartbeat_at` to `marketing_agent_run`. The count-then-insert window is still racy; a conditional-update claim (qqq#1178) is a follow-up.
- Move to the assistant provider SPI `0.1.0-RC.12` (package `com.kingsrook.qbits.assistant.spi`) and `qbit-build-parent` `2.1.2`, and drop the per-repository guardrail allow-lists. `qbit-assistant-provider-openai` becomes a test dependency; hosts that register it add it themselves.
+5 -1
View File
@@ -102,7 +102,11 @@ Set any existing Marketing Policy provider override to `openai-compatible` as we
The help text ships with the code it describes, as a QQQ help bundle at `help/qbit-marketing.yaml` (a classpath resource, layer `QBIT`). It has 346 entries: the Marketing app, every `marketing_*` table and each of its non-trivial fields, every record and table action, every widget (the dashboard widgets and the child lists) and every possible-value source. Each entry has a one-line summary for the tooltip and a few sentences of plain help. Labels, maximum lengths and enum values are `{{...}}` references, so the help does not repeat what the metadata already says.
The bundle is loaded with `HelpBundles.register(qInstance, "help/qbit-marketing.yaml")` once qqq#1198 ships. The producer will register it then; until that core API exists, nothing registers it. `HelpBundleCoverageTest` checks that the bundle is complete, that every key and reference resolves, and that summaries stay short and plain.
The producer registers the bundle with the QQQ core help registry (`HelpBundles.register`, qqq#1198). Registration is metadata-only; on hosts that install core help content (`HelpContentMetaDataProvider`), core's startup sync writes the entries to `helpContent` and core resolves the `{{...}}` references when it serves them, so the table help shows when a table is empty. Because the bundle is at the `QBIT` layer, a host bundle or host-edited help for the same key wins. Every table entry says **How rows get created** and names the **Load Verified Starter Data** action (Marketing Product) for the eight tables it seeds. `HelpBundleCoverageTest` runs core's `HelpBundleAssertions` gate (complete coverage, every key and reference resolves, short plain summaries) and also checks widget and possible-value-source coverage.
## Starter data and setup
A new install has no products, channels or policy row, and the agent and execution stay off until the policy row exists. Run **Load Verified Starter Data** (Marketing Product table actions, needs `marketing.prepare`) once: it adds the four starter channels, the `default` policy and three verified products with their sources, claims, audiences, draft campaigns and measurement plans. It is idempotent and never changes what exists. See [docs/STARTER-DATA.md](docs/STARTER-DATA.md). The Configuration Status widget on the home shows what is configured. The other tables (content, approvals, executions, attempts, test deliveries, agent runs and steps, findings, outcome events and daily results) hold work and results and start empty by design; their help says how rows get created. Channel rules go stale: Check Readiness warns when a channel's `rulesVerifiedDate` is older than `staleChannelRulesDays` (default 90).
## Email through qbit-email
+9
View File
@@ -0,0 +1,9 @@
# Upgrading
## Unreleased
No schema change.
- The help bundle is now applied at run time, so tables show their help (including how rows get created) when empty. A host's own table help is kept.
- New config `staleChannelRulesDays` (default 90). Check Readiness on a campaign now also warns when a channel its content uses has a `rulesVerifiedDate` older than that. Hosts that do not want it set a large number.
- New hosts: run **Load Verified Starter Data** once to get the starter channels, the `default` policy and the starter products.
+1
View File
@@ -23,6 +23,7 @@
| `requireDifferentApprover` | `false` | Four-eyes: the author of a revision can't approve it. |
| `aiDisclosureText` | `Prepared with AI assistance.` | Required in agent-written text for channels that allow AI content *with disclosure*. |
| `staleSourceDays` | 90 | When evidence counts as stale. |
| `staleChannelRulesDays` | 90 | When a channel's `rulesVerifiedDate` counts as stale: Check Readiness warns on a campaign whose content uses the channel, and points at the channel's `rulesSourceUrl`. |
| `crmGateway` | none | `QbitCrmGateway` for qbit-crm, or your own `MarketingCrmGateway`. |
**Fail closed:** without the policy row, or unless a switch is explicitly on, the agent and execution are **off**. **Load Verified Starter Data** creates the row, or you can create it by hand. Configuration Status shows the policy state.
+7 -4
View File
@@ -18,22 +18,23 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<properties>
<qqq.version>4.1.0-RC.26</qqq.version> <!-- feeds qbit-build-parent's qqq-bom-pom import -->
<revision>0.1.0-SNAPSHOT</revision>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<jacoco.version>0.8.15</jacoco.version>
<!-- The provider SPI (com.kingsrook.qbits.assistant.spi) only: no assistant runtime. -->
<qbit-assistant.version>0.1.0-RC.12</qbit-assistant.version>
<qbit-assistant.version>0.1.0-RC.25</qbit-assistant.version>
<qbit.guardrails.enforce>true</qbit.guardrails.enforce>
<qbit.guardrails.strictUiApiRoutes>true</qbit.guardrails.strictUiApiRoutes>
<qbit-email.version>0.1.0-RC.3</qbit-email.version>
<qbit-email.version>0.1.0-RC.11</qbit-email.version>
<h2.version>2.3.232</h2.version>
</properties>
<parent>
<groupId>com.kingsrook</groupId>
<artifactId>qbit-build-parent</artifactId>
<version>2.1.2</version>
<version>2.2.1</version>
</parent>
<groupId>com.kingsrook.qbits</groupId>
@@ -136,6 +137,7 @@
<directory>src/main/resources</directory>
<filtering>true</filtering>
<includes>
<include>help/*.yaml</include>
<include>qbit-marketing.properties</include>
</includes>
</resource>
@@ -143,6 +145,7 @@
<directory>src/main/resources</directory>
<filtering>false</filtering>
<excludes>
<exclude>help/*.yaml</exclude>
<exclude>qbit-marketing.properties</exclude>
</excludes>
</resource>
@@ -206,7 +209,7 @@
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.6</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.26</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -83,6 +83,7 @@ public class MarketingQBitConfig implements QBitConfig
private String aiDisclosureText = "Prepared with AI assistance.";
private Integer staleSourceDays = 90;
private Integer staleChannelRulesDays = 90;
private transient MarketingCrmGateway crmGateway;
/** Sources that are Marketing's own and cannot be an importer's name. */
@@ -107,6 +108,7 @@ public class MarketingQBitConfig implements QBitConfig
"agent limits (maxToolCallsPerRun, maxModelCallsPerRun, maxRunSeconds, maxConcurrentAgentRuns) must be positive", errors);
assertCondition(positive(defaultMaxAttempts) && positive(workerBatchSize) && positive(leaseSeconds) && positive(maxRecipientsPerExecution),
"execution limits (defaultMaxAttempts, workerBatchSize, leaseSeconds, maxRecipientsPerExecution) must be positive", errors);
assertCondition(positive(staleChannelRulesDays), "staleChannelRulesDays must be positive", errors);
if(StringUtils.hasContent(providerName))
{
assertCondition(modelProviders.containsKey(providerName), "providerName [" + providerName + "] is not one of the registered model providers " + modelProviders.keySet(), errors);
@@ -790,6 +792,28 @@ public class MarketingQBitConfig implements QBitConfig
/*******************************************************************************
** Getter for staleChannelRulesDays: how long a channel's rulesVerifiedDate
** stays current before Check Readiness asks for the rules to be re-read.
*******************************************************************************/
public Integer getStaleChannelRulesDays()
{
return (this.staleChannelRulesDays);
}
/*******************************************************************************
** Fluent setter for staleChannelRulesDays
*******************************************************************************/
public MarketingQBitConfig withStaleChannelRulesDays(Integer staleChannelRulesDays)
{
this.staleChannelRulesDays = staleChannelRulesDays;
return (this);
}
/*******************************************************************************
** Getter for staleSourceDays
*******************************************************************************/
@@ -32,6 +32,7 @@ import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
@@ -70,6 +71,7 @@ public class MarketingQBitProducer implements QBitMetaDataProducer<MarketingQBit
public static final String GROUP_ID = "com.kingsrook.qbits";
public static final String ARTIFACT_ID = "qbit-marketing";
public static final String VERSION = loadVersion();
public static final String HELP_BUNDLE = "help/qbit-marketing.yaml";
private MarketingQBitConfig qBitConfig;
private String namespace;
@@ -200,6 +202,11 @@ public class MarketingQBitProducer implements QBitMetaDataProducer<MarketingQBit
}
qInstance.addApp(builder.buildApp(MarketingWidgets.HOME_WIDGETS));
///////////////////////////////////////////////////////////////////////////
// help: register the bundle with core; registration is metadata-only and //
// core's startup sync applies it on hosts that have the helpContent table //
///////////////////////////////////////////////////////////////////////////
HelpBundles.register(qInstance, HELP_BUNDLE, MarketingQBitProducer.class.getClassLoader());
////////////////////////////////////
// runtime and kernel tools //
@@ -248,6 +248,7 @@ public final class MarketingTools
c.put("lead_qualification", Boolean.TRUE.equals(campaign.getLeadQualificationEnabled())
? "on: at least " + campaign.getQualifyMinFormSubmissions() + " attributed form submission(s)" + (Boolean.TRUE.equals(campaign.getQualifyRequireEmailConsent()) ? " and email consent" : "")
: "off (no leads are qualified)");
c.put("lead_qualification_note", campaign.getLeadQualificationNote());
if(campaign.getAudienceId() != null)
{
@@ -152,6 +152,7 @@ public class ReadinessChecker
MarketingProduct product = new MarketingProduct(MarketingStore.require(MarketingProduct.TABLE_NAME, campaign.getProductId()));
Integer pid = campaign.getProductId();
findings.clear();
int channelRulesStaleDays = MarketingRuntime.current().getConfig().getStaleChannelRulesDays();
ProductMaturity maturity = ProductMaturity.getById(product.getMaturity());
CampaignGoal goal = CampaignGoal.getById(campaign.getGoal());
@@ -203,11 +204,24 @@ public class ReadinessChecker
{
continue;
}
String rulesUrl = channel.getValueString("rulesSourceUrl");
String readIt = StringUtils.hasContent(rulesUrl) ? "Read " + rulesUrl + " and record" : "Read the channel's current rules and record";
if(AiContentPolicy.UNKNOWN.getId().equals(channel.getValueInteger("aiContentPolicy")) || channel.getValue("rulesVerifiedDate") == null)
{
add(pid, campaignId, contentRecord.getValueInteger("id"), null, FindingType.CHANNEL_RULE, FindingSeverity.WARNING,
"Channel rules not verified: " + channel.getValueString("name"),
"The channel's rules (including on AI-generated content and automation) are not recorded as verified.", "Read the channel's current rules and record them on the channel.");
"The channel's rules (including on AI-generated content and automation) are not recorded as verified.", readIt + " them on the channel.");
}
else if(isStale(channel.getValueLocalDate("rulesVerifiedDate"), channelRulesStaleDays))
{
////////////////////////////////////////////////////////////////////////
// same idea as a stale evidence source: rules change, so a check //
// older than staleChannelRulesDays has to be repeated //
////////////////////////////////////////////////////////////////////////
add(pid, campaignId, contentRecord.getValueInteger("id"), null, FindingType.CHANNEL_RULE, FindingSeverity.WARNING,
"Channel rules may be out of date: " + channel.getValueString("name"),
"The channel's rules were last verified " + channel.getValueLocalDate("rulesVerifiedDate") + " (over " + channelRulesStaleDays + " days ago).",
readIt + " the date, and change the channel's AI-content and automation settings if its rules changed.");
}
}
@@ -0,0 +1,122 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.marketing.security;
import java.util.Map;
import com.kingsrook.qqq.backend.core.actions.permissions.AvailablePermission;
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionSubType;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.metadata.code.InitializableViaCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReferenceWithProperties;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.DenyBehavior;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithName;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
/*******************************************************************************
** Gates a widget on one exact permission name (marketing.read for the home
** widgets, marketing.admin.read for the admin-placeable ones). QQQ's widget
** rules can only express "<base>.hasAccess", so this checker is how a widget
** requires a read permission. Denied widgets are HIDDEN: omitted from the
** dashboard rather than shown locked.
*******************************************************************************/
public class MarketingPermissionChecker implements CustomPermissionChecker, InitializableViaCodeReference
{
public static final String PERMISSION_PROPERTY = "permissionName";
private String permissionName;
/*******************************************************************************
** Permission rules requiring the named permission; hidden when missing.
*******************************************************************************/
public static QPermissionRules rules(String permissionName)
{
return (new QPermissionRules()
.withLevel(PermissionLevel.HAS_ACCESS_PERMISSION)
.withDenyBehavior(DenyBehavior.HIDDEN)
.withCustomPermissionChecker(new QCodeReferenceWithProperties(MarketingPermissionChecker.class, Map.of(PERMISSION_PROPERTY, permissionName))));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public void initialize(QCodeReference codeReference)
{
if(codeReference instanceof QCodeReferenceWithProperties withProperties)
{
this.permissionName = ValueUtils.getValueAsString(withProperties.getProperties().get(PERMISSION_PROPERTY));
}
}
/*******************************************************************************
** Deny unless the session holds the permission (fail closed). The system
** user (scheduler) passes.
*******************************************************************************/
@Override
public void checkPermissionsThrowing(AbstractActionInput actionInput, MetaDataWithPermissionRules metaDataWithPermissionRules) throws QPermissionDeniedException
{
QSession session = QContext.getQSession();
if(session instanceof QSystemUserSession)
{
return;
}
if(permissionName == null || session == null || !session.hasPermission(permissionName))
{
throw (new QPermissionDeniedException("Permission denied."));
}
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public boolean handlesBuildAvailablePermission()
{
return (true);
}
/*******************************************************************************
** The exact permission name.
*******************************************************************************/
@Override
public AvailablePermission buildAvailablePermission(QPermissionRules rules, PermissionSubType permissionSubType, String baseName, MetaDataWithName metaDataWithName, String objectType)
{
AvailablePermission permission = buildBaseAvailablePermission(rules, permissionSubType, metaDataWithName, objectType);
return (permission == null || permissionName == null ? null : permission.withName(permissionName));
}
}
@@ -48,11 +48,14 @@ import com.kingsrook.qbits.marketing.model.enums.ContentStatus;
import com.kingsrook.qbits.marketing.model.enums.ExecutionStatus;
import com.kingsrook.qbits.marketing.model.enums.FindingSeverity;
import com.kingsrook.qbits.marketing.model.enums.FindingStatus;
import com.kingsrook.qbits.marketing.security.MarketingPermissionChecker;
import com.kingsrook.qbits.marketing.security.MarketingPermissions;
import com.kingsrook.qbits.marketing.transport.MarketingTransport;
import com.kingsrook.qqq.backend.core.actions.ai.output.AiLinkBuilder;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
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;
@@ -75,15 +78,19 @@ import com.kingsrook.qqq.backend.core.utils.ValueUtils;
** The Marketing widgets: the app home dashboard (the one Marketing
** overview - other apps only link to it) and two record widgets.
**
** - Home: Needs Attention, Campaigns, Agent Activity, Configuration Status.
** - Home: Needs Attention, Campaigns (business content only).
** - Admin-placeable (not on the home): Agent Activity (model/tool calls and
** tokens) and Configuration Status (provider configuration) are system
** content; a host places ADMIN_WIDGETS on its Admin area. They need
** marketing.admin.read.
** - Campaign record: Results (per stage; "Not measured" is never shown as 0).
** - Content record: Proposed Publication (the exact text, destination,
** recipients and schedule a reviewer approves).
**
** All are full-width tables of at most three columns (or one HTML block),
** so they read at every width without inner scrolling. All data is real;
** with none, they say so. Renderers require marketing.read and read with
** the viewer's record locks.
** with none, they say so. Home widgets require marketing.read (omitted, never
** shown locked, without it) and read with the viewer's record locks.
*******************************************************************************/
public final class MarketingWidgets
{
@@ -96,7 +103,12 @@ public final class MarketingWidgets
private static final DateTimeFormatter WHEN_FORMAT = DateTimeFormatter.ofPattern("MMM d, yyyy h:mm a z", Locale.US);
public static final List<String> HOME_WIDGETS = List.of(NEEDS_ATTENTION, CAMPAIGNS, AGENT_ACTIVITY, CONFIGURATION);
public static final List<String> HOME_WIDGETS = List.of(NEEDS_ATTENTION, CAMPAIGNS);
/////////////////////////////////////////////////////////////////////////
// system content: for a host to place on its Admin area, not the home //
/////////////////////////////////////////////////////////////////////////
public static final List<String> ADMIN_WIDGETS = List.of(AGENT_ACTIVITY, CONFIGURATION);
@@ -115,12 +127,12 @@ public final class MarketingWidgets
public static List<QWidgetMetaData> buildWidgets()
{
return (List.of(
widget(NEEDS_ATTENTION, "Needs Attention", WidgetType.TABLE, NeedsAttention.class),
widget(CAMPAIGNS, "Campaigns", WidgetType.TABLE, Campaigns.class),
widget(AGENT_ACTIVITY, "Agent Activity", WidgetType.TABLE, AgentActivity.class),
widget(CONFIGURATION, "Configuration Status", WidgetType.TABLE, Configuration.class),
widget(CAMPAIGN_RESULTS, "Results", WidgetType.TABLE, CampaignResults.class),
widget(CONTENT_PROPOSAL, "Proposed Publication", WidgetType.HTML, ContentProposal.class)));
widget(NEEDS_ATTENTION, "Needs Attention", WidgetType.TABLE, NeedsAttention.class, MarketingPermissions.READ),
widget(CAMPAIGNS, "Campaigns", WidgetType.TABLE, Campaigns.class, MarketingPermissions.READ),
widget(AGENT_ACTIVITY, "Agent Activity", WidgetType.TABLE, AgentActivity.class, MarketingPermissions.ADMIN_READ),
widget(CONFIGURATION, "Configuration Status", WidgetType.TABLE, Configuration.class, MarketingPermissions.ADMIN_READ),
widget(CAMPAIGN_RESULTS, "Results", WidgetType.TABLE, CampaignResults.class, MarketingPermissions.READ),
widget(CONTENT_PROPOSAL, "Proposed Publication", WidgetType.HTML, ContentProposal.class, MarketingPermissions.READ)));
}
@@ -128,7 +140,7 @@ public final class MarketingWidgets
/*******************************************************************************
**
*******************************************************************************/
private static QWidgetMetaData widget(String name, String label, WidgetType type, Class<? extends AbstractWidgetRenderer> renderer)
private static QWidgetMetaData widget(String name, String label, WidgetType type, Class<? extends AbstractWidgetRenderer> renderer, String permission)
{
return (new QWidgetMetaData()
.withName(name)
@@ -136,7 +148,7 @@ public final class MarketingWidgets
.withType(type.getType())
.withGridColumns(12)
.withIsCard(true)
.withPermissionRules(MarketingPermissions.hasAccessRules(MarketingPermissions.BASE))
.withPermissionRules(MarketingPermissionChecker.rules(permission))
.withCodeReference(new QCodeReference(renderer)));
}
@@ -147,16 +159,26 @@ public final class MarketingWidgets
*******************************************************************************/
abstract static class Base extends AbstractWidgetRenderer
{
/*******************************************************************************
** The permission this widget needs (rendering is refused without it, behind
** the widget's own rules: no "you need ..." text is ever returned).
*******************************************************************************/
String requiredPermission()
{
return (MarketingPermissions.READ);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
QSession session = QContext.getQSession();
if(!(session instanceof QSystemUserSession) && (session == null || !session.hasPermission(MarketingPermissions.READ)))
if(!MarketingPermissions.has(requiredPermission()))
{
return (new RenderWidgetOutput(new RawHTML(null, "<p>You need " + MarketingPermissions.READ + " to see marketing data.</p>")));
throw (new QPermissionDeniedException("Permission denied."));
}
return (renderFor(input));
}
@@ -283,9 +305,33 @@ public final class MarketingWidgets
"what", "Failed: " + StringUtils.safeTruncate(r.getValueString("errorMessage"), 160, "..."), "since", when(r.getValue("createDate"))));
}
///////////////////////////////////////////////////////////////////////////////////
// a channel that cannot send: said in business words (the provider detail is in //
// the admin-only Configuration Status widget) //
///////////////////////////////////////////////////////////////////////////////////
for(QRecord r : MarketingStore.query(MarketingChannel.TABLE_NAME, new QQueryFilter(new QFilterCriteria("isActive", QCriteriaOperator.EQUALS, true)).withOrderBy(new QFilterOrderBy("id")).withLimit(50)))
{
if(StringUtils.hasContent(r.getValueString("transportName")) && !canSend(r.getValueString("transportName")))
{
rows.add(row("item", link(MarketingChannel.TABLE_NAME, r.getValue("id"), r.getValueString("name")), "what", "Sends on " + r.getValueString("name") + " are paused - ask an admin",
"since", ""));
}
}
return (new RenderWidgetOutput(table(List.of(column("Item", "item", true), column("What", "what", false), column("Since", "since", false)), rows,
"Nothing needs attention.")));
}
/*******************************************************************************
** Can the named transport send (registered, and operational or test-only)?
*******************************************************************************/
private static boolean canSend(String transportName) throws QException
{
MarketingTransport transport = MarketingRuntime.current().getTransport(transportName);
return (transport != null && transport.getStatus().isUsable());
}
}
@@ -338,6 +384,17 @@ public final class MarketingWidgets
*******************************************************************************/
public static class AgentActivity extends Base
{
/*******************************************************************************
** System content: marketing.admin.read.
*******************************************************************************/
@Override
String requiredPermission()
{
return (MarketingPermissions.ADMIN_READ);
}
/*******************************************************************************
**
*******************************************************************************/
@@ -379,6 +436,17 @@ public final class MarketingWidgets
*******************************************************************************/
public static class Configuration extends Base
{
/*******************************************************************************
** System content: marketing.admin.read.
*******************************************************************************/
@Override
String requiredPermission()
{
return (MarketingPermissions.ADMIN_READ);
}
/*******************************************************************************
**
*******************************************************************************/
+41 -3
View File
@@ -1,5 +1,5 @@
bundle: qbit-marketing
version: 0.1.0
version: ${project.version}
layer: QBIT
entries:
- key: app:marketing;slot:help
@@ -10,74 +10,110 @@ entries:
summary: "A product you market, with its facts, links and how ready it is for marketing."
content: |
The product brief: maturity, tagline, summary, value propositions, license, platforms, latest release, adoption path, official links and the date its facts were last verified. Evidence sources, claims, audiences and campaigns are listed on the product. Check Readiness reviews its evidence, claims, sources and audiences.
**How rows get created:** add one with New, or run the table action **Load Verified Starter Data** (QQQ, gclo and MarkdownViewr for Windows, each with its sources, claims, audiences, draft campaigns and measurement plan). Loading is idempotent: a product that already exists (by slug) is left alone.
- key: table:marketing_campaign;slot:help
summary: "A planned push for one product, with its goal, audience, message and how success is measured."
content: |
Holds the goal, status, audience, objective, messaging, call to action, destination, UTM campaign, schedule, owner, success criteria and lead rule. Content, the measurement plan, findings, agent runs and outcome events are listed on the campaign. The agent actions and Update Results from CRM run from here.
**How rows get created:** add one with New on a product, or run **Load Verified Starter Data** on Marketing Product, which adds a draft campaign for each starter product. Agent actions on a campaign plan it but never create a campaign.
- key: table:marketing_content;slot:help
summary: "A proposed publication: the exact text, destination, recipients, schedule and channel that a person approves."
content: |
Its status is one of: {{values:marketing_content_status}}. Status changes only through actions such as Submit for Review, Approve and Schedule. Every change to an approved field adds a revision, and an edit after approval voids the approval and returns the content to Draft. The Proposed Publication section shows exactly what a reviewer approves.
**How rows get created:** add one with New on a campaign, or run **Draft with Agent** on a campaign, which creates Draft content that a person must review. Starter data does not create content. Status changes only through the actions.
- key: table:marketing_execution;slot:help
summary: "One approved item scheduled to be sent, with its status, attempts and result."
content: |
Schedule creates one execution per approval. It is written only by Marketing's services and is read-only. Act on it with Run Now, Pause, Resume, Cancel, Reconcile with Provider, Mark as Accepted or Mark Not Sent and Retry. Read its Status Detail when it is Blocked or needs reconciliation.
**How rows get created:** created by the **Schedule** action on approved content, one per approval. Nobody adds one by hand. Empty means nothing has been scheduled.
- key: table:marketing_agent_run;slot:help
summary: "One run of the marketing agent: what it was asked, what it did and what it cost."
content: |
Runs start from the agent actions on a campaign and are read-only. Each run lists every step, including what the model saw, and the drafts and findings it created. The agent only prepares work. It never approves, schedules or sends anything.
**How rows get created:** started by **Plan with Agent**, **Draft with Agent**, **Check Claims with Agent** or **Analyze Results with Agent** on a campaign or product. Empty means the agent has not been asked anything yet.
- key: table:marketing_channel;slot:help
summary: "A place content can go, with its transport, AI-content rule, automation setting and limits."
content: |
A channel records its type, transport, whether it is active and allowed to automate, whether AI-written text is allowed, its own rules with the date they were checked, and its sending limits. It is a settings table: it needs marketing.admin.read to view and marketing.admin.write to change.
**How rows get created:** run **Load Verified Starter Data** on Marketing Product once: it adds the controlled test sink, email to the opted-in CRM list, the project blog and GitHub release notes, with their rules and the date they were checked. Or add one with New (needs marketing.admin.write). Record the rules, their source URL and the date every time you re-read them; Check Readiness warns when that date is older than the stale-channel-rules limit (90 days unless the host changes it).
- key: table:marketing_policy;slot:help
summary: "The single default row of switches and limits that govern the agent and sending."
content: |
Holds the kill switches for the agent and for execution, provider and model overrides, the daily token and monthly cost budgets, and the concurrency and tool limits. If the row is missing, or a switch is not explicitly on, the agent and execution are off. It needs marketing.admin.read to view and marketing.admin.write to change.
**How rows get created:** **Load Verified Starter Data** adds the `default` row (agent and execution on, 1,000,000 agent tokens per day, USD 25 per month, 2 concurrent runs, 24 tool calls per run). Without it the agent and execution stay off. Or add the row `default` with New. Only that row is used.
- key: table:marketing_audience;slot:help
summary: "A group of people a product is for, with the problem it solves for them."
content: |
Describes who the product helps: their problem, the task they want to get done, and the evidence for it. A campaign can target one audience. Check Readiness flags a product that has none.
**How rows get created:** add one with New on a product, or run **Load Verified Starter Data** on Marketing Product, which adds the starter audiences.
- key: table:marketing_evidence_source;slot:help
summary: "A source, such as a web page or repository, that product claims are checked against."
content: |
Records the source type, publisher, URL, an excerpt, who verified it and when. Check Readiness flags a source older than the stale-source limit (90 days unless the host changes it). Claims cite a source.
**How rows get created:** add one with New on a product, or run **Load Verified Starter Data** on Marketing Product, which adds each starter product's sources verified on the date in the file.
- key: table:marketing_claim;slot:help
summary: "A factual statement about a product, with its source, status and evidence."
content: |
Its status ({{values:marketing_claim_status}}) says whether it can be stated as fact. Check Readiness flags claims that are unverified, unsourced or disputed, and an agent draft that cites a claim that is not Released raises a Blocker finding.
**How rows get created:** add one with New on a product, or run **Load Verified Starter Data** on Marketing Product, which adds the verified claims (disputed ones are marked Disputed with the discrepancy written out).
- key: table:marketing_content_revision;slot:help
summary: "An unchangeable snapshot of a content item as it stood at one revision."
content: |
A revision is added on every insert and on every change to the approved fields: title, body, destination, call to action, recipient scope, schedule and channel. Revisions are never edited, so the history shows exactly what was proposed and who or what wrote it.
**How rows get created:** automatically, whenever content is created or an approved field changes. Nobody adds or edits one. Empty means no content exists yet.
- key: table:marketing_approval;slot:help
summary: "A person's approval of one exact revision of content, bound to its content hash."
content: |
It stores the hash, revision, destination, recipient scope and schedule that were approved. Its status is one of: {{values:marketing_approval_status}}. A change to the content that alters the hash invalidates the active approval and records why. It is read-only.
**How rows get created:** created by the **Approve** action on content that is in review. Read-only. Empty means nothing has been approved yet.
- key: table:marketing_execution_attempt;slot:help
summary: "One try at sending an execution, with its outcome and the provider's receipt."
content: |
Each attempt records its outcome, the worker, timing, recipient count and the provider's receipt. A failure that certainly sent nothing is retried with growing delays until the execution runs out of attempts. It is read-only.
**How rows get created:** written automatically each time an execution is tried (Run Now, or the due-executions job when a scheduler is configured). Empty means nothing has been sent or tried.
- key: table:marketing_test_delivery;slot:help
summary: "What the test transport accepted for an execution, stored here instead of being sent."
content: |
The controlled test transport stores each payload per recipient and sends nothing outside the application. A repeated idempotency key is counted as a duplicate rather than stored again. Executions that use it are flagged as test data and left out of results. It is read-only.
**How rows get created:** written automatically by the controlled test transport when an execution on the test channel runs. Empty means nothing has run on the test channel.
- key: table:marketing_agent_step;slot:help
summary: "One step of an agent run: a model call, a tool call or a deterministic check."
content: |
A step is one of: {{values:marketing_agent_step_kind}}. Tool steps keep the input and the result exactly as the model saw it, with secrets redacted. It is read-only.
**How rows get created:** written automatically for every model call, tool call and check inside an agent run. Empty means the agent has not run.
- key: table:marketing_finding;slot:help
summary: "A problem, gap or recommendation about a product, campaign or content, with a severity."
content: |
Findings come from Check Readiness, the agent or a person. An open Blocker finding on a content item stops it being approved. Mark Resolved and Dismiss close an open finding, and closing or downgrading a Blocker needs approval authority.
**How rows get created:** created by **Check Readiness** on a product or campaign, by the agent actions, or by a person with New. Mark Resolved and Dismiss close them. Empty means no check has run yet, or nothing was found.
- key: table:marketing_metric;slot:help
summary: "A measurement planned for a campaign: its stage, event type and how it is counted."
content: |
The measurement plan. Each metric names a funnel stage and an event type, counts raw events or unique actors, and says how fully it is instrumented. A metric that is not instrumented shows Not measured, never 0, and a partly instrumented one is a lower bound.
**How rows get created:** add one with New on a campaign (its measurement plan), or run **Load Verified Starter Data** on Marketing Product, which adds the plan of each starter campaign.
- key: table:marketing_outcome_event;slot:help
summary: "One recorded result event for a campaign, unique by its source and external id."
content: |
Events come from importers, the API, Update Results from CRM or are entered by hand. Source plus External Event Id is unique, so a replay is dropped. Events flagged as test data are left out of the numbers.
**How rows get created:** imported by an outcome importer, posted through the API, added by **Update Results from CRM** on a campaign, or entered by hand. Empty means no results have been recorded yet, so results show as not measured, never zero.
- key: table:marketing_product;field:name
summary: "The product's display name."
content: |
@@ -337,7 +373,7 @@ entries:
- key: table:marketing_channel;field:rulesVerifiedDate
summary: "When someone last read the channel's rules."
content: |
Check Readiness warns about content whose channel has no verification date, or whose AI-content rule is Unknown. Record the date every time the rules are re-read.
Check Readiness warns about content whose channel has no verification date, or whose AI-content rule is Unknown, and again when the date is older than the stale-channel-rules limit (90 days unless the host changes it). Record the date every time the rules are re-read.
- key: table:marketing_channel;field:channelType
summary: "What kind of channel it is."
content: |
@@ -357,7 +393,7 @@ entries:
- key: table:marketing_channel;field:rulesSourceUrl
summary: "Where the channel's rules are published."
content: |
Link to the page you read when you recorded the rules.
Link to the page you read when you recorded the rules. Check Readiness quotes it in its warning when the rules are unverified or out of date, so the reader knows what to re-read, and the agent can see it.
- key: table:marketing_channel;field:rulesSummary
summary: "A summary of the channel's rules."
content: |
@@ -1394,6 +1430,8 @@ entries:
summary: "Daily result totals for a campaign, kept up to date from its outcome events."
content: |
One row per campaign, UTC day, funnel stage, event type and test-data flag. Marketing rewrites these rows whenever an outcome event is added, changed or deleted, so they are read-only. The campaign Results come from these totals. Rebuild Results recomputes them from the events.
**How rows get created:** maintained automatically from the outcome events, and recomputed by **Rebuild Results**. Nobody edits it. Empty means there are no outcome events yet.
- key: table:marketing_metric_daily;field:campaignId
summary: "The campaign these totals belong to."
content: |
@@ -0,0 +1,124 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.marketing;
import java.time.LocalDate;
import java.util.List;
import com.kingsrook.qbits.marketing.model.MarketingChannel;
import com.kingsrook.qbits.marketing.model.MarketingFinding;
import com.kingsrook.qbits.marketing.model.enums.AiContentPolicy;
import com.kingsrook.qbits.marketing.model.enums.ChannelType;
import com.kingsrook.qbits.marketing.model.enums.FindingSeverity;
import com.kingsrook.qbits.marketing.model.enums.FindingStatus;
import com.kingsrook.qbits.marketing.model.enums.FindingType;
import com.kingsrook.qbits.marketing.readiness.ReadinessChecker;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** A channel's rulesVerifiedDate goes stale like an evidence source does:
** Check Readiness on a campaign warns when content uses a channel whose rules
** were last read more than staleChannelRulesDays ago, and points at the
** channel's rulesSourceUrl. The window is configuration (default 90).
*******************************************************************************/
class ChannelRulesStalenessTest extends BaseMarketingTest
{
/*******************************************************************************
** Open channel-rule findings for the campaign.
*******************************************************************************/
private List<QRecord> ruleFindings(Integer campaignId) throws QException
{
return (MarketingStore.queryBy(MarketingFinding.TABLE_NAME, "campaignId", campaignId).stream()
.filter(f -> FindingType.CHANNEL_RULE.getId().equals(f.getValueInteger("findingType")) && FindingStatus.OPEN.getId().equals(f.getValueInteger("status"))).toList());
}
/*******************************************************************************
** Fresh rules: no finding. Rules last read 91 days ago: a warning that names
** the date and the source URL. Re-reading them (a new date) clears it.
*******************************************************************************/
@Test
void staleChannelRulesRaiseAWarningAndRereadingClearsIt() throws QException
{
Integer campaignId = campaign(product("stale-rules"));
Integer channelId = channel("Forum", ChannelType.COMMUNITY, null, AiContentPolicy.ALLOWED_WITH_DISCLOSURE, false);
content(campaignId, channelId, "Body");
MarketingStore.update(MarketingChannel.TABLE_NAME, new QRecord().withValue("id", channelId).withValue("rulesSourceUrl", "https://forum.example/rules"));
new ReadinessChecker().checkCampaign(campaignId);
assertThat(ruleFindings(campaignId)).as("rules verified today").isEmpty();
LocalDate old = LocalDate.now().minusDays(91);
MarketingStore.update(MarketingChannel.TABLE_NAME, new QRecord().withValue("id", channelId).withValue("rulesVerifiedDate", old));
new ReadinessChecker().checkCampaign(campaignId);
assertThat(ruleFindings(campaignId)).singleElement().satisfies(f ->
{
assertThat(f.getValueString("title")).contains("Channel rules may be out of date").contains("Forum");
assertThat(f.getValueString("detail")).contains(old.toString()).contains("90 days");
assertThat(f.getValueString("recommendation")).contains("https://forum.example/rules");
assertThat(f.getValueInteger("severity")).isEqualTo(FindingSeverity.WARNING.getId());
});
MarketingStore.update(MarketingChannel.TABLE_NAME, new QRecord().withValue("id", channelId).withValue("rulesVerifiedDate", LocalDate.now().minusDays(89)));
new ReadinessChecker().checkCampaign(campaignId);
assertThat(ruleFindings(campaignId)).as("89 days is still current").isEmpty();
}
/*******************************************************************************
** The window is configuration, not a constant.
*******************************************************************************/
@Test
void windowIsConfigurable() throws QException
{
config.withStaleChannelRulesDays(7);
Integer campaignId = campaign(product("window"));
Integer channelId = channel("Blog", ChannelType.BLOG, null, AiContentPolicy.ALLOWED, false);
content(campaignId, channelId, "Body");
MarketingStore.update(MarketingChannel.TABLE_NAME, new QRecord().withValue("id", channelId).withValue("rulesVerifiedDate", LocalDate.now().minusDays(10)));
new ReadinessChecker().checkCampaign(campaignId);
assertThat(ruleFindings(campaignId)).singleElement().satisfies(f -> assertThat(f.getValueString("detail")).contains("7 days"));
}
/*******************************************************************************
** A channel with no rule date or an Unknown AI policy keeps the existing
** "not verified" warning (not the stale one), and it names the source URL.
*******************************************************************************/
@Test
void unverifiedChannelKeepsItsOwnWarning() throws QException
{
Integer campaignId = campaign(product("unverified"));
Integer channelId = channel("Unknown place", ChannelType.COMMUNITY, null, AiContentPolicy.UNKNOWN, false);
content(campaignId, channelId, "Body");
MarketingStore.update(MarketingChannel.TABLE_NAME, new QRecord().withValue("id", channelId).withValue("rulesSourceUrl", "https://place.example/rules"));
new ReadinessChecker().checkCampaign(campaignId);
assertThat(ruleFindings(campaignId)).singleElement().satisfies(f ->
{
assertThat(f.getValueString("title")).contains("Channel rules not verified");
assertThat(f.getValueString("recommendation")).contains("https://place.example/rules");
});
}
}
@@ -29,13 +29,16 @@ import com.kingsrook.qbits.marketing.model.MarketingContent;
import com.kingsrook.qbits.marketing.model.MarketingExecution;
import com.kingsrook.qbits.marketing.model.MarketingFinding;
import com.kingsrook.qbits.marketing.model.MarketingProduct;
import com.kingsrook.qbits.marketing.model.enums.AiContentPolicy;
import com.kingsrook.qbits.marketing.model.enums.CampaignGoal;
import com.kingsrook.qbits.marketing.model.enums.ChannelType;
import com.kingsrook.qbits.marketing.model.enums.ClaimStatus;
import com.kingsrook.qbits.marketing.model.enums.FindingSeverity;
import com.kingsrook.qbits.marketing.model.enums.FindingStatus;
import com.kingsrook.qbits.marketing.model.enums.FindingType;
import com.kingsrook.qbits.marketing.processes.MarketingActions;
import com.kingsrook.qbits.marketing.readiness.ReadinessChecker;
import com.kingsrook.qbits.marketing.security.MarketingPermissions;
import com.kingsrook.qbits.marketing.seed.MarketingStarterData;
import com.kingsrook.qbits.marketing.transport.MarketingTransport;
import com.kingsrook.qbits.marketing.transport.QqqMessagingEmailTransport;
@@ -49,8 +52,11 @@ import com.kingsrook.qqq.backend.core.actions.ai.tools.AiToolContext;
import com.kingsrook.qqq.backend.core.actions.ai.tools.AiToolInput;
import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction;
import com.kingsrook.qqq.backend.core.actions.processes.QProcessCallbackFactory;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
@@ -64,6 +70,7 @@ import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
@@ -117,7 +124,11 @@ class ComponentsTest extends BaseMarketingTest
assertThat(campaigns.getRows()).hasSize(4);
assertThat(campaigns.getColumns()).hasSizeLessThanOrEqualTo(3);
assertThat(((TableData) render(MarketingWidgets.AGENT_ACTIVITY, null).getWidgetData()).getNoRowsFoundHTML()).contains("not run yet");
assertThat(((TableData) render(MarketingWidgets.NEEDS_ATTENTION, null).getWidgetData()).getRows()).isEmpty();
//////////////////////////////////////////////////////////////////////////////////////////
// the starter email channel's transport is configured but not verified: sends paused //
//////////////////////////////////////////////////////////////////////////////////////////
assertThat(((TableData) render(MarketingWidgets.NEEDS_ATTENTION, null).getWidgetData()).getRows())
.singleElement().satisfies(r -> assertThat(String.valueOf(r.get("what"))).endsWith("are paused - ask an admin"));
TableData results = (TableData) render(MarketingWidgets.CAMPAIGN_RESULTS, campaignId).getWidgetData();
assertThat(results.getRows()).anySatisfy(r -> assertThat(String.valueOf(r.get("result"))).startsWith("0 unique actors"));
@@ -127,8 +138,81 @@ class ComponentsTest extends BaseMarketingTest
RawHTML proposal = (RawHTML) render(MarketingWidgets.CONTENT_PROPOSAL, contentId).getWidgetData();
assertThat(proposal.getHtml()).contains("&lt;script&gt;").doesNotContain("<script>").contains("Not approved");
as(user("no-read", com.kingsrook.qbits.marketing.security.MarketingPermissions.APP_ACCESS));
assertThat(((RawHTML) render(MarketingWidgets.CAMPAIGNS, null).getWidgetData()).getHtml()).contains("marketing.read");
}
/*******************************************************************************
** The business home lists only business widgets: no configuration status
** and no agent activity (token and model usage), which are admin-placeable.
*******************************************************************************/
@Test
void testHomeHasNoSystemWidgets()
{
List<String> home = qInstance.getApp("marketing").getWidgets();
assertThat(home).containsExactly(MarketingWidgets.NEEDS_ATTENTION, MarketingWidgets.CAMPAIGNS)
.doesNotContain(MarketingWidgets.CONFIGURATION, MarketingWidgets.AGENT_ACTIVITY);
assertThat(MarketingWidgets.ADMIN_WIDGETS).containsExactly(MarketingWidgets.AGENT_ACTIVITY, MarketingWidgets.CONFIGURATION);
assertThat(qInstance.getWidget(MarketingWidgets.CONFIGURATION)).isNotNull();
assertThat(qInstance.getWidget(MarketingWidgets.AGENT_ACTIVITY)).isNotNull();
}
/*******************************************************************************
** App access without marketing.read: every read-requiring widget is omitted
** (no permission) and never renders a "You need ..." shell. Admins still
** get the moved widgets; read-only users do not.
*******************************************************************************/
@Test
void testWidgetsAreOmittedWithoutRead() throws QException
{
List<String> readWidgets = List.of(MarketingWidgets.NEEDS_ATTENTION, MarketingWidgets.CAMPAIGNS, MarketingWidgets.CAMPAIGN_RESULTS, MarketingWidgets.CONTENT_PROPOSAL);
List<String> adminWidgets = MarketingWidgets.ADMIN_WIDGETS;
as(user("no-read", MarketingPermissions.APP_ACCESS));
for(String name : CollectionUtils.mergeLists(readWidgets, adminWidgets))
{
assertThat(PermissionsHelper.hasWidgetPermission(new RenderWidgetInput(), name)).as(name).isFalse();
assertThatThrownBy(() -> render(name, null)).as(name).isInstanceOf(QPermissionDeniedException.class);
}
as(user("reader", MarketingPermissions.APP_ACCESS, MarketingPermissions.READ));
for(String name : readWidgets)
{
assertThat(PermissionsHelper.hasWidgetPermission(new RenderWidgetInput(), name)).as(name).isTrue();
}
for(String name : adminWidgets)
{
assertThat(PermissionsHelper.hasWidgetPermission(new RenderWidgetInput(), name)).as(name).isFalse();
}
as(admin());
for(String name : adminWidgets)
{
assertThat(PermissionsHelper.hasWidgetPermission(new RenderWidgetInput(), name)).as(name).isTrue();
assertThat(render(name, null).getWidgetData()).as(name).isInstanceOf(TableData.class);
}
}
/*******************************************************************************
** A channel that cannot send adds one business-worded row linking to the
** channel; nothing when every channel can send.
*******************************************************************************/
@Test
void testNeedsAttentionSaysWhenAChannelCannotSend() throws QException
{
testChannel();
assertThat(((TableData) render(MarketingWidgets.NEEDS_ATTENTION, null).getWidgetData()).getRows()).isEmpty();
Integer blocked = channel("Newsletter", ChannelType.TEST_SINK, "no-such-transport", AiContentPolicy.ALLOWED, true);
List<Map<String, Object>> rows = ((TableData) render(MarketingWidgets.NEEDS_ATTENTION, null).getWidgetData()).getRows();
assertThat(rows).hasSize(1);
assertThat(String.valueOf(rows.get(0).get("what"))).isEqualTo("Sends on Newsletter are paused - ask an admin");
assertThat(String.valueOf(rows.get(0).get("item"))).contains("marketing_channel").contains(String.valueOf(blocked)).contains("Newsletter");
assertThat(String.valueOf(rows.get(0))).doesNotContain("transport").doesNotContain("Not configured");
}
@@ -17,404 +17,108 @@
package com.kingsrook.qbits.marketing;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import com.kingsrook.qbits.marketing.metadata.MarketingMetaDataBuilder;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
import com.kingsrook.qqq.backend.core.actions.help.HelpTarget;
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundle;
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundleEntry;
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundleLayer;
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.testing.HelpBundleAssertions;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** STAND-IN(core=QRun-IO/qqq#1198, removeBy=4.1.0-RC.8): replace with
** HelpBundleAssertions.forInstance(qInstance).bundles("help/qbit-marketing.yaml").owning(...).assertComplete()
** once qqq#1198 ships.
**
** The help bundle (help/qbit-marketing.yaml) covers everything the qbit
** produces, and every entry is sound:
** - the app, every marketing_* table, every non-trivial field (all but the
** primary key, createDate, modifyDate and hidden fields), every process,
** every widget and every possible-value source has an entry;
** - every entry key resolves to a real object;
** - summaries are at most 120 characters, with no markup;
** - every {{...}} reference resolves.
**
** Each test fails with the full list of missing or broken keys.
** produces, and every entry is sound. Core's HelpBundleAssertions checks
** that the app, every marketing_* table, every non-trivial field and every
** process has help, that every key and {{...}} reference resolves and that
** summaries are short plain text. Core does not require widget or
** possible-value-source coverage, so those are checked here too. QQQ's
** generated bulk table processes are core's to document and are not owned.
*******************************************************************************/
class HelpBundleCoverageTest extends BaseMarketingTest
{
private static final String BUNDLE_RESOURCE = "help/qbit-marketing.yaml";
private static final int MAX_SUMMARY_LENGTH = 120;
private static final String MARKUP_CHARACTERS = "<>*_`[]#";
private static final Pattern REFERENCE = Pattern.compile("\\{\\{([A-Za-z]+):([^}]*)}}");
/*******************************************************************************
** One bundle entry.
*******************************************************************************/
private record Entry(String key, String summary, String content)
{
}
/*******************************************************************************
** The bundle header names this qbit, at its layer.
** The producer registers exactly this qbit's bundle, at the QBIT layer.
*******************************************************************************/
@Test
void testHeader() throws IOException
void testRegisteredBundle()
{
Map<String, Object> bundle = loadBundle();
assertThat(bundle.get("bundle")).isEqualTo("qbit-marketing");
assertThat(bundle.get("layer")).isEqualTo("QBIT");
assertThat(bundle.get("version")).isNotNull();
assertThat(HelpBundles.of(qInstance).getBundles()).singleElement().satisfies(bundle ->
{
assertThat(bundle.getId()).isEqualTo("qbit-marketing");
assertThat(bundle.getLayer()).isEqualTo(HelpBundleLayer.QBIT);
assertThat(bundle.getVersion()).isEqualTo(MarketingQBitProducer.VERSION);
});
}
/*******************************************************************************
** Every object the qbit owns has an entry.
** Core coverage gate over everything the qbit owns.
*******************************************************************************/
@Test
void testEveryOwnedObjectHasAnEntry() throws IOException
void testCoreCoverageGate()
{
Set<String> keys = new LinkedHashSet<>();
List<String> problems = new ArrayList<>();
for(Entry entry : loadEntries())
{
if(!keys.add(entry.key()))
{
problems.add("duplicate entry: " + entry.key());
}
}
for(String expected : expectedKeys())
{
if(!keys.contains(expected))
{
problems.add("missing entry: " + expected);
}
}
assertNoProblems(problems);
HelpBundleAssertions.forInstance(qInstance)
.bundles(MarketingQBitProducer.HELP_BUNDLE)
.owning(HelpBundleCoverageTest::ownedByMarketing)
.assertComplete();
}
/*******************************************************************************
** Every entry key names something that exists.
** The qbit owns its app, marketing_* objects and the processes it produces.
** QQQ's generated bulk table processes (no source qbit) are core's to document.
*******************************************************************************/
private static boolean ownedByMarketing(HelpTarget target)
{
if(target.owner() instanceof QProcessMetaData process && process.getSourceQBitName() == null)
{
return (false);
}
return (target.name().equals("marketing") || target.name().startsWith("marketing_") || target.name().startsWith("Marketing"));
}
/*******************************************************************************
** Every widget and possible-value source has an entry, and every entry has
** a summary and content.
*******************************************************************************/
@Test
void testEveryKeyResolves() throws IOException
void testWidgetsSourcesAndContent()
{
List<String> problems = new ArrayList<>();
for(Entry entry : loadEntries())
Map<HelpBundles.EntryKey, HelpBundles.WinningEntry> entries = HelpBundles.of(qInstance).resolveWinningEntries();
List<String> problems = new ArrayList<>();
TreeSet<String> expected = new TreeSet<>();
qInstance.getWidgets().keySet().stream().filter(n -> n.startsWith("marketing_")).forEach(n -> expected.add("widget:" + n + ";slot:label"));
qInstance.getPossibleValueSources().keySet().stream().filter(n -> n.startsWith("marketing_")).forEach(n -> expected.add("possibleValueSource:" + n + ";slot:help"));
for(String key : expected)
{
String problem = resolveKey(entry.key());
if(problem != null)
if(!entries.containsKey(new HelpBundles.EntryKey(key, "ALL_SCREENS")))
{
problems.add(entry.key() + ": " + problem);
problems.add("missing entry: " + key);
}
}
assertNoProblems(problems);
}
/*******************************************************************************
** Summaries are short, plain text; every entry has content.
*******************************************************************************/
@Test
void testSummariesAndContent() throws IOException
{
List<String> problems = new ArrayList<>();
for(Entry entry : loadEntries())
HelpBundle bundle = HelpBundles.of(qInstance).getBundles().getFirst();
for(HelpBundleEntry entry : bundle.getEntries())
{
String summary = entry.summary();
if(summary == null || summary.isBlank())
if(entry.getSummary() == null || entry.getSummary().isBlank())
{
problems.add(entry.key() + ": no summary");
continue;
problems.add(entry.getKey() + ": no summary");
}
if(summary.length() > MAX_SUMMARY_LENGTH)
if(entry.getContent() == null || entry.getContent().isBlank())
{
problems.add(entry.key() + ": summary is " + summary.length() + " characters (limit " + MAX_SUMMARY_LENGTH + ")");
}
for(char c : MARKUP_CHARACTERS.toCharArray())
{
if(summary.indexOf(c) >= 0)
{
problems.add(entry.key() + ": summary contains the markup character " + c);
}
}
if(entry.content() == null || entry.content().isBlank())
{
problems.add(entry.key() + ": no content");
problems.add(entry.getKey() + ": no content");
}
}
assertNoProblems(problems);
}
/*******************************************************************************
** Every {{...}} reference resolves.
*******************************************************************************/
@Test
void testEveryReferenceResolves() throws IOException
{
List<String> problems = new ArrayList<>();
for(Entry entry : loadEntries())
{
for(String text : new String[] { entry.summary(), entry.content() })
{
if(text == null)
{
continue;
}
Matcher matcher = REFERENCE.matcher(text);
while(matcher.find())
{
String problem = resolveReference(matcher.group(1), matcher.group(2));
if(problem != null)
{
problems.add(entry.key() + ": {{" + matcher.group(1) + ":" + matcher.group(2) + "}} " + problem);
}
}
if(text.replaceAll(REFERENCE.pattern(), "").contains("{{"))
{
problems.add(entry.key() + ": malformed {{ reference");
}
}
}
assertNoProblems(problems);
}
/*******************************************************************************
** The keys the bundle must contain, from the produced instance.
*******************************************************************************/
private Set<String> expectedKeys()
{
Set<String> keys = new TreeSet<>();
keys.add("app:" + MarketingMetaDataBuilder.APP_NAME + ";slot:help");
for(QTableMetaData table : qInstance.getTables().values())
{
if(!table.getName().startsWith("marketing_"))
{
continue;
}
keys.add("table:" + table.getName() + ";slot:help");
for(QFieldMetaData field : table.getFields().values())
{
if(isNonTrivial(table, field))
{
keys.add("table:" + table.getName() + ";field:" + field.getName());
}
}
}
/////////////////////////////////////////////////////////////////////////
// the qbit's own processes (QQQ also generates bulk table processes) //
/////////////////////////////////////////////////////////////////////////
for(QProcessMetaData process : qInstance.getProcesses().values())
{
if(process.getSourceQBitName() != null)
{
keys.add("process:" + process.getName() + ";slot:help");
}
}
qInstance.getWidgets().keySet().stream().filter(n -> n.startsWith("marketing_")).forEach(n -> keys.add("widget:" + n + ";slot:label"));
qInstance.getPossibleValueSources().keySet().stream().filter(n -> n.startsWith("marketing_")).forEach(n -> keys.add("possibleValueSource:" + n + ";slot:help"));
return (keys);
}
/*******************************************************************************
** Every field except the primary key, createDate, modifyDate and hidden ones.
*******************************************************************************/
private static boolean isNonTrivial(QTableMetaData table, QFieldMetaData field)
{
String name = field.getName();
return (!name.equals(table.getPrimaryKeyField()) && !name.equals("createDate") && !name.equals("modifyDate") && !Boolean.TRUE.equals(field.getIsHidden()));
}
/*******************************************************************************
** Null if the key names a real object, else why not.
*******************************************************************************/
private String resolveKey(String key)
{
Map<String, String> parts = new LinkedHashMap<>();
for(String part : key.split(";"))
{
int colon = part.indexOf(':');
if(colon < 1)
{
return ("malformed key part [" + part + "]");
}
parts.put(part.substring(0, colon), part.substring(colon + 1));
}
if(parts.containsKey("app"))
{
return (qInstance.getApp(parts.get("app")) == null ? "no such app" : slotProblem(parts, "help"));
}
if(parts.containsKey("table"))
{
QTableMetaData table = qInstance.getTable(parts.get("table"));
if(table == null)
{
return ("no such table");
}
if(parts.containsKey("field"))
{
return (table.getFields().containsKey(parts.get("field")) ? null : "no such field");
}
if(parts.containsKey("section"))
{
return (table.getSections() != null && table.getSections().stream().map(QFieldSection::getName).anyMatch(parts.get("section")::equals) ? null : "no such section");
}
return (slotProblem(parts, "help"));
}
if(parts.containsKey("process"))
{
QProcessMetaData process = qInstance.getProcess(parts.get("process"));
if(process == null)
{
return ("no such process");
}
if(parts.containsKey("step"))
{
return (process.getStep(parts.get("step")) == null ? "no such step" : null);
}
return (parts.containsKey("field") ? "process fields are not covered by this bundle" : slotProblem(parts, "help"));
}
if(parts.containsKey("widget"))
{
return (qInstance.getWidget(parts.get("widget")) == null ? "no such widget" : slotProblem(parts, "label"));
}
if(parts.containsKey("possibleValueSource"))
{
return (qInstance.getPossibleValueSource(parts.get("possibleValueSource")) == null ? "no such possible-value source" : slotProblem(parts, "help"));
}
return ("unsupported key");
}
/*******************************************************************************
** The slot must be the expected one.
*******************************************************************************/
private static String slotProblem(Map<String, String> parts, String expectedSlot)
{
return (expectedSlot.equals(parts.get("slot")) ? null : "expected slot:" + expectedSlot);
}
/*******************************************************************************
** Null if the live reference resolves, else why not.
*******************************************************************************/
private String resolveReference(String kind, String target)
{
String[] tableAndField = target.split("\\.", 2);
QTableMetaData table = qInstance.getTable(tableAndField[0]);
QFieldMetaData field = table == null || tableAndField.length < 2 ? null : table.getFields().get(tableAndField[1]);
switch(kind)
{
case "label":
{
if(tableAndField.length == 2)
{
return (field == null ? "does not resolve to a table field" : null);
}
boolean found = table != null || qInstance.getProcess(target) != null || qInstance.getApp(target) != null;
return (found ? null : "does not resolve to a table, process or app");
}
case "default":
{
return (field == null ? "does not resolve to a table field" : (field.getDefaultValue() == null ? "the field has no default value" : null));
}
case "maxLength":
{
return (field == null ? "does not resolve to a table field" : (field.getMaxLength() == null ? "the field has no maximum length" : null));
}
case "values":
{
QPossibleValueSource source = qInstance.getPossibleValueSource(target);
return (source == null ? "does not resolve to a possible-value source" : (source.getType() == QPossibleValueSourceType.ENUM ? null : "is not an enum source"));
}
default:
{
return ("unsupported reference kind");
}
}
}
/*******************************************************************************
** The bundle, as parsed YAML.
*******************************************************************************/
private static Map<String, Object> loadBundle() throws IOException
{
try(InputStream inputStream = HelpBundleCoverageTest.class.getClassLoader().getResourceAsStream(BUNDLE_RESOURCE))
{
assertThat(inputStream).as("classpath resource " + BUNDLE_RESOURCE).isNotNull();
@SuppressWarnings("unchecked")
Map<String, Object> bundle = new ObjectMapper(new YAMLFactory()).readValue(inputStream, Map.class);
return (bundle);
}
}
/*******************************************************************************
** The bundle's entries.
*******************************************************************************/
@SuppressWarnings("unchecked")
private static List<Entry> loadEntries() throws IOException
{
List<Entry> entries = new ArrayList<>();
for(Map<String, Object> row : (List<Map<String, Object>>) loadBundle().get("entries"))
{
entries.add(new Entry(String.valueOf(row.get("key")), (String) row.get("summary"), (String) row.get("content")));
}
return (entries);
}
/*******************************************************************************
** Fail with every problem at once.
*******************************************************************************/
private static void assertNoProblems(List<String> problems)
{
assertThat(problems).as(problems.size() + " help bundle problem(s):\n" + String.join("\n", problems)).isEmpty();
}
@@ -0,0 +1,232 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.marketing;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import com.kingsrook.qbits.marketing.metadata.MarketingMetaDataBuilder;
import com.kingsrook.qbits.marketing.processes.MarketingTableProcesses;
import com.kingsrook.qqq.backend.core.model.metadata.help.HelpTextRenderer;
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Guardrail (owner requirement 2026-10-14: "all tables should be populated and
** the qbit should be fully using them"). For every marketing_* table:
** - runtime code (not just its model, metadata, home widgets or the starter
** data) reads or writes it, so no table exists only on paper;
** - it is either seeded by the starter data ("Load Verified Starter Data") or
** listed here with why it starts empty;
** - it has bundle help that says how its rows get created, naming the starter
** data action where that is one of the ways.
** A new table fails this test until it is wired, seeded or explained.
*******************************************************************************/
class MarketingTablesGuardrailTest extends BaseMarketingTest
{
private static final Path MAIN = Path.of("src", "main", "java", "com", "kingsrook", "qbits", "marketing");
/*******************************************************************************
** Written by MarketingStarterData (checked against its source below).
*******************************************************************************/
static final List<String> SEEDED_BY_STARTER_DATA = List.of("marketing_product", "marketing_evidence_source", "marketing_claim", "marketing_audience",
"marketing_campaign", "marketing_metric", "marketing_channel", "marketing_policy");
/*******************************************************************************
** Tables that legitimately start empty, and where their rows come from.
*******************************************************************************/
static final Map<String, String> STARTS_EMPTY = Map.ofEntries(
Map.entry("marketing_content", "user content: a person or the agent drafts it (starter data never writes publications)"),
Map.entry("marketing_content_revision", "runtime: content is created or changed"),
Map.entry("marketing_approval", "runtime: a person approves content"),
Map.entry("marketing_execution", "runtime: Schedule on approved content"),
Map.entry("marketing_execution_attempt", "runtime: an execution is tried"),
Map.entry("marketing_test_delivery", "runtime: the test transport accepts an execution"),
Map.entry("marketing_agent_run", "runtime: someone starts an agent action"),
Map.entry("marketing_agent_step", "runtime: an agent run makes a call"),
Map.entry("marketing_finding", "runtime: Check Readiness, the agent or a person"),
Map.entry("marketing_outcome_event", "runtime: an importer, the API, Update Results from CRM or a person"),
Map.entry("marketing_metric_daily", "runtime: rolled up from outcome events"));
/*******************************************************************************
** Every table is used by runtime code.
*******************************************************************************/
@Test
void everyTableIsUsedAtRuntime() throws Exception
{
Map<String, String> entityByTable = entityByTable();
List<String> runtimeSource;
try(Stream<Path> files = Files.walk(MAIN))
{
runtimeSource = files.filter(p -> p.toString().endsWith(".java"))
.filter(p -> !p.startsWith(MAIN.resolve("model")) && !p.startsWith(MAIN.resolve("metadata")) && !p.startsWith(MAIN.resolve("seed")) && !p.startsWith(MAIN.resolve("widgets")))
.map(MarketingTablesGuardrailTest::read).toList();
}
for(String table : marketingTables())
{
String entity = entityByTable.get(table);
assertThat(entity).as("entity class for " + table).isNotNull();
Pattern use = Pattern.compile("\\b" + entity + "(\\.TABLE_NAME|\\.class|\\(|::)");
assertThat(runtimeSource.stream().anyMatch(source -> use.matcher(source).find()))
.as(table + " (" + entity + ") must be read or written by runtime code, not only declared").isTrue();
}
}
/*******************************************************************************
** Every table is seeded or explained.
*******************************************************************************/
@Test
void everyTableIsSeededOrExplained() throws Exception
{
String starter = read(MAIN.resolve("seed").resolve("MarketingStarterData.java"));
Map<String, String> entityByTable = entityByTable();
for(String table : marketingTables())
{
boolean seeded = SEEDED_BY_STARTER_DATA.contains(table);
assertThat(seeded || STARTS_EMPTY.containsKey(table)).as(table + " must be seeded by the starter data or listed in STARTS_EMPTY").isTrue();
assertThat(seeded && STARTS_EMPTY.containsKey(table)).as(table + " cannot be both seeded and listed as starting empty").isFalse();
assertThat(starter.contains(entityByTable.get(table) + ".TABLE_NAME")).as(table + " seeded iff MarketingStarterData writes it").isEqualTo(seeded);
}
assertThat(STARTS_EMPTY.keySet()).allMatch(table -> qInstance.getTable(table) != null, "STARTS_EMPTY names only real tables");
assertThat(qInstance.getProcess(MarketingTableProcesses.LOAD_STARTER_DATA).getTableName()).isEqualTo("marketing_product");
}
/*******************************************************************************
** Every table and the app have help that says how rows get created, and the
** seeded tables name the starter-data action.
*******************************************************************************/
@Test
void everyTableHasEmptyStateHelp() throws Exception
{
String action = qInstance.getProcess(MarketingTableProcesses.LOAD_STARTER_DATA).getLabel();
for(String table : marketingTables())
{
String content = help("table:" + table + ";slot:help");
assertThat(content).as(table + " table help").isNotBlank();
assertThat(content).as(table + " help says how rows get created").contains("How rows get created");
assertThat(content).as(table + " help has no unresolved reference").doesNotContain("{{");
if(SEEDED_BY_STARTER_DATA.contains(table))
{
assertThat(content).as(table + " help names the starter-data action").contains(action);
}
}
assertThat(help("app:" + MarketingMetaDataBuilder.APP_NAME + ";slot:help")).isNotBlank();
assertThat(help("table:marketing_content;slot:help")).as("enum references become the values").contains("Draft");
}
/*******************************************************************************
** The bundle is registered at the QBIT layer, so a host bundle for the same
** key wins and the host's own help is kept.
*******************************************************************************/
@Test
void hostHelpIsNotOverridden()
{
HelpBundles.register(qInstance, "help/test-host-override.yaml");
assertThat(help("table:marketing_claim;slot:help")).isEqualTo("host text");
assertThat(help("table:marketing_product;slot:help")).contains("How rows get created");
}
/*******************************************************************************
** The winning bundle text for a key (ALL_SCREENS role), rendered by core
** against the instance the way the frontend sees it.
*******************************************************************************/
private String help(String key)
{
HelpBundles.WinningEntry winner = HelpBundles.of(qInstance).resolveWinningEntries().get(new HelpBundles.EntryKey(key, "ALL_SCREENS"));
if(winner == null)
{
return (null);
}
HelpTextRenderer renderer = new HelpTextRenderer(qInstance);
String summary = winner.entry().getSummary() == null ? "" : renderer.render(winner.entry().getSummary().strip());
return ((summary + "\n\n" + renderer.render(winner.entry().getContent().strip())).strip());
}
/*******************************************************************************
**
*******************************************************************************/
private List<String> marketingTables()
{
List<String> tables = qInstance.getTables().values().stream().map(QTableMetaData::getName).filter(n -> n.startsWith("marketing_")).sorted().toList();
assertThat(tables).hasSize(19);
return (tables);
}
/*******************************************************************************
** model/MarketingX.java declares TABLE_NAME = "marketing_x".
*******************************************************************************/
private static Map<String, String> entityByTable() throws Exception
{
Map<String, String> rs = new HashMap<>();
Pattern p = Pattern.compile("TABLE_NAME\\s*=\\s*\"([a-z_]+)\"");
try(Stream<Path> files = Files.list(MAIN.resolve("model")))
{
for(Path file : files.filter(f -> f.toString().endsWith(".java")).toList())
{
Matcher m = p.matcher(read(file));
if(m.find())
{
rs.put(m.group(1), file.getFileName().toString().replace(".java", ""));
}
}
}
return (rs);
}
/*******************************************************************************
**
*******************************************************************************/
private static String read(Path path)
{
try
{
return (Files.readString(path, StandardCharsets.UTF_8));
}
catch(Exception e)
{
throw (new IllegalStateException(e));
}
}
}
@@ -0,0 +1,6 @@
bundle: test-host-override
version: "1"
layer: HOST
entries:
- key: table:marketing_claim;slot:help
content: host text