28 Commits
Author SHA1 Message Date
james.maes a20d36c108 Merge pull request 'build(deps): align with Gitea registry (parent 2.2.1, QQQ 4.1.0-RC.26)' (#30) from deps/align-gitea into develop
build / maven (push) Successful in 10m19s
maven / build (push) Successful in 3m35s
2026-11-07 18:09:56 +00:00
james.maesandClaude Opus 5.5 a992133053 build(deps): align with Gitea registry (parent 2.2.1, QQQ 4.1.0-RC.26)
build / maven (push) Successful in 2m35s
maven / build (push) Successful in 2m36s
pom.xml: parent -> 2.2.1
pom.xml: qqq.snapshot.version: 4.1.0-RC.8 -> 4.1.0-RC.26
pom.xml: qqq.snapshot.version: 4.1.0-RC.7 -> 4.1.0-RC.26
pom.xml: qqq-frontend-next: 1.0.0-RC.19 -> 1.0.0-RC.38
pom.xml: removed Central snapshot repository
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 18:03:50 +00:00
james.maes 4b3ca697e5 Merge pull request 'ci: pin shared workflow to v1' (#29) 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: 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
QRun-IO/ci-policy: native / native-result (push) Successful in 17s
build / maven (push) Successful in 3m47s
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 3m47s
2026-11-06 16:08:17 +00:00
james.maesandClaude Opus 5.5 1a800da73d 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 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 (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) Successful in 6s
build / maven (push) Successful in 3m47s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 6s
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: 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
maven / build (push) Successful in 3m47s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 14:10:39 +00:00
james.maes 17a094d427 Merge pull request 'ci: use shared ci-workflows Maven workflow' (#28) 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 6s
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-business (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 5s
build / maven (push) Successful in 1m50s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow d20a578c7d818bc7ca32a2ac2c675da4e4f27385] (push) Successful in 5s
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-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 1m50s
2026-11-05 23:16:58 +00:00
james.maesandClaude Opus 5.5 009e940ee6 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 7s
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: ci / not-enrolled (pull_request) Skipped
build / maven (push) Successful in 2m11s
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 1m22s
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Failing after 1s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Failing after 3s
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Failing after 7m36s
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 1s
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: native / native-result (pull_request) Successful in 6s
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
maven / build (push) Successful in 2m11s
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:05:49 +00:00
forge-recovery b6c73cc3a5 ci: enroll shared Gitea lifecycle
QRun-IO/ci-policy: ci / ci-result (push) Successful in 4s
Shared Java Maven CI / verify (push) Successful in 1m47s
QRun-IO/ci-policy: Shared Java Maven CI / verify (push) Successful in 1m19s
qrun-release / postpublish (push) Failing after 10m30s
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 35s
QRun-IO/ci-policy: native / native-result (push) Successful in 10s
QRun-IO/ci-policy: Shared Java Maven CI / secrets (push) Successful in 36s
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
Shared Java Maven CI / gate (push) Successful in 16s
qrun-release / release (push) Successful in 46m25s
QRun-IO/ci-policy: Shared Java Maven CI / gate (push) Successful in 10s
QRun-IO/ci-policy: ci / java-maven-6 (push) Successful in 46m21s
Shared QRun source version / version (push) Failing after 10m30s
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
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
2026-11-02 22:17:18 +00:00
forge-recovery 5d4b212b56 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
QRun-IO/ci-policy: Shared Java Maven CI / secrets (push) Successful in 28s
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 7s
QRun-IO/ci-policy: Shared Java Maven CI / verify (push) Successful in 1m39s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 10s
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
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / gate (push) Successful in 24s
QRun-IO/ci-policy: ci / java-maven-6 (push) Successful in 14m29s
2026-11-02 21:33:35 +00:00
forge-recoveryandCodex 1828c7f9d8 ci: enroll shared Gitea release-candidate lifecycle
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 / head-profile (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Successful in 18s
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-7-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 / node-8 (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 / verify (pull_request) Successful in 1m29s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Successful in 16s
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Successful in 59m44s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 5s
Release candidate / release-rc (pull_request) Skipped
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
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (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-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 20:17:34 +00:00
055e33c99b ci: add the isolated postpublish job to the QRun release caller
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 / 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
Shared Java Maven CI / secrets (push) Successful in 17s
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Successful in 16s
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
Shared Java Maven CI / verify (push) Canceled after 0s
qrun-release / postpublish (push) Canceled after 0s
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 1m4s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Successful in 8s
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Successful in 3h0m4s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 3s
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 / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 7s
Shared Java Maven CI / gate (push) Canceled after 0s
qrun-release / release (push) Canceled after 2h36m39s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 15:49:57 +00:00
912f1dc3a9 ci: move the QRun release caller to the thin v2 contract
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Canceled after 0s
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
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) Successful in 31s
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Successful in 29s
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 2m35s
QRun-IO/ci-policy: ci / java-maven-4 (pull_request) Canceled after 51m49s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-7 (pull_request) Skipped
Shared Java Maven CI / gate (push) Canceled after 0s
qrun-release / release (push) Canceled after 52m3s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 15s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 06:18:44 +00:00
forge-recoveryandCodex cd995254fd ci: add the identical QRun release caller
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 7s
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: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Failing after 1s
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Failing after 1s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Failing after 12s
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 / java-maven-4 (pull_request) Failing after 2m23s
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 04:12:47 +00:00
james.maesandCodex da332ce0fb ci: adopt managed orb 0.8 patch track (#22)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 15:28:56 -05:00
james.maesandCodex 22a40ad295 ci: adopt shared orb 0.7.18 (#21)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 14:46:50 -05:00
james.maesandCodex c92df228b9 ci: adopt shared orb 0.7.17 (#20)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 14:16:33 -05:00
james.maesandCodex 5af79ae05d ci: use the shared branch-aware CodeQL worker (#18)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 10:46:11 -05:00
james.maesandCodex 469e727884 ci: adopt central orb 0.7.12 and stable gates (#16)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 08:52:58 -05:00
james.maesandCodex 6434453666 ci: centralize rc release completion in qqq orb (#14)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 02:24:27 -05:00
james.maesandCodex f2584be8c5 ci: adopt shared orb 0.7.11 (#13)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 00:59:09 -05:00
james.maes 43d65e2e58 Merge pull request #12 from QRun-IO/feature/central-ci
ci: adopt shared QQQ orb standard
2026-10-20 19:20:54 -05:00
james.maesandCodex 6dbbdacd0e ci: adopt verified central QQQ orb 0.7.3
Co-Authored-By: Codex <noreply@openai.com>
2026-10-20 19:08:18 -05:00
james.maes 913bd497ed Merge pull request #11 from QRun-IO/feature/jobs-app-permission-gate
fix(security): gate the Jobs app and home widgets behind jobs.hasAccess
2026-10-16 10:19:12 -05:00
james.maesandClaude Opus 5.5 8cb487343a fix(security): gate the Jobs app and home widgets behind <appName>.hasAccess
The Jobs app and five of its six home widgets had no permission rules, so
the nav entry and home rendered for users with no permissions. They now
require <appName>.hasAccess (default jobs.hasAccess, exposed via
JobsQBitConfig.getHasAccessPermissionName()) with HIDDEN deny behaviour.
Workers keeps its jobs_admin rule. Record widgets, the jobs REST/UI API and
jobs_job record locks are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 09:06:34 -05:00
james.maes b8bc1a7719 Merge pull request #10 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:57 -05:00
james.maesandClaude Opus 5.5 dd06743824 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:14 -05:00
james.maes 7a1f236c45 Merge pull request #9 from QRun-IO/feature/job-progress
feat(progress): job log panel, live phase/percent, log retention, parent app
2026-10-14 21:15:10 -05:00
james.maes 80b0b66c49 Merge remote-tracking branch 'origin/develop' into feature/job-progress
# Conflicts:
#	README.md
#	docs/DESIGN.md
#	pom.xml
#	src/main/java/com/kingsrook/qbits/jobs/JobsCoreContract.java
#	src/main/java/com/kingsrook/qbits/jobs/widgets/JobsWidgetRenderer.java
#	src/test/java/com/kingsrook/qbits/jobs/JobsRetentionTest.java
2026-10-14 21:12:47 -05:00
james.maesandClaude Opus 5.5 e5c6a71493 feat(progress): job log panel, live phase and percent, log retention, parent app
- Log child list on the job record (newest first), when core defines
  jobs_log
- Running jobs and the Live Progress panel show the current phase,
  percent, message, bytes and ETA from the new jobs_job progress fields,
  falling back to the rows/chunks derivation
- Retention purges jobs_log with its job
- parentAppName config places the Jobs app under a host group (for
  example Admin > Operations)

Needs QQQ 4.1.0-RC.8 (QRun-IO/qqq#1212).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:44:16 -05:00
19 changed files with 838 additions and 106 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
+21
View File
@@ -5,12 +5,33 @@ and [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.1.0] - Unreleased
### Security
- The Jobs app and its five non-admin home widgets now require `<appName>.hasAccess`
(default `jobs.hasAccess`) and are hidden otherwise. Behaviour change: hosts must grant
this permission to the users who should see the Jobs app. Workers still needs `jobs_admin`.
### Changed
- Built on QQQ 4.1.0-RC.7; stand-ins removed.
### Added
- RC.8 optional job progress fields: phase labels, explicit percent with legacy fallback,
message, binary byte counts, ETA and dropped-log notice in typed widgets.
- Full-width owner-scoped Log child list after Live Progress, ascending by log ID.
- System-session retention of log lines, including 64-bit cursors and 200-row batches.
- Host `parentAppName` configuration for Admin > Operations > Jobs placement.
- Progress/log, owner isolation, real core log permission and navigation tests.
### Changed
- Default QQQ BOM from 4.1.0-RC.5 to 4.1.0-RC.8; existing `qqq.snapshot.version`
override verifies against locally installed 4.1.0-SNAPSHOT without the demo profile.
- Storage adapter test now verifies failure/reference preservation with the current API.
### Earlier additions
- Optional effects outbox child list, viewer-scoped attention review counts and typed
Live Progress effect statistics; Data Analysis includes `jobs_effect`.
- `jobs.effect-unresolved` warning alert for effects requiring outcome review.
+27 -2
View File
@@ -35,12 +35,16 @@ The tables are core's, prefixed `jobs_`. This qbit adds no tables.
| `jobs_chunk` | The leased units of work, with attempts, checkpoints and per-chunk counts. Child list on the job. |
| `jobs_row_error` | Which rows failed and why. Child list on the job; reportable. |
| `jobs_effect` | Side effects outside the database (outbox): email, HTTP, S3, other databases; child list on the job. |
| `jobs_log` | Append-only, owner-scoped log lines (level, time, phase, message); optional child list on the job. |
| `jobs_event` | The job's timeline (launched, reclaimed, paused, completed and so on). Child list on the job. |
| `jobs_worker` | The pods that run jobs. |
| `jobs_upload` | Uploaded files (refs, owners, expiry). |
## Permissions
- Users see their own jobs: core's record lock on `jobs_job.userId` through the `jobs_user` key.
- The Jobs app and its home widgets require `<appName>.hasAccess` (default `jobs.hasAccess`, from
`JobsQBitConfig.getHasAccessPermissionName()`); without it they are hidden. Hosts must grant it to the
users who should see the Jobs app. The header Jobs tray and record locks are unaffected.
- `jobs_admin` sees and controls every job, and sees the Workers widget.
- Apply the keys in your auth customizer with `DurableJobsSecurity.applyToSession(session)`.
@@ -52,7 +56,17 @@ The tables are core's, prefixed `jobs_`. This qbit adds no tables.
- `schedulerName` (null; set to a host scheduler name to enable the schedule);
- `homeRefreshSeconds` (5);
- `recordRefreshSeconds` (3);
- `appName` (`jobs`).
- `appName` (`jobs`);
- `parentAppName` (null; an existing host app, such as `operations` under Admin).
To place Jobs at **Admin > Operations > Jobs**, register the host Admin and Operations
apps first (Operations has Admin as its parent), then produce with:
```java
new JobsQBitProducer().withQBitConfig(new JobsQBitConfig()
.withParentAppName("operations")).produce(qInstance);
```
The parent must exist and differ from `appName`; Jobs attaches to its children.
With no parent configured, Jobs remains a top-level app. Placement does not grant permissions.
## Adopting in another app
1. Enable durable jobs in core (above) and create the tables. `docs/ddl/postgres.sql` is the Postgres
@@ -76,12 +90,17 @@ On Windows use `./mvnw.cmd` in place of `./mvnw`.
## Display
The job progress fields and the Log list need QQQ 4.1.0-RC.8 or later; on older metadata
the optional progress fields may be absent and the Log list is left out.
The app produces typed data only, with no HTML. The widget label is each card's title,
so the data carries no second title.
- **Tables.** The running, attention and worker tables have two or three flexible
columns. Their cells are composite widget blocks:
- the job's label links to its record, with the process label under it;
- a progress bar shows the percent and rows done;
- a progress bar uses `percentDone` when present (including zero), otherwise rows/chunks;
- phase labels come from `progressJson.phases` with `currentPhase` as fallback;
- plain text shows the progress message and optional bytes in B/KiB/MiB/GiB;
- the status line is a label (or the waiting reason) with the rate and ETA.
Each row also keeps its plain values (`Job`, `Process`, `Status`, `Rows`, `Rate`,
`ETA`, `Path` and so on) for exports and other clients.
@@ -97,6 +116,12 @@ Its output contains standard process summary lines for jobs purged, chunk object
removed during purge, and input objects removed (uploads plus retained-job inputs).
Jobs with effects outside SENT/FAILED, or with effects needing review, keep their rows
and inputs. When any are skipped, the summary includes "N jobs kept: effects still unsettled".
The Log list follows Live Progress, before Events: level, time, phase and message,
newest first, full width, with the standard child-list 25-row limit and View all link.
Live Progress also shows the ETA and a notice when `logDropped` is positive. The log is
read-only for users, including admins; retention deletes it through the system session in
keyset batches with 64-bit log cursors, before deleting chunks and jobs.
Needs attention shows each listed job's effect-review count; Live Progress adds an Effects
group (Sent, Pending, Failed, Needs review) when the job has effects.
+27 -4
View File
@@ -23,6 +23,29 @@ and phase 4. Owner decisions: all of that spec's recommendations are accepted (2
- **R6, storage.** Retention deletes finished jobs and their chunk objects from object storage. The
qbit reads and writes no files.
## Progress and log adoption (core RC.8)
Implements sections 2 and 7 of core `JOB-PROGRESS-AND-JOBS-TRAY.md`. The default
BOM is 4.1.0-RC.8; local verification uses `-Dqqq.snapshot.version=4.1.0-SNAPSHOT`.
Optional names remain in `JobsCoreContract` without importing `core.jobs`.
- Home and Live Progress prefer nullable `percentDone`, including explicit zero,
then retain rows/chunks derivation. The snapshot supplies the current phase label;
missing or invalid JSON falls back to `currentPhase`. Both views include the
progress message, optional binary byte counts, and explicit `etaSeconds` ahead
of the existing rate-derived ETA. No HTML, fixed widths, or theme-specific colors.
- Log is a full-width standard child list immediately after Live Progress, before
Events, only if both table and join exist. It displays level, loggedAt, phase and
message, sorted by id ascending, with the standard 25-row preview/View all.
The panel reads through the current viewer and core joined owner lock.
- A positive `logDropped` produces an earlier-lines-not-kept notice in Live Progress.
- Retention deletes logs before chunks/jobs, using the existing 200-row keyset loop
with a long child cursor. Core `JobLogPermissions` explicitly permits system
sessions and denies user mutations even for admins. Tests load the actual checker.
- Hosts set `parentAppName` to their existing Operations app under Admin; the qbit
sets the parent and attaches Jobs as a child, while retaining configurable appName.
Missing/self parents fail validation before registration. Default remains top-level.
## The app
- `jobs` app, label **Jobs**. The nav lists `jobs_job` only. Chunks, row errors, effects and events are child
lists on the job record.
@@ -57,7 +80,7 @@ and phase 4. Owner decisions: all of that spec's recommendations are accepted (2
without needsReview), Failed and Needs review. Review effects appear only in Needs review.
The group is omitted when there are no effects or the effects table is absent.
It refreshes every 3s while the job is active.
- Child lists for **Chunks**, **Row errors**, **Effects** and **Events**. Effects is
- Child lists for **Log**, **Chunks**, **Row errors**, **Effects** and **Events**. Effects is
included only when the effects table and its inferred job join exist; it uses the same
joined owner lock as row errors.
- A **Changes made by this job** link to the audit table, filtered to messages containing
@@ -68,7 +91,7 @@ and phase 4. Owner decisions: all of that spec's recommendations are accepted (2
## Retention
`jobsPurgeFinished` is a hidden scheduled process, not in the nav. It runs daily by default.
- It deletes jobs that finished more than `retentionDays` ago (default 30), with their chunks, row
errors, effects, events and chunk objects. Effects are removed before chunks and jobs.
errors, effects, logs, events and chunk objects. Effects are removed before chunks and jobs.
- Jobs with any effect outside SENT/FAILED, or any effect needing review, keep their rows
and inputs. The summary reports "N jobs kept: effects still unsettled" when any are skipped.
- For COMPLETED jobs with no failed rows, it deletes the chunk input objects after
@@ -80,8 +103,8 @@ and phase 4. Owner decisions: all of that spec's recommendations are accepted (2
these to qqq-esb, qbit-quick-search and qbit-data-analysis.
## Dependencies
Requires QQQ 4.1.0-RC.7 or later. The default `qqq-snapshot` profile and the opt-in
`demo` profile both use released RC.7. Core provides table metadata, joins, possible
Requires QQQ 4.1.0-RC.8 or later (job progress and `jobs_log`). The default `qqq-snapshot` profile and the opt-in
`demo` profile both use released RC.8. Core provides table metadata, joins, possible
values, session security, signal identifiers and storage deletion. The qbit declares
the alert types and retains only field names that core does not export.
The demo server under `src/demo/java` uses the same released kernel.
+23
View File
@@ -21,6 +21,15 @@ CREATE TABLE jobs_job
semantics VARCHAR(20),
chunk_size INTEGER,
max_parallel_chunks INTEGER,
current_phase VARCHAR(250),
percent_done INTEGER,
progress_message VARCHAR(1000),
bytes_done BIGINT,
bytes_total BIGINT,
eta_seconds BIGINT,
progress_json TEXT,
log_seq BIGINT,
log_dropped INTEGER,
rows_total INTEGER,
rows_done INTEGER,
rows_ok INTEGER,
@@ -173,3 +182,17 @@ CREATE TABLE jobs_upload
job_id INTEGER,
create_date TIMESTAMPTZ
);
-- RC.8 job progress log; host migrations must also add the jobs_job columns above.
CREATE TABLE jobs_log
(
id BIGSERIAL PRIMARY KEY,
job_id INTEGER NOT NULL REFERENCES jobs_job(id) ON DELETE CASCADE,
chunk_id INTEGER REFERENCES jobs_chunk(id) ON DELETE SET NULL,
attempt INTEGER,
level VARCHAR(10) NOT NULL,
logged_at TIMESTAMPTZ NOT NULL,
phase VARCHAR(250),
message VARCHAR(1000)
);
CREATE INDEX jobs_log_cursor_idx ON jobs_log(job_id, id);
+7 -18
View File
@@ -18,7 +18,8 @@
<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>
<revision>0.1.0-RC.1</revision>
<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>
@@ -27,7 +28,7 @@
<parent>
<groupId>com.kingsrook</groupId>
<artifactId>qbit-build-parent</artifactId>
<version>2.0.0</version>
<version>2.2.1</version>
</parent>
<groupId>com.kingsrook.qbits</groupId>
@@ -171,7 +172,7 @@
<profiles>
<!-- Built against qqq 4.1. Until qbit-build-parent imports the released 4.1 BOM, this -->
<!-- profile is active by default and imports qqq-bom-pom 4.1.0-RC.7 ahead of the parent's -->
<!-- profile is active by default and imports qqq-bom-pom 4.1.0-RC.8 ahead of the parent's -->
<!-- 4.0.0 BOM (ADR-0007, as in the other qbits). -->
<profile>
<id>qqq-snapshot</id>
@@ -179,7 +180,7 @@
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.7</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.26</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -192,18 +193,6 @@
</dependency>
</dependencies>
</dependencyManagement>
<repositories>
<repository>
<id>sonatype-snapshots</id>
<url>https://central.sonatype.com/repository/maven-snapshots/</url>
<releases>
<enabled>false</enabled>
</releases>
<snapshots>
<enabled>true</enabled>
</snapshots>
</repository>
</repositories>
</profile>
<!-- Local demo server and screenshots: builds src/demo/java against released QQQ RC.7. -->
@@ -211,7 +200,7 @@
<profile>
<id>demo</id>
<properties>
<qqq.snapshot.version>4.1.0-RC.7</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.26</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -238,7 +227,7 @@
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-frontend-next</artifactId>
<version>1.0.0-RC.19</version>
<version>1.0.0-RC.38</version>
<scope>test</scope>
</dependency>
<dependency>
@@ -39,6 +39,21 @@ import com.kingsrook.qqq.backend.core.model.audits.AuditsMetaDataProvider;
*******************************************************************************/
public final class JobsCoreContract
{
// Optional progress and log contract (core 4.1.0-RC.8).
public static final String JOBS_LOG = DurableJobsTables.TABLE_LOG;
public static final String CURRENT_PHASE = "currentPhase";
public static final String PERCENT_DONE = "percentDone";
public static final String PROGRESS_MESSAGE = "progressMessage";
public static final String BYTES_DONE = "bytesDone";
public static final String BYTES_TOTAL = "bytesTotal";
public static final String ETA_SECONDS = "etaSeconds";
public static final String LOG_SEQ = "logSeq";
public static final String LOG_DROPPED = "logDropped";
public static final String PROGRESS_JSON = "progressJson";
public static final String LEVEL = "level";
public static final String LOGGED_AT = "loggedAt";
public static final String PHASE = "phase";
public static final String JOBS_JOB = DurableJobsTables.TABLE_JOB;
public static final String JOBS_CHUNK = DurableJobsTables.TABLE_CHUNK;
public static final String JOBS_ROW_ERROR = DurableJobsTables.TABLE_ROW_ERROR;
@@ -34,6 +34,7 @@ import org.quartz.CronExpression;
public class JobsQBitConfig implements QBitConfig
{
private String appName = "jobs";
private String parentAppName;
private int retentionDays = 30;
private int inputRetentionDays = 7;
private String purgeCronExpression = "0 30 3 * * ?";
@@ -41,6 +42,31 @@ public class JobsQBitConfig implements QBitConfig
private int recordRefreshSeconds = 3;
private String schedulerName = null;
/*******************************************************************************
** Get the optional host parent app.
*******************************************************************************/
public String getParentAppName()
{
return (parentAppName);
}
/*******************************************************************************
** Set the optional host parent app.
*******************************************************************************/
public void setParentAppName(String parentAppName)
{
this.parentAppName = parentAppName;
}
/*******************************************************************************
** Place Jobs under an existing host app, such as Operations under Admin.
*******************************************************************************/
public JobsQBitConfig withParentAppName(String parentAppName)
{
this.parentAppName = parentAppName;
return (this);
}
/*******************************************************************************
** Report invalid settings before adding any metadata.
*******************************************************************************/
@@ -48,6 +74,7 @@ public class JobsQBitConfig implements QBitConfig
public void validate(QInstance instance, List<String> errors)
{
assertCondition(appName != null && appName.matches("[A-Za-z][A-Za-z0-9_]*"), "appName must be a valid name", errors);
assertCondition(parentAppName == null || (!parentAppName.equals(appName) && instance.getApp(parentAppName) != null), "parentAppName must name an existing, different app", errors);
assertCondition(retentionDays > 0, "retentionDays must be positive", errors);
assertCondition(inputRetentionDays > 0 && inputRetentionDays <= retentionDays, "inputRetentionDays must be positive and no greater than retentionDays", errors);
assertCondition(homeRefreshSeconds > 0 && recordRefreshSeconds > 0, "refresh seconds must be positive", errors);
@@ -55,6 +82,15 @@ public class JobsQBitConfig implements QBitConfig
assertCondition(schedulerName == null || !schedulerName.isBlank(), "schedulerName must be null or nonblank", errors);
}
/*******************************************************************************
** The permission a user needs to see the Jobs app and its home widgets,
** "<appName>.hasAccess" (default "jobs.hasAccess"). Hosts must grant it.
*******************************************************************************/
public String getHasAccessPermissionName()
{
return (appName + ".hasAccess");
}
/*******************************************************************************
** Get appName.
*******************************************************************************/
@@ -45,6 +45,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.DenyBehavior;
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.metadata.processes.QBackendStepMetaData;
@@ -66,7 +67,12 @@ import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_CHUNK;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_EFFECT;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_EVENT;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_JOB;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_LOG;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_ROW_ERROR;
import static com.kingsrook.qbits.jobs.JobsCoreContract.LEVEL;
import static com.kingsrook.qbits.jobs.JobsCoreContract.LOGGED_AT;
import static com.kingsrook.qbits.jobs.JobsCoreContract.MESSAGE;
import static com.kingsrook.qbits.jobs.JobsCoreContract.PHASE;
import static com.kingsrook.qbits.jobs.JobsCoreContract.ROW_NUMBER;
import static com.kingsrook.qbits.jobs.JobsCoreContract.SEQ;
@@ -162,10 +168,28 @@ public class JobsQBitProducer implements QBitMetaDataProducer<JobsQBitConfig>
QWidgetMetaData workers = registerWidget(instance, "jobsHomeWorkers", "Workers", WidgetType.TABLE, qBitConfig.getHomeRefreshSeconds());
workers.withPermissionRules(adminRules());
registerWidget(instance, "jobsHomeHistory", "Job history", WidgetType.STACKED_BAR_CHART, qBitConfig.getHomeRefreshSeconds());
instance.addApp(new QAppMetaData().withName(qBitConfig.getAppName()).withLabel("Jobs").withIcon(new QIcon("pending_actions"))
.withChild(instance.getTable(JOBS_JOB)).withWidgets(new ArrayList<>(HOME_WIDGETS)));
for(String name : HOME_WIDGETS)
{
if(!"jobsHomeWorkers".equals(name))
{
instance.getWidget(name).setPermissionRules(appAccessRules());
}
}
QAppMetaData app = new QAppMetaData().withName(qBitConfig.getAppName()).withLabel("Jobs").withIcon(new QIcon("pending_actions"))
.withChild(instance.getTable(JOBS_JOB)).withWidgets(new ArrayList<>(HOME_WIDGETS))
.withPermissionRules(appAccessRules());
app.setParentAppName(qBitConfig.getParentAppName());
instance.addApp(app);
if(qBitConfig.getParentAppName() != null)
{
instance.getApp(qBitConfig.getParentAppName()).withChild(app);
}
registerWidget(instance, "jobsJobProgress", "Live Progress", WidgetType.MULTI_STATISTICS, qBitConfig.getRecordRefreshSeconds());
addRecordSection(instance, "jobsJobProgress", "Live Progress");
if(instance.getTable(JOBS_LOG) != null && instance.getJoin(JobsCoreContract.childJoin(JOBS_LOG)) != null)
{
child(instance, JOBS_LOG, "jobsJobLog", "Log", ID, false);
}
child(instance, JOBS_CHUNK, "jobsJobChunks", "Chunks", SEQ, true);
child(instance, JOBS_ROW_ERROR, "jobsJobRowErrors", "Row Errors", ROW_NUMBER, true);
if(instance.getTable(JOBS_EFFECT) != null && instance.getJoin(JobsCoreContract.childJoin(JOBS_EFFECT)) != null)
@@ -203,6 +227,10 @@ public class JobsQBitProducer implements QBitMetaDataProducer<JobsQBitConfig>
.withOrderBys(List.of(new QFilterOrderBy(order, ascending))).withMaxRows(25).getWidgetMetaData()
.withName(name).withLabel(label).withGridColumns(12).withCodeReference(new QCodeReference(JobsChildRecordListRenderer.class))
.withDefaultValue("refreshSeconds", qBitConfig.getRecordRefreshSeconds());
if(JOBS_LOG.equals(table))
{
widget.withDefaultValue(ChildRecordListRenderer.KEY_ONLY_INCLUDE_FIELD_NAMES, new ArrayList<>(List.of(LEVEL, LOGGED_AT, PHASE, MESSAGE)));
}
instance.addWidget(widget);
addRecordSection(instance, name, label);
}
@@ -216,6 +244,16 @@ public class JobsQBitProducer implements QBitMetaDataProducer<JobsQBitConfig>
.withTier(Tier.T2).withWidgetName(widgetName).withGridColumns(12));
}
/*******************************************************************************
** The Jobs app and its home widgets require "<appName>.hasAccess"; users
** without it never see the app or its widgets (hidden, not locked).
*******************************************************************************/
private QPermissionRules appAccessRules()
{
return (new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION)
.withPermissionBaseName(qBitConfig.getAppName()).withDenyBehavior(DenyBehavior.HIDDEN));
}
/*******************************************************************************
** Match the exact core permission, without a .hasAccess suffix.
*******************************************************************************/
@@ -60,6 +60,7 @@ import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_CHUNK;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_EFFECT;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_EVENT;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_JOB;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_LOG;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_ROW_ERROR;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_UPLOAD;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOB_ID;
@@ -159,6 +160,10 @@ public class JobsPurgeFinishedStep implements BackendStep
{
removeChildren(JOBS_EFFECT, lastId, counts);
}
if(QContext.getQInstance().getTable(JOBS_LOG) != null)
{
removeChildren(JOBS_LOG, lastId, counts);
}
removeChildren(JOBS_ROW_ERROR, lastId, counts);
removeChildren(JOBS_EVENT, lastId, counts);
removeChildren(JOBS_CHUNK, lastId, counts);
@@ -191,7 +196,7 @@ public class JobsPurgeFinishedStep implements BackendStep
*******************************************************************************/
private void removeChildren(String table, int jobId, int[] counts) throws QException
{
int lastId = 0;
long lastId = 0;
while(true)
{
List<QRecord> records = batch(table, new QQueryFilter().withCriteria(JOB_ID, QCriteriaOperator.EQUALS, jobId), lastId);
@@ -201,7 +206,7 @@ public class JobsPurgeFinishedStep implements BackendStep
}
for(QRecord record : records)
{
lastId = record.getValueInteger(ID);
lastId = record.getValueLong(ID);
if(JOBS_CHUNK.equals(table) && storage.delete(storage.chunkStorageTable(), record.getValueString(INPUT_STORAGE_REF)))
{
counts[1]++;
@@ -276,7 +281,7 @@ public class JobsPurgeFinishedStep implements BackendStep
/*******************************************************************************
** Keyset pagination remains correct when rows are deleted or updated.
*******************************************************************************/
private List<QRecord> batch(String table, QQueryFilter filter, int lastId) throws QException
private List<QRecord> batch(String table, QQueryFilter filter, long lastId) throws QException
{
return (new QueryAction().execute(new QueryInput().withTableName(table).withFilter(filter.withCriteria(ID, QCriteriaOperator.GREATER_THAN, lastId)
.withOrderBy(new QFilterOrderBy(ID)).withLimit(BATCH_SIZE))).getRecords());
@@ -52,6 +52,24 @@ public final class JobsFormat
/*******************************************************************************
** Binary byte units, with one decimal place above bytes.
*******************************************************************************/
public static String bytes(long value)
{
double amount = Math.max(0, value);
String[] units = {"B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"};
int unit = 0;
while(amount >= 1024 && unit < units.length - 1)
{
amount /= 1024;
unit++;
}
return (unit == 0 ? count((long) amount) + " B" : String.format(Locale.US, "%.1f %s", amount, units[unit]));
}
/*******************************************************************************
** A count with thousands separators.
*******************************************************************************/
@@ -22,6 +22,7 @@
package com.kingsrook.qbits.jobs.widgets;
import java.io.IOException;
import java.io.Serializable;
import java.math.BigDecimal;
import java.net.URLEncoder;
@@ -43,6 +44,7 @@ import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import com.fasterxml.jackson.databind.JsonNode;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
@@ -73,14 +75,18 @@ import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import static com.kingsrook.qbits.jobs.JobsCoreContract.ATTENTION_STATUSES;
import static com.kingsrook.qbits.jobs.JobsCoreContract.AUDIT;
import static com.kingsrook.qbits.jobs.JobsCoreContract.BYTES_DONE;
import static com.kingsrook.qbits.jobs.JobsCoreContract.BYTES_TOTAL;
import static com.kingsrook.qbits.jobs.JobsCoreContract.CHUNKS_DONE;
import static com.kingsrook.qbits.jobs.JobsCoreContract.CHUNKS_FAILED;
import static com.kingsrook.qbits.jobs.JobsCoreContract.CHUNKS_TOTAL;
import static com.kingsrook.qbits.jobs.JobsCoreContract.COMPLETED;
import static com.kingsrook.qbits.jobs.JobsCoreContract.COMPLETED_WITH_ERRORS;
import static com.kingsrook.qbits.jobs.JobsCoreContract.CURRENT_PHASE;
import static com.kingsrook.qbits.jobs.JobsCoreContract.DONE;
import static com.kingsrook.qbits.jobs.JobsCoreContract.DRAINING;
import static com.kingsrook.qbits.jobs.JobsCoreContract.ERROR_MESSAGE;
import static com.kingsrook.qbits.jobs.JobsCoreContract.ETA_SECONDS;
import static com.kingsrook.qbits.jobs.JobsCoreContract.FAILED;
import static com.kingsrook.qbits.jobs.JobsCoreContract.FINISHED_AT;
import static com.kingsrook.qbits.jobs.JobsCoreContract.HEARTBEAT_AT;
@@ -93,12 +99,16 @@ import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_WORKER;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOB_ID;
import static com.kingsrook.qbits.jobs.JobsCoreContract.LABEL;
import static com.kingsrook.qbits.jobs.JobsCoreContract.LAST_PROGRESS_AT;
import static com.kingsrook.qbits.jobs.JobsCoreContract.LOG_DROPPED;
import static com.kingsrook.qbits.jobs.JobsCoreContract.MESSAGE;
import static com.kingsrook.qbits.jobs.JobsCoreContract.NEEDS_REVIEW_FIELD;
import static com.kingsrook.qbits.jobs.JobsCoreContract.PENDING;
import static com.kingsrook.qbits.jobs.JobsCoreContract.PERCENT_DONE;
import static com.kingsrook.qbits.jobs.JobsCoreContract.POD_ID;
import static com.kingsrook.qbits.jobs.JobsCoreContract.PRIORITY;
import static com.kingsrook.qbits.jobs.JobsCoreContract.PROCESS_NAME;
import static com.kingsrook.qbits.jobs.JobsCoreContract.PROGRESS_JSON;
import static com.kingsrook.qbits.jobs.JobsCoreContract.PROGRESS_MESSAGE;
import static com.kingsrook.qbits.jobs.JobsCoreContract.QUEUED_AT;
import static com.kingsrook.qbits.jobs.JobsCoreContract.QUEUED_STATUSES;
import static com.kingsrook.qbits.jobs.JobsCoreContract.ROWS_DONE;
@@ -305,6 +315,13 @@ public class JobsWidgetRenderer extends AbstractWidgetRenderer
progress.withBlock(text("Unknown total", null, null));
}
progress.withBlock(small(rowText + " rows"));
for(String detail : progressDetails(job))
{
progress.withBlock(small(detail));
}
row.put("Phase", phaseLabel(job));
row.put("Message", job.getValueString(PROGRESS_MESSAGE));
row.put("Bytes", bytes(job));
row.put(JOB_CELL, jobCell(job, null));
row.put(PROGRESS_CELL, progress);
@@ -498,7 +515,7 @@ public class JobsWidgetRenderer extends AbstractWidgetRenderer
rowStats.add(statistic("Total", number(job, ROWS_TOTAL), null));
}
MultiStatisticsData.StatisticsGroupData rows = new MultiStatisticsData.StatisticsGroupData().withHeader("Rows")
.withSubheader(job.getValue(ROWS_TOTAL) == null ? "Total not known yet" : percent + " done").withStatisticList(rowStats);
.withSubheader(percentValue(job) == null ? "Total not known yet" : percent + " done").withStatisticList(rowStats);
List<MultiStatisticsData.StatisticsGroupData.Statistic> chunkStats = new ArrayList<>();
chunkStats.add(statistic("Done", number(job, CHUNKS_DONE), null));
@@ -514,7 +531,7 @@ public class JobsWidgetRenderer extends AbstractWidgetRenderer
{
speedNote = statusLabel(status) + " " + JobsFormat.time(job.getValueInstant(FINISHED_AT), now);
}
else if(RUNNING_STATUSES.contains(status))
else if(job.getValue(ETA_SECONDS) != null || RUNNING_STATUSES.contains(status))
{
speedNote = "ETA " + eta(job, now);
}
@@ -525,6 +542,17 @@ public class JobsWidgetRenderer extends AbstractWidgetRenderer
MultiStatisticsData.StatisticsGroupData speed = new MultiStatisticsData.StatisticsGroupData().withHeader("Speed").withSubheader(speedNote)
.withStatisticList(List.of(statistic("Rows per minute", Math.round(rate(job, now)), null)));
List<MultiStatisticsData.StatisticsGroupData> groups = new ArrayList<>(List.of(rows, chunks, speed));
List<String> details = progressDetails(job);
if(!details.isEmpty())
{
groups.addFirst(new MultiStatisticsData.StatisticsGroupData().withHeader("Progress")
.withSubheader(String.join(" | ", details)).withStatisticList(List.of()));
}
if(number(job, LOG_DROPPED) > 0)
{
groups.add(new MultiStatisticsData.StatisticsGroupData().withHeader("Log")
.withSubheader(JobsFormat.count(number(job, LOG_DROPPED)) + " earlier lines not kept").withStatisticList(List.of()));
}
if(QContext.getQInstance().getTable(JOBS_EFFECT) != null)
{
List<QRecord> effects = query(JOBS_EFFECT, new QQueryFilter().withCriteria(JOB_ID, QCriteriaOperator.EQUALS, job.getValue(ID)));
@@ -760,10 +788,69 @@ public class JobsWidgetRenderer extends AbstractWidgetRenderer
/*******************************************************************************
** Percent done from rows, else from chunks; null when neither total is known.
** Resolve the phase label from the optional snapshot; old or malformed data
** still displays the reported phase name.
*******************************************************************************/
private static String phaseLabel(QRecord job)
{
String current = job.getValueString(CURRENT_PHASE);
String json = job.getValueString(PROGRESS_JSON);
if(current != null && json != null)
{
try
{
JsonNode snapshot = JsonUtils.toObject(json, JsonNode.class);
for(JsonNode phase : snapshot == null ? List.<JsonNode>of() : snapshot.path("phases"))
{
if(current.equals(phase.path("name").asText()) && !phase.path("label").asText().isBlank())
{
return (phase.path("label").asText());
}
}
}
catch(IOException ignored)
{
// The denormalized progress remains usable without a valid snapshot.
}
}
return (current);
}
/*******************************************************************************
** Optional byte counts, retaining unknown totals rather than inventing zero.
*******************************************************************************/
private static String bytes(QRecord job)
{
String done = job.getValue(BYTES_DONE) == null ? null : JobsFormat.bytes(number(job, BYTES_DONE));
String total = job.getValue(BYTES_TOTAL) == null ? null : JobsFormat.bytes(number(job, BYTES_TOTAL));
return (total == null ? done : done == null ? total + " total" : done + " / " + total);
}
/*******************************************************************************
** Plain typed text shared by the home and record progress views.
*******************************************************************************/
private static List<String> progressDetails(QRecord job)
{
List<String> details = new ArrayList<>();
for(String detail : new String[] {phaseLabel(job), job.getValueString(PROGRESS_MESSAGE), bytes(job)})
{
if(detail != null && !detail.isBlank())
{
details.add(detail);
}
}
return (details);
}
/*******************************************************************************
** Explicit percent, else rows then chunks; null when no total is known.
*******************************************************************************/
private static BigDecimal percentValue(QRecord job)
{
if(job.getValue(PERCENT_DONE) != null)
{
return (BigDecimal.valueOf(Math.max(0, Math.min(100, number(job, PERCENT_DONE)))));
}
long total = number(job, ROWS_TOTAL);
long done = number(job, ROWS_DONE);
if(total <= 0)
@@ -805,6 +892,10 @@ public class JobsWidgetRenderer extends AbstractWidgetRenderer
*******************************************************************************/
private static String eta(QRecord job, Instant now)
{
if(job.getValue(ETA_SECONDS) != null)
{
return ("in " + JobsFormat.span(number(job, ETA_SECONDS)));
}
double rate = rate(job, now);
if(!RUNNING_STATUSES.contains(job.getValueString(STATUS)) || rate <= 0 || job.getValue(ROWS_TOTAL) == null)
{
@@ -0,0 +1,109 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* 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
*
* https://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.jobs;
import java.util.List;
import com.kingsrook.qqq.backend.core.actions.metadata.MetaDataAction;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.metadata.MetaDataInput;
import com.kingsrook.qqq.backend.core.model.actions.metadata.MetaDataOutput;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.DenyBehavior;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** The Jobs app and its home widgets are hidden from users without
** "jobs.hasAccess"; Workers additionally needs jobs_admin.
*******************************************************************************/
class JobsAppPermissionTest extends BaseJobsTest
{
private static final List<String> NON_ADMIN_WIDGETS = JobsQBitProducer.HOME_WIDGETS.stream().filter(w -> !"jobsHomeWorkers".equals(w)).toList();
/*******************************************************************************
** The permission name is public and follows the configured app name.
*******************************************************************************/
@Test
void permissionNameAndRules()
{
assertThat(config.getHasAccessPermissionName()).isEqualTo("jobs.hasAccess");
assertThat(new JobsQBitConfig().withAppName("bg").getHasAccessPermissionName()).isEqualTo("bg.hasAccess");
assertThat(instance.getApp("jobs").getPermissionRules().getLevel()).isEqualTo(PermissionLevel.HAS_ACCESS_PERMISSION);
assertThat(instance.getApp("jobs").getPermissionRules().getPermissionBaseName()).isEqualTo("jobs");
assertThat(instance.getApp("jobs").getPermissionRules().getDenyBehavior()).isEqualTo(DenyBehavior.HIDDEN);
assertThat(instance.getWidget("jobsJobProgress").getPermissionRules().getLevel()).isNotEqualTo(PermissionLevel.HAS_ACCESS_PERMISSION);
for(String name : NON_ADMIN_WIDGETS)
{
assertThat(instance.getWidget(name).getPermissionRules().getPermissionBaseName()).isEqualTo("jobs");
assertThat(instance.getWidget(name).getPermissionRules().getDenyBehavior()).isEqualTo(DenyBehavior.HIDDEN);
}
}
/*******************************************************************************
** No permissions: no app, no home widgets, every render denied.
*******************************************************************************/
@Test
void noPermissionsSeesNothing() throws Exception
{
instance.setEnforcePermissionsInCoreActions(true);
login("nobody", false);
MetaDataOutput output = new MetaDataAction().execute(new MetaDataInput());
assertThat(output.getApps()).doesNotContainKey("jobs");
assertThat(output.getWidgets()).doesNotContainKeys(JobsQBitProducer.HOME_WIDGETS.toArray(String[]::new));
for(String name : JobsQBitProducer.HOME_WIDGETS)
{
assertThatThrownBy(() -> render(name, null)).isInstanceOf(QPermissionDeniedException.class);
}
}
/*******************************************************************************
** jobs.hasAccess: app and the five widgets, Workers only with jobs_admin.
*******************************************************************************/
@Test
void hasAccessSeesAppAndWidgets() throws Exception
{
instance.setEnforcePermissionsInCoreActions(true);
login("user", false).withPermission(config.getHasAccessPermissionName());
MetaDataOutput output = new MetaDataAction().execute(new MetaDataInput());
assertThat(output.getApps()).containsKey("jobs");
assertThat(output.getWidgets()).containsKeys(NON_ADMIN_WIDGETS.toArray(String[]::new));
assertThat(output.getWidgets()).doesNotContainKey("jobsHomeWorkers");
for(String name : NON_ADMIN_WIDGETS)
{
assertThat(render(name, null)).isNotNull();
}
assertThatThrownBy(() -> render("jobsHomeWorkers", null)).isInstanceOf(QPermissionDeniedException.class);
login("admin", true).withPermission(config.getHasAccessPermissionName());
assertThat(new MetaDataAction().execute(new MetaDataInput()).getWidgets()).containsKey("jobsHomeWorkers");
assertThat(render("jobsHomeWorkers", null)).isNotNull();
}
}
@@ -0,0 +1,290 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* 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
*
* https://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.jobs;
import java.time.temporal.ChronoUnit;
import java.util.List;
import com.kingsrook.qbits.jobs.processes.JobsPurgeFinishedStep;
import com.kingsrook.qbits.jobs.widgets.JobsFormat;
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.ChildRecordListData;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.MultiStatisticsData;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn;
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType;
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
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.model.session.QSystemUserSession;
import org.junit.jupiter.api.Test;
import static com.kingsrook.qbits.jobs.JobsCoreContract.BYTES_DONE;
import static com.kingsrook.qbits.jobs.JobsCoreContract.BYTES_TOTAL;
import static com.kingsrook.qbits.jobs.JobsCoreContract.CHUNKS_TOTAL;
import static com.kingsrook.qbits.jobs.JobsCoreContract.COMPLETED;
import static com.kingsrook.qbits.jobs.JobsCoreContract.CURRENT_PHASE;
import static com.kingsrook.qbits.jobs.JobsCoreContract.ETA_SECONDS;
import static com.kingsrook.qbits.jobs.JobsCoreContract.ID;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_JOB;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_LOG;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_USER;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOB_ID;
import static com.kingsrook.qbits.jobs.JobsCoreContract.LEVEL;
import static com.kingsrook.qbits.jobs.JobsCoreContract.LOGGED_AT;
import static com.kingsrook.qbits.jobs.JobsCoreContract.LOG_DROPPED;
import static com.kingsrook.qbits.jobs.JobsCoreContract.LOG_SEQ;
import static com.kingsrook.qbits.jobs.JobsCoreContract.MESSAGE;
import static com.kingsrook.qbits.jobs.JobsCoreContract.PERCENT_DONE;
import static com.kingsrook.qbits.jobs.JobsCoreContract.PHASE;
import static com.kingsrook.qbits.jobs.JobsCoreContract.PROGRESS_JSON;
import static com.kingsrook.qbits.jobs.JobsCoreContract.PROGRESS_MESSAGE;
import static com.kingsrook.qbits.jobs.JobsCoreContract.ROWS_TOTAL;
import static com.kingsrook.qbits.jobs.JobsCoreContract.RUNNING;
import static com.kingsrook.qbits.jobs.JobsCoreContract.USER_ID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Optional RC.8 progress and logs, including the actual core log permission checker.
*******************************************************************************/
class JobsProgressTest extends BaseJobsTest
{
/*******************************************************************************
** Add optional fields and a log using the real kernel permission checker by
** name, preserving the default suite's coverage of older kernel metadata.
*******************************************************************************/
private void enableProgress() throws Exception
{
for(String field : List.of(CURRENT_PHASE, PROGRESS_MESSAGE, PROGRESS_JSON))
{
instance.getTable(JOBS_JOB).withField(new QFieldMetaData(field, QFieldType.TEXT));
}
for(String field : List.of(PERCENT_DONE, LOG_DROPPED))
{
instance.getTable(JOBS_JOB).withField(new QFieldMetaData(field, QFieldType.INTEGER));
}
for(String field : List.of(BYTES_DONE, BYTES_TOTAL, ETA_SECONDS, LOG_SEQ))
{
instance.getTable(JOBS_JOB).withField(new QFieldMetaData(field, QFieldType.LONG));
}
instance.setEnforcePermissionsInCoreActions(true);
//////////////////////////////////////////////////////////////////////
// core 4.1.0-RC.8 defines jobs_log; older metadata gets this mirror //
//////////////////////////////////////////////////////////////////////
if(instance.getTable(JOBS_LOG) != null)
{
return;
}
instance.addTable(new QTableMetaData().withName(JOBS_LOG).withBackendName("memory").withPrimaryKeyField(ID)
.withField(new QFieldMetaData(ID, QFieldType.LONG))
.withField(new QFieldMetaData(JOB_ID, QFieldType.INTEGER))
.withField(new QFieldMetaData(LEVEL, QFieldType.STRING))
.withField(new QFieldMetaData(LOGGED_AT, QFieldType.DATE_TIME))
.withField(new QFieldMetaData(PHASE, QFieldType.STRING))
.withField(new QFieldMetaData(MESSAGE, QFieldType.STRING))
.withPermissionRules(new QPermissionRules().withCustomPermissionChecker(new QCodeReference(
Class.forName("com.kingsrook.qqq.backend.core.jobs.progress.JobLogPermissions"))))
.withRecordSecurityLock(new RecordSecurityLock().withSecurityKeyType(JOBS_USER).withFieldName(JOBS_JOB + "." + USER_ID)
.withJoinNameChain(List.of(JobsCoreContract.childJoin(JOBS_LOG)))));
instance.addJoin(new QJoinMetaData().withLeftTable(JOBS_JOB).withRightTable(JOBS_LOG).withType(JoinType.ONE_TO_MANY)
.withJoinOn(new JoinOn(ID, JOB_ID)).withInferredName());
clearPresentation();
new JobsQBitProducer().withQBitConfig(config).produce(instance);
new QInstanceEnricher(instance).enrich();
}
/*******************************************************************************
** Rich fields override counters, resolve labels, and remain typed and flexible.
*******************************************************************************/
@Test
void richProgressOnHomeAndRecord() throws Exception
{
enableProgress();
QRecord job = job(USER_A, RUNNING, null);
update(job, new QRecord().withValue(CURRENT_PHASE, "upload").withValue(PERCENT_DONE, 73)
.withValue(PROGRESS_MESSAGE, "Uploading manifest").withValue(BYTES_DONE, 1536L).withValue(BYTES_TOTAL, 1048576L)
.withValue(ETA_SECONDS, 90L).withValue(LOG_DROPPED, 12)
.withValue(PROGRESS_JSON, "{\"phases\":[{\"name\":\"upload\",\"label\":\"Upload backup\"}]}"));
login(USER_A, false).withPermission(config.getHasAccessPermissionName());
TableData home = (TableData) render("jobsHomeActive", null);
assertThat(home.getRows()).singleElement().satisfies(row -> assertThat(row)
.containsEntry("Progress", "73%").containsEntry("Phase", "Upload backup")
.containsEntry("Message", "Uploading manifest").containsEntry("Bytes", "1.5 KiB / 1.0 MiB").containsEntry("ETA", "in 1m"));
assertThat(home.getColumns()).hasSize(3).allSatisfy(column -> assertThat(column.getWidth()).isNull());
MultiStatisticsData record = (MultiStatisticsData) render("jobsJobProgress", job.getValueString(ID));
assertThat(record.getStatisticsGroupData()).extracting(MultiStatisticsData.StatisticsGroupData::getSubheader)
.contains("Upload backup | Uploading manifest | 1.5 KiB / 1.0 MiB", "73% done", "ETA in 1m", "12 earlier lines not kept");
login(USER_B, false).withPermission(config.getHasAccessPermissionName());
assertThat(((TableData) render("jobsHomeActive", null)).getRows()).isEmpty();
assertThat(((MultiStatisticsData) render("jobsJobProgress", job.getValueString(ID))).getStatisticsGroupData()).isEmpty();
}
/*******************************************************************************
** Null percent uses rows then chunks, zero is explicit, malformed JSON is safe.
*******************************************************************************/
@Test
void fallbacksAndPartialProgress() throws Exception
{
enableProgress();
QRecord job = job(USER_A, RUNNING, null);
update(job, new QRecord().withValue(CURRENT_PHASE, "copy").withValue(PROGRESS_JSON, "bad json").withValue(BYTES_TOTAL, 1024L));
assertThat(((TableData) render("jobsHomeActive", null)).getRows().getFirst())
.containsEntry("Progress", "45%").containsEntry("Phase", "copy").containsEntry("Bytes", "1.0 KiB total");
update(job, new QRecord().withValue(ROWS_TOTAL, null).withValue(PROGRESS_JSON, "null"));
assertThat(((TableData) render("jobsHomeActive", null)).getRows().getFirst()).containsEntry("Progress", "40%");
update(job, new QRecord().withValue(PERCENT_DONE, 0).withValue(BYTES_TOTAL, null).withValue(BYTES_DONE, 0L));
assertThat(((TableData) render("jobsHomeActive", null)).getRows().getFirst()).containsEntry("Progress", "0%").containsEntry("Bytes", "0 B");
update(job, new QRecord().withValue(PERCENT_DONE, null).withValue(CHUNKS_TOTAL, null));
assertThat(((TableData) render("jobsHomeActive", null)).getRows().getFirst()).containsEntry("Progress", "Unknown");
assertThat(JobsFormat.bytes(1073741824L)).isEqualTo("1.0 GiB");
assertThat(JobsFormat.bytes(Long.MAX_VALUE)).isEqualTo("8.0 EiB");
}
/*******************************************************************************
** The log is ordered, full width and owner scoped, with no user mutations.
*******************************************************************************/
@Test
void logPanelAndPermissions() throws Exception
{
enableProgress();
QRecord job = job(USER_A, RUNNING, null);
log(job, 1000000002L);
log(job, 1000000001L);
assertThat(instance.getWidget("jobsJobLog").getGridColumns()).isEqualTo(12);
assertThat(instance.getTable(JOBS_JOB).getSections()).extracting(QFieldSection::getWidgetName)
.containsSubsequence("jobsJobProgress", "jobsJobLog", "jobsJobEvents");
login(USER_A, false).withPermission("jobs_job.read");
ChildRecordListData data = (ChildRecordListData) render("jobsJobLog", job.getValueString(ID));
assertThat(data.getQueryOutput().getRecords()).extracting(record -> record.getValueLong(ID)).containsExactly(1000000002L, 1000000001L);
assertThat(data.getOnlyIncludeFieldNames()).containsExactly(LEVEL, LOGGED_AT, PHASE, MESSAGE);
assertThat(data.getCanAddChildRecord()).isFalse();
assertThatThrownBy(() -> new DeleteAction().execute(new DeleteInput().withTableName(JOBS_LOG).withPrimaryKey(1000000001L))).hasMessageContaining("read-only");
assertThatThrownBy(() -> log(job, 1000000003L)).hasMessageContaining("read-only");
login("admin", true).withPermission("jobs_job.read");
assertThatThrownBy(() -> new UpdateAction().execute(new UpdateInput().withTableName(JOBS_LOG)
.withRecord(new QRecord().withValue(ID, 1000000001L).withValue(MESSAGE, "changed")))).hasMessageContaining("read-only");
login(USER_B, false).withPermission("jobs_job.read");
assertThatThrownBy(() -> render("jobsJobLog", job.getValueString(ID))).hasMessageContaining("Could not find");
}
/*******************************************************************************
** The system purge crosses a full batch with LONG IDs; active logs remain.
*******************************************************************************/
@Test
void purgeLogBatchesWithSystemPermission() throws Exception
{
enableProgress();
QRecord old = job(USER_A, COMPLETED, now.minus(31, ChronoUnit.DAYS));
QRecord active = job(USER_B, RUNNING, null);
for(long id = 1000000000L; id < 1000000205L; id++)
{
log(old, id);
}
log(active, 1000000300L);
QRecord held = job(USER_A, COMPLETED, now.minus(31, ChronoUnit.DAYS));
effect(held, JobsCoreContract.PENDING, false);
log(held, 1000000301L);
var caller = login("admin", true);
new JobsPurgeFinishedStep().run(new RunBackendStepInput(), new RunBackendStepOutput());
assertThat(QContext.getQSession()).isSameAs(caller);
QContext.setQSession(new QSystemUserSession());
assertThat(records(JOBS_LOG)).extracting(record -> record.getValue(JOB_ID)).containsExactlyInAnyOrder(active.getValue(ID), held.getValue(ID));
assertThat(records(JOBS_JOB)).extracting(record -> record.getValue(ID)).containsExactlyInAnyOrder(active.getValue(ID), held.getValue(ID));
}
/*******************************************************************************
** Log metadata is optional; hosts choose the Admin/Operations parent.
*******************************************************************************/
@Test
void optionalLogAndHostNavigation() throws Exception
{
assertThat(instance.getWidget("jobsJobLog")).as("core RC.8 defines jobs_log, so the Log list is produced").isNotNull();
enableProgress();
instance.getJoins().remove(JobsCoreContract.childJoin(JOBS_LOG));
instance.getWidgets().remove("jobsJobLog");
clearPresentation();
new JobsQBitProducer().produce(instance);
assertThat(instance.getWidget("jobsJobLog")).isNull();
QAppMetaData admin = new QAppMetaData().withName("admin").withLabel("Admin");
QAppMetaData operations = new QAppMetaData().withName("operations").withLabel("Operations");
operations.setParentAppName("admin");
instance.addApp(admin.withChild(operations));
instance.addApp(operations);
config.withAppName("backgroundJobs").withParentAppName("operations");
clearPresentation();
new JobsQBitProducer().withQBitConfig(config).produce(instance);
assertThat(instance.getApp("backgroundJobs").getParentAppName()).isEqualTo("operations");
assertThat(operations.getChildren()).extracting(child -> child.getName()).containsExactly("backgroundJobs");
config.setParentAppName("missing");
assertThatThrownBy(() -> config.validate(instance)).hasMessageContaining("parentAppName");
config.setParentAppName("backgroundJobs");
assertThatThrownBy(() -> config.validate(instance)).hasMessageContaining("parentAppName");
config.setParentAppName(null);
config.validate(instance);
}
/*******************************************************************************
** Rebuild presentation against changed optional metadata in these tests.
*******************************************************************************/
private void clearPresentation()
{
instance.getQBits().clear();
instance.getWidgets().clear();
instance.getApps().remove("jobs");
instance.getProcesses().remove(JobsQBitProducer.PURGE_PROCESS);
instance.getTable(JOBS_JOB).getSections().removeIf(section -> section.getWidgetName() != null);
}
/*******************************************************************************
** Persist progress through core actions.
*******************************************************************************/
private void update(QRecord job, QRecord values) throws Exception
{
var output = new UpdateAction().execute(new UpdateInput().withTableName(JOBS_JOB).withRecord(values.withValue(ID, job.getValue(ID))));
assertThat(output.getRecords().getFirst().getErrors()).isNullOrEmpty();
}
/*******************************************************************************
** Explicit LONG cursors make ordering assertions deterministic. Core memory
** sequence bookkeeping currently restricts inserts to the integer range.
*******************************************************************************/
private void log(QRecord job, long id) throws Exception
{
insert(JOBS_LOG, new QRecord().withValue(ID, id).withValue(JOB_ID, job.getValue(ID)).withValue(LEVEL, "INFO")
.withValue(LOGGED_AT, now).withValue(PHASE, "upload").withValue(MESSAGE, "Wrote manifest"));
}
}
@@ -97,13 +97,13 @@ class JobsQBitProducerTest extends BaseJobsTest
@Test
void navigationWidgetsAndRecordSections()
{
assertThat(instance.getTables()).hasSize(11);
assertThat(instance.getTables()).hasSize(12);
assertThat(instance.getApp("jobs").getChildren()).extracting(child -> child.getName()).containsExactly(JOBS_JOB);
assertThat(instance.getApp("jobs").getWidgets()).containsExactlyElementsOf(JobsQBitProducer.HOME_WIDGETS);
assertThat(instance.getWidgets()).hasSize(12);
assertThat(instance.getWidgets()).hasSize(13);
assertThat(instance.getWidgets().values()).allSatisfy(widget -> assertThat(widget.getType()).isNotEqualTo(WidgetType.HTML.getType()));
assertThat(instance.getTable(JOBS_JOB).getSections()).extracting(QFieldSection::getWidgetName)
.containsSubsequence("jobsJobProgress", "jobsJobChunks", "jobsJobRowErrors", "jobsJobEffects", "jobsJobEvents", "jobsJobAudit");
.containsSubsequence("jobsJobProgress", "jobsJobLog", "jobsJobChunks", "jobsJobRowErrors", "jobsJobEffects", "jobsJobEvents", "jobsJobAudit");
for(String name : JobsQBitProducer.HOME_WIDGETS)
{
assertThat(instance.getWidget(name).getDefaultValues()).containsEntry("refreshSeconds", 5);
@@ -132,7 +132,7 @@ class JobsQBitProducerTest extends BaseJobsTest
.withPurgeCronExpression("0 0 4 * * ?").withHomeRefreshSeconds(9).withRecordRefreshSeconds(8);
new JobsQBitProducer().withQBitConfig(custom).produce(other);
assertThat(other.getWidget("jobsJobAudit")).isNull();
assertThat(other.getWidgets()).hasSize(11);
assertThat(other.getWidgets()).hasSize(12);
assertThat(other.getApp("backgroundJobs")).isNotNull();
QScheduleMetaData schedule = other.getProcess(JobsQBitProducer.PURGE_PROCESS).getSchedule();
assertThat(schedule.getSchedulerName()).isEqualTo("scheduler");