Compare commits

...
32 Commits
Author SHA1 Message Date
james.maes 5f0dbae0be Merge pull request 'fix(admin): decide a station delete's refusal under the lock, before revoking' (#40) from fix/delete-refusal-under-lock into main
build / maven (push) Successful in 2m42s
maven / build (push) Successful in 2m42s
2026-11-12 15:11:14 +00:00
james.maesandClaude Fable 5.2 d7c7c203c3 fix(delete): write names only onto jobs that lack one; correct the changelog on the old backfill
build / maven (push) Successful in 2m22s
maven / build (push) Successful in 2m22s
Review of PR 40: the name backfill at delete rewrote every job of the station
while holding the tenant's admission lock, and the changelog called the old
startup backfill a no-op where it nulled a queue name.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 17:30:50 -06:00
james.maesandClaude Fable 5.2 b063f9a55f fix(admin): decide a station delete's refusal under the lock, before revoking
build / maven (push) Successful in 2m13s
maven / build (push) Successful in 2m13s
deleteDevice decided "connected and printing" from a read without a lock,
revoked the station, and only then locked and read again. A revoked station
never counts as connected, so the locked read could not refuse: a station
that claimed a job between the first read and the revocation was deleted and
the job closed as OUTCOME_UNKNOWN although the station had its content.

The delete is now one transaction: tenant admission lock, then the station's
row lock (the lock a claim, a job event and a heartbeat take first), the
refusal, the REVOKE control on the same connection, the job closing and the
row deletes, one commit. A refused or failed delete changes nothing; the
station is no longer left revoked when the delete's transaction fails.
PrintingDeviceControls.action gains an overload that runs inside the
caller's transaction. deletePrinter already decided under the station's row
lock and is unchanged.

Also: the printer approval route returns "Printer was removed; share it from
the device again before changing its approval" again (RC.3 changed an HTTP
API text); the startup name backfill only updates jobs whose station or
printer still exists.

Tests: the claim that lands just before the delete locks (station, printer),
the claim in flight that the delete waits for, a printer refused on a live
station, an already revoked station, rollback and retry of a failed
transaction, the 23503 mapping, the final step refusing after the review,
review plan errors, and a tenant-scoped aggregate over print jobs.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 17:20:44 -06:00
james.maes cf650c51aa Merge pull request 'feat(admin): delete print stations and printers; "Print station"; an aggregate is a read' (#39) from feat/delete-stations-printers-20261111 into main
build / maven (push) Successful in 3m37s
maven / build (push) Successful in 3m37s
2026-11-11 21:56:53 +00:00
james.maesandClaude Fable 5.2 7ac849d09f feat(admin): delete print stations and printers; "Print station"; an aggregate is a read
build / maven (push) Successful in 2m38s
maven / build (push) Successful in 2m38s
Delete print stations (printingDeleteDevices) and Delete printers
(printingDeleteSelectedPrinters), on a selection and on the record screen,
behind a new permission, printing_delete, required in addition to
printing_admin. A station is revoked through the existing REVOKE control,
then its queued jobs are canceled, jobs sent without a reported result are
closed as OUTCOME_UNKNOWN, and its control receipts, printers and row are
deleted in one transaction. A station or printer that is connected and
printing is refused by name; the rest of the selection is still deleted.

Jobs are never deleted. printing_job gains device_name, computer_name,
printer_name and queue_name (the names at submission), keeps device_id,
printer_id and printer_row_id as the identifiers the records had, and loses
its foreign key to printing_device. printing_deletion records who deleted
what, when and what went with it (table Deletion history). A computer
enrolled again is a new station; a queue shared again is a new printer
awaiting approval.

Labels: "Print device" becomes "Print station" everywhere an administrator
reads it, from one place (PrintingNames). Table, field and process names,
the HTTP API texts and the agent protocol are unchanged.

PrintingReadPermission refused an aggregate (and an export) as a write, for
everybody. Both are reads and now need exactly the permission a query needs.

Schema step for hosts that own the schema is in CHANGELOG.md.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 12:57:43 -06:00
james.maes 40089937bf Merge pull request 'fix(jobs): store the print job creation instant as a real date-time (created_time)' (#38) from fix/print-job-created-date-time into main
build / maven (push) Successful in 3m13s
maven / build (push) Successful in 3m13s
2026-11-11 14:09:21 +00:00
james.maesandClaude Fable 5.2 d9f0c357a7 fix(jobs): store the print job creation instant as a real date-time (created_time)
build / maven (push) Successful in 2m14s
maven / build (push) Successful in 2m14s
printing_job.created_at is ISO-8601 text and its QQQ field is a string, so QQQ
could not apply a relative date filter ("created in the last day"), a date sort
or a date bucket to print jobs.

- printing_job gains created_time, a TIMESTAMP without time zone holding UTC
  (the QQQ date-time convention), written by the same INSERT from the same
  instant as created_at.
- The job table exposes it as the read-only DATE_TIME field "Created"; it
  replaces the text field in the default columns and Summary section. The text
  field stays (relabelled "Created at (ISO text)") because the queue order,
  history cursors, admission seeding, the Printing home and the job API read it.
- initialize() adds the column, backfills existing rows from created_at and
  creates printing_job_created_time_idx (tenant_id, created_time, id).
- A host that owns the schema must add the column first (PostgreSQL statements
  in CHANGELOG.md). Without it every submission is rolled back with the
  missing-column error and QQQ cannot read the table; nothing is stored silently.

No wire or contract change (extension 2.4.0, wire v1).

Tests: PrintingJobCreatedTimeTest (5 tests; all fail without the change).
./mvnw -B verify -Pui-demo: 209 tests, 0 failures, 0 errors, 1 skipped
(opt-in workload test), 0 Checkstyle violations.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 07:21:31 -06:00
james.maes bd08d03654 Merge pull request 'build(deps): align with Gitea releases (parent 2.2.1, QQQ 4.1.0-RC.25)' (#35) from deps/align-gitea into main
build / maven (push) Successful in 5m31s
maven / build (push) Successful in 5m31s
2026-11-07 17:18:31 +00:00
james.maesandClaude Opus 5.5 107a566891 build(deps): align with Gitea releases (parent 2.2.1, QQQ 4.1.0-RC.25)
build / maven (push) Successful in 4m41s
maven / build (push) Successful in 4m41s
pom.xml: parent -> 2.2.1
pom.xml: qqq.version: 4.1.0-RC.22 -> 4.1.0-RC.25
pom.xml: qqq-frontend-next: 1.0.0-RC.37 -> 1.0.0-RC.38

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:09:59 +00:00
james.maes 31e36b0b5b Merge pull request 'docs(release): record QQQ Printing 1.1.2 as the current stable library' (#34) from docs/release-1.1.2-20261106 into main
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
build / maven (push) Successful in 5m49s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 9s
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (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 15s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (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 / original-business (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 539bec410f7926fec3ef7fabe51934a91e0248be] (push) Successful in 14s
QRun-IO/ci-policy: ci / gradle-1 (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 / shared-pipeline (push) Skipped
maven / build (push) Successful in 5m49s
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
2026-11-06 20:47:58 +00:00
james.maesandClaude Fable 5.1 c2f6fc300a docs(release): record QQQ Printing 1.1.2 as the current stable library
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 539bec410f7926fec3ef7fabe51934a91e0248be] (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-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 / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
build / maven (push) Successful in 7m16s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 11s
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 10s
QRun-IO/ci-policy: ci / java-core-2-head (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 / android-0 (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: ci / shared-pipeline (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-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-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
maven / build (push) Successful in 7m16s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
1.1.2 = the 1.1.1 code on QQQ 4.1.0-RC.22 from the Gitea registry, built and
published by the shared ci-workflows pipeline (push build 6435, tag build 6475).
README, RELEASES, REALTIME and SESSION-STATE point at it with the verified hashes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-06 14:45:40 -06:00
james.maes f5639a98be Merge pull request 'build: QQQ 4.1.0-RC.22 from the Gitea registry; shared ci-workflows Maven pipeline; 1.1.2' (#33) from ci/gitea-registry-rc22-20261106 into main
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
build / maven (push) Successful in 9m36s
QRun-IO/ci-policy: native / native-result (push) Successful in 13s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (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 13s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (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 / original-business (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 539bec410f7926fec3ef7fabe51934a91e0248be] (push) Successful in 17s
QRun-IO/ci-policy: ci / gradle-1 (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 / shared-pipeline (push) Skipped
maven / build (push) Successful in 9m36s
QRun-IO/ci-policy: ci / java-docker-3-head (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
2026-11-06 20:18:16 +00:00
james.maesandClaude Fable 5.1 c31a8f5f33 docs: frontend-next 1.0.0-RC.37 is served from the Gitea registry
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 539bec410f7926fec3ef7fabe51934a91e0248be] (pull_request) Successful in 11s
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-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 / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
build / maven (push) Successful in 6m52s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 12s
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 11s
QRun-IO/ci-policy: ci / java-core-2-head (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 / android-0 (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: ci / shared-pipeline (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-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-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
maven / build (push) Successful in 6m52s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
Mirrored byte-for-byte from Maven Central (verified against Central's SHA-1 and read
back from the registry), so the ui-demo profile resolves from Gitea too.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-06 13:32:16 -06:00
james.maesandClaude Fable 5.1 8d5b72302c test: pin the vendored protocol fixtures README to protocol 1.1.0 (5005f3c)
The copied files are byte-identical between 42c0d249 and 5005f3c9; only the
documented pin moves, matching the released protocol the server pairs with.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-06 13:32:16 -06:00
james.maesandClaude Fable 5.1 3c39a6af1b build: QQQ 4.1.0-RC.22 from the Gitea registry; shared ci-workflows Maven pipeline; 1.1.2
Everything moves to the QRun-IO Gitea Maven registry. QQQ 4.1.0-RC.22 exists only
there, and the protected qrun-release channel resolves only Maven Central, so the
repository now builds on the shared KofTwentyTwo/ci-workflows maven workflow (@v1):
every push runs -Pui-demo verify against the registry with the job token; a vX.Y.Z
tag tests once and deploys the JAR/POM under that version. The qrun-release caller
and its trusted-publisher path are retired for this repository; PrintingContractTest
keeps using the vendored protocol fixtures. No printing contract change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-06 13:32:15 -06:00
james.maes c761eca970 Merge pull request 'ci: use shared ci-workflows Maven workflow' (#31) from ci/ci-workflows into main
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
build / maven (push) Successful in 5m55s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 6s
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (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) Waiting to run
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (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 / original-business (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 539bec410f7926fec3ef7fabe51934a91e0248be] (push) Successful in 10s
QRun-IO/ci-policy: ci / gradle-1 (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 / shared-pipeline (push) Skipped
maven / build (push) Successful in 5m55s
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
2026-11-06 18:02:15 +00:00
james.maes f1ed178355 Merge pull request 'docs(release): record QQQ Printing 1.1.1 as the current stable library' (#32) from docs/release-1.1.1-20261106 into main
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
Shared Java Maven CI / verify (push) Successful in 6m12s
qrun-release / postpublish (push) Successful in 30s
QRun-IO/ci-policy: native / native-result (push) Successful in 12s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
Shared Java Maven CI / secrets (push) Successful in 39s
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (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: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (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 / original-business (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 539bec410f7926fec3ef7fabe51934a91e0248be] (push) Successful in 12s
QRun-IO/ci-policy: ci / gradle-1 (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 / shared-pipeline (push) Skipped
Shared Java Maven CI / gate (push) Successful in 16s
qrun-release / release (push) Successful in 6m45s
Shared QRun source version / version (push) Successful in 29s
QRun-IO/ci-policy: ci / java-docker-3-head (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
2026-11-06 16:36:22 +00:00
james.maesandClaude Fable 5.1 f10cc03d9e docs(release): record QQQ Printing 1.1.1 as the current stable library
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 539bec410f7926fec3ef7fabe51934a91e0248be] (pull_request) Successful in 12s
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-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 / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
Shared Java Maven CI / verify (push) Successful in 8m7s
qrun-release / postpublish (push) Successful in 40s
Shared Java Maven CI / secrets (push) Successful in 44s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 10s
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 11s
QRun-IO/ci-policy: ci / java-core-2-head (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 / android-0 (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: ci / shared-pipeline (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-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-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
Shared Java Maven CI / gate (push) Successful in 34s
qrun-release / release (push) Successful in 8m43s
Shared QRun source version / version (push) Successful in 40s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
README, RELEASES, REALTIME and SESSION-STATE now point at 1.1.1 (source 2a56181,
main run 5976, reactor artifact 4426, release 1381) with the verified JAR/POM hashes,
and mark 1.1.0 as superseded by the realtime upgrade header-case fix. The release
caller is untouched, so the publisher enrollment hash stays valid.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-06 10:33:25 -06:00
james.maesandClaude Opus 5.5 454410081d ci: use shared ci-workflows Maven workflow
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 / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-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-voyage (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 4m55s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Successful in 19s
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Successful in 5m18s
build / maven (push) Successful in 5m1s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Successful in 18s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 9s
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (pull_request) Successful in 8s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 5s
QRun-IO/ci-policy: ci / java-core-2-head (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 / android-0 (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: ci / shared-pipeline (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-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-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
maven / build (push) Successful in 5m1s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
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-06 15:51:08 +00:00
james.maes 2a561815fc Merge pull request 'fix(realtime): resolve the device on the authorized upgrade (lowercase headers); 1.1.1' (#30) from fix/realtime-header-case-20261106 into main
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
Shared Java Maven CI / verify (push) Successful in 6m56s
qrun-release / postpublish (push) Successful in 43s
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-4 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (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: Shared Java Maven CI / verify (push) Successful in 4m8s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 6s
Shared Java Maven CI / secrets (push) Successful in 38s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (push) Successful in 7s
QRun-IO/ci-policy: ci / application-template-0 (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 / original-business (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (push) Successful in 31s
qrun-release / release (push) Successful in 7m28s
QRun-IO/ci-policy: ci / gradle-1 (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 / android-0 (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / gate (push) Successful in 13s
QRun-IO/ci-policy: ci / java-maven-7 (push) Successful in 4m28s
Shared Java Maven CI / gate (push) Successful in 23s
Shared QRun source version / version (push) Successful in 43s
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
2026-11-06 15:30:59 +00:00
james.maesandClaude Fable 5.1 e7721bdf90 fix(realtime): resolve the device on the authorized upgrade so lowercase headers connect; 1.1.1
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-maven-6-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-voyage (pull_request) Skipped
Shared Java Maven CI / verify (push) Successful in 4m14s
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 3m51s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Successful in 13s
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Successful in 4m8s
qrun-release / postpublish (push) Successful in 27s
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
Shared Java Maven CI / secrets (push) Successful in 21s
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Successful in 20s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 8s
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 7s
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 / android-0 (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: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (pull_request) Successful in 12s
QRun-IO/ci-policy: ci / shared-pipeline (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-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-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
Shared Java Maven CI / gate (push) Successful in 15s
qrun-release / release (push) Successful in 4m32s
Shared QRun source version / version (push) Successful in 27s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
The WebSocket callbacks read X-QQQ-Device-Id from the socket context, whose
header lookup is case-sensitive, while the HTTP upgrade matches names
case-insensitively. Behind an ingress that lowercases header names the device
resolved to null on connect and channels.put threw before WELCOME (Forge's
adoption test of released 1.1.0; Forge had carried a local patch on 1.0.0).
Resolve the device once after authorization on the upgrade and carry it as a
connection attribute for connect, message and close. The new
lowercaseUpgradeHeadersReachWelcomeAndFrames test fails on the previous code
with the same NullPointerException and passes here. No contract change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-06 08:24:44 -06:00
james.maes c197c5acab Merge pull request 'docs(release): record QQQ Printing 1.1.0 and pin protocol fixtures to 1.1.0' (#29) from docs/release-1.1.0-20261105 into main
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
Shared Java Maven CI / verify (push) Successful in 5m34s
qrun-release / postpublish (push) Successful in 31s
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 10s
Shared Java Maven CI / secrets (push) Successful in 26s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (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 8s
QRun-IO/ci-policy: Shared Java Maven CI / verify (push) Successful in 4m55s
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (push) Successful in 11s
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 / application-template-0 (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 / original-business (push) Skipped
qrun-release / release (push) Successful in 5m54s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (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 / android-0 (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / gate (push) Successful in 17s
QRun-IO/ci-policy: ci / java-maven-7 (push) Successful in 5m59s
Shared QRun source version / version (push) Successful in 31s
Shared Java Maven CI / gate (push) Successful in 13s
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
2026-11-06 14:16:35 +00:00
james.maesandClaude Fable 5.1 ba7fdc23d0 docs(realtime): connector 1.2.1
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Successful in 4m2s
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 / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 3m43s
Shared Java Maven CI / verify (push) Successful in 3m45s
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
qrun-release / postpublish (push) Successful in 14s
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-voyage (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Successful in 14s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 6s
Shared Java Maven CI / secrets (push) Successful in 20s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Successful in 23s
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (pull_request) Successful in 7s
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 8s
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
qrun-release / release (push) Successful in 3m58s
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 / android-0 (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: ci / shared-pipeline (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-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
Shared Java Maven CI / gate (push) Successful in 8s
Shared QRun source version / version (push) Successful in 14s
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 / node-9 (pull_request) Skipped
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-06 07:55:28 -06:00
james.maesandClaude Fable 5.1 ab51f8da06 docs: the paired Windows connector is 1.2.1 (1.2.0 was never published)
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-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-voyage (pull_request) Skipped
Shared Java Maven CI / verify (push) Successful in 3m57s
qrun-release / postpublish (push) Successful in 15s
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 4m45s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Successful in 9s
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Successful in 4m57s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Successful in 28s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 4s
Shared Java Maven CI / secrets (push) Successful in 18s
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Canceled after 0s
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (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: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (pull_request) Successful in 7s
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
qrun-release / release (push) Successful in 4m8s
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-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
Shared Java Maven CI / gate (push) Successful in 10s
Shared QRun source version / version (push) Successful in 15s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-06 07:54:57 -06:00
james.maesandClaude Fable 5.1 50d643d959 docs(readme): released 1.1.0, install snippets and the 1.2.1 connector pairing
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-maven-6-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-voyage (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Canceled after 0s
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Canceled after 34s
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Canceled after 33s
Shared Java Maven CI / verify (push) Successful in 3m49s
qrun-release / postpublish (push) Successful in 17s
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
Shared Java Maven CI / secrets (push) Successful in 17s
QRun-IO/ci-policy: ci / ci-result (pull_request) Canceled after 0s
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Canceled after 27s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (pull_request) Successful in 6s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 6s
QRun-IO/ci-policy: ci / java-core-2-head (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 / android-0 (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: ci / shared-pipeline (pull_request) Skipped
qrun-release / release (push) Successful in 4m3s
QRun-IO/ci-policy: native / dotnet-0 (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-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 / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
Shared Java Maven CI / gate (push) Successful in 11s
Shared QRun source version / version (push) Successful in 17s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-06 07:54:20 -06:00
james.maesandClaude Fable 5.1 102fde779c docs(release): record QQQ Printing 1.1.0 and pin protocol fixtures to 1.1.0
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow d20a578c7d818bc7ca32a2ac2c675da4e4f27385] (pull_request) Failing after 1s
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-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 / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
Shared Java Maven CI / verify (push) Successful in 5m27s
qrun-release / postpublish (push) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
Shared Java Maven CI / secrets (push) Failing after 0s
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Waiting to run
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Failing after 1s
QRun-IO/ci-policy: ci / java-core-2-head (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 / android-0 (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: ci / shared-pipeline (pull_request) Skipped
qrun-release / release (push) Failing after 5m49s
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-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
Shared Java Maven CI / gate (push) Failing after 13s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-05 17:09:05 -06:00
james.maes eb2c16785d Merge pull request 'feat(printers): remove printers and devices as archives, purged by retention' (#28) from feat/remove-printers-devices-main-20261105 into main
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
Shared Java Maven CI / verify (push) Successful in 3m35s
qrun-release / postpublish (push) Successful in 18s
QRun-IO/ci-policy: native / native-result (push) Failing after 1s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (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) Waiting to run
Shared Java Maven CI / secrets (push) Successful in 17s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1b3648d9421543350067e95e54959dfb471b26d5] (push) Failing after 1s
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (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 / original-business (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (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 / shared-pipeline (push) Skipped
Shared Java Maven CI / gate (push) Successful in 11s
qrun-release / release (push) Successful in 3m49s
Shared QRun source version / version (push) Successful in 17s
QRun-IO/ci-policy: ci / java-docker-3-head (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
2026-11-05 22:59:43 +00:00
james.maesandClaude Fable 5.1 e33823bbbd feat(printers): remove printers and devices as archives, purged by retention
Shared Java Maven CI / verify (push) Successful in 4m44s
qrun-release / postpublish (push) Skipped
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 / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-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-voyage (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 4m2s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Successful in 8s
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Successful in 4m11s
Shared Java Maven CI / secrets (push) Failing after 1s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1b3648d9421543350067e95e54959dfb471b26d5] (pull_request) Successful in 6s
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Successful in 28s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 3s
qrun-release / release (push) Failing after 4m59s
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (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 / android-0 (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: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 8s
Shared Java Maven CI / gate (push) Failing after 11s
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-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 / node-9 (pull_request) Skipped
Add nullable removed_at to printing_printer and printing_device (read-only
"Removed at" on both tables) through the existing idempotent migration.

Remove printers (printingRemoveSelectedPrinters, record and selection) and
PrintingService.removePrinter set removed_at, clear approval and exposure,
and drop the printer from the operator catalog, submissions, claims, the
device/printer selectors and the PRINTERS frame, which is pushed again. A
removed queue that the device shares again returns exposed but not
approved; approval changes on a removed printer are refused.

Remove device (printingRemoveDevices, confirmation required) revokes access
through the existing REVOKE control, then PrintingService.removeDevice
archives the device and all of its printers; an already revoked device is
archived without a new command. Removed devices leave the device list,
every device selector and all authenticated paths; job history stays.

Retention cleanup hard-deletes removed printers that no job references and
removed devices with no jobs and no printers, bounded by the batch size and
logged. Native inventory authorization still covers removed rows so a
re-shared queue can reactivate.

Tests cover hiding, re-sharing, device removal with history, purge bounds
and process metadata. Docs: README, UI walkthrough, storage and retention,
realtime, changelog (1.1.0).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-05 16:31:33 -06:00
james.maes d6edfced8a Merge pull request 'feat(realtime): printer approval over the socket (extension 2.4.0, product 1.1.0)' (#25) from feat/printer-approval-20261105 into main
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
Shared Java Maven CI / verify (push) Failing after 3s
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
qrun-release / postpublish (push) Skipped
Shared Java Maven CI / secrets (push) Failing after 2s
QRun-IO/ci-policy: native / native-result (push) Successful in 8s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (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: Shared Java Maven CI / verify (push) Successful in 4m23s
QRun-IO/ci-policy: ci / ci-result (push) Failing after 2s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1b3648d9421543350067e95e54959dfb471b26d5] (push) Successful in 8s
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (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 / original-business (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (push) Failing after 2s
qrun-release / release (push) Failing after 2m16s
QRun-IO/ci-policy: ci / gradle-1 (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 / android-0 (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
Shared Java Maven CI / gate (push) Failing after 14s
QRun-IO/ci-policy: Shared Java Maven CI / gate (push) Failing after 0s
QRun-IO/ci-policy: ci / java-maven-7 (push) Failing after 4m26s
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
2026-11-05 22:22:18 +00:00
james.maesandClaude Fable 5.1 3fa78decc8 feat(realtime): printer approval over the socket (extension 2.4.0, product 1.1.0)
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 / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-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-voyage (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 3m6s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Successful in 8s
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Successful in 3m16s
Shared Java Maven CI / verify (push) Successful in 3m31s
qrun-release / postpublish (push) Successful in 14s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1b3648d9421543350067e95e54959dfb471b26d5] (pull_request) Successful in 6s
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
Shared Java Maven CI / secrets (push) Successful in 15s
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Successful in 14s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 5s
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (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 / android-0 (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: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 3s
qrun-release / release (push) Successful in 3m41s
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-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (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
Shared Java Maven CI / gate (push) Successful in 9s
Shared QRun source version / version (push) Successful in 14s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
Add the printer-approval-v1 capability and the server-to-client PRINTERS frame
so a connector can show per-printer approval for the server it is connected to.

- HELLO accepts up to five capabilities including printer-approval-v1; the HTTP
  control poll accepts up to four and stores it in control_capabilities like the
  others; the ControlPollRequest schema enumeration is extended.
- After an accepted INVENTORY's ACK, a connection that advertised the capability
  receives PRINTERS: { "printers": [ { "printerId", "enabled" } ] } covering every
  printer in the device's latest accepted inventory (empty inventory: []).
- Every approval change (admin route, native approve/disable/set-approval
  processes, PrintingService.enablePrinter) already publishes the commit wakeup;
  the connection's single serialized writer now compares the current approval
  state with the last PRINTERS frame it sent and resends on change only.
  Connections without the capability never receive PRINTERS.
- PrintingService.printerApprovals(deviceId) exposes the list to host code.
- Advertise native extension 2.4.0; regenerate the OpenAPI fragment; bump the
  product to 1.1.0 (pairs with protocol 1.1.0 and Windows connector 1.2.0; wire
  payload v1 and subprotocol unchanged). CHANGELOG, README, REALTIME/OPERATIONS
  docs and a dated in-progress session-state note.
- Tests: five-capability HELLO, ACK-then-PRINTERS ordering with new printers
  disabled, approval via the admin route and the service pushing fresh frames,
  no PRINTERS on an unrelated job wakeup, empty inventory, a four-capability
  connection never receiving PRINTERS, and control-poll acceptance/bounds.

Verified: .\mvnw.cmd -Pui-demo verify -> 195 tests, 0 failures, 0 errors,
1 skipped (opt-in workload), 0 Checkstyle violations, BUILD SUCCESS.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-05 15:45:16 -06:00
james.maes 0ab07fc87c Merge pull request 'docs: close out printing 1.0 setup and release guidance' (#24) from docs/printing-release-closeout-20261104 into main
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
Shared Java Maven CI / verify (push) Successful in 4m19s
qrun-release / postpublish (push) Successful in 15s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (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
Shared Java Maven CI / secrets (push) Successful in 16s
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 5s
QRun-IO/ci-policy: Shared Java Maven CI / verify (push) Failing after 3m33s
QRun-IO/ci-policy: ci / ci-result (push) Failing after 5s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (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 / original-business (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (push) Successful in 16s
qrun-release / release (push) Successful in 4m30s
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / gate (push) Failing after 12s
QRun-IO/ci-policy: ci / java-maven-7 (push) Failing after 4m17s
Shared Java Maven CI / gate (push) Successful in 10s
Shared QRun source version / version (push) Successful in 15s
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-04 15:48:58 +00:00
james.maesandCodex 1f6342e871 docs: close out printing 1.0 release documentation
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 / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 3m41s
Shared Java Maven CI / verify (push) Successful in 3m49s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Successful in 10s
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Successful in 3m53s
qrun-release / postpublish (push) Successful in 16s
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
Shared Java Maven CI / secrets (push) Successful in 13s
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Successful in 15s
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 5s
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 / android-0 (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 / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 3s
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
qrun-release / release (push) Successful in 4m1s
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-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 / java-docker-3-head (pull_request) Skipped
Shared Java Maven CI / gate (push) Successful in 10s
Shared QRun source version / version (push) Successful in 16s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
Update setup, operating and release guidance for the published Gitea artifacts, native API and real-time transport. Preserve historical evidence and scope the older optional Authentik fixture accurately.

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
2026-11-04 09:39:13 -06:00
57 changed files with 3914 additions and 365 deletions

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
# Shared QRun Maven CI/CD (KofTwentyTwo/ci-workflows): every push verifies; vX.Y.Z tags publish the
# tested JAR/POM to the QRun-IO Gitea Maven registry. QQQ resolves from that registry with the job token.
name: build
on:
push:
jobs:
maven:
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@v1
with:
maven-args: -Pui-demo
secrets: inherit
-26
View File
@@ -1,26 +0,0 @@
# Standard QRun release caller (v2, thin contract): every push and manual dispatch delegates to the protected qrun-release-v1 channel. Do not edit per repository.
name: qrun-release
on:
push: {}
workflow_dispatch: {}
permissions:
contents: read
actions: read
jobs:
release:
uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/java-maven.yaml@qrun-release-v1
with:
runner: linux-small
maven-profiles: ui-demo
fixture-repository: QRun-IO/qqq-print-protocol
fixture-ref: 42c0d249f81395f00408ec0f92638dcbbf5deb03
fixture-path: qqq-print-protocol
postpublish:
if: ${{ needs.release.outputs.capture == 'true' }}
needs: release
permissions:
contents: write
actions: write
uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/qrun-source-version.yaml@qrun-release-v1
with:
runner: linux-small
+239
View File
@@ -1,5 +1,244 @@
# Changelog
## Unreleased (1.2.0)
Print jobs have a real creation date-time. No wire or contract change (extension 2.4.0, wire
v1, subprotocol unchanged); connectors need no update. **Schema change: a host that owns the
printing schema must add one column before deploying this version** (below).
- `printing_job.created_at` is ISO-8601 text and its QQQ field is a string, so QQQ could not
apply a relative date filter ("created in the last day"), a date sort or a date bucket to
print jobs. The table gains `created_time`, a `TIMESTAMP` without time zone holding UTC
(the QQQ date-time convention), written by the same `INSERT` from the same instant as
`created_at`. It is the read-only QQQ field **Created** (`created_time`, `DATE_TIME`) and
replaces the text field in the Print jobs default columns and Summary section.
- `created_at` is unchanged: the queue order, history cursors, admission seeding, the Printing
home and the `createdAt` value of the job API still read it. Its QQQ field stays in Details,
relabelled **Created at (ISO text)**.
- `initialize()` adds the column (`ADD COLUMN IF NOT EXISTS`), backfills every existing row
from `created_at` on each start (so a row written by an older node during a rolling restart
is repaired) and creates `printing_job_created_time_idx (tenant_id, created_time, id)`.
- A host that owns the schema (it never calls `initialize()`) appends the equivalent to its
own migrations. For PostgreSQL, in one transaction:
```sql
ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS created_time TIMESTAMP WITHOUT TIME ZONE;
UPDATE printing_job SET created_time = (created_at::timestamptz AT TIME ZONE 'UTC') WHERE created_time IS NULL;
ALTER TABLE printing_job ALTER COLUMN created_time SET DEFAULT (now() AT TIME ZONE 'UTC');
ALTER TABLE printing_job ALTER COLUMN created_time SET NOT NULL;
CREATE INDEX IF NOT EXISTS printing_job_created_time_idx ON printing_job(tenant_id, created_time, id);
```
The default and `NOT NULL` are optional hardening for a rolling deployment: a node still on
an older library omits the column, and the default gives its rows the database clock in UTC
instead of a null that date filters would skip. The library itself needs only the nullable
column and always writes it.
- On a schema without the column this version fails loudly and stores nothing: every
submission is rolled back with the database's missing-column error for `created_time`, and
QQQ cannot read the Print jobs table. Apply the schema step first; the previous library
keeps working on the new schema.
### Delete print stations and printers; "Print station"; grouped reads
No wire or contract change (extension 2.4.0, wire v1, subprotocol unchanged); connectors need
no update. **Schema change: a host that owns the printing schema applies the step below
before deploying this version.**
- **Names.** The computer that runs the print client is a **Print station** in every label,
action, preview, home page text and process message (it was "Print device", which read like
the printer). **Printer** is unchanged. The words live in `PrintingNames`; a different
choice is one edit there. Table, field and process names (`printing_device`,
`printingEnrollDevice`, ...), the HTTP API texts and the agent protocol are unchanged.
Changed labels a host may have help or tests for: tables **Print stations** and **Print
station control history**; actions **Enroll a print station**, **Disconnect print
stations**, **Reconnect print stations**, **Revoke print station access**, **Remove print
station**; the enrollment field `deviceName` is **Computer name**.
- **Delete print stations** (`printingDeleteDevices`) and **Delete printers**
(`printingDeleteSelectedPrinters`), on the list selection and on the record screen. Each
asks for a confirmation, then shows what will happen (printers that go with a station,
queued jobs canceled, jobs closed as outcome unknown, finished jobs kept) and names every
record it will not delete, with the reason. Service: `PrintingService.deleteDevice(deviceId,
actor)`, `deletePrinter(printerRowId, actor)`, and the read-only `deviceDeletionPlan` /
`printerDeletionPlan`.
- A station is revoked through the existing REVOKE control (credential rotated,
disconnected, pending commands closed) and, in the same transaction (RC.4, below; it was
a separate step before): its queued jobs become
`FAILED` ("Canceled before dispatch: its print station was deleted"); jobs it was sent
without a reported result (`CLAIMED`, `RECEIVED`, `SUBMITTING`) become `OUTCOME_UNKNOWN`;
each of those gets a `printing_event` row (`event_origin` `SERVER_DELETION`) naming the
actor; its control receipts, its printers and the station row are deleted.
- A printer: its queued jobs are canceled and its sent-but-unreported jobs closed the same
way; the printer row is deleted; its station stays in service.
- Refused, per record: a station (or a printer on a station) that holds a live credential,
shows as ONLINE or PAUSED and has jobs in `CLAIMED`, `RECEIVED` or `SUBMITTING`. Wait, or
use Disconnect or Revoke access first. A record that is gone or not the caller's is
reported and skipped. The other records of the selection are still deleted.
- Jobs are never deleted. `printing_job` gains `device_name`, `computer_name`,
`printer_name` and `queue_name`: the names the station and printer had when the job was
submitted (written by the same `INSERT`; existing jobs are filled from the current rows;
a delete fills any job that still lacks them). They are the read-only fields **Print
station**, **Computer name**, **Printer** and **Windows queue name** on Print jobs, and
the first two replace the record links in the default columns. `device_id`, `printer_id`
and `printer_row_id` keep the identifiers the deleted records had; they are no longer
references that must resolve, so the foreign key from `printing_job.device_id` to
`printing_device` is dropped.
- A computer enrolled again is a new station with a new identifier. A queue that a station
still shares is reported by its next inventory and comes back as a new printer, exposed
and not approved. Old jobs are not attached to either.
- Everything a deleted station sends afterwards (heartbeat, inventory, claim, content,
event, control poll, control receipt) and every management action on it is answered
`404 Device not found`; its token no longer authenticates (`401`). **Print again** on one
of its jobs is refused with the reason.
- New table `printing_deletion`, read-only as **Deletion history** in the Printing app: one
row per deleted station and per deleted printer with what it was (kind, name, computer
name, queue name, facility, the identifiers it had), who deleted it (`actor`), when
(`deleted_time`, a UTC timestamp) and what went with it (printers, queued jobs canceled,
jobs closed as unknown, jobs kept). It has no reference to the deleted rows.
- New permission **`printing_delete`** (`PrintingDeletePermission`). Both actions require
`printing_delete` **and** `printing_admin`; `printing_admin` alone does not delete and is
not offered the actions. A host grants it where it grants the others.
- **Remove printers**, **Remove print station** and **Revoke print station access** are
unchanged (same process names, `printing_admin`). Remove still archives (`removed_at`,
record kept); a removed record can be deleted.
- **A grouped read is a read.** `PrintingReadPermission` let a query, a get and a count
through and refused everything else as a write, so an aggregate ("print jobs by format")
was refused for everybody, administrators included, wherever permissions are enforced. It
now accepts `AggregateInput` and `ExportInput` with exactly the permission a query needs
(`printing_operator` or `printing_admin` on stations and printers, `printing_admin` on the
other tables). Inserts, updates and deletes through table actions stay refused.
- Schema step for a host that owns the schema (PostgreSQL, one transaction):
```sql
ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS device_name VARCHAR(1024);
ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS computer_name VARCHAR(1024);
ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS printer_name VARCHAR(1024);
ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS queue_name VARCHAR(1024);
UPDATE printing_job j SET device_name = d.name, computer_name = d.computer_name
FROM printing_device d WHERE d.id = j.device_id AND j.device_name IS NULL;
UPDATE printing_job j SET printer_name = p.name, queue_name = p.queue_name
FROM printing_printer p WHERE p.id = j.printer_row_id AND j.printer_name IS NULL;
ALTER TABLE printing_job DROP CONSTRAINT IF EXISTS printing_job_device_id_fkey;
CREATE TABLE IF NOT EXISTS printing_deletion (id VARCHAR(36) PRIMARY KEY, tenant_id VARCHAR(200) NOT NULL,
kind VARCHAR(20) NOT NULL, record_id VARCHAR(36) NOT NULL, name VARCHAR(1024) NOT NULL,
computer_name VARCHAR(1024), queue_name VARCHAR(1024), facility_id VARCHAR(200), device_id VARCHAR(36),
actor VARCHAR(200) NOT NULL, deleted_time TIMESTAMP WITHOUT TIME ZONE NOT NULL,
printer_count INTEGER DEFAULT 0 NOT NULL, cancelled_job_count INTEGER DEFAULT 0 NOT NULL,
unknown_job_count INTEGER DEFAULT 0 NOT NULL, kept_job_count INTEGER DEFAULT 0 NOT NULL);
CREATE INDEX IF NOT EXISTS printing_deletion_tenant_idx ON printing_deletion(tenant_id, deleted_time, id);
```
`printing_job_device_id_fkey` is the name PostgreSQL gives the inline `REFERENCES` of the
original `CREATE TABLE`; a schema that named it otherwise drops that name. `initialize()`
does all of this itself and finds the foreign key by metadata.
- Without the step this version fails loudly: every submission is rolled back with the
database's missing-column error for `device_name`, and QQQ cannot read Print jobs. If only
the foreign key is left in place, deleting a station that has jobs is refused (409, naming
the step) before anything is revoked. The previous library keeps working on the new schema;
jobs it writes get their names when their station or printer is deleted.
### 1.2.0-RC.4: a station delete decides its refusal under the lock
No wire, contract or schema change; the schema step above is the same.
- **Fixed: deleting a station could close a job as `OUTCOME_UNKNOWN` that the station had
just been sent and was about to print.** `deleteDevice` decided "connected and printing"
from a read without a lock, revoked the station, and only then locked and read again; a
revoked station never counts as connected, so the second read could not refuse. A station
that was idle at the first read and claimed a job before the revocation was deleted with
that job in its hands.
- The delete is now one transaction. It takes the tenant admission lock and then the
station's row lock (the order the other paths use; a claim, a job event and a heartbeat
take the station's row lock before they change anything), decides the refusal there,
applies the REVOKE control on the same connection, closes the jobs, deletes the rows and
commits once. A claim either commits before the delete locks, and the delete is refused
(409, the station and its job untouched), or waits for the delete and is then answered
`404 Device not found`: the job was canceled by the delete and was never sent.
- A delete that is refused or fails changes nothing. Before, a delete whose transaction
failed left the station revoked; now the revocation rolls back with the rest and the
delete can simply be run again. A station that an earlier version left revoked, or that
was revoked with **Revoke print station access**, is deleted without a second revocation.
- The station's connection is woken once, after the commit. The revocation of a delete no
longer goes through the public `PrintingService.deviceAction`; a host that wraps that
method to observe revocations sees a delete in `printing_deletion` instead.
- `deletePrinter` did not have the window (it decides under the station's row lock and
revokes nothing) and is unchanged.
- **HTTP API text restored.** RC.3 changed one text an HTTP route returns, contrary to the
note above: approving or unapproving a removed printer (`PUT
/printing/v1/admin/devices/{deviceId}/printers/{printerId}`, 409) answers "Printer was
removed; share it from the device again before changing its approval" again. The other
text RC.3 changed ("Revoke the print station's access before removing it") is raised only
by `removeDevice`, which no HTTP or agent route calls, and stays.
- `initialize()` fills `device_name` / `printer_name` only on jobs whose station or printer
still exists. Before, jobs of a deleted station or printer that have no name were rewritten on
every start, and where such a job had a queue name but no printer name the queue name was
set to null. They are still read on every start; there is no index on the name columns.
- Deleting a station or a printer writes the names only onto its jobs that lack one, so a
long job history is not rewritten while the tenant's admission lock is held.
- Tests added for: the claim that lands just before the delete locks (station and printer)
and the claim in flight that the delete waits for; a printer refused while it prints on a
connected station; an already revoked station; a failed transaction rolled back and
retried; the foreign-key violation (`23503`) named as the missing schema step; the final
step refusing a record that passed the review; the review naming a record that went away
or conflicts; a grouped read of print jobs counting the caller's tenant only. They run on
the in-memory database of the test suite; the build has no PostgreSQL driver or container
support, so the delete and the schema step are not exercised on PostgreSQL here.
## 1.1.2
Runtime and publication move to the QRun-IO Gitea Maven registry; no printing contract
change (extension 2.4.0, wire v1, subprotocol unchanged). The server now runs on QQQ
**4.1.0-RC.22**, published only in the Gitea registry (it carries the session-logout,
process-identity and scoped-session fixes the Forge cohort depends on). CI moves from the
`qrun-release` caller and trusted publisher to the shared `ci-workflows` Maven workflow,
which resolves QQQ from the Gitea registry with the job token and publishes `vX.Y.Z` tags
straight to it. The ui-demo frontend (`qqq-frontend-next` 1.0.0-RC.37) was mirrored into the
registry byte-for-byte from Central, so every QRun artifact resolves from Gitea; third-party
dependencies still resolve through the Maven Central mirror until the registry proxies them.
Same test gate (`-Pui-demo verify`).
## 1.1.1
Realtime upgrade fix; no contract change. The WebSocket callbacks read the device
identifier from the socket context's headers, which are matched case-sensitively, while the
HTTP upgrade matches them case-insensitively. Behind an ingress that lowercases header names
the device resolved to null on connect and the connection failed before WELCOME (Forge had
carried a local patch for this on 1.0.0). The device is now resolved once on the authorized
upgrade and carried on the connection; a regression test connects with lowercase header
names and verifies WELCOME, an acknowledged inventory, the PRINTERS push and socket replacement.
## 1.1.0
Native printing extension **2.4.0** (additive; wire payload v1 and the agent
subprotocol `qqq-printing.v1` are unchanged). Pairs with protocol 1.1.0 and Windows
connector 1.2.1; 1.0.x connectors keep working unchanged.
- New HELLO and HTTP control-poll capability `printer-approval-v1` (HELLO accepts up to
five capabilities, the control poll up to four; it is stored in the device's control
capabilities like the others).
- New server-to-client realtime frame `PRINTERS`: `{ "printers": [ { "printerId", "enabled" } ] }`,
the complete approval state of every printer in the device's latest accepted inventory.
Sent only to connections that advertised the capability, after each accepted INVENTORY's
ACK and whenever a printer's approval changes through any path (admin route, native
approve/disable/set-approval processes, `PrintingService.enablePrinter`). An empty
inventory yields an empty list; other connections never receive the frame.
- `PrintingService.printerApprovals(deviceId)` exposes the same list to host code.
- Remove printers and devices without losing history. New nullable `removed_at` columns on
`printing_printer` and `printing_device` (read-only field **Removed at** on both tables, added
by the idempotent migration). **Remove printers** (`printingRemoveSelectedPrinters`, on a
printer record or a selection) and `PrintingService.removePrinter(deviceId, printerId)` set
`removed_at`, clear approval and exposure, and drop the printer from the Printers default
view (add the **Removed at is empty** filter), the operator catalog, claims and submissions,
the device/printer selectors and the `PRINTERS` frame, which is pushed again on removal. A
queue that the device shares again returns exposed but not approved. **Remove device**
(`printingRemoveDevices`, confirmation required) revokes access through the existing REVOKE
control, then `PrintingService.removeDevice(deviceId)` archives the device and all of its
printers; an already revoked device is archived without a new command, and a removed device
cannot be restored. Retention cleanup hard-deletes removed printers that no job references
and removed devices with no jobs and no printers, bounded by the run's batch size.
- Regenerated OpenAPI fragment (`printing.yaml`): semantic version 2.4.0 and the extended
`ControlPollRequest` capability enumeration. No authorization, queue, replay or document
behavior changed.
## 1.0.0
Product version 1.0.0 includes record, table-list and text print processes,
+4 -4
View File
@@ -1,14 +1,14 @@
# Contributing
Use Java 21 and the checked-in Maven 3.9.11 wrapper. Clone `QRun-IO/qqq-print-protocol` as a sibling and check out the protocol commit pinned in `.github/workflows/ci.yml`. See README for the Windows and Unix build commands.
Use Java 21 and the checked-in Maven 3.9.11 wrapper. Clone `QRun-IO/qqq-print-protocol` as a sibling and check out the protocol commit pinned in `.gitea/workflows/qrun-release.yaml`. See README for the Windows and Unix build commands.
Run `./mvnw verify` (`.\mvnw.cmd verify` on Windows). The validate phase enforces the repository Checkstyle configuration; tests cover real H2 persistence, native QQQ metadata/processes and the public loopback HTTP contract. Add focused coverage for changed behavior. Do not replace spooler acceptance with a physical-print assertion.
Run `bash ./mvnw -Pui-demo verify` (`.\mvnw.cmd -Pui-demo verify` on Windows), matching the primary CI profile. The validate phase enforces the repository Checkstyle configuration; tests cover real H2 persistence, native QQQ metadata/processes and the public loopback HTTP contract. Add focused coverage for changed behavior. Do not replace spooler acceptance with a physical-print assertion.
Changes belong in a pull request with a concise behavior description and actual validation results. Update README, changelog, relevant tests and shared contract together when changing the wire interface. Use Conventional Commit subjects. Do not include local databases, logs, device credentials, customer documents or machine-specific printer information.
Changes belong in a pull request on [the primary Gitea repository](https://git.kof22.com/QRun-IO/qbit-printing) with a concise behavior description and actual validation results. Update README, changelog, relevant tests and shared contract together when changing the wire interface. Use Conventional Commit subjects. Do not include local databases, logs, device credentials, customer documents or machine-specific printer information.
Keep the QBit compatible with QQQ 4.1+ and resolve runtime services through the current QQQ instance. Preserve tenant/device isolation and the distinction between local exposure (`available`) and server approval (`enabled`). Application callers must authorize destinations before submitting or emitting an effect.
Maintainers create reviewed version tags. CI produces JAR/POM/checksum assets and publishes GitHub prereleases for hyphenated versions. Maven Central publication, deployment and physical printer tests are separate operations; this workflow does not perform them.
Maintainers publish reviewed versions through the primary Gitea CI and trusted Maven publisher, then attach the verified JAR/POM/checksums to a Gitea release. Follow [release operations](docs/RELEASES.md). GitHub is a shadow/archive; its Actions are retired for this repository. Historical `.github` scripts remain available as local quality checks. Deployment and physical printer acceptance are separate from library publication.
## Script quality
+39 -27
View File
@@ -1,12 +1,14 @@
# QQQ Printing
Cloud printing for QQQ 4.1+, paired with the [Windows AppKit connector](https://git.kof22.com/QRun-IO/qqq-print-agent) 1.0.1 and the [shared protocol](https://git.kof22.com/QRun-IO/qqq-print-protocol) 1.0.0. The Windows connector opens an outbound, authenticated two-way WebSocket; documents download separately over HTTPS. The cloud needs no inbound route into the facility. Version **1.0.0** supports native printing **2.3**, wire payload v1, real-time jobs and controls, and native browser sign-in through QQQ. Upgrade the server before the matching Windows connector.
Cloud printing for QQQ 4.1+, paired with the [Windows AppKit connector](https://git.kof22.com/QRun-IO/qqq-print-agent) 1.2.1 and the [shared protocol](https://git.kof22.com/QRun-IO/qqq-print-protocol) 1.1.0 (1.0.1 / 1.0.0 remain compatible). The Windows connector opens an outbound, authenticated two-way WebSocket; documents download separately over HTTPS. The cloud needs no inbound route into the facility. Version **1.1.0** supports native printing **2.4**, wire payload v1, real-time jobs and controls, native browser sign-in through QQQ, and pushes each exposed printer's approval to connectors that advertise `printer-approval-v1`. Upgrade the server before the matching Windows connector.
**Current dependency:** QQQ **4.1.0-RC.21**, published on Maven Central with native session login, and Java 21. See the [printing releases](https://git.kof22.com/QRun-IO/qbit-printing/releases) for downloads. The optional native UI demo uses QQQ frontend **1.0.0-RC.37**. GitHub releases provide a library JAR, POM and SHA-256 checksums, with build attestations for all three assets. Verify the exact downloaded release; publication does not establish installed or physical-output acceptance. This repository does not claim Maven Central publication or provide a standalone executable/fat JAR.
**Released:** [QQQ Printing 1.1.2](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.1.2), with a library JAR, matching POM and `SHA256SUMS`, built on QQQ 4.1.0-RC.22 from the Gitea registry ([1.1.1](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.1.1) is the same code on RC.21 from Maven Central; [1.1.0](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.1.0) is superseded by the realtime upgrade fix; [1.0.0](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.0.0) remains available for rollback). The same JAR/POM are published in the QRun-IO Gitea Maven registry. Gitea requires sign-in for downloads. The QBit itself is not published to Maven Central and is not a standalone executable/fat JAR. See [installation and release access](docs/RELEASES.md).
**Runtime:** Java 21 and QQQ **4.1.0-RC.22**, published in the QRun-IO Gitea Maven registry (not on Maven Central) with native session login. The optional native UI demo uses QQQ frontend **1.0.0-RC.37**, also served from that registry. Product version 1.1.2, native extension 2.4.0 and wire payload v1 are separate version contracts.
Implemented: single-use enrollment, device credentials, inventory/approval, PDF/ZPL submission, durable JDBC queue, idempotency, claim/content/events, native QQQ setup/test-print processes, read-only device/printer/job tables and a durable enqueue effect target.
The 0.2.0-rc.1 source adds native record/selection actions, per-device printers/jobs/queue/history, desired versus observed status and standard-session authentication at the discovered `/qqq/v1/qbit/printing` extension. Device credentials become secondary identity proof alongside the current QQQ user's permissions. Upgrade the server before the 0.2.0 Windows client, then explicitly associate an existing unbound pairing during sign-in. These changes are included in the published release above. See the [authentication contract](https://github.com/QRun-IO/qqq-print-protocol/blob/main/docs/QQQ-AUTHENTICATION.md); bound devices cannot fall back to legacy `/printing/v1` authentication.
Native record/selection actions expose per-device printers, jobs, queue and history, with desired versus observed status. The discovered `/qqq/v1/qbit/printing` extension uses standard QQQ session authentication. Device credentials are secondary identity proof alongside the current QQQ user's permissions. Existing unbound pairings require explicit association during sign-in. See the [authentication contract](https://git.kof22.com/QRun-IO/qqq-print-protocol/src/branch/main/docs/QQQ-AUTHENTICATION.md); bound devices cannot fall back to legacy `/printing/v1` authentication.
```mermaid
flowchart LR
@@ -27,14 +29,14 @@ Java/.NET WebSocket jobs and S3 checks without an installed connector or printer
## Build and verify
Install Java 21. Clone this repository and `qqq-print-protocol` as siblings. This source pins protocol commit `42c0d249f81395f00408ec0f92638dcbbf5deb03` (the integrated contract under Gitea PR 2; the same ref CI checks out); use that commit for reproducible compatibility tests. Use the checked-in wrapper (Maven 3.9.11).
Install Java 21. Clone this repository and `qqq-print-protocol` as siblings from Gitea. Check out protocol commit `5005f3c9e27053b6c327bb923695012832c0fdcc`, the immutable fixture ref in `.gitea/workflows/qrun-release.yaml`. Use the checked-in wrapper (Maven 3.9.11).
```powershell
.\mvnw.cmd verify
.\mvnw.cmd -Pui-demo verify
.\mvnw.cmd dependency:build-classpath '-Dmdep.outputFile=target/runtime-classpath.txt' '-DincludeScope=runtime'
```
On Linux/macOS use `bash ./mvnw verify` and the corresponding wrapper commands. First builds download dependencies; `-o` is only appropriate after they are cached.
On Linux/macOS use `bash ./mvnw -Pui-demo verify` and the corresponding wrapper commands. First builds download dependencies; `-o` is only appropriate after they are cached.
Checkstyle runs during validate. Tests exercise real H2 persistence, native QQQ metadata/processes and real loopback HTTP through Javalin, including the shared fixtures and withdrawal of a locally exposed printer. Keep `../qqq-print-protocol` checked out or pass `-Dprinting.protocol.fixtures=<fixture-directory>`.
@@ -70,14 +72,14 @@ Open `http://127.0.0.1:18082`. On Linux/macOS use `:` as the classpath separator
The protocol-only `PrintingDemo` and synthetic, mock-authenticated `PrintingUiDemo` remain separate. Do not point the synthetic demo at real paired printer data. The local host retains database document storage until an actual dedicated S3 binding is configured in a deployment-owned application; it does not claim a remote cloud deployment.
Local native deployment is accepted with authentication and access-denial checks, preserved pairing/history, connector reconnection and 365-day retention. The previously accepted 0.1.4-rc.1 release passed 73 tests and zero Checkstyle violations; the user confirmed all three Query diagnostic outputs good. Production S3/IAM and remote deployment remain separate qualification work. The 0.2.0 controls/authentication release passed 25 real client/native QQQ interoperability checks; the user later confirmed installed Letter PDF and Zebra ZPL output, the Connected footer and both printers. These results do not qualify installed or physical behavior for 0.3.
The local setup uses the same native authorization and transport paths as an integrated application. Production authentication mappings, S3/IAM and database operation belong to the deploying host; the local fixture does not certify those settings. Dated test evidence is retained in [qualification notes](docs/QUALIFICATION-2026-11-01.md).
## Add to a QQQ application
For a source checkout, `mvnw.cmd install` (or `bash ./mvnw install`) installs the verified library into your local Maven repository. For a GitHub release, download the JAR, matching POM and `SHA256SUMS`, verify the checksums, then install those artifacts locally or through your organization's artifact repository. Example using the release files in the current directory:
Use the [authenticated Gitea Maven registry](docs/RELEASES.md#use-the-maven-registry), or download the release JAR, matching POM and `SHA256SUMS` and verify both files. For a local installation of the downloaded artifacts:
```powershell
.\mvnw.cmd install:install-file '-Dfile=qbit-printing-0.3.0-rc.1.jar' '-DpomFile=qbit-printing-0.3.0-rc.1.pom'
.\mvnw.cmd install:install-file '-Dfile=qbit-printing-1.1.2.jar' '-DpomFile=qbit-printing-1.1.2.pom'
```
After the artifact is available in your configured repository, add:
@@ -86,7 +88,7 @@ After the artifact is available in your configured repository, add:
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-printing</artifactId>
<version>0.3.0-rc.1</version>
<version>1.1.2</version>
</dependency>
```
@@ -96,6 +98,8 @@ Device and printer names, descriptions and locations are edited in the Windows c
`initialize()` adds profile columns, backfills physical names, widens existing name/title columns and creates dispatch/history indexes without deleting records. The existing `name` column becomes the effective display label; `computer_name`/`queue_name` retain technical names and `display_name` holds the optional override. Back up the database and run schema initialization during a maintenance/deployment window; column widening and index creation can lock populated tables.
Print jobs carry their creation instant twice: `created_at` is the ISO-8601 text the queue, history cursors and job API use, and `created_time` is the same instant as a UTC `TIMESTAMP` (no time zone, the QQQ convention), exposed as the date-time field **Created** so native filters ("created in the last day"), sorting and date buckets work. `initialize()` adds the column, backfills existing rows from `created_at` and creates `printing_job_created_time_idx (tenant_id, created_time, id)`. A host that owns the schema must add the column before deploying this version (see the [changelog](CHANGELOG.md) for the statements): without it every submission fails with the database's missing-column error and stores nothing, and the Print jobs table cannot be read.
Job history uses `GET /printing/v1/admin/jobs?limit=200&before=<nextCursor>` with opaque keyset cursors and a maximum 200 rows per page. Claims read one candidate, pending inspection reads 50, and all these queries project metadata without fetching stored documents. Native dashboard lists already cap attention rows at 10 per category and recent work at 25. Dispatch/history indexes support growing histories; the synthetic 10,000-row regression test checks bounded pages and claim correctness, **not** a certified 10,000-print/day throughput measurement. Driver speed, document size, database hardware and retention policy still determine capacity. Document cleanup follows the configured retention policy and host schedule; the audit does not run a live purge or delete job history.
```java
@@ -106,26 +110,30 @@ new PrintingQBitProducer().withConfig(new PrintingQBitConfig()
.withStorageTableName("printing_documents") // existing dedicated S3 storage table
.withSchedulerName("appScheduler") // existing host scheduler
.withService(printing)).produce(qInstance, "printing");
server.withAdditionalRouteProviders(List.of(new PrintingRoutes(printing, context -> hostPrintingAdminGuard(context))));
server.withAdditionalRouteProviders(List.of(new PrintingRealtimeRoutes(printing)));
```
`hostPrintingAdminGuard` is the host's own authenticated administrator policy, not a supplied library method. The service is bound to one tenant; use a separate host service instance for each tenant. Device token verification and job ownership are enforced on every device operation. Management service methods are intended for trusted application code; enforce user/facility authorization before calling them.
The producer registers the native printing API; the additional provider supplies its WebSocket transport. Preserve any other host route providers and close the printing provider during shutdown. The same service instance must be used for metadata and transport. The service is bound to one tenant; use a separate host service instance for each tenant. Device token verification and job ownership are enforced on every device operation. Management service methods are intended for trusted application code; enforce user/facility authorization before calling them. See [real-time integration](docs/REALTIME.md).
The storage/scheduler options above are included in 0.1.4-rc.1. Configure a dedicated private printing bucket for the customer/environment through existing QQQ S3 metadata; no bucket credentials belong in printing forms. Admin retention defaults to 365 days after successful completion. Scheduled cleanup handles bounded batches every five minutes and preserves active, failed and uncertain work plus all receipts/history. Without a configured storage binding, legacy database documents remain supported; without a host scheduler, automatic cleanup does not run. Review [storage configuration, bucket isolation and S3 versioning](docs/STORAGE-AND-RETENTION.md) before deployment.
Configure a dedicated private printing bucket for the customer/environment through existing QQQ S3 metadata; no bucket credentials belong in printing forms. Admin retention defaults to 365 days after successful completion. Scheduled cleanup handles bounded batches every five minutes and preserves active, failed and uncertain work plus all receipts/history. Without a configured storage binding, legacy database documents remain supported; without a host scheduler, automatic cleanup does not run. Review [storage configuration, bucket isolation and S3 versioning](docs/STORAGE-AND-RETENTION.md) before deployment.
Enrollment uses a named facility selector. Set `withFacilityPossibleValueSourceName("your_existing_facility_source")` to reuse an already registered host source and its record security. Without it, the standalone choice is **Default facility**. The backend validates the selection under the current user's permissions before issuing an enrollment code; typing or forging an unknown/foreign-tenant ID cannot bypass that check. Existing stored facility IDs are preserved; configure their original source to resolve their names.
For native QQQ screens, grant `printing_admin` and populate the session's `printing_tenant` security key with the tenant ID. The Printing home shows device health, queue status and work needing attention, with Devices, Printers and Print jobs beneath it. It nests under an existing `admin` app when available. Processes are dashboard/table/record actions, not navigation entries. Secret/token and document columns are excluded from metadata. Table mutations are rejected; use the service/processes instead.
For native QQQ screens, grant `printing_admin` and populate the session's `printing_tenant` security key with the tenant ID; grant `printing_delete` as well to whoever may delete print stations and printers. A **print station** is the computer that runs the print client (table `printing_device`); a **printer** is a Windows queue it shares. The Printing home shows print station health, queue status and work needing attention, with Print stations, Printers, Print jobs, Print station control history and Deletion history beneath it. It nests under an existing `admin` app when available. Processes are dashboard/table/record actions, not navigation entries. Secret/token and document columns are excluded from metadata. Table mutations are rejected; use the service/processes instead.
Typical setup: run **Enroll a print device** from Printing home or Print devices, enroll the Windows connector, and select local queues to expose in its companion/tray. Use **Set printer approval** to choose a named device and one of its printers. Test-print forms offer only exposed, approved printers supporting the chosen format. Device and printer choices respect the current user's tenant access.
Typical setup: run **Enroll a print station** from Printing home or Print stations, enroll the Windows connector, and select local queues to expose in its companion/tray. Use **Set printer approval** to choose a named print station and one of its printers. Test-print forms offer only exposed, approved printers supporting the chosen format. Device and printer choices respect the current user's tenant access.
For label printers, configure the machine's **Windows Printing Defaults** for the actual stock size and print method before testing PDFs through the service. A test profile selects document size and ZPL resolution; it does not change the Windows driver or printer's media configuration. Our 300 dpi Zebra required 4 × 6-inch stock and Thermal Direct instead of its previous 4.09 × 2-inch Thermal Transfer defaults. After correcting those settings and resuming the printer, the user confirmed the built-in PDF label, alongside the already confirmed ZPL label and laser PDF. Barcode scanning and measured alignment remain separate checks; `SPOOLED` alone is not proof of physical output.
On Printers, select one or more rows or open a printer record to **Approve printers**, **Disable printers**, **Print a test page**, or **Print a test label**. Standard QQQ review/result screens show the selected scope; preview does not print. Each selected printer is read again under the current user's permissions before execution. Jobs display the related printer and device names; approval, availability, format and status fields have named choices.
On Printers, select one or more rows or open a printer record to **Approve printers**, **Disable printers**, **Remove printers**, **Print a test page**, or **Print a test label**. Standard QQQ review/result screens show the selected scope; preview does not print. Each selected printer is read again under the current user's permissions before execution. Jobs display the printer and print station names they were submitted under (stored on the job, so they read the same after a rename or a delete); approval, availability, format and status fields have named choices.
Deleting is separate from removing. **Delete printers** and **Delete print stations** (on a selection or a record; they need `printing_delete` as well as `printing_admin`) take the record out of every list for good. A station is revoked first, so its computer must be enrolled again and then is a new station; its printers go with it. Queued jobs are canceled, jobs already sent without a reported result are closed as outcome unknown, and finished jobs stay in Print jobs with the names they were printed under. A record is refused, by name, while its station is connected and printing. A printer that its station still shares comes back from the next inventory as a new printer awaiting approval. Every deletion is recorded in **Deletion history** (who, what, when, what went with it). See the [walkthrough](docs/UI-WALKTHROUGH.md#delete-a-print-station-or-a-printer).
Removing is archiving, not deleting. **Remove printers** sets **Removed at** on the selected printers, clears their approval and exposure, and takes them out of the operator catalog, claims, the `PRINTERS` frame and every device/printer selector; their rows and job history stay. The Printers and Print stations tables have no server-side default filter, so add the filter **Removed at is empty** to the view you use day to day. If the device shares a removed queue again, the next inventory brings it back exposed but **not approved**, exactly like a newly discovered queue. **Remove print station** (an action that requires confirmation) revokes the station's access through the existing REVOKE control, then archives the station and all of its printers; an already revoked station is archived without a new command. A removed station cannot be restored; connect the computer again with a fresh enrollment, which creates a new station. A removed record can be deleted. Retention cleanup later hard-deletes removed printers and devices that no job references (see [storage and retention](docs/STORAGE-AND-RETENTION.md)).
Metadata names follow QBit conventions: tables and data sources use `printing_*`, while processes/widgets use `printing...`. The initial namespace is fixed to `printing`. The QBit preserves the host's `QBrandingMetaData`; customer identity belongs in the host branding, not a custom QBit build.
Two independent settings govern dispatch: `available` means the queue is present in the latest locally selected inventory; `enabled` means a QQQ administrator approved it. New submissions and claims require both. Removing a queue from local selection makes it unavailable without deleting its approval/history. An empty inventory withdraws all queues. Withdrawing a queue cannot retract a job already accepted by Windows.
Two independent settings govern dispatch: `available` means the queue is present in the latest locally selected inventory; `enabled` means a QQQ administrator approved it. New submissions and claims require both. A connector whose HELLO advertises `printer-approval-v1` (extension 2.4.0, connector 1.2.1) receives a `PRINTERS` frame listing `{ printerId, enabled }` for every queue in its latest accepted inventory after each accepted inventory and whenever an approval changes through any path; older connectors never receive it and keep reading the operator catalog. Removing a queue from local selection makes it unavailable without deleting its approval/history; removing a printer in QQQ additionally drops it from the frame until the device shares it again. An empty inventory withdraws all queues. Withdrawing a queue cannot retract a job already accepted by Windows.
Application code calls `printing.submit(JSONObject)` with the shared `SubmitJobRequest` fields. A stable business idempotency key returns the same job when retried; changing document/options/target under the same key fails. PDF is sent as bytes encoded once in base64; ZPL is UTF-8 printer-ready bytes. Default decoded limits are 100 MiB for PDF and 20 MiB for ZPL, both configurable in the native Printing connection and limits process. Queue approval and format compatibility are checked before insertion.
@@ -143,31 +151,35 @@ String printJobId = job.getString("jobId");
For terminal and web clients the QBit also offers ready-made print processes: `PrintingRecordProcesses.registerAll(instance)` (after all host tables are added) puts **Print** on every readable table, rendering a record sheet from the table's sections and child lists; `printingPrintTableList` prints a grid of any table (columns, filter, sort, page or all rows, paper and orientation); `printingPrintText` prints text such as a screen capture. Opt a table out with `PrintingTableSettings().withPrintable(false)`. Each returns the queued job id in `printingJobIds`. `printingRenderToPdf` (extension 2.2.0) renders the same documents to a PDF download for the caller instead ("Save as PDF"), and every document carries the app header and footer the client sends (`headerApp`, `headerTitle`, `headerServer`, `headerUser`, `headerPrintedAt`, `footerLeft`, `footerNote`).
The returned ID identifies durable queue acceptance. Check job status asynchronously. The [protocol v1 specification](https://github.com/QRun-IO/qqq-print-protocol/blob/f977bd01b62ceb7539190fd2861809537fcf504c/README.md) defines HTTP endpoints, JSON fields, integrity checks and lifecycle semantics.
The returned ID identifies durable queue acceptance. Check job status asynchronously. The [protocol v1 specification](https://git.kof22.com/QRun-IO/qqq-print-protocol/src/branch/main/README.md) defines HTTP endpoints, JSON fields, integrity checks and lifecycle semantics.
For durable QQQ jobs register `PrintEnqueueEffectTarget` as an `IDEMPOTENT_EXTERNAL` target in `DurableJobsMetaData`, then emit the submit-request payload. The target replaces the payload idempotency key with the durable effect key. Its SENT result means accepted into the queue, not physical output. This implementation uses its own JDBC transaction; do not register it as LOCAL_TX. Authorize the destination in the emitting process before writing the effect. QQQ's durable dispatcher runs with a `QSystemUserSession` and does not propagate the user's tenant keys. For that trusted outbox path only, the target resolves the service configured on the dispatcher's current QQQ instance; that service fixes the tenant and rejects devices belonging to another tenant. The payload cannot choose a service or tenant. A cached target resolves the current instance on every call. Inline user calls retain the `printing_tenant` scope check, and native management processes (including enrollment) still require both administrator permission and matching tenant scope. Do not expose direct effect emission through an arbitrary user-supplied target/payload endpoint.
## Deliberate first-version limits
## Deployment boundaries
The implemented path targets an explicit device/printer. Logical destination bindings, cancellation, reprint workflow, automatic reconciliation, clustered throughput optimization, retention and alerts are follow-ups. Documents are durably stored as base64 in the database for this first path; object-storage integration is deferred. Job paper (`paperWidthMm` and `paperHeightMm`, both positive, at most 2000, never with `mediaProfile`) is accepted; the built-in 4 x 1 and 4 x 2 in PDF test labels use it (101.6 x 25.4 / 50.8 mm) so Windows does not feed 4 x 6 stock. Only a client from the same unreleased 0.4 cohort applies it; older clients reject the job. Other jobs follow the Windows queue default. Received/submitting jobs are not automatically offered again. The queue records SPOOLED honestly and does not assert physical delivery.
Jobs target an explicit device/printer. Logical destination routing and a distributed real-time bus belong to the integrating host. The release includes cancellation of queued jobs, explicit reprints, owned spool-cancellation commands, durable receipt reconciliation, S3 delivery storage and scheduled retention. Received/submitting jobs are not automatically offered again; `SPOOLED` never asserts physical delivery.
The protocol demo and synthetic native UI preview are separate local hosts. Attaching the QBit to a deployed application and its real authentication/facility policy remains a host integration task. PostgreSQL behavior, production deployment, upgrades, cancellation/reprint and broad printer-driver coverage are not certified by the initial H2/laser tests.
Job paper (`paperWidthMm` and `paperHeightMm`, both positive and at most 2000, never combined with `mediaProfile`) is supported by the matching Windows 1.0.1 connector. The built-in 4 × 1 and 4 × 2 PDF tests use 101.6 × 25.4 / 50.8 mm. Without explicit paper or a reported media selection, jobs follow the Windows queue default. See [media selection](docs/OPERATOR-CATALOG.md).
## Verified initial path
The protocol demo and synthetic native UI preview are separate local hosts. Attaching the QBit to a deployed application requires that host's real authentication, facility policy, database, storage and supervision. PostgreSQL and multiple QQQ nodes are not qualified by the local H2/MinIO fixture.
On 2026-10-29, both the development Windows connector and the installed LocalSystem service printed a PDF to a local laser printer. The user confirmed both physical pages. MSI installation/start and temporary uninstall passed. Zebra hardware testing is deferred until a Zebra is connected. A SPOOLED status alone is never used as proof of physical output.
## Physical output
The user confirmed physical laser PDF and Zebra ZPL/PDF output during development, including a 4 × 1 test. A configured stock profile does not prove a physical result on another printer or stock; inspect the first output after changing media. Historical acceptance records remain in [session history](docs/SESSION-STATE.md).
## CI and releases
Pull requests and `main` pushes run Java 21/Maven wrapper verification with the immutable sibling protocol checkout. A tag matching the POM version runs build/tests and the secrets, workflow, dependency and malicious-package security gates before publishing the tested JAR, POM and `SHA256SUMS` as GitHub release assets. Build verification enables the `ui-demo` profile used by the local host. Maven generates its selected compile/runtime graph as a CycloneDX SBOM, including frontend assets. Trivy and OSV scan that graph; a guard rejects missing or different package versions, missing frontend assets, and empty inventories. CI retains the resolved graph and scanner evidence. OSV uses `--all-packages`; its pinned 2.6.0 reporter [omits Maven groups](https://github.com/google/osv-scanner/issues/3099), so the guard checks every reported artifact/version and rejects ambiguous names while the verified input retains full package URLs. Hyphenated versions create prereleases. The packaged JAR includes the repository LICENSE and NOTICE under `META-INF`, alongside the existing font license and diagnostic assets. The workflow does not publish to Maven Central, deploy a QQQ application or print to hardware. The [0.1.0-rc.1 release](https://github.com/QRun-IO/qbit-printing/releases/tag/v0.1.0-rc.1) passed these hosted gates.
Gitea is the source, pull-request, CI and release authority. The thin `.gitea/workflows/qrun-release.yaml` delegates to the protected `qrun-release-v1` channel with Java 21, the Maven wrapper, `ui-demo` and the immutable protocol fixture checkout. Required verify, secrets and gate checks precede merge; successful main builds capture the tested Maven reactor for the trusted registry publisher. Maintainers publish the corresponding tagged Gitea release from those verified bytes. See [release operations](docs/RELEASES.md#maintainer-release-path).
GitHub is a shadow/archive. Its Actions were retired for this repository on 2026-11-04; checked-in `.github` workflows describe the former pipeline and do not publish current printing releases. The separate Windows repository retains its signing/update-feed workflow. The 1.0.0 and 1.1.0 server releases do not claim GitHub attestations or Maven Central publication. The JAR includes LICENSE, NOTICE and font/diagnostic asset licenses; publication does not deploy a QQQ host or print to hardware.
## Printing home
The admin-only Printing home uses stock QQQ widgets: a purpose/scope/help header, actionable exceptions, four health tiles, and a full-width pending/recent work list. Counts and record links use QQQ queries under the viewer's tenant. Device disconnection means no heartbeat for 90 seconds; job failure tiles cover jobs created in the past 24 hours. Older failures remain in Print jobs. An empty installation explains enrollment and printer setup.
This RC implements the happy path of the [QQQ dashboard standard](https://github.com/QRun-IO/qqq/blob/main/docs/DASHBOARD-STANDARD.md), with explicit remaining gaps: RC.20 carries role/priority hints in widget default values; attention uses scoped database queries and 60-second refresh rather than the core live attention feed; dismiss/snooze, interactive URL-backed page scope, and stale/retry presentation depend on further core/frontend integration. It has not passed the standard's full responsive/accessibility audit. No custom frontend or HTML dashboard is used.
The home uses the standard QQQ frontend widgets and scoped queries. Its timed dashboard refresh updates the visible summary; job delivery uses the independent real-time WebSocket and does not wait for a dashboard refresh. No custom frontend or HTML dashboard is required.
### Operator actions (0.1.1-rc.1)
### Operator actions
The Windows companion can request test prints and explicit reprints through device-scoped authenticated routes. The same printer approval/exposure rules apply. Upgrade the QBit and connector together for these additive protocol features. Reprints create a new job; uncertain/spooled originals require duplicate-output consent and remain unchanged.
@@ -177,6 +189,6 @@ QQQ's **Print jobs** table and records expose **Print again** and **Cancel queue
All built-in printer test actions share `PrintingTestDocuments`: QQQ branding, the official [Query dog mascot](https://qrun.io/query), large warehouse-style identifiers and embedded JetBrains Mono Bold with a dotted zero and distinct 1/I/l. Query's original artwork and aspect ratio are preserved in PDF and thermal output; [asset provenance](src/main/resources/com/kingsrook/qbits/printing/images/README.md) ships with the resource. The font is included under SIL OFL 1.1; its license and provenance ship in the JAR. PDF embeds a font subset. ZPL renders the same glyphs into document-local, row-aligned graphics below Zebra's 99,999-byte per-field limit; it does not download or save a printer font.
Native record and selected-table actions expose a possible-value stock/resolution selector. Letter and A4 are PDF-only. Label profiles are 4 × 6 inches at 203, 300 or 600 dpi; PDF label output is always 101.6 × 152.4 mm. Select matching stock and printer resolution. Bulk actions apply the chosen profile to every selected destination, so group compatible printers. The document does not reconfigure Windows stock, calibrate the printer or save device configuration; ZPL width/length are transient format settings.
Native record and selected-table actions expose a possible-value stock/resolution selector. Letter and A4 are PDF-only. Label profiles cover 4 × 1, 4 × 2 and 4 × 6 inches at 203, 300 or 600 dpi, with corresponding PDF dimensions of 101.6 × 25.4, 50.8 or 152.4 mm. Select matching loaded stock and printer resolution. Bulk actions apply the chosen profile to every selected destination, so group compatible printers. The document does not change the Windows queue defaults, calibrate the printer or save device configuration; ZPL width/length are transient format settings. Direct-thermal stock needs the driver's Direct Thermal setting, without a ribbon.
The device test-print API accepts optional `testProfile` with the same choices. Existing clients default to Letter PDF or 203-dpi 4 × 6 ZPL. Invalid profiles fail before enqueueing. Test actions use the ordinary durable queue and existing printer exposure/approval rules; discovery never automatically prints. A ZPL barcode, glyph comparisons and size/alignment marks support physical inspection. Rendering previews and spool acceptance do not prove paper or scanner quality.
+3 -3
View File
@@ -1,9 +1,9 @@
# Security
Report vulnerabilities privately through [GitHub vulnerability reporting](https://github.com/QRun-IO/qbit-printing/security/advisories/new), or `contact@kingsrook.com` if that channel is unavailable. Include affected versions, a minimal synthetic reproduction and expected versus actual authorization behavior. Do not put enrollment codes, bearer credentials, customer documents or exploit details in public issues.
Report vulnerabilities privately through [GitHub vulnerability reporting](https://github.com/QRun-IO/qbit-printing/security/advisories/new), or `contact@kingsrook.com` if that channel is unavailable. Include affected versions, a minimal synthetic reproduction and expected versus actual authorization behavior. Ordinary source changes and pull requests belong in Gitea. Do not put enrollment codes, bearer credentials, customer documents or exploit details in public issues.
The current prerelease targets QQQ 4.1+. Fixes target the current prerelease; older QQQ versions are outside scope.
The supported release line is 1.0.x for QQQ 4.1+, currently built against QQQ 4.1.0-RC.21. Report the exact QBit, QQQ and Windows connector versions. Older QQQ versions are outside scope.
The host must provide authenticated management authorization and the correct tenant/facility context. Native QQQ operations require `printing_admin` and a matching `printing_tenant` session security value. Device credentials only authorize their device's queue/content/status operations. HTTPS is required for remote deployment; the supplied HTTP demos deliberately bind to loopback.
The host must provide authenticated management authorization and the correct tenant/facility context. Native QQQ operations require `printing_admin` and a matching `printing_tenant` session security value. Native device operations require both the current QQQ user session and bound-device proof; a device token alone cannot authorize them. Operator catalog/submission uses `printing_operator` or administration plus native destination/process permissions; it grants no general device administration. See [authorization and controls](docs/OPERATIONS.md#device-controls-and-durable-action-receipts). HTTPS is required for remote deployment; the supplied HTTP demos deliberately bind to loopback.
The UI demonstration uses a fixed mock identity and synthetic data and must remain local. It registers no connector transport routes. RAW ZPL is printer-control input: submit trusted generated labels, not arbitrary untrusted printer commands. Stored documents can contain personal information. See [storage and retention](docs/STORAGE-AND-RETENTION.md) for dedicated bucket boundaries, tenant isolation and the default 365-day policy.
+21 -9
View File
@@ -1,12 +1,24 @@
# Local Authentik reference
Run the optional real-provider checks with Docker Compose and PowerShell 7.4+.
The pinned fixture images use Linux/amd64; other hosts need Docker's corresponding
Linux-container support or emulation:
The released application uses QQQ 4.1.0-RC.21, including the native session-login
grant. A production host configures QQQ's standard OAuth2/Authentik provider,
trusted HTTPS, redirect registration and current user/tenant permissions. The
Windows connector discovers that setup through the QQQ UI/UX API; provider
refresh credentials remain on the server. See [the native authentication
contract](https://git.kof22.com/QRun-IO/qqq-print-protocol/src/branch/main/docs/QQQ-AUTHENTICATION.md).
```powershell
pwsh ./dev/Invoke-Dev.ps1 -Action Test -Authentik
```
## Historical reference fixture compatibility
The optional `Invoke-Dev.ps1 -Action Test -Authentik` diagnostic below was written
for RC.20. `Invoke-Authentik.ps1` explicitly expects API 1.8 without
`nativeSessionLogin`, so that diagnostic is not compatible with the current RC.21
application baseline. Do not interpret its expected-absence assertion as a
production native-authentication failure or use it as a 1.0 release gate. The
separately pinned experimental profile remains available for reproducing its
recorded result. No fixture code or pins were changed during documentation closeout.
The following describes the retained fixture and its recorded scope. It requires
Docker Compose, PowerShell 7.4+ and Linux/amd64 container support.
The command creates a fresh, owned development project, runs the normal QQQ and
MinIO smoke test plus the Authentik reference checks, then removes that project's
@@ -33,7 +45,7 @@ authentication.
S256 capability. A valid state/S256 request with the registered callback
continues to the provider login flow without following it. Changing only that
callback to an unregistered redirect is rejected before any user login.
- The current QQQ RC.20 host advertises UI API 1.8 without `nativeSessionLogin`.
- The historical QQQ RC.20 host advertised UI API 1.8 without `nativeSessionLogin`.
That expected absence is recorded as a native-client prerequisite failure,
not silently replaced by bearer authentication or an unpublished core.
@@ -89,8 +101,8 @@ denials, logout replay rejection, renewal after expiry and provider revocation
with the production Core client. Its synthetic HTTP driver and HTTPS callback
receiver do not qualify the installed Windows browser UI or private URI callback.
Production adoption still requires a compatible published central artifact
tuple. Provider refresh tokens stay on the server. The short lifetime test does
The released application now adopts the compatible QQQ RC.21 tuple; this
historical experimental result does not by itself qualify a different host configuration. Provider refresh tokens stay on the server. The short lifetime test does
not prove months or years of unattended use. Do not route the private Windows
callback to a live companion whose connection must remain intact.
+11 -11
View File
@@ -32,13 +32,13 @@ The host provides native printing screens, settings, a dedicated MinIO document
bucket and scheduled retention. No printer is enrolled automatically in this host.
The test workload uses a separate disposable database and object store.
Add `-Authentik` when creating an environment or running a fresh test to include
real Authentik/PostgreSQL. See [the provider checks and remaining native grant
prerequisite](AUTHENTIK.md). Provider discovery does not establish successful OIDC
user login or refresh; the current QQQ RC.20 API lacks that native-client grant.
The current application dependency is QQQ RC.21, which supplies native session
login. The optional `-Authentik` diagnostic still asserts the older RC.20 API 1.8
absence of that feature; it is a retained historical check, not a current RC.21
login test. See [provider setup and fixture compatibility](AUTHENTIK.md).
Authentik uses PostgreSQL; the printing host itself uses H2, not PostgreSQL or MySQL.
```powershell
pwsh ./dev/Invoke-Dev.ps1 -Action Test -Authentik
pwsh ./dev/Invoke-Dev.ps1 -Action Test -Workload 10000
```
@@ -126,11 +126,11 @@ It is opt-in, Test-only, and does not change the default released QQQ dependency
## Continuous integration
The development workflow runs the Pester and PowerShell analysis checks on
Windows and Ubuntu for every pull request and main-branch push. Ubuntu also runs
the real 40-job/S3 Docker smoke. Both client fixture images also have build-only
jobs on every pull request, including the optional native client; these jobs run
only fake-HTTP discovery checks and do not start a provider.
Primary Gitea CI runs the shared Maven verification with `ui-demo` and the pinned
protocol fixtures. GitHub Actions were retired for this repository on 2026-11-04.
The checked-in `.github/workflows/development.yml` records the former Windows/Linux
Pester, image-build and Docker checks; it is not an active CI guarantee. The
commands above remain available for intentional local development checks.
Maven Checkstyle checks all four Java fixture sources in `dev/java` and
`dev/native-auth/java` during `validate`. They are deliberately **not** Maven
@@ -149,5 +149,5 @@ package dependency were added. For a local build, supply the real pinned Core
DLL through `-p:CoreAssemblyPath=/absolute/path/Qqq.PrintAgent.Core.dll`.
The longer experimental native-auth profile is
an explicit manual workflow option; it is not silently substituted for the
an explicit local command; it is not silently substituted for the
released dependency baseline.
+9 -6
View File
@@ -1,7 +1,9 @@
# Experimental native Authentik qualification
This optional profile tests an **unreleased upstream QQQ candidate**. It does
not replace the default released RC.20 TABLE/MinIO development environment,
This optional profile preserves a **historical upstream candidate** for reproducible
qualification. The normal application now uses published QQQ RC.21 with native
session login; this profile still builds its explicit older source pin. It does
not replace the default TABLE/MinIO development environment,
install a connector, or qualify the Windows browser callback UI.
From the repository root, with Docker and PowerShell 7.4 or later:
@@ -77,9 +79,10 @@ private input/result files, rather than a host home or credential directory.
The QQQ fixture uses fresh standard session, redirect-state and encrypted
refresh-token tables. Its customizer maps the verified provider's synthetic
`printing_admin` group to the existing native permission. It creates no device
pairing, job, print operation or fabricated user session. Released RC.20 lacks
the native-session grant; this experiment must stay opt-in until an appropriate
immutable upstream tuple is published and separately adopted.
pairing, job, print operation or fabricated user session. RC.20 lacked the
native-session grant; RC.21 now supplies it in the ordinary application. This
fixture remains opt-in and pinned to its recorded source rather than silently
substituting a different runtime into historical results.
## Recorded local qualification
@@ -93,4 +96,4 @@ probe compatibility correction also passed all eight checks on 2026-11-01.
All source files matched the pre-run hash manifest through completion, and
independent cleanup again found no owned containers, volumes or networks.
The native image's 12 discovery checks and default workload's nine checks pass.
The unreleased upstream and installed-browser limitations above still apply.
The historical source pin and installed-browser limitations above still apply.
+2 -2
View File
@@ -19,7 +19,7 @@ Run the usual quality gate:
./mvnw -B -ntp -Pui-demo verify
```
CI also runs the bounded compiler-configuration guard:
For a compiler-configuration change, also run the existing bounded guard locally:
```sh
python3 .github/scripts/check-java-compiler.py
@@ -30,7 +30,7 @@ under `target/compiler-guard`. Both main and test sources have a positive
probe and negative probes for NullAway, Error Prone's `DeadException`, and a
deprecated call rejected by warnings-as-errors. A random Maven failure does
not satisfy a negative probe: the expected diagnostic must be present.
Logs and a result manifest are retained in the normal CI test evidence.
Logs and a result manifest are written under `target/compiler-guard`. The former GitHub workflow ran this probe explicitly; primary Gitea verification enforces the POM compiler checks during the Maven build.
No artifact is installed or published. Optional `--maven`, `--settings`,
`--local-repository` and `--offline` arguments support an isolated local cache.
+4 -4
View File
@@ -10,7 +10,7 @@
| `PrintingUiDemo` | Isolated in-memory synthetic data, mock administrator, no device routes | Native UI demonstration only |
| `PrintingLocalHost` | Durable H2 shared by native QQQ and device routes, real password/session authentication | Authenticated local application |
The local host adds its own `printing_admin_user` and `printing_admin_session` tables. It preserves existing printing tables, device credential hashes, receipt history and tenant identity. Physical table/column names are mapped to the existing H2 case convention; it does not rename an uppercase database or change `DATABASE_TO_UPPER`. Every host instance uses a distinct internal backend name to avoid cross-instance provider-cache collisions in QQQ RC20.
The local host adds its own `printing_admin_user` and `printing_admin_session` tables. It preserves existing printing tables, device credential hashes, receipt history and tenant identity. Physical table/column names are mapped to the existing H2 case convention; it does not rename an uppercase database or change `DATABASE_TO_UPPER`. Every host instance uses a distinct internal backend name to avoid cross-instance provider-cache collisions observed during RC.20 development.
## Required configuration
@@ -36,7 +36,7 @@ $dependencies = (Get-Content target/local-host-classpath.txt -Raw).Trim()
& "$env:JAVA_HOME/bin/java.exe" -cp "target/classes;$dependencies" com.kingsrook.qbits.printing.PrintingLocalHost
```
Set the required environment values securely before launch. Maven contract tests require the documented sibling protocol fixture checkout or `printing.protocol.fixtures` override. Browse to `http://localhost:18081/` (or the configured port) and use the native username/password screen. The existing Windows connector continues using the same server origin and device token; it does not receive the browser password or management token.
Set the required environment values securely before launch. Maven contract tests require the documented sibling protocol fixture checkout or `printing.protocol.fixtures` override. Browse to `http://localhost:18081/` (or the configured port) and use the native username/password screen. Configure that origin in the Windows connector Connection settings and sign in through the discovered QQQ authentication flow. Preserve an existing pairing when upgrading; the native path requires the current user session as well as device proof. The connector does not receive the management token.
This command runs the host in the foreground. `QApplicationLauncher` owns graceful HTTP/scheduler shutdown. It does not install an operating-system service or auto-start registration; the deploying operator must provide supervision, protected credential loading, logs and backups for unattended use.
@@ -60,7 +60,7 @@ Existing facility IDs are retained. The standalone enrollment form offers the na
QQQ supplies password verification, failed-sign-in throttling, HttpOnly sessions, session expiry and logout. The local authorization subclass calls native verification first and maps only the configured account to printing permissions. Other valid accounts cannot gain those permissions. Native QQQ permission and tenant checks still apply to every table/process.
The host rejects non-loopback Host headers, cross-origin Origin headers and cross-site browser requests. Cookie-authenticated unsafe requests require an exact same-origin Origin header, including logout and native process actions. Explicit bearer/device API calls without ambient cookies remain usable from the connector. Use one loopback hostname consistently in the browser; `localhost` and `127.0.0.1` are different browser origins. These controls do not turn a local HTTP host into an Internet-facing service.
The host rejects non-loopback Host headers, cross-origin Origin headers and cross-site browser requests. Cookie-authenticated unsafe requests require an exact same-origin Origin header, including logout and native process actions. Explicit API calls without ambient cookies still require their normal route credentials; native bound devices use QQQ session authentication plus device proof. Use one loopback hostname consistently in the browser; `localhost` and `127.0.0.1` are different browser origins. These controls do not turn a local HTTP host into an Internet-facing service.
## Retention, storage and remaining deployment work
@@ -68,4 +68,4 @@ The native Quartz scheduler runs the existing bounded cleanup process every five
This local host deliberately leaves the S3 binding unconfigured. Native settings report legacy database storage. New and existing documents therefore remain in the database; merely running this host does not move documents to S3. A deployed application must supply its existing dedicated `printing_*` S3 table/backend and scheduler configuration, with bucket/IAM/tenant separation described in [storage and retention](STORAGE-AND-RETENTION.md). The host must retain historical storage bindings when changing configuration.
Remote HTTPS deployment, real S3/IAM, production OAuth roles, PostgreSQL and operating-system supervision remain separate qualification work. Record Files source-version/actor integration remains pending its concrete public contract; standalone printing delivery storage must not own or delete canonical attachments.
Remote HTTPS deployment, real S3/IAM, production OAuth roles, PostgreSQL and operating-system supervision remain separate qualification work. The optional [retained-source facade](RETAINED-PRINT-FACADE.md) requires a host-supplied source authority; standalone printing delivery storage must not own or delete canonical attachments.
+12 -8
View File
@@ -24,7 +24,7 @@ Normal progression is QUEUED → CLAIMED → RECEIVED → SUBMITTING → SPOOLED
Use a stable business idempotency key for retries of the same request. A changed target/document/options with the same key is a conflict. A deliberately new print requires a new key. The initial implementation does not automatically reclaim RECEIVED/SUBMITTING jobs or resend ambiguous output; inspect the queue and physical result before deciding to print again.
The pending 0.1.4 implementation stores new documents through the host's configured dedicated QQQ S3 storage table; existing inline database content remains readable. Administration defaults to 365 days after server-observed successful completion, with bounded scheduled cleanup when a host scheduler is configured. Active, failed, uncertain and unknown-completion legacy jobs remain protected. See [storage and retention](STORAGE-AND-RETENTION.md) for bucket separation, recovery and versioning details. No live storage migration or cleanup is implied by a source upgrade.
Version 1.0.0 stores new documents through the host's configured dedicated QQQ S3 storage table; existing inline database content remains readable. Administration defaults to 365 days after server-observed successful completion, with bounded scheduled cleanup when a host scheduler is configured. Active, failed, uncertain and unknown-completion legacy jobs remain protected. See [storage and retention](STORAGE-AND-RETENTION.md) for bucket separation, recovery and versioning details. No live storage migration or cleanup is implied by a source upgrade.
## Admission capacity and inventory history
@@ -49,16 +49,17 @@ Inventory snapshots remain limited to 200 entries. Rotating IDs cannot bypass cu
## Device controls and durable action receipts
The native client API is the registered `printing` extension, semantic version
`1.0.0`, under `/qqq/v1/qbit/printing`. Discover it through QQQ's version endpoint;
`2.4.0`, under `/qqq/v1/qbit/printing`. Discover it through QQQ's version endpoint;
its generated OpenAPI fragment is `src/main/resources/openapi/qqq-v1/printing.yaml`.
The contract test checks this fragment against the registered endpoint specs and
the initial semantic compatibility baseline. Core QQQ session authentication and
credential scopes run before printing operations; the application does not issue
another kind of user login token.
Every native operation requires the authenticated QQQ principal, `printing_admin`,
the configured tenant security key, and applicable native table, process and row
access. Device operations additionally require `X-QQQ-Device-Id` and
Native administration and device operations require the authenticated QQQ principal,
`printing_admin`, the configured tenant security key, and applicable native table,
process and row access. The separate [operator catalog and submission API](OPERATOR-CATALOG.md)
also permits `printing_operator`, with its narrower destination/process checks. Device operations additionally require `X-QQQ-Device-Id` and
`X-QQQ-Device-Token` as secondary proof. Enrollment at `/devices/enroll` validates
the user's facility access before consuming the enrollment code and atomically
binds the device to the core user's stable identity. A legacy unbound device can
@@ -94,7 +95,7 @@ reach that context; printing does not assert that an upstream cached session is
fresh. Cookie-authenticated unsafe calls must also satisfy the host's Origin/CSRF
policy. The local host requires its canonical same-origin authority.
In the qualified QQQ 4.1.0-RC.20 runtime, OAuth2 `sessionStoreEnabled` defaults
In the recorded RC.20 authentication qualification, OAuth2 `sessionStoreEnabled` defaults
to `false`. Ordinary requests and session resume rerun the configured
`QAuthenticationModuleCustomizerInterface` hooks; the host can load current
permissions, tenant keys, and account status from its authoritative directory.
@@ -151,7 +152,8 @@ seven days; an unchanged old request still expires after its receipt is removed.
Remote inventory refresh and owned spool cancellation require advertised agent
capabilities, expire after 15 minutes, and have a limit of 100 pending commands
per device. Polls deliver at most 20 commands. The retained action history is
per device. Control delivery is bounded to 20 commands per batch over the real-time
channel; there is no timed job/control polling fallback. The retained action history is
limited to 1,000 records per device, with one additional reserved revocation;
eligible old terminal records are reclaimed before admission. Capacity returns
429 explicitly. History pages contain at most 200 records and use the preceding
@@ -180,4 +182,6 @@ cancellation request, not that paper was withdrawn or a physical print was undon
## Release qualification
Prior released checks include user-confirmed laser PDF and Zebra ZPL/PDF output with correct machine driver settings. Audit source checks cover H2, native processes, host HTTP registration, synthetic storage and small real connection pools. Follow-up qualification includes live S3/IAM, PostgreSQL, production host authentication, upgrade/migration paths, multiple QQQ nodes, prolonged disconnects and a broader printer/driver set. Current prerelease artifacts must not be described as certifying those scenarios.
The stable 1.0.0 library and its matching release/registry bytes are published; see [release evidence](RELEASES.md). Local checks cover H2 persistence, native QQQ operations, real HTTP/WebSocket transport, S3-compatible storage and bounded job histories. User-confirmed physical output includes laser PDF and Zebra ZPL/PDF.
A deployment still supplies and validates its own database, S3/IAM, authentication mapping, supervision and printer drivers. PostgreSQL and multiple-node operation are not qualified by the local H2/MinIO fixture. The default real-time bus is in-process; consult [host integration](REALTIME.md) before scaling hosts. These are deployment boundaries, not pending 1.0 publication tasks.
+7 -4
View File
@@ -1,7 +1,7 @@
# Operator catalog and queue media
This source adds native printing extension 2.3.0 (2.2.0 is the rendering extension and carries no media negotiation). Publication and Windows media
agent qualification are pending. Existing administrator and device routes keep
QQQ Printing 1.0.0 includes native printing extension 2.3.0, paired with Windows
connector 1.0.1. Extension 2.2.0 introduced rendering; 2.3.0 adds this operator/media API. Existing administrator and device routes keep
their permissions; this is an additive operator API, not a device bearer API.
Hosts grant `printing_operator` and the `printing_tenant` security key. The native
@@ -55,8 +55,11 @@ replays remain accepted. The capable Windows agent rechecks its actual current
default before spooling. Selected-media dispatch requires the current agent
capability `printer-media-v1`, including after reconnect/downgrade. Legacy
inventory without profiles clears stale media facts. Omitted selection preserves
ordinary existing queue behavior. Paper-width/height overrides remain unsupported,
including when a media selection is supplied.
ordinary existing queue behavior. An explicit job paper size instead uses both
`paperWidthMm` and `paperHeightMm` (positive, at most 2000 mm); it cannot be
combined with `mediaProfile`. The matching connector applies that job paper
without changing the machine-wide queue defaults. Select media matching the
loaded stock and calibrate the physical printer after changing label sizes.
Generated/retained source authorization belongs to the configured source owner.
The retained facade shares this operator permission and destination boundary; it
+29 -7
View File
@@ -1,9 +1,10 @@
# Real-time printing host integration
Release candidate [0.3.0-rc.1](https://github.com/QRun-IO/qbit-printing/releases/tag/v0.3.0-rc.1)
implements printing extension **2.0.0** (wire envelope v1). Upgrade the server
before the matching Windows connector; a real-time connector rejects a server that advertises only extension
1.x. There is no timed job/control polling fallback.
QQQ Printing 1.1.2 (the released library is [1.1.2](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.1.2))
implements native printing extension **2.4.0** (wire envelope v1), paired with Windows
connector **1.2.1**; connector 1.0.1 remains compatible. Upgrade the server before the connector; a real-time connector
rejects a server that advertises only extension 1.x. There is no timed job/control
polling fallback.
## Connection and authorization
@@ -97,13 +98,34 @@ notifications to one another.
The existing `/printing/v1` routes remain explicit legacy diagnostic compatibility.
They are not a real-time fallback and cannot authorize a native bound device.
The full message and settings contract is in
[the shared protocol](https://github.com/QRun-IO/qqq-print-protocol).
[the shared protocol](https://git.kof22.com/QRun-IO/qqq-print-protocol).
## Printing API major upgrade
The extension is **2.0.0** because native HTTP claims now require a synchronized
Extension **2.0.0** introduced the major-version change because native HTTP claims require a synchronized
WebSocket. A published 1.0 HTTP-only connector no longer meets that precondition;
this is a breaking behavior change even though request bodies remain wire v1.
The QQQ framework API `/qqq/v1`, extension path `/qbit/printing` and WebSocket
subprotocol `qqq-printing.v1` remain unchanged. Upgrade server and client together;
an older client must report upgrade-required instead of using polling fallback.
an incompatible older client must report upgrade-required instead of using polling fallback.
Extension 2.3.0 retained that requirement and added operator/media support; 2.4.0
adds printer approval over the socket (below). The product/library version is
independently 1.1.0.
## Printer approval (extension 2.4.0)
A HELLO may carry up to five capabilities; the fifth, `printer-approval-v1`, is
also accepted and stored by the HTTP control poll. Only on a connection that
advertised it, the server sends a `PRINTERS` frame
(`{ "printers": [ { "printerId", "enabled" } ] }`) with the complete approval
state of every printer in the device's latest accepted inventory: after the ACK
of each accepted INVENTORY, and whenever a printer's `enabled` changes for that
device through any path (the admin route, the native approve/disable/set-approval
processes or application code calling `enablePrinter`). Every approval path
publishes the existing commit wakeup; the connection's serialized writer compares
the current state with the last frame it sent and resends only on a change, so an
unrelated job wakeup does not repeat it. An empty inventory yields
`{ "printers": [] }`. A printer removed in QQQ (`removed_at` set) is left out and
the frame is pushed again; it reappears, not approved, once the device shares the
queue again. The client does not acknowledge the frame; it carries no job
or document data. Connections without the capability never receive it.
+56
View File
@@ -0,0 +1,56 @@
# Installation and release operations
The stable server library is [QQQ Printing 1.1.2](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.1.2) (1.1.1 is the same code on QQQ RC.21 from Maven Central; 1.1.0 is superseded: its realtime upgrade fails behind an ingress that lowercases header names), paired with protocol 1.1.0 and Windows connector 1.2.1 (1.0.x/1.1.x connectors keep working on the unchanged 2.3 behaviour). It requires Java 21 and the QQQ 4.1.0-RC.22 dependency tuple, which is published only in the QRun-IO Gitea Maven registry. Its native printing extension is 2.4.0; the WebSocket subprotocol remains `qqq-printing.v1`. The previous stable library, [1.0.0](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.0.0), remains available for rollback.
Gitea is primary for source, pull requests, CI, packages and server releases. This forge requires sign-in even for download links on public repositories. An HTML sign-in page is not an artifact. Use your existing approved forge identity; never commit its token or put it in a command line.
## Use the Maven registry
Configure the consuming application's existing Maven repository setup with repository ID `qrun-io` and URL `https://git.kof22.com/api/packages/QRun-IO/maven`. Enable releases and disable snapshots. Match that ID to a `<server>` entry in your private Maven `settings.xml`, using your existing managed username/token or encrypted Maven credentials. For example, environment references keep literal credentials out of the configuration:
```xml
<servers>
<server>
<id>qrun-io</id>
<username>${env.GITEA_USERNAME}</username>
<password>${env.GITEA_TOKEN}</password>
</server>
</servers>
```
Merge this entry into the existing settings; preserve other servers, mirrors and profiles. Supply the environment through your approved credential loader. The credential needs package read access. Add `com.kingsrook.qbits:qbit-printing:1.1.1` as shown in the [README](../README.md#add-to-a-qqq-application). QQQ 4.1.0-RC.22 and the printing library both resolve from this registry; third-party dependencies still come from the Maven Central mirror until the registry proxies them.
## Download and verify release files
Sign in to Gitea and download all three assets from the stable release: `qbit-printing-1.1.2.jar`, `qbit-printing-1.1.2.pom` and `SHA256SUMS`. In their download directory:
```powershell
Get-Content ./SHA256SUMS | ForEach-Object {
$hash, $name = $_ -split ' ', 2
if((Get-FileHash -LiteralPath $name -Algorithm SHA256).Hash -ine $hash) {
throw "Checksum mismatch: $name"
}
}
```
On Linux, use `sha256sum -c SHA256SUMS`; on macOS, use `shasum -a 256 -c SHA256SUMS`. Install the verified JAR/POM locally with the README's `install:install-file` command if your application does not consume the registry directly. The library needs its normal Maven runtime dependencies; copying the JAR alone does not deploy a complete host.
## Maintainer release path
Since 1.1.2 the repository builds on the shared `KofTwentyTwo/ci-workflows` Maven workflow (`.gitea/workflows/build.yaml`, pinned to `@v1`): every push runs `-Pui-demo verify`; a `vX.Y.Z` tag runs the same tests once and deploys the JAR/POM to the QRun-IO Gitea Maven registry under exactly that version. The former caller's pinned protocol checkout is gone; `PrintingContractTest` reads the vendored copy in `src/test/resources/protocol-fixtures` (kept byte-identical to the reviewed protocol commit named in its README), which is also what local `verify` uses. Earlier releases (1.0.0, 1.1.0, 1.1.1) went through the protected `qrun-release-v1` channel and the trusted publisher; their evidence below stands.
1. Update the POM version (the tag must equal it), `qqq.version` if the runtime moves, and active documentation on a branch in Gitea. Open a normal pull request; the push build must be green.
2. Merge normally and wait for the green main build (verify only; main never publishes).
3. Create `v<POM version>` at the qualified main commit without moving an existing tag and push it. The tag build tests and deploys `com.kingsrook.qbits:qbit-printing:<version>` to the registry; a tag build that fails publishes nothing and the next attempt is the next patch version (tags are immutable).
4. Read the registry JAR/POM back with the approved forge identity, create the Gitea release on the tag, attach the same JAR/POM and a flat `SHA256SUMS`, read them back, then publish. Never replace different bytes under a released version.
5. Record the source, tag build, release and verified hashes here. Publication is separate from installing a host or accepting physical printer output.
For 1.1.2, source `f5639a98be9a2f15d4c61cf0955fe4ecef85824f` (main; merge of PR 33: QQQ 4.1.0-RC.22 from the Gitea registry, shared ci-workflows pipeline), push build `6435` (`-Pui-demo verify`, 200 tests, 0 failures, 0 errors, 1 skipped, Checkstyle clean), tag build `6475` (tests again, deploy to the registry), release `v1.1.2`. Registry files and release assets match byte-for-byte on authenticated readback. The JAR SHA-256 is `786e6e570c5df29b01529562efb246a4f4562bb1d9fff6bea77d264e0d7536ce`; the POM SHA-256 is `7691a4e1cb7b03c7a559b0e2b92eacd92f86545b98bddafac86fc70f8602b03d`. RC.22 is not on Maven Central, so the green builds prove resolution from the registry.
For 1.1.1, source `2a561815fcec8b7c95e76bb4b36fba314f512027` (main; merge of PR 30, the realtime upgrade header-case fix), main run `5976` (verify, secrets scan, gate and post-publish succeeded), reactor artifact `4426`. The trusted publisher first refused the run because the 1.1.0 documentation merge had re-pinned the protocol fixture in `qrun-release.yaml`, changing the caller hash its enrollment verifies; the enrollment was re-reviewed in cluster-gitops (PRs 283 and 285) and the capture was then published unchanged. Registry files and release assets match the capture byte-for-byte. The JAR SHA-256 is `156720da6928f62d77101521a2953f6e1026d58d02e7fc7815a8c8fcbcedaefa`; the POM SHA-256 is `2fa451260a26f5b5ede30800d46c78050e223694d3d8dda640f9ca875d120ab0`.
For 1.1.0, source `eb2c16785d0723a47d4a22c68b8b04503108fc58` (main; merges of PR 25 printer approval and PR 28 printer/device removal), main run `5473` (verify, secrets scan, gate and post-publish all succeeded after the CI quota incident of the same afternoon), reactor artifact `4306`, release assets read back by the approved forge identity and matching the registry files byte-for-byte. The JAR SHA-256 is `3a51b7e7cf457cce5db16f138c4cc65b901730bda17ccaf1d3c714cf11d2bbf4`; the POM SHA-256 is `52e5a588e788bbe123dad574455493aea0a7df6cc473c78f15be09343048a5d4`.
For 1.0.0, source `2906c2bd5049924abd2114c8ef51a0ac9aac325e`, main run `3594`, reactor artifact `2741` and release `1356` were verified. Release assets and registry files matched byte-for-byte on authenticated readback. The JAR SHA-256 is `7ecb00e1f49272d6e885dc5f515bd4832aa52dd84e0613d9b6751f9e235a0961`; the POM SHA-256 is `8be7c30208a7fbb875dcebedb0ba6baf0e172aa0e7cf478a3d2863d1316ddf5f`.
GitHub remains a shadow/archive. GitHub Actions for this server repository and the protocol repository were retired on 2026-11-04. Their checked-in workflows and older release evidence are historical. The Windows repository retains its separate signing/update-feed workflow. Do not restart shadow workflows to publish a server release, claim GitHub attestations for 1.0.0, or imply the QBit was published to Maven Central.
+6 -6
View File
@@ -1,6 +1,6 @@
# Retained print acceptance (additive candidate)
# Retained print acceptance
This optional Java integration preserves the existing native generation print
Included in QQQ Printing 1.0.0, this optional Java integration preserves the existing native generation print
process and the existing printing queue. It adds no device wire fields or REST
routes. It is disabled until the host supplies
`PrintingQBitConfig.withRetainedSourceAuthority`. Current `printing_operator`
@@ -108,7 +108,7 @@ Coverage includes commit-then-throw acknowledgment loss, concurrent exact
replay, held-admission-lock lookup, mutation ceilings, owner observation
refusal, callback revocation, exact content hash/length, configured document
bounds, title upgrade/replay, read-only historical lookup and original-error
preservation when rollback fails. Qualification of this adopted source is
recorded in the session notes; the older peer candidate's test result is not
evidence for this revision. Cross-QBit integration remains a separate gate. No
release, install, shared-cache substitution or runtime migration has occurred.
preservation when rollback fails. The library is released; [release evidence](RELEASES.md) identifies its source
and artifacts. Configuring and qualifying a cross-QBit source authority remains
the integrating host's responsibility. Merely upgrading the library does not
enable an adapter, migrate stored source bindings or grant source access.
+23 -9
View File
@@ -1,14 +1,28 @@
# Session state — 2026-10-31
# Session state — 2026-11-06
## CURRENT checkpoint — 1.0.0 release preparation (2026-11-03)
## Current — 1.1.2 released (2026-11-06)
The release branch includes the primary head `a583f91` and the prepared 1.0.0 version. Windows connector 1.0.1
and protocol 1.0.0 are published. The QQQ pin is now the published `4.1.0-RC.21` candidate that provides native
session login; its BOM was independently retrieved from Maven Central. The existing `-Pui-demo verify` gate
passed against that dependency on 2026-11-03: 193 tests reported, zero failures or errors, one existing opt-in
workload skip and zero Checkstyle violations. Main and test sources compile under the existing Error Prone,
NullAway and warnings-as-errors checks. Printing 1.0.0 publication remains pending its normal CI and release
process. Every checklist below is historical.
[QQQ Printing 1.1.2](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.1.2) is published in the QRun-IO Gitea Maven registry (`com.kingsrook.qbits:qbit-printing:1.1.2`) and as a Gitea release. It is the 1.1.1 code built on QQQ **4.1.0-RC.22**, which exists only in that registry (James's direction: everything moves to Gitea, no more Maven Central), on the shared `ci-workflows` Maven pipeline (`build.yaml@v1`, `-Pui-demo`; the protected `qrun-release` caller and the trusted-publisher enrollment are retired, cluster-gitops PR 288). `qqq-frontend-next` 1.0.0-RC.37 was mirrored into the registry byte-for-byte from Central so the ui-demo profile resolves from Gitea too; only third-party libraries still come through the Central mirror. Source `f5639a98be9a2f15d4c61cf0955fe4ecef85824f` (PR 33), push build 6435 (200 tests, 0 failures), tag build 6475 (deploy); JAR `786e6e570c5df29b01529562efb246a4f4562bb1d9fff6bea77d264e0d7536ce`, POM `7691a4e1cb7b03c7a559b0e2b92eacd92f86545b98bddafac86fc70f8602b03d`. No printing contract change.
## Superseded — 1.1.1 released (2026-11-06)
[QQQ Printing 1.1.1](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.1.1) is published on primary Gitea and its Maven registry (`com.kingsrook.qbits:qbit-printing:1.1.1`). It supersedes 1.1.0: Forge's adoption qualification of 1.1.0 (`~/.cache/gg-forge-printing-cohort-20261106/HANDOFF.md`) reproduced a connection failure before WELCOME when the WebSocket upgrade headers arrive lowercase, because the socket callbacks read `X-QQQ-Device-Id` from the WebSocket context, whose header lookup is case-sensitive. 1.1.1 resolves the device once on the authorized HTTP upgrade and carries it as a connection attribute; `PrintingRealtimeTest.lowercaseUpgradeHeadersReachWelcomeAndFrames` fails on the old code and passes on the fix. No contract change: extension 2.4.0, wire v1, subprotocol unchanged. Source `2a561815fcec8b7c95e76bb4b36fba314f512027` (PR 30), main run `5976`, reactor artifact `4426`, release `1381`; JAR `156720da6928f62d77101521a2953f6e1026d58d02e7fc7815a8c8fcbcedaefa`, POM `2fa451260a26f5b5ede30800d46c78050e223694d3d8dda640f9ca875d120ab0`, read back against the capture and the registry. The publisher had refused the run until its enrollment hash was re-reviewed (cluster-gitops PRs 283 and 285) after the 1.1.0 docs merge re-pinned the protocol fixture in the release caller.
## Superseded — 1.1.0 released (2026-11-05)
[QQQ Printing 1.1.0](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.1.0) is published on primary Gitea and its Maven registry: native printing extension **2.4.0** (`printer-approval-v1` capability and the `PRINTERS` frame) and the Remove printers / Remove device archives purged by retention. Source `eb2c16785d0723a47d4a22c68b8b04503108fc58`, main run `5473`, reactor artifact `4306`; JAR/POM hashes in [release operations](RELEASES.md). Pairs with [protocol 1.1.0](https://git.kof22.com/QRun-IO/qqq-print-protocol/releases/tag/v1.1.0); the Windows connector 1.2.1 is published (https://git.kof22.com/QRun-IO/qqq-print-agent/releases/tag/v1.2.1). The CI fixture pin now follows protocol 1.1.0 source `5005f3c9e27053b6c327bb923695012832c0fdcc`. Forge adoption is a separate, coordinated step; 1.0.0 stays available for rollback.
## Current — 1.0.0 released
[QQQ Printing 1.0.0](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.0.0) is published on primary Gitea and its Maven registry. Source `2906c2bd5049924abd2114c8ef51a0ac9aac325e` passed main run `3594`; reactor artifact `2741`, release `1356` and both registry files were verified against the captured bytes by authenticated readback. Gitea requires sign-in. See [release operations and hashes](RELEASES.md).
Windows connector 1.0.1 and protocol 1.0.0 are published. The server uses QQQ 4.1.0-RC.21, native printing 2.3.0, wire v1 and client-initiated WebSocket control with separate HTTPS documents. The release preparation's existing `-Pui-demo verify` reported 193 tests, zero failures/errors, one opt-in workload skip and zero Checkstyle violations; the normal hosted main checks subsequently passed.
GitHub is a shadow/archive; server and protocol Actions were retired on 2026-11-04. Windows signing/update-feed workflows remain separate. Current setup, operator, storage and release documentation now describe this released state. This documentation closeout changes no application code or workflows and performs no new hardware/provider qualification. No server 1.0 publication task remains open.
## Historical checkpoints
The entries below are dated development evidence, including superseded pending tasks and older versions. They are not the current release status; use the section above and [current tasks](TODO.md).
## Standards and small-label release cohort — 2026-11-01
+2 -2
View File
@@ -13,5 +13,5 @@ Only leading language comments count. Copyright text in a string, Python docstri
JSON/schema/fixture/lock data, explicit generated output directories, vendor legal assets and upstream Maven wrappers are excluded with reasons in the policy. Solution/activation/test-runner metadata and `.editorconfig` remain outside this completed pass; their applicable root LICENSE/NOTICE remains authoritative. No broad claim about third-party licensing follows from this check. New owned file types or provenance must be reviewed before changing the allowlist; do not add blanket exclusions to make a failure disappear.
The implementation and its negative tests are intentionally maintained copies across the three printing repositories, with each repository's own copyright/license header and independently reviewed scope. They do not fetch policy or execute source. CI invokes them in existing jobs using the runner's available Python, with no new package or workflow context. Existing native C# IDE0073 and Java Checkstyle conventions remain unchanged.
This repository accepts Kingsrook, LLC / Apache-2.0 for its reviewed source/build/config families. The existing CI verify job runs the guard.
The implementation and its negative tests are intentionally maintained copies across the three printing repositories, with each repository's own copyright/license header and independently reviewed scope. They do not fetch policy or execute source. The former GitHub jobs invoked them using the runner's available Python; the scripts remain usable locally without a new package or workflow context. Existing native C# IDE0073 and Java Checkstyle conventions remain unchanged.
This repository accepts Kingsrook, LLC / Apache-2.0 for its reviewed source/build/config families. Run these checks locally when modifying owned notices; the primary shared CI channel owns its current check selection. The old `.github` workflow invocation is retained as history.
+8 -6
View File
@@ -1,6 +1,6 @@
# Print document storage and retention
Status: audit implementation pending final verification and release. QQQ 4.1+ hosts provide the existing storage and scheduler metadata; this module does not create buckets or copy credentials into printing settings.
Included in the released QQQ Printing 1.0.0 library. QQQ 4.1+ hosts provide the existing storage and scheduler metadata; this module does not create buckets or copy credentials into printing settings.
## Deployment boundary
@@ -10,15 +10,15 @@ Keep access keys, role credentials and endpoint configuration in the host's esta
The code validates the selected S3 metadata, detects conflicting bucket bindings within the QQQ instance and uses tenant-specific object namespaces. Each job retains its storage table, destination identity and exact document reference. Retrieval and cleanup validate those values and fail closed if metadata is retargeted or the reference belongs to another tenant/job. Cleanup deletes exact eligible objects; it never lists and wipes a prefix. Tenant prefixes are an application isolation control, not a substitute for separately scoped IAM roles or separate customer buckets.
Application metadata cannot prove that a bucket is unused by another application or that deployed IAM/bucket policies are correct. Deployment qualification must check those external controls, public-access blocking, transport/encryption policy and the intended customer/environment boundary. No live bucket has been created or changed by the audit.
Application metadata cannot prove that a bucket is unused by another application or that deployed IAM/bucket policies are correct. Deployment qualification must check those external controls, public-access blocking, transport/encryption policy and the intended customer/environment boundary. The QBit does not create or change bucket policies.
## S3 key layout and migration
QQQ filesystem 4.1.0-RC.20 builds S3 keys with the host filesystem separator. With empty base paths, Windows writes two leading backslashes before the logical `printing/<tenant-hash>/<job>.ext` reference; Linux writes the canonical reference. Same-platform object operations have been qualified in isolated S3-compatible storage. The logical reference alone is therefore insufficient for IAM prefix rules or migration between platforms.
The reviewed `qqq-s3-filesystem-v1` layout uses the host filesystem separator; the original platform comparison was recorded against QQQ 4.1.0-RC.20. With empty base paths, Windows writes two leading backslashes before the logical `printing/<tenant-hash>/<job>.ext` reference; Linux writes the canonical reference. Same-platform object operations have been qualified in isolated S3-compatible storage. The logical reference alone is therefore insufficient for IAM prefix rules or migration between platforms.
Printing fingerprints the reviewed `qqq-s3-filesystem-v1` layout and the actual `File.separatorChar` code point (47 or 92), alongside the endpoint, bucket, region, path-style setting and backend/table base paths. Ordered JSON encoding preserves the identity component boundaries, including valid multiline base paths. A different layout rejects historical reads, new retries and cleanup with a destination-mismatch error before accessing those objects. New retries whose source binding is already incompatible fail before reserving another upload; the document loader also rechecks the binding when reading. An already-created retry still returns its idempotent receipt. Cleanup preserves content availability, retained-byte accounting and orphan reservations on mismatch.
This S3 feature is unreleased. Old experimental fingerprints without the layout marker are deliberately rejected, even on their original platform. Startup never rewrites those bindings or moves/deletes their objects. Preserve the original database and objects until an explicitly reviewed migration or disposal; do not replace hashes merely to bypass the guard. Inline database documents remain readable and can be explicitly retried into the configured destination without any automatic storage migration.
The released storage implementation requires this layout marker. Old experimental fingerprints without the layout marker are deliberately rejected, even on their original platform. Startup never rewrites those bindings or moves/deletes their objects. Preserve the original database and objects until an explicitly reviewed migration or disposal; do not replace hashes merely to bypass the guard. Inline database documents remain readable and can be explicitly retried into the configured destination without any automatic storage migration.
This guard does not make QQQ keys portable. The upstream owner must provide portable key construction and legacy-key handling. Adopting that change, including a change on the same operating system, requires an explicit layout-marker review and a migration plan for historical bindings and exact objects. The marker is a reviewed compatibility contract, not automatic detection of dependency implementation changes. Cross-platform migration and production IAM remain separate qualification work.
@@ -26,9 +26,9 @@ This guard does not make QQQ keys portable. The upstream owner must provide port
Record Files owns original versions and attachments; document generation owns rendering. Printing owns a separate delivery payload and queue receipts. Never bind printing cleanup to a Record Files bucket or pass an original's storage key as a printing-owned reference. Printing expiration must not remove a source attachment or weaken its retention policy.
The current independent integration seam accepts bytes and a stable business idempotency key. An authorized immutable-version reader and source/job correlation contract are being coordinated with Record Files; that API is not yet implemented or assumed here. Printing currently checks device/destination authorization at dispatch, not later revocation of the source attachment's permissions. A shared adapter must define that boundary before promising revocation propagation.
Direct submission accepts bytes and a stable business idempotency key. The optional [retained-source facade](RETAINED-PRINT-FACADE.md) additionally binds an authorized immutable source, actor, destination and acceptance receipt. It is disabled until the host supplies its source authority; this repository does not configure a Record Files adapter automatically. Printing checks device/destination authorization at dispatch, not later revocation of the source attachment's permissions. The integrating owner must define that boundary before promising revocation propagation.
The existing `PrintEnqueueEffectTarget` accepts base64 document bytes in the host's durable outbox. That outbox may retain another delivery copy outside this module's S3 cleanup policy. Hosts must manage its retention separately; do not claim that configuring printing S3 moves existing outbox payloads or all upstream copies into the printing bucket. The future retained-version adapter should carry authorized opaque source IDs/hash/length/media type and actor/correlation, not bucket URLs or duplicated document bytes. Direct byte submission stores the printing-owned payload through the configured storage backend without adding this host-outbox copy.
The existing `PrintEnqueueEffectTarget` accepts base64 document bytes in the host's durable outbox. That outbox may retain another delivery copy outside this module's S3 cleanup policy. Hosts must manage its retention separately; do not claim that configuring printing S3 moves existing outbox payloads or all upstream copies into the printing bucket. A host-provided retained-version adapter should carry authorized opaque source IDs/hash/length/media type and actor/correlation, not bucket URLs or duplicated document bytes. Direct byte submission stores the printing-owned payload through the configured storage backend without adding this host-outbox copy.
## Administration and scheduled cleanup
@@ -36,6 +36,8 @@ Printing administration exposes retention settings and a reviewed **Run cleanup*
When the host supplies a scheduler, maintenance runs every five minutes in bounded batches of at most 200. That permits a nominal 57,600 eligible documents per day before storage latency or failures; it is not measured production throughput. Changing a retention setting does not itself purge documents. Disabling cleanup preserves content. Without a configured scheduler there is no automatic job; the administration screen must show that condition.
**Delete printers** and **Delete print stations** delete the record at once and keep every job (see the [walkthrough](UI-WALKTHROUGH.md#delete-a-print-station-or-a-printer)); a deleted station's retained-byte counter stays until cleanup has removed its last stored document, then cleanup deletes the counter. The same run purges archived inventory. **Remove printers** and **Remove print station** never delete rows; they set `removed_at` (shown as **Removed at**) so the rows leave the default views, selectors, catalog, claims and the realtime `PRINTERS` frame while job history keeps resolving to them. After the document step, each cleanup run hard-deletes, within the remaining batch allowance, removed printers that no `printing_job` references (neither by `printer_row_id` nor by device and queue identity) and then removed devices that have no jobs and no printer rows left; a removed device's control receipts and resource accounting go with it. Anything a job references is never deleted, so a removed printer or device stays, hidden, for as long as its jobs exist. The purge counts toward the run's reported total and is logged with the printer and device counts; disabling cleanup pauses it together with document removal. Cleanup never deletes an active (non-removed) printer or device, and never deletes a job.
Only eligible successful terminal jobs lose their document content. Active, failed or uncertain work remains protected. Job metadata, document hashes, receipts and idempotency records remain available for auditing and duplicate prevention. Legacy completion times are recovered only from durable successful events; unknown completion dates remain protected. Existing inline database documents remain readable; the initial storage integration does not silently migrate old bytes to S3. Reprinting a job whose document has been removed returns an explicit unavailable-document result.
## Failure and versioning behavior
+11 -4
View File
@@ -1,10 +1,17 @@
# Follow-up
## CURRENT — 1.0.0 (everything below this section is historical)
## Current — release closeout (2026-11-04)
- [x] Adopt the published QQQ `4.1.0-RC.21` candidate in `pom.xml` and the active release documentation; the BOM resolves from Maven Central.
- [x] Complete the existing normal verification (`./mvnw -Pui-demo verify`) against RC.21: 193 tests reported, zero failures/errors, one existing opt-in workload skip and zero Checkstyle violations.
- [ ] Complete the existing normal publication of `1.0.0` after its required CI checks.
- [x] Adopt published QQQ 4.1.0-RC.21 and complete the existing local verification.
- [x] Pass normal primary CI, publish qbit-printing 1.0.0 and verify the release/registry bytes.
- [x] Publish protocol 1.0.0 and Windows connector 1.0.1; keep their distinct API/product versions documented.
- [x] Update active setup, operating, security and release documentation for Gitea and the released features.
No server 1.0 release task remains open. Deployment-specific database, IAM/provider policy and optional cross-QBit adapters remain the integrating application's responsibilities, described in the operating guides. The legacy optional Authentik diagnostic is documented as an RC.20-era fixture; updating it is not part of this release closeout.
## Historical checklists
Everything below records earlier development checkpoints. Unchecked historical items do not reopen completed or excluded release work.
## Echo integration candidate
+25 -4
View File
@@ -10,15 +10,36 @@ Open **Printing** in the navigation. The home presents the current tenant and re
## Enroll a facility computer
Choose **Enroll a print device** from Printing home or the Print devices table. Use the resulting short-lived enrollment code in the Windows companion's Connection settings. Once enrolled, select the installed printer queues to expose in the companion. Exposure is opt-in and persists in the service.
Choose **Enroll a print station** from Printing home or the Print stations table. A print station is the computer that runs the print client. Use the resulting short-lived enrollment code in the Windows companion's Connection settings. Once enrolled, select the installed printer queues to expose in the companion. Exposure is opt-in and persists in the service.
Open **Printers** in QQQ. The operational columns show the printer name, named print device, status, approval, local exposure, and last contact. Approval and exposure are independent: a printer must be both approved in QQQ and exposed on the computer before new jobs can be dispatched.
Open **Printers** in QQQ. The operational columns show the printer name, named print station, status, approval, local exposure, and last contact. Approval and exposure are independent: a printer must be both approved in QQQ and exposed on the computer before new jobs can be dispatched.
![Filtered printer table](screenshots/printers.png)
## Act on one printer or a selection
Select one or more printer rows and open **Actions**, or open an individual printer record and use its actions. Available operations include **Approve printers**, **Disable printers**, **Print a test page**, and **Print a test label**. The standard QQQ review screen describes the selection; reviewing does not print. Submitting a test action queues one job per selected eligible printer.
Select one or more printer rows and open **Actions**, or open an individual printer record and use its actions. Available operations include **Approve printers**, **Disable printers**, **Remove printers**, **Delete printers**, **Print a test page**, and **Print a test label**. The standard QQQ review screen describes the selection; reviewing does not print. Submitting a test action queues one job per selected eligible printer.
**Remove printers** archives the selection: each printer gets a **Removed at** time, loses approval and exposure, and disappears from the operator catalog, the connector's approval frame and the device/printer choices in the setup and test-print forms. Its record and job history stay. The Printers table has no server-side default filter, so add **Removed at is empty** to the Printers view you keep (the screenshots above predate this field) to hide archived rows; clear it to audit them. If the Windows companion shares the same queue again, the next inventory brings the printer back as exposed but not approved, so approve it again before it can print.
## Remove a print station
Open a Print stations record or select rows and choose **Remove print station**. The action requires the confirmation checkbox. It revokes the station's access permanently through the same control as **Revoke print station access**, then marks the station and all of its printers removed. The station drops out of the Print stations view once you add **Removed at is empty** there, the setup and test-print choices and every enrollment, claim and control path; its jobs remain in Print jobs and its control history keeps the REVOKE receipt. A removed station cannot be un-removed; enroll the computer again to create a new station. Retention cleanup eventually hard-deletes removed stations and printers that no job references. To take the record out of the list now, delete it.
## Delete a print station or a printer
Deleting needs the `printing_delete` permission in addition to `printing_admin`; without it the two actions are not offered.
Open a Print stations record, or select rows in Print stations, and choose **Delete print stations**. Tick the confirmation and continue. The review screen then says exactly what will happen for the selection: how many stations are deleted, how many printers go with them, how many queued jobs are canceled, how many jobs already sent to a station are closed as outcome unknown, and how many finished jobs keep their history. It also lists, by name, every record that will not be deleted and why. Nothing has changed yet. Submit to delete.
- The station's access is revoked first. The computer cannot connect as that station again; enroll it again to print, and it is then a new station.
- Its printers are deleted with it.
- Queued jobs become **Failed** with the message "Canceled before dispatch: its print station was deleted". Jobs that were already sent to the station and had not reported a result become **Outcome unknown**: check the printer before printing them again.
- Finished jobs stay in Print jobs. Their **Print station**, **Computer name**, **Printer** and **Windows queue name** columns show the names they were printed under.
- A station that is connected (status Online or Paused) and printing is refused. Wait for the jobs to finish, or use **Disconnect print stations** or **Revoke print station access** first.
- **Deletion history** in the Printing app records who deleted what and when.
**Delete printers** works the same way on Printers. The printer's station stays in service. If the station still shares that Windows queue, its next inventory reports it again and it comes back as a new printer that is exposed and not approved; stop sharing it in the print client on that computer to keep it away.
![Actions on the selected printer](screenshots/printer-actions.png)
@@ -38,4 +59,4 @@ The Windows companion shows recent jobs and a persistent status footer. **View s
## Preview acceptance and limits
The live preview was inspected with frontend 1.0.0-RC.37: the six operational printer columns, named device relationship, disabled generic write actions, and dashboard-to-filtered-table navigation were observed. Selecting the synthetic Zebra, opening Print a test label, reviewing and submitting completed with one printer processed. No physical printer was connected to this demo. Native process authorization and multi-printer execution are also covered by Java tests. A broad browser/accessibility run and final elevated companion interaction run remain separate acceptance tasks; see the README and the Windows repository's UX review.
The live preview was inspected with frontend 1.0.0-RC.37: the six operational printer columns, named device relationship, disabled generic write actions, and dashboard-to-filtered-table navigation were observed. Selecting the synthetic Zebra, opening Print a test label, reviewing and submitting completed with one printer processed. No physical printer was connected to this demo. Native process authorization and multi-printer execution are also covered by Java tests. These are historical preview observations, not evidence for every browser size or deployed printer driver. Current release status is in the [README](../README.md) and [release guide](RELEASES.md).
+4 -4
View File
@@ -6,16 +6,16 @@
<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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent><groupId>com.kingsrook</groupId><artifactId>qbit-build-parent</artifactId><version>2.0.0</version><relativePath/></parent>
<parent><groupId>com.kingsrook</groupId><artifactId>qbit-build-parent</artifactId><version>2.2.1</version><relativePath/></parent>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-printing</artifactId>
<version>1.0.0</version>
<version>1.1.2</version>
<name>QQQ Printing</name>
<licenses><license><name>Apache License, Version 2.0</name><url>https://www.apache.org/licenses/LICENSE-2.0</url></license></licenses>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<qqq.version>4.1.0-RC.21</qqq.version>
<qqq.version>4.1.0-RC.25</qqq.version>
<maven.compiler.proc>none</maven.compiler.proc>
</properties>
<dependencyManagement><dependencies>
@@ -37,7 +37,7 @@
<dependency><groupId>org.junit.jupiter</groupId><artifactId>junit-jupiter</artifactId><version>6.0.1</version><scope>test</scope></dependency>
</dependencies>
<profiles><profile><id>ui-demo</id><dependencies>
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-frontend-next</artifactId><version>1.0.0-RC.37</version><scope>runtime</scope></dependency>
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-frontend-next</artifactId><version>1.0.0-RC.38</version><scope>runtime</scope></dependency>
</dependencies></profile></profiles>
<build>
<resources>
@@ -93,6 +93,16 @@ public final class PrintingAgentAuthorization
cursor = page.isNull("nextCursor") ? null : page.getString("nextCursor");
}
while(cursor != null);
// Removed printers are hidden from the page but still belong to the device's row set: a re-shared queue reactivates one.
for(var removed : PrintingControlAuthorization.query(new QueryInput().withTableName(PrintingTables.PRINTER)
.withFilter(new QQueryFilter(new QFilterCriteria("device_id", QCriteriaOperator.EQUALS, device)).withCriteria(new QFilterCriteria("removed_at", QCriteriaOperator.IS_NOT_BLANK)).withLimit(10001))).getRecords())
{
authorized.add(removed.getValueString("id"));
if(authorized.size() > 10000)
{
throw new PrintingException(429, "Inventory exceeds the bounded native review limit");
}
}
// Even an empty inventory must have native table READ access.
PrintingControlAuthorization.query(new QueryInput().withTableName(PrintingTables.PRINTER)
.withFilter(new QQueryFilter(new QFilterCriteria("device_id", QCriteriaOperator.EQUALS, device)).withLimit(1)));
@@ -68,7 +68,7 @@ final class PrintingApiSchemas
add(schemas, "TestRequest", fields("requestId", id(), "printerId", id(), "format", enumeration("PDF", "ZPL"), "testProfile", text(40)), "requestId", "printerId", "format");
add(schemas, "RetryRequest", fields("requestId", id(), "confirmDuplicateRisk", bool()), "requestId");
add(schemas, "DesiredState", fields("revision", number(), "dispatchPaused", bool(), "connectionState", enumeration("CONNECTED", "DISCONNECTED")), "revision", "dispatchPaused", "connectionState");
add(schemas, "ControlPollRequest", fields("controlVersion", number(), "agentVersion", text(100), "capabilities", array(enumeration("REFRESH_INVENTORY", "CANCEL_SPOOL", "printer-media-v1")), "lastAppliedRevision", number()), "controlVersion", "agentVersion", "capabilities", "lastAppliedRevision");
add(schemas, "ControlPollRequest", fields("controlVersion", number(), "agentVersion", text(100), "capabilities", array(enumeration("REFRESH_INVENTORY", "CANCEL_SPOOL", "printer-media-v1", "printer-approval-v1")), "lastAppliedRevision", number()), "controlVersion", "agentVersion", "capabilities", "lastAppliedRevision");
add(schemas, "Command", fields("commandId", id(), "requestId", id(), "action", enumeration("REFRESH_INVENTORY", "CANCEL_SPOOL"), "createdAt", time(), "expiresAt", time(), "jobId", id()), "commandId", "requestId", "action", "createdAt", "expiresAt");
add(schemas, "ControlPoll", fields("controlVersion", number(), "serverTime", time(), "pollIntervalSeconds", number(), "desiredState", ref("DesiredState"), "commands", array(ref("Command"))), "controlVersion", "serverTime", "pollIntervalSeconds", "desiredState", "commands");
add(schemas, "ReceiptRequest", fields("receiptId", id(), "outcome", enumeration("INVENTORY_REFRESHED", "CANCEL_REQUESTED", "ALREADY_ABSENT", "OWNERSHIP_MISMATCH", "UNSUPPORTED", "FAILED", "EXPIRED", "OUTCOME_UNKNOWN"), "completedAt", time(), "message", text(1024)), "receiptId", "outcome", "completedAt");
@@ -133,7 +133,8 @@ public final class PrintingControlAuthorization
PrintingDeviceProcesses.Action selected = null;
for(PrintingDeviceProcesses.Action action : PrintingDeviceProcesses.Action.values())
{
if(action.wire.equals(request.optString("action")))
// The first action owning a wire value is its control process; Remove device reuses REVOKE's wire without owning it.
if(selected == null && action.wire.equals(request.optString("action")))
{
selected = action;
}
@@ -0,0 +1,64 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. 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.printing;
import com.kingsrook.qqq.backend.core.actions.permissions.UseOtherPermissionNameCustomPermissionChecker;
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.permissions.MetaDataWithPermissionRules;
import org.jspecify.annotations.Nullable;
/*******************************************************************************
** Deleting a print station or a printer needs its own grant, printing_delete,
** in addition to printing_admin: an administrator who may pause, approve and
** revoke does not delete records unless a host grants this permission too.
*******************************************************************************/
public class PrintingDeletePermission extends UseOtherPermissionNameCustomPermissionChecker
{
public static final String NAME = "printing_delete";
/***************************************************************************
** Expose the permission in QQQ's available-permission catalog.
***************************************************************************/
public PrintingDeletePermission()
{
withPermissionName(NAME);
}
/***************************************************************************
** Both grants are required; neither implies the other.
***************************************************************************/
@Override
public void checkPermissionsThrowing(AbstractActionInput input, @Nullable MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
{
if(QContext.getQSession() == null || !QContext.getQSession().hasPermission(NAME) || !QContext.getQSession().hasPermission("printing_admin"))
{
throw new QPermissionDeniedException("Permission to delete print stations and printers is required");
}
}
}
@@ -0,0 +1,431 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. 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.printing;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
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.processes.Status;
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.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.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
import org.json.JSONObject;
import org.jspecify.annotations.Nullable;
/*******************************************************************************
** Delete print stations and printers from their lists and record screens.
**
** Both actions need printing_delete as well as printing_admin. They ask for a
** confirmation, then show exactly what will happen (printers that go with a
** station, queued jobs canceled, jobs closed as outcome unknown, job history
** kept) and which records are refused and why. Nothing changes before the
** final step; every record is read again there with the caller's access, and
** a record that cannot be deleted is reported by name without stopping the
** others. PrintingService.deleteDevice and deletePrinter do the work.
*******************************************************************************/
public final class PrintingDeleteProcesses
{
public static final String DEVICES = "printingDeleteDevices";
public static final String PRINTERS = "printingDeleteSelectedPrinters";
public static final String DEVICES_LABEL = "Delete " + PrintingNames.STATIONS_LOWER;
public static final String PRINTERS_LABEL = "Delete " + PrintingNames.PRINTERS_LOWER;
static final String CONFIRM = "confirmPrintingDelete";
static final String DEVICES_PREVIEW = "Each selected " + PrintingNames.STATION_LOWER + " is deleted together with its " + PrintingNames.PRINTERS_LOWER + ". Its access is revoked first, so the computer must be enrolled again before it can print."
+ " Queued jobs are canceled, and jobs already sent to it without a reported result are closed as outcome unknown. Finished print jobs are kept, with the " + PrintingNames.STATION_LOWER + " and "
+ PrintingNames.PRINTER_LOWER + " names they were printed under. This cannot be undone.";
static final String PRINTERS_PREVIEW = "Each selected " + PrintingNames.PRINTER_LOWER + " is deleted; its " + PrintingNames.STATION_LOWER + " stays in service. Queued jobs for it are canceled, and jobs already sent without a reported result are closed as outcome unknown."
+ " Finished print jobs are kept, with the " + PrintingNames.PRINTER_LOWER + " and " + PrintingNames.STATION_LOWER + " names they were printed under. A " + PrintingNames.PRINTER_LOWER + " that its " + PrintingNames.STATION_LOWER
+ " still shares comes back as a new " + PrintingNames.PRINTER_LOWER + " awaiting approval; stop sharing it in the print client to keep it away. This cannot be undone.";
/***************************************************************************
** Utility class.
***************************************************************************/
private PrintingDeleteProcesses()
{
}
/***************************************************************************
** One action on each table, for a selection and for one record.
***************************************************************************/
public static void register(QInstance instance)
{
add(instance, DEVICES, DEVICES_LABEL, PrintingTables.DEVICE, DEVICES_PREVIEW,
"I understand: the selected " + PrintingNames.STATIONS_LOWER + " and their " + PrintingNames.PRINTERS_LOWER + " are deleted, access is revoked and each computer must be enrolled again");
add(instance, PRINTERS, PRINTERS_LABEL, PrintingTables.PRINTER, PRINTERS_PREVIEW,
"I understand: the selected " + PrintingNames.PRINTERS_LOWER + " are deleted and their queued jobs are canceled");
}
/***************************************************************************
** The grant every step of both actions checks.
***************************************************************************/
public static QPermissionRules permissionRules()
{
return new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION).withCustomPermissionChecker(new QCodeReference(PrintingDeletePermission.class));
}
/***************************************************************************
** prepare, confirm, review (what will happen), execute, result.
***************************************************************************/
private static void add(QInstance instance, String name, String label, String table, String preview, String confirmation)
{
Map<String, Serializable> values = new HashMap<>();
values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, table);
values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, table);
values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, true);
values.put(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true);
values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE, preview);
QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(PrintingExtract.class, Transform.class, Load.class, values)
.withName(name).withLabel(label).withTableName(table).withMinInputRecords(1).withPermissionRules(permissionRules());
process.withStep(0, new QBackendStepMetaData().withName("prepareDelete").withCode(new QCodeReference(Prepare.class)));
process.withStep(1, new QFrontendStepMetaData().withName("confirmDelete").withLabel(label)
.withFormField(new QFieldMetaData(CONFIRM, QFieldType.BOOLEAN).withLabel(confirmation).withDefaultValue(false))
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM)));
instance.addProcess(process);
}
/***************************************************************************
** The signed-in user who holds both grants, in this printing tenant.
***************************************************************************/
static String actor(RunBackendStepInput input) throws QException
{
new PrintingDeletePermission().checkPermissionsThrowing(input, null);
if(!DEVICES.equals(input.getProcessName()) && !PRINTERS.equals(input.getProcessName()))
{
throw new QException("Unknown printing delete action");
}
return PrintingDeviceProcesses.actor(input);
}
/***************************************************************************
** The record as the caller may read it now, or null when it is gone or not
** the caller's to see.
***************************************************************************/
private static @Nullable QRecord current(String table, @Nullable String id) throws QException
{
if(id == null || id.isBlank())
{
return null;
}
GetInput get = new GetInput();
get.setTableName(table);
get.setPrimaryKey(id);
get.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
return new GetAction().execute(get).getRecord();
}
/***************************************************************************
** What a delete of this record would do, or the reason it is refused.
***************************************************************************/
private static JSONObject plan(RunBackendStepInput input, QRecord record) throws QException
{
try
{
PrintingService service = PrintingProcesses.currentService();
return DEVICES.equals(input.getProcessName()) ? service.deviceDeletionPlan(record.getValueString("id")) : service.printerDeletionPlan(record.getValueString("id"));
}
catch(PrintingException exception)
{
return new JSONObject().put("refusal", exception.getStatus() == 404 ? name(input, record) + " no longer exists" : name(input, record) + ": " + exception.getMessage());
}
catch(java.sql.SQLException exception)
{
throw new QException("Could not read what the delete would do", exception);
}
}
/***************************************************************************
** The words for one record in a message.
***************************************************************************/
private static String name(RunBackendStepInput input, QRecord record)
{
return (DEVICES.equals(input.getProcessName()) ? PrintingNames.STATION : PrintingNames.PRINTER) + " " + record.getValueString("name");
}
/***************************************************************************
** The line listing every record that is not deleted, with its reason.
***************************************************************************/
private static void addRefusals(List<ProcessSummaryLineInterface> lines, List<String> refusals, boolean past)
{
if(!refusals.isEmpty())
{
lines.add(new ProcessSummaryLine(Status.ERROR, refusals.size(), refusals.size() == 1 ? (past ? "record was not deleted" : "record will not be deleted") : (past ? "records were not deleted" : "records will not be deleted"))
.withBulletsOfText(new ArrayList<>(refusals)));
}
}
/***************************************************************************
** Keep the selection across the confirmation form.
***************************************************************************/
public static class Prepare implements BackendStep
{
/************************************************************************
** Nothing is read or changed here beyond the caller's authority.
************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
actor(input);
PrintingProcesses.keepSelection(input, output);
}
}
/***************************************************************************
** The review: counts what will happen and names what is refused. Read-only.
***************************************************************************/
public static class Transform extends AbstractTransformStep implements ProcessSummaryProviderInterface
{
private final List<String> refusals = new ArrayList<>();
private final ProcessSummaryLine ready = new ProcessSummaryLine(Status.OK, 0, null);
private boolean devices;
private long printers;
private long queued;
private long inProgress;
private long kept;
/************************************************************************
** Only records that can be deleted now go on to the final step.
************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
actor(input);
devices = DEVICES.equals(input.getProcessName());
for(QRecord selected : input.getRecords())
{
QRecord record = current(devices ? PrintingTables.DEVICE : PrintingTables.PRINTER, selected.getValueString("id"));
if(record == null)
{
refusals.add("A selected record no longer exists or is not yours to delete");
continue;
}
JSONObject plan = plan(input, record);
if(plan.has("refusal"))
{
refusals.add(plan.getString("refusal"));
continue;
}
printers += plan.optLong("printers", 0);
queued += plan.getLong("queuedJobs");
inProgress += plan.getLong("jobsInProgress");
kept += plan.getLong("jobs") - plan.getLong("queuedJobs") - plan.getLong("jobsInProgress");
ready.incrementCountAndAddPrimaryKey(record.getValue("id"));
output.getRecords().add(record);
}
}
/************************************************************************
** The records refused in review, for the final step's result.
************************************************************************/
List<String> refusals()
{
return refusals;
}
/************************************************************************
** One line for each consequence that applies, then the refusals.
************************************************************************/
// QQQ ProcessSummaryProvider requires this concrete return type; retain binary/API compatibility.
@SuppressWarnings("NonApiType")
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
if(devices)
{
ready.withSingularFutureMessage(PrintingNames.STATION_LOWER + " will be deleted; its access is revoked and the computer must be enrolled again before it can print")
.withPluralFutureMessage(PrintingNames.STATIONS_LOWER + " will be deleted; their access is revoked and each computer must be enrolled again before it can print");
}
else
{
ready.withSingularFutureMessage(PrintingNames.PRINTER_LOWER + " will be deleted").withPluralFutureMessage(PrintingNames.PRINTERS_LOWER + " will be deleted");
}
ready.addSelfToListIfAnyCount(lines);
add(lines, Status.WARNING, printers, PrintingNames.PRINTER_LOWER + " will be deleted with its " + PrintingNames.STATION_LOWER, PrintingNames.PRINTERS_LOWER + " will be deleted with their " + PrintingNames.STATIONS_LOWER);
add(lines, Status.WARNING, queued, "queued job will be canceled", "queued jobs will be canceled");
add(lines, Status.WARNING, inProgress, "job already sent without a reported result will be closed as outcome unknown", "jobs already sent without a reported result will be closed as outcome unknown");
add(lines, Status.INFO, kept, "finished print job keeps its history and the names it was printed under", "finished print jobs keep their history and the names they were printed under");
addRefusals(lines, refusals, false);
return lines;
}
/************************************************************************
** A counted consequence, shown only when it applies.
************************************************************************/
private static void add(List<ProcessSummaryLineInterface> lines, Status status, long count, String singular, String plural)
{
if(count > 0)
{
lines.add(new ProcessSummaryLine(status, Math.toIntExact(count), count == 1 ? singular : plural));
}
}
}
/***************************************************************************
** The delete itself, after the confirmation and the review.
***************************************************************************/
public static class Load extends AbstractLoadStep implements ProcessSummaryProviderInterface
{
private final List<String> refusals = new ArrayList<>();
private final ProcessSummaryLine deleted = new ProcessSummaryLine(Status.OK, 0, null);
private boolean devices;
private long printers;
private long cancelled;
private long unknown;
private long kept;
/************************************************************************
** Each record is read again with the caller's access and deleted on its
** own; a refusal names the record and does not stop the others.
************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
String actor = actor(input);
if(!Boolean.TRUE.equals(input.getValueBoolean(CONFIRM)))
{
throw new QException("Confirm the delete before continuing");
}
devices = DEVICES.equals(input.getProcessName());
PrintingService service = PrintingProcesses.currentService();
for(QRecord selected : input.getRecords())
{
QRecord record = current(devices ? PrintingTables.DEVICE : PrintingTables.PRINTER, selected.getValueString("id"));
if(record == null)
{
refusals.add("A selected record no longer exists or is not yours to delete");
continue;
}
try
{
JSONObject result = devices ? service.deleteDevice(record.getValueString("id"), actor) : service.deletePrinter(record.getValueString("id"), actor);
printers += result.optLong("printers", 0);
cancelled += result.getLong("cancelledJobs");
unknown += result.getLong("unknownJobs");
kept += result.getLong("keptJobs") - result.getLong("cancelledJobs") - result.getLong("unknownJobs");
deleted.incrementCount();
output.getRecords().add(record);
}
catch(PrintingException exception)
{
refusals.add(exception.getStatus() == 409 ? exception.getMessage() : exception.getStatus() == 404 ? name(input, record) + " no longer exists" : name(input, record) + ": " + exception.getMessage());
}
catch(java.sql.SQLException exception)
{
throw new QException(name(input, record) + " could not be deleted; nothing was changed for it. Earlier records in this selection were deleted.", exception);
}
}
}
/************************************************************************
** What was deleted and what went with it, then every refusal, including
** the records the review already set aside.
************************************************************************/
// QQQ ProcessSummaryProvider requires this concrete return type; retain binary/API compatibility.
@SuppressWarnings("NonApiType")
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
if(devices)
{
deleted.withSingularPastMessage(PrintingNames.STATION_LOWER + " was deleted; its access is revoked").withPluralPastMessage(PrintingNames.STATIONS_LOWER + " were deleted; their access is revoked");
}
else
{
deleted.withSingularPastMessage(PrintingNames.PRINTER_LOWER + " was deleted").withPluralPastMessage(PrintingNames.PRINTERS_LOWER + " were deleted");
}
deleted.addSelfToListIfAnyCount(lines);
Transform.add(lines, Status.OK, printers, PrintingNames.PRINTER_LOWER + " was deleted with its " + PrintingNames.STATION_LOWER, PrintingNames.PRINTERS_LOWER + " were deleted with their " + PrintingNames.STATIONS_LOWER);
Transform.add(lines, Status.WARNING, cancelled, "queued job was canceled", "queued jobs were canceled");
Transform.add(lines, Status.WARNING, unknown, "job was closed as outcome unknown; check the printer for output before printing it again", "jobs were closed as outcome unknown; check the printer for output before printing them again");
Transform.add(lines, Status.INFO, kept, "finished print job keeps its history and the names it was printed under", "finished print jobs keep their history and the names they were printed under");
List<String> all = new ArrayList<>(refusals);
if(getTransformStep() instanceof Transform review)
{
all.addAll(review.refusals());
}
addRefusals(lines, all, true);
return lines;
}
}
}
@@ -102,6 +102,41 @@ final class PrintingDeviceControls
/** A native authorized queue snapshot must still match after taking the device lock. */
JSONObject action(String deviceId, JSONObject request, String actor, @Nullable Set<String> authorizedQueue) throws SQLException
{
Validated valid = validated(request, actor);
try(Connection connection = source.getConnection())
{
connection.setAutoCommit(false);
try
{
JSONObject response = apply(connection, deviceId, request, valid, actor, authorizedQueue);
connection.commit();
return response;
}
catch(Exception exception)
{
connection.rollback();
throw exception;
}
}
}
/** The same action inside a transaction its caller began and will commit or roll back; nothing is committed here. */
JSONObject action(Connection connection, String deviceId, JSONObject request, String actor) throws SQLException
{
return apply(connection, deviceId, request, validated(request, actor), actor, null);
}
/** The checked parts of one action request. */
private record Validated(String requestId, String action, @Nullable String jobId)
{
}
/** Strict input is refused before a connection or a lock is taken. */
private static Validated validated(JSONObject request, String actor)
{
fields(request, Set.of("requestId", "issuedAt", "action", "jobId"));
String requestId = text(request, "requestId", 128);
@@ -119,141 +154,136 @@ final class PrintingDeviceControls
{
throw new PrintingException(400, "A bounded authenticated actor is required");
}
try(Connection connection = source.getConnection())
return new Validated(requestId, action, jobId);
}
/** Lock the device, replay a retained receipt or apply and record the action; the caller owns the transaction. */
private JSONObject apply(Connection connection, String deviceId, JSONObject request, Validated valid, String actor, @Nullable Set<String> authorizedQueue) throws SQLException
{
String requestId = valid.requestId();
String action = valid.action();
String jobId = valid.jobId();
JSONObject device = lock(connection, deviceId, false);
List<JSONObject> old = query(connection, "SELECT * FROM printing_device_command WHERE tenant_id=? AND device_id=? AND request_id=?", tenant, deviceId, requestId);
if(!old.isEmpty())
{
connection.setAutoCommit(false);
try
if(!new JSONObject(old.getFirst().getString("request_json")).similar(request))
{
JSONObject device = lock(connection, deviceId, false);
List<JSONObject> old = query(connection, "SELECT * FROM printing_device_command WHERE tenant_id=? AND device_id=? AND request_id=?", tenant, deviceId, requestId);
if(!old.isEmpty())
{
if(!new JSONObject(old.getFirst().getString("request_json")).similar(request))
{
throw new PrintingException(409, "Request identity was already used for a different action");
}
connection.commit();
return record(old.getFirst());
}
Instant issued;
try
{
issued = Instant.parse(utc(request, "issuedAt"));
}
catch(java.time.format.DateTimeParseException exception)
{
throw new PrintingException(400, "Invalid action time");
}
Instant current = Instant.now();
if(issued.isBefore(current.minusSeconds(900)))
{
throw new PrintingException(410, "Action request expired; review a new action explicitly");
}
if(issued.isAfter(current.plusSeconds(60)))
{
throw new PrintingException(400, "Action time is too far in the future");
}
if(device.getInt("enabled") != 1 || device.has("revoked_at"))
{
throw new PrintingException(409, "Revoked devices require a new local enrollment");
}
if(!REMOTE.contains(action) && !Set.of("CLEAR_QUEUE", "REVOKE").contains(action) && device.getLong("control_revision") >= 9007199254740991L)
{
throw new PrintingException(409, "Control revision capacity reached");
}
expire(connection, deviceId);
update(connection, "DELETE FROM printing_device_command WHERE id IN (SELECT id FROM printing_device_command WHERE tenant_id=? AND device_id=? AND status<>'PENDING' AND created_at<? AND expires_at<? ORDER BY created_at,id LIMIT 200)", tenant, deviceId, Instant.now().minusSeconds(7 * 86400).toString(), Instant.now().toString());
long history = count(connection, "SELECT COUNT(*) AS n FROM printing_device_command WHERE tenant_id=? AND device_id=?", tenant, deviceId);
if(history >= 1000 && !"REVOKE".equals(action))
{
throw new PrintingException(429, "Device control receipt capacity reached; receipts are retained and revocation remains available");
}
String id = UUID.randomUUID().toString();
String now = current.toString();
String expires = current.plusSeconds(900).toString();
String result = null;
Integer cancelled = null;
if(REMOTE.contains(action))
{
if(device.getInt("control_version") != 1 || !new JSONArray(device.getString("control_capabilities")).toList().contains(action))
{
throw new PrintingException(409, "CONTROL_UNSUPPORTED");
}
if(count(connection, "SELECT COUNT(*) AS n FROM printing_device_command WHERE tenant_id=? AND device_id=? AND status='PENDING'", tenant, deviceId) >= 100)
{
throw new PrintingException(429, "Too many pending device commands");
}
if(jobId != null)
{
List<JSONObject> jobs = query(connection, "SELECT id,status,spooler_job_id FROM printing_job WHERE tenant_id=? AND device_id=? AND id=?", tenant, deviceId, jobId);
if(jobs.isEmpty())
{
throw new PrintingException(404, "Job not found");
}
if(!Set.of("SPOOLED", "SPOOLER_COMPLETED").contains(jobs.getFirst().getString("status")) || jobs.getFirst().optString("spooler_job_id").isBlank())
{
throw new PrintingException(409, "Only an owned job with a recorded spool identity can be canceled");
}
}
}
else if("CLEAR_QUEUE".equals(action))
{
if(authorizedQueue != null)
{
Set<String> currentQueue = new java.util.HashSet<>();
for(JSONObject row : query(connection, "SELECT id FROM printing_job WHERE tenant_id=? AND device_id=? AND status='QUEUED' LIMIT 10001", tenant, deviceId))
{
currentQueue.add(row.getString("id"));
}
if(!currentQueue.equals(authorizedQueue))
{
throw new PrintingException(409, "The queue changed; review the current authorized jobs before retrying");
}
}
cancelled = update(connection, "UPDATE printing_job SET status='FAILED',message=? WHERE tenant_id=? AND device_id=? AND status='QUEUED'", PrintingService.CANCELED_BEFORE_DISPATCH, tenant, deviceId);
result = "QUEUED_JOBS_CANCELLED";
}
else if("REVOKE".equals(action))
{
update(connection, "UPDATE printing_device SET enabled=0,token_hash=?,revoked_at=?,dispatch_paused=1,connection_state='DISCONNECTED',control_revision=? WHERE tenant_id=? AND id=?", UUID.randomUUID().toString(), now, Math.min(9007199254740991L, device.getLong("control_revision") + 1), tenant, deviceId);
update(connection, "UPDATE printing_device_command SET status='OUTCOME_UNKNOWN' WHERE tenant_id=? AND device_id=? AND status='PENDING'", tenant, deviceId);
result = "CREDENTIAL_REVOKED";
}
else
{
int paused = device.getInt("dispatch_paused");
String connected = device.getString("connection_state");
if("PAUSE_DISPATCH".equals(action))
{
paused = 1;
}
if("RESUME_DISPATCH".equals(action))
{
paused = 0;
}
if("DISCONNECT".equals(action))
{
connected = "DISCONNECTED";
}
if("RECONNECT".equals(action))
{
connected = "CONNECTED";
}
update(connection, "UPDATE printing_device SET dispatch_paused=?,connection_state=?,control_revision=control_revision+1 WHERE tenant_id=? AND id=?", paused, connected, tenant, deviceId);
result = "DESIRED_STATE_UPDATED";
}
update(connection, "INSERT INTO printing_device_command (id,tenant_id,device_id,request_id,action,job_id,actor,request_json,status,created_at,expires_at,result_code,cancelled_job_count) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
id, tenant, deviceId, requestId, action, jobId, actor, request.toString(), REMOTE.contains(action) ? "PENDING" : "SUCCEEDED", now, expires, result, cancelled);
JSONObject response = record(command(connection, deviceId, id));
connection.commit();
return response;
throw new PrintingException(409, "Request identity was already used for a different action");
}
catch(Exception exception)
return record(old.getFirst());
}
Instant issued;
try
{
issued = Instant.parse(utc(request, "issuedAt"));
}
catch(java.time.format.DateTimeParseException exception)
{
throw new PrintingException(400, "Invalid action time");
}
Instant current = Instant.now();
if(issued.isBefore(current.minusSeconds(900)))
{
throw new PrintingException(410, "Action request expired; review a new action explicitly");
}
if(issued.isAfter(current.plusSeconds(60)))
{
throw new PrintingException(400, "Action time is too far in the future");
}
if(device.getInt("enabled") != 1 || device.has("revoked_at"))
{
throw new PrintingException(409, "Revoked devices require a new local enrollment");
}
if(!REMOTE.contains(action) && !Set.of("CLEAR_QUEUE", "REVOKE").contains(action) && device.getLong("control_revision") >= 9007199254740991L)
{
throw new PrintingException(409, "Control revision capacity reached");
}
expire(connection, deviceId);
update(connection, "DELETE FROM printing_device_command WHERE id IN (SELECT id FROM printing_device_command WHERE tenant_id=? AND device_id=? AND status<>'PENDING' AND created_at<? AND expires_at<? ORDER BY created_at,id LIMIT 200)", tenant, deviceId, Instant.now().minusSeconds(7 * 86400).toString(), Instant.now().toString());
long history = count(connection, "SELECT COUNT(*) AS n FROM printing_device_command WHERE tenant_id=? AND device_id=?", tenant, deviceId);
if(history >= 1000 && !"REVOKE".equals(action))
{
throw new PrintingException(429, "Device control receipt capacity reached; receipts are retained and revocation remains available");
}
String id = UUID.randomUUID().toString();
String now = current.toString();
String expires = current.plusSeconds(900).toString();
String result = null;
Integer cancelled = null;
if(REMOTE.contains(action))
{
if(device.getInt("control_version") != 1 || !new JSONArray(device.getString("control_capabilities")).toList().contains(action))
{
connection.rollback();
throw exception;
throw new PrintingException(409, "CONTROL_UNSUPPORTED");
}
if(count(connection, "SELECT COUNT(*) AS n FROM printing_device_command WHERE tenant_id=? AND device_id=? AND status='PENDING'", tenant, deviceId) >= 100)
{
throw new PrintingException(429, "Too many pending device commands");
}
if(jobId != null)
{
List<JSONObject> jobs = query(connection, "SELECT id,status,spooler_job_id FROM printing_job WHERE tenant_id=? AND device_id=? AND id=?", tenant, deviceId, jobId);
if(jobs.isEmpty())
{
throw new PrintingException(404, "Job not found");
}
if(!Set.of("SPOOLED", "SPOOLER_COMPLETED").contains(jobs.getFirst().getString("status")) || jobs.getFirst().optString("spooler_job_id").isBlank())
{
throw new PrintingException(409, "Only an owned job with a recorded spool identity can be canceled");
}
}
}
else if("CLEAR_QUEUE".equals(action))
{
if(authorizedQueue != null)
{
Set<String> currentQueue = new java.util.HashSet<>();
for(JSONObject row : query(connection, "SELECT id FROM printing_job WHERE tenant_id=? AND device_id=? AND status='QUEUED' LIMIT 10001", tenant, deviceId))
{
currentQueue.add(row.getString("id"));
}
if(!currentQueue.equals(authorizedQueue))
{
throw new PrintingException(409, "The queue changed; review the current authorized jobs before retrying");
}
}
cancelled = update(connection, "UPDATE printing_job SET status='FAILED',message=? WHERE tenant_id=? AND device_id=? AND status='QUEUED'", PrintingService.CANCELED_BEFORE_DISPATCH, tenant, deviceId);
result = "QUEUED_JOBS_CANCELLED";
}
else if("REVOKE".equals(action))
{
update(connection, "UPDATE printing_device SET enabled=0,token_hash=?,revoked_at=?,dispatch_paused=1,connection_state='DISCONNECTED',control_revision=? WHERE tenant_id=? AND id=?", UUID.randomUUID().toString(), now, Math.min(9007199254740991L, device.getLong("control_revision") + 1), tenant, deviceId);
update(connection, "UPDATE printing_device_command SET status='OUTCOME_UNKNOWN' WHERE tenant_id=? AND device_id=? AND status='PENDING'", tenant, deviceId);
result = "CREDENTIAL_REVOKED";
}
else
{
int paused = device.getInt("dispatch_paused");
String connected = device.getString("connection_state");
if("PAUSE_DISPATCH".equals(action))
{
paused = 1;
}
if("RESUME_DISPATCH".equals(action))
{
paused = 0;
}
if("DISCONNECT".equals(action))
{
connected = "DISCONNECTED";
}
if("RECONNECT".equals(action))
{
connected = "CONNECTED";
}
update(connection, "UPDATE printing_device SET dispatch_paused=?,connection_state=?,control_revision=control_revision+1 WHERE tenant_id=? AND id=?", paused, connected, tenant, deviceId);
result = "DESIRED_STATE_UPDATED";
}
update(connection, "INSERT INTO printing_device_command (id,tenant_id,device_id,request_id,action,job_id,actor,request_json,status,created_at,expires_at,result_code,cancelled_job_count) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
id, tenant, deviceId, requestId, action, jobId, actor, request.toString(), REMOTE.contains(action) ? "PENDING" : "SUCCEEDED", now, expires, result, cancelled);
return record(command(connection, deviceId, id));
}
@@ -267,8 +297,8 @@ final class PrintingDeviceControls
}
String version = text(request, "agentVersion", 100);
long applied = integer(request, "lastAppliedRevision");
if(!(request.opt("capabilities") instanceof JSONArray capabilities) || capabilities.length() > 3
|| capabilities.toList().stream().anyMatch(value -> !REMOTE.contains(value) && !"printer-media-v1".equals(value)) || Set.copyOf(capabilities.toList()).size() != capabilities.length())
if(!(request.opt("capabilities") instanceof JSONArray capabilities) || capabilities.length() > 4
|| capabilities.toList().stream().anyMatch(value -> !REMOTE.contains(value) && !"printer-media-v1".equals(value) && !"printer-approval-v1".equals(value)) || Set.copyOf(capabilities.toList()).size() != capabilities.length())
{
throw new PrintingException(400, "Invalid control capabilities");
}
@@ -75,13 +75,16 @@ public final class PrintingDeviceProcesses
PAUSE("printingPauseDevices", "Pause printing", "PAUSE_DISPATCH", false, false,
"New work will stop being dispatched. Jobs already received by Windows may still print."),
RESUME("printingResumeDevices", "Resume printing", "RESUME_DISPATCH", false, false,
"Dispatch will resume for connected, authorized devices. Administrative disconnect remains unchanged."),
DISCONNECT("printingDisconnectDevices", "Disconnect devices", "DISCONNECT", false, false,
"Dispatch will resume for connected, authorized " + PrintingNames.STATIONS_LOWER + ". Administrative disconnect remains unchanged."),
DISCONNECT("printingDisconnectDevices", "Disconnect " + PrintingNames.STATIONS_LOWER, "DISCONNECT", false, false,
"Normal printing traffic will stop. The outbound control channel remains available for reconnect."),
RECONNECT("printingReconnectDevices", "Reconnect devices", "RECONNECT", false, false,
RECONNECT("printingReconnectDevices", "Reconnect " + PrintingNames.STATIONS_LOWER, "RECONNECT", false, false,
"Connected agents will be asked to resume normal traffic. An offline computer cannot be reached immediately."),
REVOKE("printingRevokeDevices", "Revoke device access", "REVOKE", true, false,
REVOKE("printingRevokeDevices", "Revoke " + PrintingNames.STATION_LOWER + " access", "REVOKE", true, false,
"Access will be revoked permanently. The old credential cannot be restored; local re-enrollment is required."),
REMOVE("printingRemoveDevices", "Remove " + PrintingNames.STATION_LOWER, "REVOKE", true, false,
"Access will be revoked permanently and the " + PrintingNames.STATION_LOWER + " and its " + PrintingNames.PRINTERS_LOWER + " will be hidden from the printing views; the records stay, marked removed. Job history is kept. A removed "
+ PrintingNames.STATION_LOWER + " cannot be restored; a new enrollment creates a new " + PrintingNames.STATION_LOWER + ". To take the record out of the list as well, use " + PrintingDeleteProcesses.DEVICES_LABEL + "."),
REFRESH("printingRefreshDevices", "Refresh printer inventory", "REFRESH_INVENTORY", false, false,
"A bounded refresh request will wait for a compatible agent. Acceptance does not mean it has executed."),
CLEAR("printingClearDeviceQueues", "Cancel queued jobs", "CLEAR_QUEUE", true, false,
@@ -180,7 +183,7 @@ public final class PrintingDeviceProcesses
if(QContext.getQSession() == null || QContext.getQSession().getUser() == null
|| QContext.getQSession().getUser().getIdReference() == null || QContext.getQSession().getUser().getIdReference().isBlank())
{
throw new QException("Sign in with an authorized QQQ user before controlling a print device");
throw new QException("Sign in with an authorized QQQ user before controlling a " + PrintingNames.STATION_LOWER);
}
return QContext.getQSession().getUser().getIdReference();
}
@@ -200,7 +203,7 @@ public final class PrintingDeviceProcesses
QRecord record = new GetAction().execute(get).getRecord();
if(record == null)
{
throw new QException("Choose an accessible print " + (action.job ? "job" : "device"));
throw new QException("Choose an accessible " + (action.job ? "print job" : PrintingNames.STATION_LOWER));
}
if(action.job && "QUEUED".equals(record.getValueString("status")))
{
@@ -285,8 +288,8 @@ public final class PrintingDeviceProcesses
public static class Load extends AbstractLoadStep implements ProcessSummaryProviderInterface
{
private final ProcessSummaryLine applied = new ProcessSummaryLine(Status.OK, 0, null)
.withSingularPastMessage("server control action was applied; device observation is shown separately")
.withPluralPastMessage("server control actions were applied; device observations are shown separately");
.withSingularPastMessage("server control action was applied; what the " + PrintingNames.STATION_LOWER + " reports is shown separately")
.withPluralPastMessage("server control actions were applied; what each " + PrintingNames.STATION_LOWER + " reports is shown separately");
private final ProcessSummaryLine pending = new ProcessSummaryLine(Status.WARNING, 0, null)
.withSingularPastMessage("agent request is pending; no remote execution is confirmed")
.withPluralPastMessage("agent requests are pending; no remote execution is confirmed");
@@ -326,8 +329,17 @@ public final class PrintingDeviceProcesses
}
try
{
JSONObject result = PrintingControlAuthorization.deviceAction(deviceId, request);
String status = result.getString("status");
String status;
if(action == Action.REMOVE)
{
// Removal reuses the REVOKE control for the credential; an already revoked device only needs archiving.
status = current.getValueString("revoked_at") == null ? PrintingControlAuthorization.deviceAction(deviceId, request).getString("status") : "SUCCEEDED";
PrintingProcesses.currentService().removeDevice(deviceId);
}
else
{
status = PrintingControlAuthorization.deviceAction(deviceId, request).getString("status");
}
ProcessSummaryLine summary = "PENDING".equals(status) ? pending : ("SUCCEEDED".equals(status) ? applied : unresolved);
summary.incrementCountAndAddPrimaryKey(current.getValue("id"));
output.getRecords().add(current);
@@ -84,11 +84,11 @@ public class PrintingHome extends AbstractWidgetRenderer
{
add(instance, app, "printingHomeHeader", "Printing operations", WidgetType.COMPOSITE, "header", 0, 12);
add(instance, app, "printingHomeAttention", "Needs attention", WidgetType.TABLE, "systemAttention", 10, 12);
add(instance, app, "printingHomeOffline", "Disconnected devices", WidgetType.STATISTICS, "systemHealth", 20, 3);
add(instance, app, "printingHomePrinters", "Printers needing attention", WidgetType.STATISTICS, "systemHealth", 21, 3);
add(instance, app, "printingHomeOffline", "Disconnected " + PrintingNames.STATIONS_LOWER, WidgetType.STATISTICS, "systemHealth", 20, 3);
add(instance, app, "printingHomePrinters", PrintingNames.PRINTERS + " needing attention", WidgetType.STATISTICS, "systemHealth", 21, 3);
add(instance, app, "printingHomeFailures", "Failed or uncertain jobs", WidgetType.STATISTICS, "systemHealth", 22, 3);
add(instance, app, "printingHomePending", "Pending print jobs", WidgetType.STATISTICS, "systemHealth", 23, 3);
add(instance, app, "printingHomeControls", "Pending device commands", WidgetType.STATISTICS, "systemHealth", 24, 12);
add(instance, app, "printingHomeControls", "Pending " + PrintingNames.STATION_LOWER + " commands", WidgetType.STATISTICS, "systemHealth", 24, 12);
add(instance, app, "printingHomeWork", "Pending and recent print work", WidgetType.TABLE, "queue", 30, 12);
}
@@ -134,7 +134,7 @@ public class PrintingHome extends AbstractWidgetRenderer
case "printingHomeFailures" -> statistic(JOB, failures(now), "Jobs created in last 24 hours · target 0 failed or uncertain");
case "printingHomePending" -> statistic(JOB, statuses(PENDING), "Jobs · now · target queue drains after printing");
case "printingHomeControls" -> statistic(PrintingTables.COMMAND, statuses(List.of("PENDING"))
.withCriteria("expires_at", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.toString()), "Device commands · now · awaiting an agent receipt; not confirmed applied");
.withCriteria("expires_at", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.toString()), PrintingNames.STATION + " commands · now · awaiting an agent receipt; not confirmed applied");
case "printingHomeWork" -> work(now);
default -> throw new QException("Unknown printing home widget");
};
@@ -149,17 +149,18 @@ public class PrintingHome extends AbstractWidgetRenderer
***************************************************************************/
private static CompositeWidgetData header() throws QException
{
CompositeWidgetData data = column().withBlock(text("Keep packing slips and shipping labels moving by resolving disconnected devices, printer issues and print failures."))
.withBlock(text("Scope: current tenant. Devices/printers: now. Job exceptions/recent work: last 24 hours."))
.withBlock(text("Enroll a print device").withLink(new BlockLink("/app/printingEnrollDevice/?returnTo=%2Fapp%2Fprinting%2F")))
CompositeWidgetData data = column().withBlock(text("Keep packing slips and shipping labels moving by resolving disconnected " + PrintingNames.STATIONS_LOWER + ", " + PrintingNames.PRINTER_LOWER + " issues and print failures."))
.withBlock(text("Scope: current tenant. " + PrintingNames.STATIONS + " and " + PrintingNames.PRINTERS_LOWER + ": now. Job exceptions/recent work: last 24 hours."))
.withBlock(text("Enroll a " + PrintingNames.STATION_LOWER).withLink(new BlockLink("/app/printingEnrollDevice/?returnTo=%2Fapp%2Fprinting%2F")))
.withBlock(text("Printing retention settings").withLink(new BlockLink("/app/printingRetentionSettings/?returnTo=%2Fapp%2Fprinting%2F")))
.withBlock(text("Printing connection and limits").withLink(new BlockLink("/app/printingRealtimeSettings/?returnTo=%2Fapp%2Fprinting%2F")))
.withBlock(text("Printing queue and storage limits").withLink(new BlockLink("/app/printingAdmissionSettings/?returnTo=%2Fapp%2Fprinting%2F")))
.withBlock(text("Run retention cleanup").withLink(new BlockLink("/app/printingRunRetention/?returnTo=%2Fapp%2Fprinting%2F")))
.withBlock(text("Printing setup and help").withLink(new BlockLink("https://github.com/QRun-IO/qbit-printing/blob/main/docs/OPERATIONS.md")));
if(count(DEVICE, new QQueryFilter()) == 0)
QQueryFilter present = new QQueryFilter().withCriteria("removed_at", QCriteriaOperator.IS_BLANK);
if(count(DEVICE, present) == 0)
{
data.withBlock(text("No print devices are connected. Open Print devices and use Enroll a print device; then connect the Windows agent and expose its printers.").withLink(new BlockLink(tableUrl(DEVICE, new QQueryFilter()))));
data.withBlock(text("No " + PrintingNames.STATIONS_LOWER + " are connected. Open " + PrintingNames.STATIONS + " and use Enroll a " + PrintingNames.STATION_LOWER + "; then connect the Windows agent and expose its " + PrintingNames.PRINTERS_LOWER + ".").withLink(new BlockLink(tableUrl(DEVICE, present))));
}
return data;
}
@@ -180,25 +181,25 @@ public class PrintingHome extends AbstractWidgetRenderer
{
boolean disconnected = "DISCONNECTED".equals(device.getValueString("connection_state"));
rows.add(issue(DEVICE, device, device.getValueString("name"), disconnected ? "Administratively disconnected" : "No connection for " + freshnessSeconds + " seconds",
disconnected ? "Open the device and use Reconnect; review its observed control revision." : "Open the Windows agent and check its service, sign-in and outbound connection.", "last_seen_at"));
disconnected ? "Open the " + PrintingNames.STATION_LOWER + " and use Reconnect; review its observed control revision." : "Open the Windows agent and check its service, sign-in and outbound connection.", "last_seen_at"));
}
for(QRecord device : query(DEVICE, new QQueryFilter().withCriteria("enabled", QCriteriaOperator.EQUALS, 1)
.withCriteria("dispatch_paused", QCriteriaOperator.EQUALS, 1).withOrderBy(new QFilterOrderBy("name")).withLimit(10)))
{
rows.add(issue(DEVICE, device, device.getValueString("name"), "Dispatch paused", "Open the device queue, then use Resume dispatch when it is ready.", "control_last_poll_at"));
rows.add(issue(DEVICE, device, device.getValueString("name"), "Dispatch paused", "Open the " + PrintingNames.STATION_LOWER + " queue, then use Resume printing when it is ready.", "control_last_poll_at"));
}
for(QRecord command : query(PrintingTables.COMMAND, statuses(List.of("PENDING", "FAILED", "EXPIRED", "OUTCOME_UNKNOWN"))
.withCriteria("created_at", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minusSeconds(86400).toString())
.withOrderBy(new QFilterOrderBy("created_at", false)).withLimit(10)))
{
rows.add(issue(PrintingTables.COMMAND, command, command.getValueString("action"), command.getValueString("command_status"),
"Review command history and the device connection before submitting another request.", "created_at"));
"Review command history and the " + PrintingNames.STATION_LOWER + " connection before submitting another request.", "created_at"));
}
for(QRecord printer : query(PRINTER, printerIssues().withOrderBy(new QFilterOrderBy("last_seen_at", true)).withLimit(10)))
{
rows.add(issue(PRINTER, printer, printer.getValueString("name"), printer.getValueString("status"), "Check paper, paused/error state and whether this queue is still exposed by the agent.", "last_seen_at"));
}
return table(rows, "No issues in this scope. If no devices are enrolled, start from Print devices.",
return table(rows, "No issues in this scope. If no " + PrintingNames.STATIONS_LOWER + " are enrolled, start from " + PrintingNames.STATIONS + ".",
new TableData.Column("composite", "Issue / record", "issue", null, "left"),
new TableData.Column("default", "Next step", "action", null, "left"),
new TableData.Column("default", "Observed at", "observed", null, "left"));
@@ -286,7 +287,7 @@ public class PrintingHome extends AbstractWidgetRenderer
private static StatisticsData offlineStatistic(Instant now) throws QException
{
long seconds = freshnessSeconds();
return statistic(DEVICE, offline(now, seconds), "Enabled devices · now · disconnected by an administrator or unseen for " + seconds + " seconds");
return statistic(DEVICE, offline(now, seconds), "Enabled " + PrintingNames.STATIONS_LOWER + " · now · disconnected by an administrator or unseen for " + seconds + " seconds");
}
@@ -80,7 +80,7 @@ public class PrintingJobProcesses
values.put(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true);
values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE, retry
? "Each selected job will create a new print job. Previously spooled or uncertain output may print twice. Inspect the printer before continuing."
: "Only selected jobs still queued in QQQ will be canceled. Jobs already received by a device must be inspected in the Windows companion.");
: "Only selected jobs still queued in QQQ will be canceled. Jobs already received by a " + PrintingNames.STATION_LOWER + " must be inspected in the Windows companion.");
QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(PrintingExtract.class, Transform.class, Load.class, values)
.withName(name).withLabel(retry ? "Print again" : "Cancel queued jobs").withTableName(PrintingTables.JOB).withMinInputRecords(1)
.withPermissionRules(PrintingProcesses.permissionRules());
@@ -243,6 +243,10 @@ public class PrintingJobProcesses
}
catch(java.sql.SQLException | PrintingException exception)
{
if(exception instanceof PrintingException refused && refused.getStatus() == 404 && "Device not found".equals(refused.getMessage()))
{
throw new QException("Job " + job.getValueString("title") + " cannot be processed: its " + PrintingNames.STATION_LOWER + " was deleted, removed or revoked. Print the document again from where it was printed.", exception);
}
throw new QException("Print job action failed: " + exception.getMessage(), exception);
}
}
@@ -0,0 +1,54 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. 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.printing;
/*******************************************************************************
** The words an administrator reads for the two things printing manages. Every
** label, action label, preview and message is built from these constants, so
** a different choice of name is one edit here. Table, field and process names
** (printing_device, printingEnrollDevice, ...) and the agent wire protocol do
** not change with them.
**
** - Station: the computer that runs the print client (table printing_device).
** - Printer: a physical printer, seen as a Windows print queue that a station
** shares (table printing_printer).
*******************************************************************************/
public final class PrintingNames
{
public static final String STATION = "Print station";
public static final String STATIONS = "Print stations";
public static final String STATION_LOWER = "print station";
public static final String STATIONS_LOWER = "print stations";
public static final String PRINTER = "Printer";
public static final String PRINTERS = "Printers";
public static final String PRINTER_LOWER = "printer";
public static final String PRINTERS_LOWER = "printers";
/***************************************************************************
** Constants only.
***************************************************************************/
private PrintingNames()
{
}
}
@@ -175,6 +175,7 @@ public final class PrintingOperatorAuthorization
}
QQueryFilter filter = new QQueryFilter(new QFilterCriteria("enabled", QCriteriaOperator.EQUALS, 1))
.withCriteria(new QFilterCriteria("available", QCriteriaOperator.EQUALS, 1))
.withCriteria(new QFilterCriteria("removed_at", QCriteriaOperator.IS_BLANK))
.withCriteria(new QFilterCriteria("tenant_id", QCriteriaOperator.EQUALS, PrintingProcesses.currentService().getTenantId()))
.withOrderBy(new QFilterOrderBy("id", true)).withLimit(limit + 1);
if(after != null)
@@ -81,14 +81,16 @@ public class PrintingProcesses implements BackendStep
{
PrintingJobProcesses.register(instance);
registerProfiles(instance);
add(instance, "printingEnrollDevice", "Enroll a print device", List.of(PrintingFacilities.field(instance), field("deviceName", "Device name")));
add(instance, "printingEnablePrinter", "Set printer approval", List.of(deviceField(), printerField(null), new QFieldMetaData("enabled", QFieldType.BOOLEAN).withLabel("Approved for printing").withIsRequired(true)));
add(instance, "printingEnrollDevice", "Enroll a " + PrintingNames.STATION_LOWER, List.of(PrintingFacilities.field(instance), field("deviceName", "Computer name")));
add(instance, "printingEnablePrinter", "Set " + PrintingNames.PRINTER_LOWER + " approval", List.of(deviceField(), printerField(null), new QFieldMetaData("enabled", QFieldType.BOOLEAN).withLabel("Approved for printing").withIsRequired(true)));
add(instance, "printingTestZpl", "Print a test label", List.of(deviceField(), printerField("ZPL"), profileField(false)));
add(instance, "printingTestPdf", "Print a test page", List.of(deviceField(), printerField("PDF"), profileField(true)));
selected(instance, "printingTestSelectedPdf", "Print a test page", "Each selected PDF printer will receive one test page after you continue.");
selected(instance, "printingTestSelectedZpl", "Print a test label", "Each selected ZPL printer will receive one test label after you continue.");
selected(instance, "printingEnableSelectedPrinters", "Approve printers", "The selected printers will be approved for printing after you continue.");
selected(instance, "printingDisableSelectedPrinters", "Disable printers", "The selected printers will stop receiving new print jobs after you continue.");
selected(instance, "printingEnableSelectedPrinters", "Approve " + PrintingNames.PRINTERS_LOWER, "The selected " + PrintingNames.PRINTERS_LOWER + " will be approved for printing after you continue.");
selected(instance, "printingDisableSelectedPrinters", "Disable " + PrintingNames.PRINTERS_LOWER, "The selected " + PrintingNames.PRINTERS_LOWER + " will stop receiving new print jobs after you continue.");
selected(instance, "printingRemoveSelectedPrinters", "Remove " + PrintingNames.PRINTERS_LOWER, "The selected " + PrintingNames.PRINTERS_LOWER + " will be hidden from the operator catalog, the selectors and their " + PrintingNames.STATIONS_LOWER + " after you continue; the records stay, marked removed. Job history is kept; sharing a queue again from its "
+ PrintingNames.STATION_LOWER + " brings it back awaiting approval. To take the record out of the list as well, use " + PrintingDeleteProcesses.PRINTERS_LABEL + ".");
}
@@ -154,7 +156,8 @@ public class PrintingProcesses implements BackendStep
***************************************************************************/
private static QFieldMetaData deviceField()
{
return field("deviceId", "Print device").withPossibleValueSourceName(PrintingTables.DEVICE);
return field("deviceId", PrintingNames.STATION).withPossibleValueSourceName(PrintingTables.DEVICE)
.withPossibleValueSourceFilter(new QQueryFilter(new QFilterCriteria("removed_at", QCriteriaOperator.IS_BLANK)));
}
@@ -164,14 +167,15 @@ public class PrintingProcesses implements BackendStep
***************************************************************************/
private static QFieldMetaData printerField(@Nullable String format)
{
QQueryFilter filter = new QQueryFilter(new QFilterCriteria("device_id", QCriteriaOperator.EQUALS, "${input.deviceId}"));
QQueryFilter filter = new QQueryFilter(new QFilterCriteria("device_id", QCriteriaOperator.EQUALS, "${input.deviceId}"))
.withCriteria(new QFilterCriteria("removed_at", QCriteriaOperator.IS_BLANK));
if(format != null)
{
filter.withCriteria(new QFilterCriteria("enabled", QCriteriaOperator.EQUALS, 1));
filter.withCriteria(new QFilterCriteria("available", QCriteriaOperator.EQUALS, 1));
filter.withCriteria(new QFilterCriteria("formats", QCriteriaOperator.CONTAINS, format));
}
return field("printerId", "Printer").withPossibleValueSourceName(PrintingTables.PRINTER)
return field("printerId", PrintingNames.PRINTER).withPossibleValueSourceName(PrintingTables.PRINTER)
.withPossibleValueSourceFilter(filter).withPossibleValueSourceDependsOn(List.of("deviceId"));
}
@@ -310,7 +314,7 @@ public class PrintingProcesses implements BackendStep
QRecord printer = new GetAction().execute(get).getRecord();
if(printer == null || (deviceId != null && !deviceId.equals(printer.getValueString("device_id"))))
{
throw new QException("Choose an accessible printer belonging to the selected device");
throw new QException("Choose an accessible " + PrintingNames.PRINTER_LOWER + " belonging to the selected " + PrintingNames.STATION_LOWER);
}
return printer;
}
@@ -532,6 +536,7 @@ public class PrintingProcesses implements BackendStep
case "printingTestSelectedPdf" -> submitTest(printer, true, input.getValueString("testProfile"), testRequestKey(input, printer));
case "printingTestSelectedZpl" -> submitTest(printer, false, input.getValueString("testProfile"), testRequestKey(input, printer));
case "printingEnableSelectedPrinters", "printingDisableSelectedPrinters" -> currentService().enablePrinter(printer.getValueString("device_id"), printer.getValueString("printer_id"), "printingEnableSelectedPrinters".equals(input.getProcessName()));
case "printingRemoveSelectedPrinters" -> currentService().removePrinter(printer.getValueString("device_id"), printer.getValueString("printer_id"));
default -> throw new QException("Unknown selected printing action");
}
done.incrementCountAndAddPrimaryKey(printer.getValue("id"));
@@ -79,7 +79,7 @@ public class PrintingQBitProducer implements QBitProducer
/***************************************************************************
** Register device, printer and job tables and the Printing navigation app.
** Register the station (device), printer, job, control and deletion tables and the Printing navigation app.
***************************************************************************/
@Override
public void produce(QInstance instance, String namespace) throws QException
@@ -96,7 +96,7 @@ public class PrintingQBitProducer implements QBitProducer
config.getService().configureStorage(instance, config.getStorageTableName());
instance.addQBit(new QBitMetaData().withGroupId("com.kingsrook.qbits").withArtifactId("qbit-printing").withVersion(version()).withNamespace("printing").withConfig(config));
instance.addSecurityKeyType(new QSecurityKeyType().withName("printing_tenant"));
QTableMetaData devices = table(PrintingTables.DEVICE, "Print devices", List.of("id", "tenant_id", "facility_id", "name", "display_name", "computer_name", "description", "location", "agent_version", "last_seen_at"));
QTableMetaData devices = table(PrintingTables.DEVICE, PrintingNames.STATIONS, List.of("id", "tenant_id", "facility_id", "name", "display_name", "computer_name", "description", "location", "agent_version", "last_seen_at", "removed_at"));
devices.withField(new QFieldMetaData("enabled", QFieldType.INTEGER).withLabel("Enabled").withPossibleValueSourceName("printing_enabled"));
devices.withField(new QFieldMetaData("dispatch_paused", QFieldType.INTEGER).withLabel("Printing paused").withPossibleValueSourceName("printing_paused"));
devices.withField(new QFieldMetaData("connection_state", QFieldType.STRING).withLabel("Requested connection").withPossibleValueSourceName("printing_connectionState"));
@@ -106,19 +106,28 @@ public class PrintingQBitProducer implements QBitProducer
devices.withField(new QFieldMetaData("control_capabilities", QFieldType.STRING).withLabel("Agent control capabilities"));
devices.withField(new QFieldMetaData("control_last_poll_at", QFieldType.STRING).withLabel("Last control connection"));
devices.withField(new QFieldMetaData("revoked_at", QFieldType.STRING).withLabel("Access revoked at"));
devices.withVirtualField(new QVirtualFieldMetaData("device_status", QFieldType.STRING).withLabel("Device status").withPossibleValueSourceName("printing_deviceStatus").withIsQuerySelectable(true));
devices.withVirtualField(new QVirtualFieldMetaData("device_status", QFieldType.STRING).withLabel(PrintingNames.STATION + " status").withPossibleValueSourceName("printing_deviceStatus").withIsQuerySelectable(true));
devices.withVirtualField(new QVirtualFieldMetaData("control_status", QFieldType.STRING).withLabel("Control application").withPossibleValueSourceName("printing_controlApplication").withIsQuerySelectable(true));
devices.withCustomizer(TableCustomizers.POST_QUERY_RECORD, new QCodeReference(PrintingDeviceStatus.class));
devices.getField("facility_id").withLabel("Facility").withPossibleValueSourceName(PrintingFacilities.register(instance, config));
QTableMetaData printers = table(PrintingTables.PRINTER, "Printers", List.of("id", "tenant_id", "device_id", "printer_id", "name", "display_name", "queue_name", "description", "location", "driver_name", "port_name", "status", "formats", "last_seen_at"));
QTableMetaData printers = table(PrintingTables.PRINTER, PrintingNames.PRINTERS, List.of("id", "tenant_id", "device_id", "printer_id", "name", "display_name", "queue_name", "description", "location", "driver_name", "port_name", "status", "formats", "last_seen_at", "removed_at"));
printers.withField(new QFieldMetaData("enabled", QFieldType.INTEGER).withLabel("Approved for printing").withPossibleValueSourceName("printing_approved"));
printers.withField(new QFieldMetaData("available", QFieldType.INTEGER).withLabel("Exposed by device").withPossibleValueSourceName("printing_available"));
printers.withField(new QFieldMetaData("available", QFieldType.INTEGER).withLabel("Exposed by " + PrintingNames.STATION_LOWER).withPossibleValueSourceName("printing_available"));
printers.withField(new QFieldMetaData("media_profiles_json", QFieldType.STRING).withLabel("Reported queue default media"));
QTableMetaData jobs = table(PrintingTables.JOB, "Print jobs", List.of("id", "tenant_id", "device_id", "printer_row_id", "title", "format", "content_hash", "options_json", "idempotency_key", "created_at", "status", "message", "spooler_job_id"));
QTableMetaData jobs = table(PrintingTables.JOB, "Print jobs", List.of("id", "tenant_id", "device_id", "printer_row_id", "title", "format", "content_hash", "options_json", "idempotency_key", "created_at", "status", "message", "spooler_job_id",
"device_name", "computer_name", "printer_name", "queue_name"));
// What the station and printer were called when the job was submitted: the job reads the same after either is renamed or deleted.
jobs.getField("device_name").withLabel(PrintingNames.STATION).withMaxLength(1024);
jobs.getField("computer_name").withLabel("Computer name").withMaxLength(1024);
jobs.getField("printer_name").withLabel(PrintingNames.PRINTER).withMaxLength(1024);
jobs.getField("queue_name").withLabel("Windows queue name").withMaxLength(1024);
jobs.withField(new QFieldMetaData("content_length", QFieldType.INTEGER).withLabel("Document size (bytes)"));
QTableMetaData commands = table(PrintingTables.COMMAND, "Device control history", List.of("id", "tenant_id", "device_id", "request_id", "action", "job_id", "actor", "status", "created_at", "expires_at", "result_code"));
// created_at stays the ISO-8601 text the queue SQL orders by; created_time is the same instant as a real date-time for relative filters, sorting and date buckets.
jobs.withField(new QFieldMetaData("created_time", QFieldType.DATE_TIME).withLabel("Created"));
jobs.getField("created_at").withLabel("Created at (ISO text)");
QTableMetaData commands = table(PrintingTables.COMMAND, PrintingNames.STATION + " control history", List.of("id", "tenant_id", "device_id", "request_id", "action", "job_id", "actor", "status", "created_at", "expires_at", "result_code"));
commands.withField(new QFieldMetaData("cancelled_job_count", QFieldType.INTEGER).withLabel("Queued jobs canceled"));
commands.getField("device_id").withLabel("Print device").withPossibleValueSourceName(PrintingTables.DEVICE);
commands.getField("device_id").withLabel(PrintingNames.STATION).withPossibleValueSourceName(PrintingTables.DEVICE);
commands.getField("job_id").withLabel("Print job").withPossibleValueSourceName(PrintingTables.JOB);
commands.getField("action").withPossibleValueSourceName("printing_controlAction");
commands.getField("status").withLabel("Recorded status").withPossibleValueSourceName("printing_commandStatus");
@@ -126,7 +135,7 @@ public class PrintingQBitProducer implements QBitProducer
.withPossibleValueSourceName("printing_commandStatus").withIsQuerySelectable(true));
commands.withCustomizer(TableCustomizers.POST_QUERY_RECORD, new QCodeReference(PrintingCommandStatus.class));
commands.withRecordLabelFormatAndFields("%s", "action");
jobs.getField("printer_row_id").withLabel("Printer").withPossibleValueSourceName(PrintingTables.PRINTER);
jobs.getField("printer_row_id").withLabel(PrintingNames.PRINTER + " record").withPossibleValueSourceName(PrintingTables.PRINTER);
jobs.getField("format").withPossibleValueSourceName("printing_format");
jobs.getField("status").withPossibleValueSourceName("printing_jobStatus");
printers.getField("status").withPossibleValueSourceName("printing_printerStatus");
@@ -156,30 +165,50 @@ public class PrintingQBitProducer implements QBitProducer
printers.withRecordLabelFormatAndFields("%s", "name");
jobs.withRecordLabelFormatAndFields("%s", "title");
printers.getField("device_id").withPossibleValueSourceName(PrintingTables.DEVICE);
jobs.getField("device_id").withLabel("Print device").withPossibleValueSourceName(PrintingTables.DEVICE);
printers.getField("device_id").withLabel("Print device");
jobs.getField("device_id").withLabel(PrintingNames.STATION + " record").withPossibleValueSourceName(PrintingTables.DEVICE);
printers.getField("device_id").withLabel(PrintingNames.STATION);
instance.addPossibleValueSource(com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource.newForTable(PrintingTables.DEVICE));
instance.addPossibleValueSource(com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource.newForTable(PrintingTables.PRINTER));
instance.addPossibleValueSource(com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource.newForTable(PrintingTables.JOB));
layout(devices, List.of("name", "device_status", "control_status", "location", "facility_id", "last_seen_at"));
layout(printers, List.of("name", "location", "description", "device_id", "status", "enabled", "available", "last_seen_at"));
layout(jobs, List.of("title", "device_id", "printer_row_id", "status", "created_at", "format"));
layout(jobs, List.of("title", "printer_name", "device_name", "status", "created_time", "format"));
QTableMetaData deletions = table(PrintingTables.DELETION, "Deletion history", List.of("id", "tenant_id", "kind", "name", "computer_name", "queue_name", "facility_id", "actor", "record_id", "device_id"));
deletions.withField(new QFieldMetaData("deleted_time", QFieldType.DATE_TIME).withLabel("Deleted"));
deletions.withField(new QFieldMetaData("printer_count", QFieldType.INTEGER).withLabel(PrintingNames.PRINTERS + " deleted with it"));
deletions.withField(new QFieldMetaData("cancelled_job_count", QFieldType.INTEGER).withLabel("Queued jobs canceled"));
deletions.withField(new QFieldMetaData("unknown_job_count", QFieldType.INTEGER).withLabel("Jobs closed as outcome unknown"));
deletions.withField(new QFieldMetaData("kept_job_count", QFieldType.INTEGER).withLabel("Print jobs kept"));
deletions.getField("kind").withLabel("What was deleted").withPossibleValueSourceName("printing_deletionKind");
deletions.getField("name").withMaxLength(1024);
deletions.getField("computer_name").withLabel("Computer name").withMaxLength(1024);
deletions.getField("queue_name").withLabel("Windows queue name").withMaxLength(1024);
deletions.getField("facility_id").withLabel("Facility").withPossibleValueSourceName(devices.getField("facility_id").getPossibleValueSourceName());
deletions.getField("actor").withLabel("Deleted by");
deletions.getField("record_id").withLabel("Identifier it had");
deletions.getField("device_id").withLabel("Identifier of its " + PrintingNames.STATION_LOWER);
deletions.withRecordLabelFormatAndFields("%s", "name");
instance.addPossibleValueSource(new QPossibleValueSource().withName("printing_deletionKind").withType(QPossibleValueSourceType.ENUM)
.withEnumValues(List.of(new QPossibleValue<>("DEVICE", PrintingNames.STATION), new QPossibleValue<>("PRINTER", PrintingNames.PRINTER))));
layout(deletions, List.of("kind", "name", "computer_name", "actor", "deleted_time", "printer_count", "cancelled_job_count", "unknown_job_count", "kept_job_count"));
layout(commands, List.of("device_id", "action", "command_status", "created_at", "expires_at", "result_code", "cancelled_job_count"));
instance.addTable(devices);
instance.addTable(printers);
instance.addTable(jobs);
instance.addTable(commands);
related(instance, devices, printers, "device_id", "Printers");
instance.addTable(deletions);
related(instance, devices, printers, "device_id", PrintingNames.PRINTERS);
related(instance, devices, jobs, "device_id", "Jobs and queue");
related(instance, devices, commands, "device_id", "Control history");
related(instance, printers, jobs, "printer_row_id", "Jobs and queue");
QAppMetaData app = new QAppMetaData().withName("printing").withLabel("Printing").withIcon(new QIcon("print")).withChild(devices).withChild(printers).withChild(jobs).withChild(commands);
QAppMetaData app = new QAppMetaData().withName("printing").withLabel("Printing").withIcon(new QIcon("print")).withChild(devices).withChild(printers).withChild(jobs).withChild(commands).withChild(deletions);
app.withPermissionRules(PrintingProcesses.permissionRules());
PrintingProcesses.register(instance, app);
PrintingOperatorProcesses.register(instance);
PrintingListProcesses.register(instance);
PrintingRenderProcesses.register(instance);
PrintingDeviceProcesses.register(instance);
PrintingDeleteProcesses.register(instance);
PrintingUiApi.register(instance);
PrintingRetentionProcesses.register(instance, config);
PrintingRealtimeProcesses.register(instance, app);
@@ -26,6 +26,8 @@ import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.actions.metadata.MetaDataInput;
import com.kingsrook.qqq.backend.core.model.actions.metadata.TableMetaDataInput;
import com.kingsrook.qqq.backend.core.model.actions.reporting.ExportInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
@@ -53,12 +55,26 @@ public class PrintingReadPermission extends PrintingAdminPermission
{
super.checkPermissionsThrowing(input, metadata);
}
if(input != null && !(input instanceof QueryInput) && !(input instanceof GetInput) && !(input instanceof CountInput)
&& !(input instanceof MetaDataInput) && !(input instanceof TableMetaDataInput))
if(input != null && !isRead(input))
{
throw new QPermissionDeniedException("Use the printing management service to change operational state");
}
}
/***************************************************************************
** The inputs that only read rows or describe the table. QQQ does not tell a
** custom checker whether it is being asked about a read or a write, so the
** input's own type decides: a query, a get, a count, an aggregate (a grouped
** read: counts and sums by a field) and an export (a query written to a file)
** read; anything else is treated as a write and refused.
***************************************************************************/
static boolean isRead(AbstractActionInput input)
{
return input instanceof QueryInput || input instanceof GetInput || input instanceof CountInput || input instanceof AggregateInput
|| input instanceof ExportInput || input instanceof MetaDataInput || input instanceof TableMetaDataInput;
}
}
@@ -53,7 +53,7 @@ public class PrintingRealtimeProcesses implements BackendStep
new Setting("maxPdfMiB", "maxPdfBytes", "Maximum PDF size (MiB)", MEBIBYTE),
new Setting("maxZplMiB", "maxZplBytes", "Maximum ZPL size (MiB)", MEBIBYTE),
new Setting("maxCopies", "maxCopies", "Maximum copies per job", 1),
new Setting("maxStagedMiB", "maxStagedBytes", "Maximum staged documents on each device (MiB)", MEBIBYTE));
new Setting("maxStagedMiB", "maxStagedBytes", "Maximum staged documents on each " + PrintingNames.STATION_LOWER + " (MiB)", MEBIBYTE));
private static final List<Setting> TRANSFERS = List.of(
new Setting("maxConcurrentDownloads", "maxConcurrentDownloads", "Concurrent document downloads", 1),
new Setting("maxConcurrentPrints", "maxConcurrentPrints", "Concurrent printer submissions", 1),
@@ -76,16 +76,16 @@ public class PrintingRealtimeProcesses implements BackendStep
new Setting("maxQueuedFrames", "maxQueuedFrames", "Maximum queued control messages", 1),
new Setting("maxQueuedControlMiB", "maxQueuedFrameBytes", "Maximum queued control message data (MiB)", MEBIBYTE));
private static final List<Setting> ADMISSION_COUNTS = List.of(
new Setting("deviceOutstandingJobs", "deviceOutstandingJobs", "Outstanding jobs per device", 1),
new Setting("deviceOutstandingJobs", "deviceOutstandingJobs", "Outstanding jobs per " + PrintingNames.STATION_LOWER, 1),
new Setting("tenantOutstandingJobs", "tenantOutstandingJobs", "Outstanding jobs across this tenant", 1),
new Setting("deviceJobsPerHour", "deviceJobsPerHour", "Jobs allocated per device per hour", 1),
new Setting("deviceJobsPerHour", "deviceJobsPerHour", "Jobs allocated per " + PrintingNames.STATION_LOWER + " per hour", 1),
new Setting("tenantJobsPerHour", "tenantJobsPerHour", "Jobs allocated across this tenant per hour", 1),
new Setting("devicePrinters", "devicePrinters", "Printers per device", 1),
new Setting("devicePrinters", "devicePrinters", PrintingNames.PRINTERS + " per " + PrintingNames.STATION_LOWER, 1),
new Setting("tenantPrinters", "tenantPrinters", "Printers across this tenant", 1));
private static final List<Setting> ADMISSION_STORAGE = List.of(
new Setting("deviceOutstandingMiB", "deviceOutstandingBytes", "Outstanding document data per device (MiB)", MEBIBYTE),
new Setting("deviceOutstandingMiB", "deviceOutstandingBytes", "Outstanding document data per " + PrintingNames.STATION_LOWER + " (MiB)", MEBIBYTE),
new Setting("tenantOutstandingMiB", "tenantOutstandingBytes", "Outstanding document data across this tenant (MiB)", MEBIBYTE),
new Setting("deviceRetainedGiB", "deviceRetainedBytes", "Retained document data per device (GiB)", 1024 * MEBIBYTE),
new Setting("deviceRetainedGiB", "deviceRetainedBytes", "Retained document data per " + PrintingNames.STATION_LOWER + " (GiB)", 1024 * MEBIBYTE),
new Setting("tenantRetainedGiB", "tenantRetainedBytes", "Retained document data across this tenant (GiB)", 1024 * MEBIBYTE));
@@ -150,9 +150,9 @@ public class PrintingRealtimeProcesses implements BackendStep
output.addValue(setting.input(), BigDecimal.valueOf(policy.getLong(setting.policy())).divide(BigDecimal.valueOf(setting.unit())));
}
}
output.addValue("transport", "The device opens a two-way WebSocket to this server. Documents use separate HTTPS transfers; job and command polling is not used.");
output.addValue("transport", "The " + PrintingNames.STATION_LOWER + " opens a two-way WebSocket to this server. Documents use separate HTTPS transfers; job and command polling is not used.");
output.addValue("scope", admission ? "These limits apply to this tenant. Capacity is checked when jobs and printers are admitted. Changing a limit does not remove existing jobs, documents or history."
: "These settings apply to this tenant. Saved changes are sent to connected devices. Unsupported device limits require an explicit configuration correction; they are not silently reduced.");
: "These settings apply to this tenant. Saved changes are sent to connected " + PrintingNames.STATIONS_LOWER + ". Unsupported " + PrintingNames.STATION_LOWER + " limits require an explicit configuration correction; they are not silently reduced.");
output.addValue("revisionLabel", admission ? "Tenant admission policy" : "Policy revision " + policy.getLong("revision"));
}
else if("execute".equals(input.getStepName()))
@@ -167,7 +167,7 @@ public class PrintingRealtimeProcesses implements BackendStep
}
JSONObject saved = admission ? service.saveAdmissionSettings(requested) : service.saveRealtimeSettings(requested);
output.addValue("result", admission ? "Queue, rate, printer and storage limits saved for this tenant. Existing jobs and history were preserved."
: "Connection and document limits saved as policy revision " + saved.getLong("revision") + ". Connected devices receive the update over their existing WebSocket; no documents or job history were removed.");
: "Connection and document limits saved as policy revision " + saved.getLong("revision") + ". Connected " + PrintingNames.STATIONS_LOWER + " receive the update over their existing WebSocket; no documents or job history were removed.");
}
else
{
@@ -27,6 +27,7 @@ import java.time.Duration;
import java.time.Instant;
import java.util.ArrayDeque;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
@@ -59,6 +60,8 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
{
public static final String PATH = "/qqq/v1/qbit/printing/device/realtime";
public static final String SUBPROTOCOL = "qqq-printing.v1";
/** Device identifier resolved on the authorized HTTP upgrade and carried to the WebSocket callbacks. */
static final String DEVICE_ATTRIBUTE = "printingRealtimeDevice";
private final PrintingService service;
private final Map<String, Channel> channels = new ConcurrentHashMap<>();
private final ExecutorService workers = Executors.newVirtualThreadPerTaskExecutor();
@@ -97,6 +100,10 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
{
validateUpgrade(context);
authorize(context);
// The HTTP upgrade context resolves header names case-insensitively; the WebSocket context does not, and an ingress
// that lowercases header names would otherwise leave the device null on connect. Resolve it once here, after
// authorization, and carry it on the connection.
context.attribute(DEVICE_ATTRIBUTE, Objects.requireNonNull(context.header(PrintingAgentAuthorization.DEVICE_HEADER), "authorized upgrade without a device header"));
String cookieName = context.cookie("sessionId") != null && !context.cookie("sessionId").isBlank() ? "sessionId" : "sessionUUID";
context.attribute("printingRealtimeCredential", new Credential(cookieName, context.cookie(cookieName), context.header(PrintingAgentAuthorization.TOKEN_HEADER), context.header("X-QQQ-Client")));
}
@@ -113,7 +120,7 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
{
socket.onConnect(context ->
{
String device = context.header(PrintingAgentAuthorization.DEVICE_HEADER);
String device = Objects.requireNonNull(context.attribute(DEVICE_ATTRIBUTE), "WebSocket connected without the authorized device attribute");
Channel next = new Channel(context, device, context.attribute("printingRealtimeCredential"));
next.policy = service.realtimePolicy();
Channel previous = channels.put(device, next);
@@ -149,7 +156,8 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
@SuppressWarnings("ReferenceEquality")
private @Nullable Channel current(WsContext context)
{
Channel channel = channels.get(context.header(PrintingAgentAuthorization.DEVICE_HEADER));
String device = context.attribute(DEVICE_ATTRIBUTE);
Channel channel = device == null ? null : channels.get(device);
return channel != null && channel.socket.session == context.session ? channel : null;
}
@@ -319,6 +327,7 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
private long appliedSettings = -1;
private long sentState = -1;
private long sentSettings = -1;
private @Nullable String sentPrinters;
/** Bind one mailbox to its authenticated socket. */
@@ -482,8 +491,8 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
{
if(hello != null || !payload.keySet().equals(Set.of("agentVersion", "capabilities", "lastAppliedRevision", "lastSettingsRevision", "limits"))
|| !payload.getJSONArray("capabilities").toList().contains("realtime-v1")
|| payload.getJSONArray("capabilities").length() > 4
|| !Set.of("realtime-v1", "REFRESH_INVENTORY", "CANCEL_SPOOL", "printer-media-v1").containsAll(payload.getJSONArray("capabilities").toList()))
|| payload.getJSONArray("capabilities").length() > 5
|| !Set.of("realtime-v1", "REFRESH_INVENTORY", "CANCEL_SPOOL", "printer-media-v1", "printer-approval-v1").containsAll(payload.getJSONArray("capabilities").toList()))
{
throw new PrintingException(409, "Realtime capability is required");
}
@@ -523,7 +532,13 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
changed();
}
case "HEARTBEAT" -> service.heartbeat(device, payload);
case "INVENTORY" -> service.inventory(device, payload.getJSONArray("printers"), PrintingAgentAuthorization.inventory(device));
case "INVENTORY" ->
{
service.inventory(device, payload.getJSONArray("printers"), PrintingAgentAuthorization.inventory(device));
send("ACK", new JSONObject().put("acknowledgedMessageId", messageId));
printers(true, true);
return;
}
case "PING" ->
{
service.realtimeContact(device);
@@ -606,7 +621,7 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
JSONArray capabilities = new JSONArray();
for(Object value : hello.getJSONArray("capabilities"))
{
if(Set.of("REFRESH_INVENTORY", "CANCEL_SPOOL", "printer-media-v1").contains(value))
if(Set.of("REFRESH_INVENTORY", "CANCEL_SPOOL", "printer-media-v1", "printer-approval-v1").contains(value))
{
capabilities.put(value);
}
@@ -660,6 +675,7 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
send("DESIRED_STATE", desired);
}
}
printers(!initial, false);
if(!ready(sentSettings))
{
return;
@@ -674,6 +690,34 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
}
/***************************************************************************
** Push the complete approval state of the device's current inventory to a
** connection that advertised printer-approval-v1. An accepted INVENTORY
** always sends it; a commit wakeup sends it only when approval changed since
** the last frame, and the initial snapshot only records the baseline so an
** unrelated wakeup never produces a frame. Other connections never receive it.
***************************************************************************/
private void printers(boolean push, boolean always) throws Exception
{
if(!helloPayload().getJSONArray("capabilities").toList().contains("printer-approval-v1"))
{
return;
}
JSONObject payload = new JSONObject().put("printers", service.printerApprovals(device));
String current = payload.toString();
boolean changed;
synchronized(this)
{
changed = always || (sentPrinters != null && !current.equals(sentPrinters));
sentPrinters = current;
}
if(push && changed)
{
send("PRINTERS", payload);
}
}
/** Revalidate authority and await one bounded asynchronous socket write. */
@@ -167,6 +167,7 @@ public final class PrintingRecordProcesses
{
QQueryFilter filter = new QQueryFilter(new QFilterCriteria("enabled", QCriteriaOperator.EQUALS, 1))
.withCriteria(new QFilterCriteria("available", QCriteriaOperator.EQUALS, 1))
.withCriteria(new QFilterCriteria("removed_at", QCriteriaOperator.IS_BLANK))
.withCriteria(new QFilterCriteria("formats", QCriteriaOperator.CONTAINS, "PDF"));
return new QFieldMetaData(PRINTER_FIELD, QFieldType.STRING).withLabel("Printer").withIsRequired(true)
.withPossibleValueSourceName(PrintingTables.PRINTER).withPossibleValueSourceFilter(filter);
@@ -188,7 +189,7 @@ public final class PrintingRecordProcesses
if(!Integer.valueOf(1).equals(printer.getValueInteger("enabled")) || !Integer.valueOf(1).equals(printer.getValueInteger("available"))
|| printer.getValueString("formats") == null || !new org.json.JSONArray(printer.getValueString("formats")).toList().contains("PDF"))
{
throw new QException("Printer " + printer.getValueString("name") + " must be exposed by its device, approved, and support PDF");
throw new QException("Printer " + printer.getValueString("name") + " must be exposed by its " + PrintingNames.STATION_LOWER + ", approved, and support PDF");
}
return printer;
}
@@ -177,7 +177,7 @@ public class PrintingRetentionProcesses implements BackendStep
else if("execute".equals(input.getStepName()) && (RUN_NOW.equals(input.getProcessName()) || scheduled))
{
int removed = service.runRetention();
output.addValue("result", "Removed " + removed + " eligible documents or abandoned uploads. Job receipts and uncertain or active work were preserved. Cleanup processes one bounded batch per run.");
output.addValue("result", "Removed " + removed + " eligible documents, abandoned uploads or purged removed " + PrintingNames.PRINTERS_LOWER + " and " + PrintingNames.STATIONS_LOWER + " that no job references. Job receipts and uncertain or active work were preserved. Cleanup processes one bounded batch per run.");
}
else
{
@@ -212,7 +212,7 @@ public class PrintingRetentionProcesses implements BackendStep
output.addValue("progress", "Eligible documents waiting: " + settings.optLong("eligibleBacklog", 0)
+ "; last run: " + (lastRun.isBlank() ? "Not run yet" : lastRun) + "; removed: " + settings.optInt("lastRunCount", 0)
+ (settings.optString("lastRunError", "").isBlank() ? "" : "; last run problem: " + settings.optString("lastRunError")));
output.addValue("safety", "Cleanup removes only old successful-terminal document content. Active, failed and uncertain jobs, receipt metadata and deduplication records remain. Removed documents cannot be reprinted from history. Continue to edit the policy or run the selected cleanup action.");
output.addValue("safety", "Cleanup removes only old successful-terminal document content, and purges removed " + PrintingNames.PRINTERS_LOWER + " and " + PrintingNames.STATIONS_LOWER + " that no job references. Active, failed and uncertain jobs, receipt metadata and deduplication records remain. Removed documents cannot be reprinted from history. Continue to edit the policy or run the selected cleanup action.");
}
@@ -37,10 +37,13 @@ import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.uber.nullaway.annotations.EnsuresNonNull;
import org.json.JSONArray;
import org.json.JSONObject;
import org.jspecify.annotations.Nullable;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
@@ -50,6 +53,10 @@ import org.jspecify.annotations.Nullable;
public class PrintingService
{
static final String CANCELED_BEFORE_DISPATCH = "Canceled by operator before dispatch";
private static final QLogger LOG = QLogger.getLogger(PrintingService.class);
// The jobs of one printer: by its row, or by station and queue identity for a job written before printer_row_id existed. Parameters: printer row id, device id, printer_id.
private static final String PRINTER_JOBS = "(printer_row_id=? OR (printer_row_id IS NULL AND device_id=? AND printer_id=?))";
private static final String SCHEMA_STEP_MISSING = "Print jobs are still tied to their print station by a database foreign key (printing_job.device_id). Apply the schema step of this qbit-printing version, then delete again.";
private static final String JOB_COLUMNS = "id, tenant_id, device_id, printer_id, title, format, content_hash, content_length, options_json, idempotency_key, created_at, status, message, spooler_job_id, printer_row_id";
private final DataSource dataSource;
private final String tenantId;
@@ -281,6 +288,8 @@ public class PrintingService
"CREATE TABLE IF NOT EXISTS printing_printer (id VARCHAR(36) PRIMARY KEY, tenant_id VARCHAR(200) NOT NULL, device_id VARCHAR(36) NOT NULL REFERENCES printing_device(id), printer_id VARCHAR(200) NOT NULL, name VARCHAR(300) NOT NULL, driver_name VARCHAR(300), port_name VARCHAR(300), status VARCHAR(40) NOT NULL, formats VARCHAR(100) NOT NULL, enabled INTEGER NOT NULL, last_seen_at VARCHAR(40) NOT NULL, UNIQUE(device_id, printer_id))",
"ALTER TABLE printing_printer ADD COLUMN IF NOT EXISTS available INTEGER DEFAULT 0 NOT NULL",
"ALTER TABLE printing_printer ADD COLUMN IF NOT EXISTS media_profiles_json TEXT DEFAULT '[]' NOT NULL",
"ALTER TABLE printing_printer ADD COLUMN IF NOT EXISTS removed_at VARCHAR(40)",
"ALTER TABLE printing_device ADD COLUMN IF NOT EXISTS removed_at VARCHAR(40)",
"CREATE TABLE IF NOT EXISTS printing_job (id VARCHAR(36) PRIMARY KEY, tenant_id VARCHAR(200) NOT NULL, device_id VARCHAR(36) NOT NULL REFERENCES printing_device(id), printer_id VARCHAR(200) NOT NULL, title VARCHAR(300) NOT NULL, format VARCHAR(10) NOT NULL, content_hash VARCHAR(64) NOT NULL, content_base64 TEXT NOT NULL, content_length INTEGER NOT NULL, options_json VARCHAR(2000) NOT NULL, idempotency_key VARCHAR(300) NOT NULL, created_at VARCHAR(40) NOT NULL, status VARCHAR(40) NOT NULL, message VARCHAR(2000), spooler_job_id VARCHAR(100), UNIQUE(tenant_id, idempotency_key))",
"CREATE TABLE IF NOT EXISTS printing_event (id VARCHAR(100) PRIMARY KEY, job_id VARCHAR(36) NOT NULL REFERENCES printing_job(id), status VARCHAR(40) NOT NULL, event_json VARCHAR(4000) NOT NULL, created_at VARCHAR(40) NOT NULL)",
"ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS printer_row_id VARCHAR(36)",
@@ -292,6 +301,8 @@ public class PrintingService
"ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS content_available INTEGER DEFAULT 1 NOT NULL",
"ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS completed_at VARCHAR(40)",
"UPDATE printing_job SET completed_at=(SELECT MAX(e.created_at) FROM printing_event e WHERE e.job_id=printing_job.id AND e.status IN ('SPOOLED','SPOOLER_COMPLETED')) WHERE completed_at IS NULL AND status IN ('SPOOLED','SPOOLER_COMPLETED')",
"ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS created_time TIMESTAMP",
"UPDATE printing_job SET created_time=CAST(REPLACE(REPLACE(created_at, 'T', ' '), 'Z', '') AS TIMESTAMP) WHERE created_time IS NULL",
"ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS content_storage_table VARCHAR(200)",
"ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS content_reference VARCHAR(500)",
"ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS content_binding VARCHAR(64)",
@@ -316,8 +327,17 @@ public class PrintingService
"ALTER TABLE printing_printer ADD COLUMN IF NOT EXISTS location VARCHAR(512)",
"UPDATE printing_device SET computer_name=name WHERE computer_name IS NULL",
"UPDATE printing_printer SET queue_name=name WHERE queue_name IS NULL",
"ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS device_name VARCHAR(1024)",
"ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS computer_name VARCHAR(1024)",
"ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS printer_name VARCHAR(1024)",
"ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS queue_name VARCHAR(1024)",
"UPDATE printing_job SET device_name=(SELECT d.name FROM printing_device d WHERE d.id=printing_job.device_id), computer_name=(SELECT d.computer_name FROM printing_device d WHERE d.id=printing_job.device_id) WHERE device_name IS NULL AND EXISTS (SELECT 1 FROM printing_device d WHERE d.id=printing_job.device_id)",
"UPDATE printing_job SET printer_name=(SELECT p.name FROM printing_printer p WHERE p.id=printing_job.printer_row_id), queue_name=(SELECT p.queue_name FROM printing_printer p WHERE p.id=printing_job.printer_row_id) WHERE printer_name IS NULL AND EXISTS (SELECT 1 FROM printing_printer p WHERE p.id=printing_job.printer_row_id)",
"CREATE TABLE IF NOT EXISTS printing_deletion (id VARCHAR(36) PRIMARY KEY, tenant_id VARCHAR(200) NOT NULL, kind VARCHAR(20) NOT NULL, record_id VARCHAR(36) NOT NULL, name VARCHAR(1024) NOT NULL, computer_name VARCHAR(1024), queue_name VARCHAR(1024), facility_id VARCHAR(200), device_id VARCHAR(36), actor VARCHAR(200) NOT NULL, deleted_time TIMESTAMP NOT NULL, printer_count INTEGER DEFAULT 0 NOT NULL, cancelled_job_count INTEGER DEFAULT 0 NOT NULL, unknown_job_count INTEGER DEFAULT 0 NOT NULL, kept_job_count INTEGER DEFAULT 0 NOT NULL)",
"CREATE INDEX IF NOT EXISTS printing_deletion_tenant_idx ON printing_deletion(tenant_id, deleted_time, id)",
"CREATE INDEX IF NOT EXISTS printing_job_dispatch_idx ON printing_job(tenant_id, device_id, status, created_at, id)",
"CREATE INDEX IF NOT EXISTS printing_job_history_idx ON printing_job(tenant_id, created_at, id)",
"CREATE INDEX IF NOT EXISTS printing_job_created_time_idx ON printing_job(tenant_id, created_time, id)",
"CREATE INDEX IF NOT EXISTS printing_job_retention_completed_idx ON printing_job(tenant_id, content_available, status, completed_at, id)",
"CREATE INDEX IF NOT EXISTS printing_event_job_idx ON printing_event(job_id)",
"CREATE INDEX IF NOT EXISTS printing_device_tenant_idx ON printing_device(tenant_id, last_seen_at)",
@@ -336,6 +356,7 @@ public class PrintingService
PrintingAdmission.initialize(connection, tenantId);
PrintingDeviceControls.initialize(connection);
update(connection, "ALTER TABLE printing_device ADD COLUMN IF NOT EXISTS bound_user_id VARCHAR(200)");
dropJobDeviceForeignKey(connection);
widenColumn(connection, "printing_device", "name", 1024);
widenColumn(connection, "printing_enrollment", "device_name", 1024);
widenColumn(connection, "printing_printer", "name", 1024);
@@ -351,6 +372,62 @@ public class PrintingService
/***************************************************************************
** A print job outlives the print station it was sent to: once a station can
** be deleted, printing_job.device_id is the identifier the station had, not
** a reference that must resolve. Drop the foreign key the first schema
** created (its name is database-generated, so it is found, not assumed).
***************************************************************************/
private static void dropJobDeviceForeignKey(Connection connection) throws SQLException
{
for(String constraint : jobDeviceForeignKeys(connection))
{
try(PreparedStatement statement = connection.prepareStatement("ALTER TABLE printing_job DROP CONSTRAINT \"" + constraint + "\""))
{
statement.execute();
}
}
}
/***************************************************************************
** The names of the foreign keys from printing_job to printing_device in the
** connection's schema; none once this version's schema step has run.
***************************************************************************/
private static Set<String> jobDeviceForeignKeys(Connection connection) throws SQLException
{
List<String> jobTables = new ArrayList<>();
try(ResultSet tables = connection.getMetaData().getTables(connection.getCatalog(), connection.getSchema(), "%", new String[] {"TABLE"}))
{
while(tables.next())
{
if("printing_job".equalsIgnoreCase(tables.getString("TABLE_NAME")))
{
jobTables.add(tables.getString("TABLE_NAME"));
}
}
}
Set<String> constraints = new java.util.LinkedHashSet<>();
for(String jobTable : jobTables)
{
try(ResultSet keys = connection.getMetaData().getImportedKeys(connection.getCatalog(), connection.getSchema(), jobTable))
{
while(keys.next())
{
String name = keys.getString("FK_NAME");
if("printing_device".equalsIgnoreCase(keys.getString("PKTABLE_NAME")) && name != null && name.matches("[A-Za-z0-9_$]+"))
{
constraints.add(name);
}
}
}
}
return constraints;
}
/***************************************************************************
** Widen fixed application columns without changing identifiers or content.
***************************************************************************/
@@ -782,6 +859,11 @@ public class PrintingService
String now = Instant.now().toString();
String driver = optionalText(printer.optString("driverName"), "driverName", 1024);
String port = optionalText(printer.optString("portName"), "portName", 1024);
if(update(connection, "UPDATE printing_printer SET removed_at=NULL, enabled=0 WHERE device_id=? AND printer_id=? AND removed_at IS NOT NULL", deviceId, printerId) > 0)
{
// A removed queue shared again re-enters the inventory as it was first discovered: exposed, awaiting approval, history intact.
LOG.info("A removed printer re-appeared in the device inventory and awaits approval again", logPair("deviceId", deviceId), logPair("printerId", printerId));
}
int changed = update(connection, "UPDATE printing_printer SET queue_name=?, name=COALESCE(NULLIF(display_name,''),?), driver_name=?, port_name=?, status=?, formats=?, last_seen_at=?, available=1 WHERE device_id=? AND printer_id=?", name, name, driver, port, status, formats, now, deviceId, printerId);
if(changed == 0)
{
@@ -804,15 +886,66 @@ public class PrintingService
/***************************************************************************
** Approval of every printer in the device's latest accepted inventory, for
** the realtime PRINTERS frame: printerId and enabled per available queue,
** nothing else. A withdrawn queue is not listed even though its approval and
** history are retained; a removed queue is never listed.
***************************************************************************/
public JSONArray printerApprovals(String deviceId) throws SQLException
{
try(Connection connection = dataSource.getConnection())
{
requireDevice(connection, deviceId);
JSONArray result = new JSONArray();
for(JSONObject row : query(connection, "SELECT printer_id, enabled FROM printing_printer WHERE tenant_id=? AND device_id=? AND available=1 AND removed_at IS NULL ORDER BY printer_id LIMIT 200", tenantId, deviceId))
{
result.put(new JSONObject().put("printerId", row.getString("printer_id")).put("enabled", row.getInt("enabled") == 1));
}
return result;
}
}
/***************************************************************************
** Enable a queue explicitly; discovery alone never grants print access.
** Every approval path (admin route, native processes, application code)
** converges here, so the commit wakeup reaches the device's live connection.
***************************************************************************/
public void enablePrinter(String deviceId, String printerId, boolean enabled) throws SQLException
{
try(Connection connection = dataSource.getConnection())
{
requireDevice(connection, deviceId);
if(update(connection, "UPDATE printing_printer SET enabled=? WHERE device_id=? AND printer_id=?", enabled ? 1 : 0, deviceId, printerId) == 0)
if(update(connection, "UPDATE printing_printer SET enabled=? WHERE device_id=? AND printer_id=? AND removed_at IS NULL", enabled ? 1 : 0, deviceId, printerId) == 0)
{
if(!query(connection, "SELECT id FROM printing_printer WHERE device_id=? AND printer_id=?", deviceId, printerId).isEmpty())
{
// The HTTP printer approval route returns this text; an existing client reads it as it always was.
throw new PrintingException(409, "Printer was removed; share it from the device again before changing its approval");
}
throw new PrintingException(404, "Printer not found");
}
}
realtimeBus.publish(tenantId, deviceId);
}
/***************************************************************************
** Archive a printer: it leaves the Printers view, the operator catalog, claims
** and the PRINTERS frame, but its row and job history stay. A later inventory
** that shares the same queue again brings it back exposed and not approved.
** Removing an already removed printer changes nothing.
***************************************************************************/
public void removePrinter(String deviceId, String printerId) throws SQLException
{
try(Connection connection = dataSource.getConnection())
{
requireDeviceRow(connection, deviceId);
if(update(connection, "UPDATE printing_printer SET removed_at=?, enabled=0, available=0 WHERE tenant_id=? AND device_id=? AND printer_id=? AND removed_at IS NULL", Instant.now().toString(), tenantId, deviceId, printerId) == 0
&& query(connection, "SELECT id FROM printing_printer WHERE tenant_id=? AND device_id=? AND printer_id=?", tenantId, deviceId, printerId).isEmpty())
{
throw new PrintingException(404, "Printer not found");
}
@@ -822,6 +955,419 @@ public class PrintingService
/***************************************************************************
** Archive a device whose access has already been revoked: the device and all
** of its printers leave the default views and every dispatch path, while job
** history stays readable. Revocation is the existing REVOKE control action
** (credential rotation, disconnect, pending-command outcome); this method
** never performs it and refuses a device that still holds a live credential.
** A removed device cannot be restored; a new enrollment creates a new device.
***************************************************************************/
public void removeDevice(String deviceId) throws SQLException
{
try(Connection connection = dataSource.getConnection())
{
JSONObject device = requireDeviceRow(connection, deviceId);
if(!device.has("revoked_at"))
{
throw new PrintingException(409, "Revoke the " + PrintingNames.STATION_LOWER + "'s access before removing it");
}
String now = Instant.now().toString();
update(connection, "UPDATE printing_device SET removed_at=? WHERE tenant_id=? AND id=? AND removed_at IS NULL", now, tenantId, deviceId);
update(connection, "UPDATE printing_printer SET removed_at=?, enabled=0, available=0 WHERE tenant_id=? AND device_id=? AND removed_at IS NULL", now, tenantId, deviceId);
}
realtimeBus.publish(tenantId, deviceId);
}
/***************************************************************************
** What deleting a print station would do, without changing anything: the
** printers that go with it, the queued jobs that would be canceled, the jobs
** already sent to it that would be closed as outcome unknown, the jobs whose
** history is kept, and "refusal" when it cannot be deleted now.
***************************************************************************/
public JSONObject deviceDeletionPlan(String deviceId) throws SQLException
{
try(Connection connection = dataSource.getConnection())
{
return deviceDeletionPlan(connection, deviceId, false);
}
}
/***************************************************************************
** What deleting one printer would do, without changing anything.
***************************************************************************/
public JSONObject printerDeletionPlan(String printerRowId) throws SQLException
{
try(Connection connection = dataSource.getConnection())
{
return printerDeletionPlan(connection, printerRowId);
}
}
/***************************************************************************
** Delete a print station and its printers for good.
**
** All of it is one transaction that takes the tenant admission lock and then
** the station's row lock, the lock a claim, a job event and a heartbeat take
** before they change anything. Nothing the station does can therefore land
** between the decision and the delete, and a delete that is refused or fails
** changes nothing: the station is not left revoked.
**
** 1. Refused (409) while the station is connected and has jobs it has been
** sent but not reported on: their result would be lost. This is decided
** under the locks, before anything is revoked.
** 2. Its credential is revoked through the REVOKE control, exactly as Revoke
** access does, so the computer is disconnected and cannot come back as
** this identity. An already revoked station skips this step.
** 3. Every job of the station gets the station and printer names it was
** printed under (if it does not have them yet); queued jobs become FAILED
** (canceled before dispatch); jobs sent to the station without a reported
** result become OUTCOME_UNKNOWN; each of those gets a job event naming the
** actor; the deletion is written to printing_deletion (one row for the
** station, one for each printer); the control receipts, the printers and
** the station row are deleted.
**
** Jobs are never deleted and keep device_id, printer_id and printer_row_id
** as the identifiers the station and printer had. A computer enrolled again
** is a new station with a new identifier. Anything that arrives later from
** the deleted station (heartbeat, claim, event, control poll or receipt) is
** answered "Device not found".
***************************************************************************/
public JSONObject deleteDevice(String deviceId, String actor) throws SQLException
{
requireActor(actor);
JSONObject result;
try(Connection connection = dataSource.getConnection())
{
// Schema metadata only, read before any lock is held.
boolean jobsReferenceStation = !jobDeviceForeignKeys(connection).isEmpty();
PrintingAdmission.initializeTenant(connection, tenantId);
connection.setAutoCommit(false);
try
{
PrintingAdmission.lock(connection, tenantId);
JSONObject plan = deviceDeletionPlan(connection, deviceId, true);
if(jobsReferenceStation && plan.getLong("jobs") > 0)
{
throw new PrintingException(409, SCHEMA_STEP_MISSING);
}
// The station row is locked: a claim or a job event waits for this transaction, so what is decided here still holds when the station is revoked and deleted below.
refuseIfPlanned(plan);
boolean revoked = plan.getBoolean("credentialLive");
if(revoked)
{
revokeBeforeDelete(connection, deviceId, actor);
}
Instant now = Instant.now();
String name = plan.getString("name");
String computer = plan.getString("computerName");
update(connection, "UPDATE printing_job SET device_name=COALESCE(device_name,?), computer_name=COALESCE(computer_name,?) WHERE tenant_id=? AND device_id=? AND (device_name IS NULL OR computer_name IS NULL)", name, computer, tenantId, deviceId);
List<JSONObject> printers = query(connection, "SELECT id, printer_id, name, queue_name FROM printing_printer WHERE tenant_id=? AND device_id=? ORDER BY name, id", tenantId, deviceId);
for(JSONObject printer : printers)
{
namePrinterJobs(connection, printer, deviceId);
}
int[] closed = closeOpenJobs(connection, "device_id=?", new Object[] {deviceId}, PrintingNames.STATION_LOWER, actor, now);
long kept = query(connection, "SELECT COUNT(*) AS n FROM printing_job WHERE tenant_id=? AND device_id=?", tenantId, deviceId).getFirst().getLong("n");
for(JSONObject printer : printers)
{
recordDeletion(connection, "PRINTER", printer.getString("id"), printer.getString("name"), computer, printer.optString("queue_name", printer.getString("name")), plan.optString("facilityId", null), deviceId, actor, now, 0, 0, 0, 0);
}
recordDeletion(connection, "DEVICE", deviceId, name, computer, null, plan.optString("facilityId", null), null, actor, now, printers.size(), closed[0], closed[1], kept);
update(connection, "DELETE FROM printing_device_command WHERE tenant_id=? AND device_id=?", tenantId, deviceId);
update(connection, "DELETE FROM printing_printer WHERE tenant_id=? AND device_id=?", tenantId, deviceId);
// The retained-byte counter stays while stored documents of this station's jobs are still charged to it; cleanup removes it afterwards.
update(connection, "DELETE FROM printing_resource_usage WHERE tenant_id=? AND device_id=? AND retained_bytes=0", tenantId, deviceId);
update(connection, "DELETE FROM printing_device WHERE tenant_id=? AND id=?", tenantId, deviceId);
connection.commit();
result = new JSONObject().put("deleted", true).put("deviceId", deviceId).put("name", name).put("printers", printers.size())
.put("cancelledJobs", closed[0]).put("unknownJobs", closed[1]).put("keptJobs", kept).put("credentialRevoked", revoked);
}
catch(Exception exception)
{
connection.rollback();
refuseIfSchemaStepMissing(exception);
throw exception;
}
}
LOG.info("A print station was deleted", logPair("tenantId", tenantId), logPair("deviceId", deviceId), logPair("actor", actor), logPair("printers", result.getInt("printers")),
logPair("cancelledJobs", result.getInt("cancelledJobs")), logPair("unknownJobs", result.getInt("unknownJobs")));
realtimeBus.publish(tenantId, deviceId);
return result;
}
/***************************************************************************
** Delete one printer for good, leaving its print station in service.
**
** Refused (409) while its station is connected and has jobs for this printer
** that it has been sent but not reported on. Otherwise, in one transaction:
** the printer's jobs get the printer and station names they were printed
** under (if missing); its queued jobs become FAILED (canceled before
** dispatch); jobs sent without a reported result become OUTCOME_UNKNOWN;
** the deletion is written to printing_deletion; the printer row is deleted.
**
** A queue the station still shares is reported again by its next inventory
** and comes back as a new printer, exposed and not approved.
***************************************************************************/
public JSONObject deletePrinter(String printerRowId, String actor) throws SQLException
{
requireActor(actor);
JSONObject result;
String deviceId;
try(Connection connection = dataSource.getConnection())
{
PrintingAdmission.initializeTenant(connection, tenantId);
connection.setAutoCommit(false);
try
{
PrintingAdmission.lock(connection, tenantId);
deviceId = printerDeletionPlan(connection, printerRowId).getString("deviceId");
query(connection, "SELECT id FROM printing_device WHERE tenant_id=? AND id=? FOR UPDATE", tenantId, deviceId);
JSONObject plan = printerDeletionPlan(connection, printerRowId);
refuseIfPlanned(plan);
Instant now = Instant.now();
JSONObject printer = new JSONObject().put("id", printerRowId).put("printer_id", plan.getString("printerKey")).put("name", plan.getString("name")).put("queue_name", plan.getString("queueName"));
update(connection, "UPDATE printing_job SET device_name=COALESCE(device_name,?), computer_name=COALESCE(computer_name,?) WHERE tenant_id=? AND " + PRINTER_JOBS, plan.getString("stationName"), plan.getString("computerName"), tenantId, printerRowId, deviceId, plan.getString("printerKey"));
namePrinterJobs(connection, printer, deviceId);
int[] closed = closeOpenJobs(connection, PRINTER_JOBS, new Object[] {printerRowId, deviceId, plan.getString("printerKey")}, PrintingNames.PRINTER_LOWER, actor, now);
long kept = query(connection, "SELECT COUNT(*) AS n FROM printing_job WHERE tenant_id=? AND " + PRINTER_JOBS, tenantId, printerRowId, deviceId, plan.getString("printerKey")).getFirst().getLong("n");
recordDeletion(connection, "PRINTER", printerRowId, plan.getString("name"), plan.getString("computerName"), plan.getString("queueName"), plan.optString("facilityId", null), deviceId, actor, now, 0, closed[0], closed[1], kept);
update(connection, "DELETE FROM printing_printer WHERE tenant_id=? AND id=?", tenantId, printerRowId);
connection.commit();
result = new JSONObject().put("deleted", true).put("printerId", printerRowId).put("deviceId", deviceId).put("name", plan.getString("name"))
.put("cancelledJobs", closed[0]).put("unknownJobs", closed[1]).put("keptJobs", kept);
}
catch(Exception exception)
{
connection.rollback();
throw exception;
}
}
LOG.info("A printer was deleted", logPair("tenantId", tenantId), logPair("printerRowId", printerRowId), logPair("deviceId", deviceId), logPair("actor", actor),
logPair("cancelledJobs", result.getInt("cancelledJobs")), logPair("unknownJobs", result.getInt("unknownJobs")));
realtimeBus.publish(tenantId, deviceId);
return result;
}
/***************************************************************************
** Revoke with the control the Revoke access action uses (credential rotated,
** disconnected, pending commands closed), inside the delete's transaction
** and under its station row lock: a delete that then fails rolls the
** revocation back with everything else. The connection is woken once, after
** the delete commits.
***************************************************************************/
private void revokeBeforeDelete(Connection connection, String deviceId, String actor) throws SQLException
{
new PrintingDeviceControls(dataSource, tenantId).action(connection, deviceId, new JSONObject().put("requestId", "delete-" + UUID.randomUUID()).put("issuedAt", Instant.now().toString()).put("action", "REVOKE"), actor);
}
/***************************************************************************
** Read (and with lock, hold) one station and count what a delete touches.
***************************************************************************/
private JSONObject deviceDeletionPlan(Connection connection, String deviceId, boolean lock) throws SQLException
{
List<JSONObject> rows = query(connection, "SELECT * FROM printing_device WHERE tenant_id=? AND id=?" + (lock ? " FOR UPDATE" : ""), tenantId, deviceId);
if(rows.isEmpty())
{
throw new PrintingException(404, "Device not found");
}
JSONObject device = rows.getFirst();
String name = device.getString("name");
long inProgress = jobCount(connection, "device_id=? AND status IN ('CLAIMED','RECEIVED','SUBMITTING')", deviceId);
JSONObject plan = new JSONObject().put("deviceId", deviceId).put("name", name).put("computerName", device.optString("computer_name", name)).put("facilityId", device.getString("facility_id"))
.put("printers", query(connection, "SELECT COUNT(*) AS n FROM printing_printer WHERE tenant_id=? AND device_id=?", tenantId, deviceId).getFirst().getLong("n"))
.put("queuedJobs", jobCount(connection, "device_id=? AND status='QUEUED'", deviceId)).put("jobsInProgress", inProgress).put("jobs", jobCount(connection, "device_id=?", deviceId))
.put("credentialLive", device.getInt("enabled") == 1 && !device.has("revoked_at"));
if(inProgress > 0 && reporting(device, Instant.now()))
{
plan.put("refusal", PrintingNames.STATION + " " + name + " is connected and is printing " + inProgress + (inProgress == 1 ? " job" : " jobs")
+ " right now. Wait until " + (inProgress == 1 ? "it finishes" : "they finish") + ", or use Disconnect or Revoke access first (the result is then recorded as unknown), and delete it again.");
}
return plan;
}
/***************************************************************************
** Read one printer with its station and count what a delete touches.
***************************************************************************/
private JSONObject printerDeletionPlan(Connection connection, String printerRowId) throws SQLException
{
List<JSONObject> rows = query(connection, "SELECT id, device_id, printer_id, name, queue_name FROM printing_printer WHERE tenant_id=? AND id=?", tenantId, printerRowId);
if(rows.isEmpty())
{
throw new PrintingException(404, "Printer not found");
}
JSONObject printer = rows.getFirst();
String deviceId = printer.getString("device_id");
String key = printer.getString("printer_id");
String name = printer.getString("name");
JSONObject device = query(connection, "SELECT * FROM printing_device WHERE tenant_id=? AND id=?", tenantId, deviceId).getFirst();
long inProgress = jobCount(connection, PRINTER_JOBS + " AND status IN ('CLAIMED','RECEIVED','SUBMITTING')", printerRowId, deviceId, key);
JSONObject plan = new JSONObject().put("printerId", printerRowId).put("deviceId", deviceId).put("printerKey", key).put("name", name).put("queueName", printer.optString("queue_name", name))
.put("stationName", device.getString("name")).put("computerName", device.optString("computer_name", device.getString("name"))).put("facilityId", device.getString("facility_id"))
.put("queuedJobs", jobCount(connection, PRINTER_JOBS + " AND status='QUEUED'", printerRowId, deviceId, key)).put("jobsInProgress", inProgress)
.put("jobs", jobCount(connection, PRINTER_JOBS, printerRowId, deviceId, key));
if(inProgress > 0 && reporting(device, Instant.now()))
{
plan.put("refusal", PrintingNames.PRINTER + " " + name + " is printing " + inProgress + (inProgress == 1 ? " job" : " jobs") + " right now on " + PrintingNames.STATION_LOWER + " " + device.getString("name")
+ ". Wait until " + (inProgress == 1 ? "it finishes" : "they finish") + ", or disconnect the " + PrintingNames.STATION_LOWER + " first (the result is then recorded as unknown), and delete it again.");
}
return plan;
}
/***************************************************************************
** Count this tenant's jobs matching a fixed predicate.
***************************************************************************/
private long jobCount(Connection connection, String predicate, Object... values) throws SQLException
{
Object[] scoped = new Object[values.length + 1];
scoped[0] = tenantId;
System.arraycopy(values, 0, scoped, 1, values.length);
return query(connection, "SELECT COUNT(*) AS n FROM printing_job WHERE tenant_id=? AND " + predicate, scoped).getFirst().getLong("n");
}
/***************************************************************************
** Can this station still report the result of a job it holds? Only one that
** holds a live credential and shows as ONLINE or PAUSED in the Status column
** (seen in the last 90 seconds and not disconnected by an administrator).
***************************************************************************/
private static boolean reporting(JSONObject device, Instant now)
{
if(device.getInt("enabled") != 1 || device.has("revoked_at"))
{
return false;
}
QRecord record = new QRecord().withValue("enabled", device.getInt("enabled")).withValue("connection_state", device.optString("connection_state", "CONNECTED"))
.withValue("last_seen_at", device.optString("last_seen_at", null)).withValue("control_last_poll_at", device.optString("control_last_poll_at", null))
.withValue("dispatch_paused", device.optInt("dispatch_paused", 0));
return Set.of("ONLINE", "PAUSED").contains(PrintingDeviceStatus.presence(record, now));
}
/***************************************************************************
** A plan that names a refusal is not carried out.
***************************************************************************/
private static void refuseIfPlanned(JSONObject plan)
{
if(plan.has("refusal"))
{
throw new PrintingException(409, plan.getString("refusal"));
}
}
/***************************************************************************
** A deletion is always attributed to a real, bounded actor.
***************************************************************************/
private static void requireActor(@Nullable String actor)
{
if(actor == null || actor.isBlank() || actor.length() > 200)
{
throw new PrintingException(400, "A bounded authenticated actor is required");
}
}
/***************************************************************************
** Give the printer's jobs the printer and queue names, where they have none.
***************************************************************************/
private void namePrinterJobs(Connection connection, JSONObject printer, String deviceId) throws SQLException
{
String name = printer.getString("name");
update(connection, "UPDATE printing_job SET printer_name=COALESCE(printer_name,?), queue_name=COALESCE(queue_name,?) WHERE tenant_id=? AND " + PRINTER_JOBS + " AND (printer_name IS NULL OR queue_name IS NULL)",
name, printer.optString("queue_name", name), tenantId, printer.getString("id"), deviceId, printer.getString("printer_id"));
}
/***************************************************************************
** Close work that can no longer complete: a queued job is canceled (FAILED),
** a job that was sent without a reported result becomes OUTCOME_UNKNOWN.
** Each gets a job event naming the actor. Returns {canceled, unknown}.
***************************************************************************/
private int[] closeOpenJobs(Connection connection, String predicate, Object[] values, String what, String actor, Instant now) throws SQLException
{
Object[] scoped = new Object[values.length + 1];
scoped[0] = tenantId;
System.arraycopy(values, 0, scoped, 1, values.length);
int[] closed = new int[2];
for(JSONObject job : query(connection, "SELECT id, status FROM printing_job WHERE tenant_id=? AND " + predicate + " AND status IN ('QUEUED','CLAIMED','RECEIVED','SUBMITTING') ORDER BY created_at, id FOR UPDATE", scoped))
{
boolean queued = "QUEUED".equals(job.getString("status"));
String status = queued ? "FAILED" : "OUTCOME_UNKNOWN";
String message = queued ? "Canceled before dispatch: its " + what + " was deleted" : "Its " + what + " was deleted before a result was reported; check the printer for output before printing again";
JSONObject event = new JSONObject().put("type", "DELETED").put("origin", "SERVER_DELETION").put("status", status).put("actor", actor).put("message", message);
update(connection, "INSERT INTO printing_event (id, job_id, status, event_json, created_at, event_origin) VALUES (?, ?, ?, ?, ?, 'SERVER_DELETION')", UUID.randomUUID().toString(), job.getString("id"), status, event.toString(), now.toString());
update(connection, "UPDATE printing_job SET status=?, message=? WHERE id=?", status, message, job.getString("id"));
closed[queued ? 0 : 1]++;
}
return closed;
}
/***************************************************************************
** The permanent record of a deletion: who deleted what, when, and what went
** with it. It has no reference to the deleted rows, only what they were.
***************************************************************************/
private void recordDeletion(Connection connection, String kind, String recordId, String name, @Nullable String computer, @Nullable String queue, @Nullable String facility, @Nullable String deviceId,
String actor, Instant now, int printers, int cancelled, int unknown, long kept) throws SQLException
{
update(connection, "INSERT INTO printing_deletion (id, tenant_id, kind, record_id, name, computer_name, queue_name, facility_id, device_id, actor, deleted_time, printer_count, cancelled_job_count, unknown_job_count, kept_job_count) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
UUID.randomUUID().toString(), tenantId, kind, recordId, name, computer, queue, facility, deviceId, actor, java.time.LocalDateTime.ofInstant(now, java.time.ZoneOffset.UTC), printers, cancelled, unknown, kept);
}
/***************************************************************************
** A host that owns its schema and has not applied this version's step still
** has the job-to-station foreign key: say so instead of a constraint error.
***************************************************************************/
private static void refuseIfSchemaStepMissing(Exception exception)
{
if(exception instanceof SQLException sql && "23503".equals(sql.getSQLState()))
{
throw new PrintingException(409, SCHEMA_STEP_MISSING);
}
}
/***************************************************************************
** Tenant ownership of a device row regardless of its revoked or removed state.
***************************************************************************/
private JSONObject requireDeviceRow(Connection connection, String deviceId) throws SQLException
{
List<JSONObject> rows = query(connection, "SELECT id, revoked_at, removed_at FROM printing_device WHERE id=? AND tenant_id=?", deviceId, tenantId);
if(rows.isEmpty())
{
throw new PrintingException(404, "Device not found");
}
return rows.getFirst();
}
/***************************************************************************
** Durably enqueue an immutable document. Repeated identical keys return it.
***************************************************************************/
@@ -1071,7 +1617,7 @@ public class PrintingService
throw new SQLException("Interrupted while waiting for the existing submission", exception);
}
}
List<JSONObject> printers = query(connection, "SELECT * FROM printing_printer WHERE device_id=? AND printer_id=? AND enabled=1 AND available=1", deviceId, printerId);
List<JSONObject> printers = query(connection, "SELECT * FROM printing_printer WHERE device_id=? AND printer_id=? AND enabled=1 AND available=1 AND removed_at IS NULL", deviceId, printerId);
if(printers.isEmpty() || !new JSONArray(printers.getFirst().getString("formats")).toList().contains(format))
{
throw new PrintingException(400, "Printer is not enabled for this format");
@@ -1099,7 +1645,7 @@ public class PrintingService
requireDevice(connection, deviceId);
query(connection, "SELECT id FROM printing_device WHERE id=? FOR UPDATE", deviceId);
requireDevice(connection, deviceId);
printers = query(connection, "SELECT * FROM printing_printer WHERE device_id=? AND printer_id=? AND enabled=1 AND available=1", deviceId, printerId);
printers = query(connection, "SELECT * FROM printing_printer WHERE device_id=? AND printer_id=? AND enabled=1 AND available=1 AND removed_at IS NULL", deviceId, printerId);
if(printers.isEmpty() || !new JSONArray(printers.getFirst().getString("formats")).toList().contains(format))
{
throw new PrintingException(400, "Printer is not enabled for this format");
@@ -1122,7 +1668,12 @@ public class PrintingService
configuredStorage().put(storageTableName, reference, format, content);
}
checkSubmission(guard);
update(connection, "INSERT INTO printing_job (id, tenant_id, device_id, printer_id, title, format, content_hash, content_base64, content_length, options_json, idempotency_key, created_at, status, printer_row_id, content_storage_table, content_reference, content_binding) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'QUEUED', ?, ?, ?, ?)", id, tenantId, deviceId, printerId, title, format, digest, storageTableName == null ? Base64.getEncoder().encodeToString(content) : null, content.length, options.toString(), key, Instant.now().toString(), printers.getFirst().getString("id"), storageTableName, storageTableName == null ? null : reference, storageTableName == null ? null : binding);
// One instant, two columns: created_at is the ISO-8601 text the queue SQL and protocol compare; created_time is the UTC timestamp QQQ filters as a date-time.
Instant createdAt = Instant.now();
// The names the station and printer have now are stored on the job, so its history reads the same after either is renamed or deleted.
JSONObject station = query(connection, "SELECT name, computer_name FROM printing_device WHERE tenant_id=? AND id=?", tenantId, deviceId).getFirst();
update(connection, "INSERT INTO printing_job (id, tenant_id, device_id, printer_id, title, format, content_hash, content_base64, content_length, options_json, idempotency_key, created_at, created_time, status, printer_row_id, content_storage_table, content_reference, content_binding, device_name, computer_name, printer_name, queue_name) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'QUEUED', ?, ?, ?, ?, ?, ?, ?, ?)", id, tenantId, deviceId, printerId, title, format, digest, storageTableName == null ? Base64.getEncoder().encodeToString(content) : null, content.length, options.toString(), key, createdAt.toString(), java.time.LocalDateTime.ofInstant(createdAt, java.time.ZoneOffset.UTC), printers.getFirst().getString("id"), storageTableName, storageTableName == null ? null : reference, storageTableName == null ? null : binding,
station.getString("name"), station.optString("computer_name", station.getString("name")), printers.getFirst().getString("name"), printers.getFirst().optString("queue_name", printers.getFirst().getString("name")));
if(bound != null)
{
String eventId = UUID.randomUUID().toString();
@@ -1380,7 +1931,7 @@ public class PrintingService
connection.commit();
return new JSONObject().put("job", JSONObject.NULL);
}
List<JSONObject> candidates = query(connection, "SELECT j." + JOB_COLUMNS.replace(", ", ", j.") + " FROM printing_job j JOIN printing_printer p ON p.device_id=j.device_id AND p.printer_id=j.printer_id WHERE j.tenant_id=? AND j.device_id=? AND p.enabled=1 AND p.available=1 AND j.status IN ('QUEUED','CLAIMED') AND ((j.format='PDF' AND p.formats LIKE '%\"PDF\"%') OR (j.format='ZPL' AND p.formats LIKE '%\"ZPL\"%'))" + (authorizedJobId == null ? "" : " AND j.id=?") + " ORDER BY j.created_at, j.id LIMIT 1", authorizedJobId == null ? new Object[] { tenantId, deviceId } : new Object[] { tenantId, deviceId, authorizedJobId });
List<JSONObject> candidates = query(connection, "SELECT j." + JOB_COLUMNS.replace(", ", ", j.") + " FROM printing_job j JOIN printing_printer p ON p.device_id=j.device_id AND p.printer_id=j.printer_id WHERE j.tenant_id=? AND j.device_id=? AND p.enabled=1 AND p.available=1 AND p.removed_at IS NULL AND j.status IN ('QUEUED','CLAIMED') AND ((j.format='PDF' AND p.formats LIKE '%\"PDF\"%') OR (j.format='ZPL' AND p.formats LIKE '%\"ZPL\"%'))" + (authorizedJobId == null ? "" : " AND j.id=?") + " ORDER BY j.created_at, j.id LIMIT 1", authorizedJobId == null ? new Object[] { tenantId, deviceId } : new Object[] { tenantId, deviceId, authorizedJobId });
if(candidates.isEmpty())
{
connection.commit();
@@ -1540,7 +2091,7 @@ public class PrintingService
try(Connection connection = dataSource.getConnection())
{
JSONArray result = new JSONArray();
for(JSONObject row : query(connection, "SELECT * FROM printing_device WHERE tenant_id=? ORDER BY name", tenantId))
for(JSONObject row : query(connection, "SELECT * FROM printing_device WHERE tenant_id=? AND removed_at IS NULL ORDER BY name", tenantId))
{
result.put(new JSONObject().put("deviceId", row.getString("id")).put("facilityId", row.getString("facility_id")).put("deviceName", row.getString("name")).put("computerName", row.getString("computer_name"))
.put("profile", new JSONObject().put("name", row.optString("display_name")).put("description", row.optString("description")).put("location", row.optString("location")))
@@ -1583,7 +2134,7 @@ public class PrintingService
try(Connection connection = dataSource.getConnection())
{
requireDevice(connection, deviceId);
String sql = "SELECT * FROM printing_printer WHERE tenant_id=? AND device_id=?";
String sql = "SELECT * FROM printing_printer WHERE tenant_id=? AND device_id=? AND removed_at IS NULL";
List<JSONObject> rows = name == null ? query(connection, sql + " ORDER BY name,id LIMIT ?", tenantId, deviceId, limit + 1)
: query(connection, sql + " AND (name>? OR (name=? AND id>?)) ORDER BY name,id LIMIT ?", tenantId, deviceId, name, name, id, limit + 1);
JSONArray result = new JSONArray();
@@ -1947,7 +2498,7 @@ public class PrintingService
***************************************************************************/
private void requireDevice(Connection connection, String deviceId) throws SQLException
{
if(query(connection, "SELECT id FROM printing_device WHERE id=? AND tenant_id=? AND enabled=1 AND revoked_at IS NULL", deviceId, tenantId).isEmpty())
if(query(connection, "SELECT id FROM printing_device WHERE id=? AND tenant_id=? AND enabled=1 AND revoked_at IS NULL AND removed_at IS NULL", deviceId, tenantId).isEmpty())
{
throw new PrintingException(404, "Device not found");
}
@@ -2062,6 +2613,7 @@ public class PrintingService
renewRetentionLease(connection, token);
progress[0] += cleanupOrphan(connection, orphan.getString("id"));
}
progress[0] += purgeRemoved(connection, Math.max(0, policy.batchSize() - progress[0]), token);
}
update(connection, "UPDATE printing_settings SET last_run_at=?, last_run_count=?, last_run_error='', lease_token=NULL, lease_expires_at=NULL WHERE tenant_id=? AND lease_token=?", Instant.now().toString(), progress[0], tenantId, token);
connection.commit();
@@ -2079,6 +2631,50 @@ public class PrintingService
/***************************************************************************
** Hard-delete removed printers that no job references (by printer row or by
** device and queue identity) and removed devices that have no jobs and no
** printers left, at most `limit` rows per run. Anything a job references is
** never deleted, so history stays queryable for as long as the job exists.
** A removed device's control receipts go with it; its jobs would have kept it.
***************************************************************************/
private int purgeRemoved(Connection connection, int limit, String token) throws SQLException
{
if(limit < 1)
{
return 0;
}
int printers = 0;
int devices = 0;
String unreferencedPrinter = " AND NOT EXISTS (SELECT 1 FROM printing_job j WHERE j.printer_row_id=printing_printer.id OR (j.device_id=printing_printer.device_id AND j.printer_id=printing_printer.printer_id))";
for(JSONObject row : query(connection, "SELECT id FROM printing_printer WHERE tenant_id=? AND removed_at IS NOT NULL" + unreferencedPrinter + " ORDER BY removed_at, id LIMIT ?", tenantId, limit))
{
renewRetentionLease(connection, token);
printers += update(connection, "DELETE FROM printing_printer WHERE tenant_id=? AND id=? AND removed_at IS NOT NULL" + unreferencedPrinter, tenantId, row.getString("id"));
}
String unreferencedDevice = " AND NOT EXISTS (SELECT 1 FROM printing_job j WHERE j.device_id=printing_device.id) AND NOT EXISTS (SELECT 1 FROM printing_printer p WHERE p.device_id=printing_device.id)";
for(JSONObject row : query(connection, "SELECT id FROM printing_device WHERE tenant_id=? AND removed_at IS NOT NULL" + unreferencedDevice + " ORDER BY removed_at, id LIMIT ?", tenantId, Math.max(0, limit - printers)))
{
renewRetentionLease(connection, token);
if(query(connection, "SELECT id FROM printing_device WHERE tenant_id=? AND id=? AND removed_at IS NOT NULL" + unreferencedDevice, tenantId, row.getString("id")).isEmpty())
{
continue;
}
update(connection, "DELETE FROM printing_device_command WHERE tenant_id=? AND device_id=?", tenantId, row.getString("id"));
update(connection, "DELETE FROM printing_resource_usage WHERE tenant_id=? AND device_id=?", tenantId, row.getString("id"));
devices += update(connection, "DELETE FROM printing_device WHERE tenant_id=? AND id=?", tenantId, row.getString("id"));
}
// A deleted station's retained-byte counter stays until its last stored document is purged; then nothing refers to it.
update(connection, "DELETE FROM printing_resource_usage WHERE tenant_id=? AND device_id<>'' AND retained_bytes=0 AND NOT EXISTS (SELECT 1 FROM printing_device d WHERE d.id=printing_resource_usage.device_id)", tenantId);
if(printers > 0 || devices > 0)
{
LOG.info("Purged removed printing rows that no job references", logPair("tenantId", tenantId), logPair("printers", printers), logPair("devices", devices));
}
return printers + devices;
}
/***************************************************************************
** Stop safely if another worker acquired an expired maintenance lease.
***************************************************************************/
@@ -30,6 +30,7 @@ public final class PrintingTables
public static final String PRINTER = "printing_printer";
public static final String JOB = "printing_job";
public static final String COMMAND = "printing_device_command";
public static final String DELETION = "printing_deletion";
@@ -49,11 +49,11 @@ public final class PrintingUiApi
MiddlewareSpecExtensionsMetaData.ofOrWithNew(instance).withExtension(extension());
}
/** Semantic extension 2.0.0 requires authenticated realtime delivery for native claims; 2.1.0 adds the record, table-list and text print processes; 2.2.0 adds printingRenderToPdf, the header/footer values and content sections; 2.3.0 adds authorized operator submission, reported queue-default media and the retained-source facade. */
/** Semantic extension 2.0.0 requires authenticated realtime delivery for native claims; 2.1.0 adds the record, table-list and text print processes; 2.2.0 adds printingRenderToPdf, the header/footer values and content sections; 2.3.0 adds authorized operator submission, reported queue-default media and the retained-source facade; 2.4.0 adds the printer-approval-v1 capability and the realtime PRINTERS frame carrying each exposed printer's approval. */
public static MiddlewareSpecExtension extension()
{
return new MiddlewareSpecExtension().withName("printing").withSemanticVersion("2.3.0")
.withDescription("Native current-user printing with secondary device proof, device-control-v1 and realtime-v1; record, table-list and text printing processes; render to PDF with the app header and footer; least-privilege operator catalog and printer-media-v1.")
return new MiddlewareSpecExtension().withName("printing").withSemanticVersion("2.4.0")
.withDescription("Native current-user printing with secondary device proof, device-control-v1 and realtime-v1; record, table-list and text printing processes; render to PDF with the app header and footer; least-privilege operator catalog and printer-media-v1; printer-approval-v1 and the realtime PRINTERS approval frame.")
.withSpec(Session.class)
.withSpec(Bind.class)
.withSpec(Enroll.class)
@@ -208,6 +208,7 @@ components:
- "REFRESH_INVENTORY"
- "CANCEL_SPOOL"
- "printer-media-v1"
- "printer-approval-v1"
type: "string"
type: "array"
lastAppliedRevision:
@@ -752,9 +753,10 @@ info:
email: "contact@kingsrook.com"
description: "Native current-user printing with secondary device proof, device-control-v1\
\ and realtime-v1; record, table-list and text printing processes; render to PDF\
\ with the app header and footer; least-privilege operator catalog and printer-media-v1."
\ with the app header and footer; least-privilege operator catalog and printer-media-v1;\
\ printer-approval-v1 and the realtime PRINTERS approval frame."
title: "QQQ Middleware API extension: printing"
version: "2.3.0"
version: "2.4.0"
openapi: "3.0.3"
paths:
/qqq/v1/qbit/printing/session:
File diff suppressed because it is too large. Load diff
@@ -274,6 +274,21 @@ class PrintingDeviceControlsTest
}
/** The HTTP control poll stores printer-approval-v1 like the other capabilities and still bounds the list. */
@Test
void pollAcceptsAndStoresPrinterApprovalCapability() throws Exception
{
Fixture f = new Fixture();
JSONArray capabilities = new JSONArray(List.of("REFRESH_INVENTORY", "CANCEL_SPOOL", "printer-media-v1", "printer-approval-v1"));
f.service.deviceControlPoll(f.device, poll().put("capabilities", capabilities));
JSONObject control = f.service.devices().getJSONObject(0).getJSONObject("control");
assertEquals(capabilities.toString(), control.getJSONArray("capabilities").toString());
assertEquals(400, assertThrows(PrintingException.class, () -> f.service.deviceControlPoll(f.device, poll().put("capabilities", new JSONArray(List.of("REFRESH_INVENTORY", "CANCEL_SPOOL", "printer-media-v1", "printer-approval-v1", "REFRESH_INVENTORY"))))).getStatus());
assertEquals(400, assertThrows(PrintingException.class, () -> f.service.deviceControlPoll(f.device, poll().put("capabilities", new JSONArray(List.of("printer-approval-v2"))))).getStatus());
assertEquals(capabilities.toString(), f.service.devices().getJSONObject(0).getJSONObject("control").getJSONArray("capabilities").toString());
}
/** Explicit fresh action identity, retained unchanged by callers when retrying. */
private static JSONObject action(String action)
{
@@ -132,6 +132,58 @@ class PrintingDeviceProcessesTest
/***************************************************************************
** Remove device revokes through the existing control, archives the device and
** its printers, keeps job history readable, and replays without a new command.
***************************************************************************/
@Test
void removeDeviceRevokesArchivesAndKeepsHistory() throws Exception
{
var fixture = PrintingProcessesTest.fixture();
var process = fixture.instance().getProcess(PrintingDeviceProcesses.Action.REMOVE.process);
assertEquals("Remove print station", process.getLabel());
assertTrue(process.getStep("confirmControl") instanceof com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData);
assertTrue(PrintingDeviceProcesses.Action.REMOVE.preview.contains("history is kept"));
String job = submit(fixture.service(), fixture.device());
RunBackendStepInput input = prepared(PrintingDeviceProcesses.Action.REMOVE, fixture.device());
assertThrows(QException.class, () -> new PrintingDeviceProcesses.Load().runOnePage(input, new RunBackendStepOutput()));
assertEquals(2, fixture.service().devices().length());
input.addValue(PrintingDeviceProcesses.CONFIRM, true);
new PrintingDeviceProcesses.Load().runOnePage(input, new RunBackendStepOutput());
QRecord device = get(PrintingTables.DEVICE, fixture.device());
assertFalse(device.getValueString("revoked_at").isBlank());
assertFalse(device.getValueString("removed_at").isBlank());
assertEquals(0, device.getValueInteger("enabled"));
assertEquals(List.of(fixture.otherDevice()), fixture.service().devices().toList().stream().map(row -> ((Map<?, ?>) row).get("deviceId")).toList());
QRecord printer = get(PrintingTables.PRINTER, fixture.printerId());
assertFalse(printer.getValueString("removed_at").isBlank());
assertEquals(0, printer.getValueInteger("enabled"));
assertEquals(0, printer.getValueInteger("available"));
assertEquals(404, assertThrows(PrintingException.class, () -> fixture.service().printers(fixture.device())).getStatus());
assertEquals(404, assertThrows(PrintingException.class, () -> fixture.service().claim(fixture.device())).getStatus());
assertEquals("QUEUED", fixture.service().getJob(job).getString("status"));
assertEquals(job, get(PrintingTables.JOB, job).getValueString("id"));
var commands = fixture.service().deviceCommandsPage(fixture.device(), 50, null).getJSONArray("commands");
assertEquals(1, commands.length());
assertEquals("REVOKE", commands.getJSONObject(0).getString("action"));
new PrintingDeviceProcesses.Load().runOnePage(input, new RunBackendStepOutput());
assertEquals(1, fixture.service().deviceCommandsPage(fixture.device(), 50, null).getJSONArray("commands").length());
assertEquals(device.getValueString("removed_at"), get(PrintingTables.DEVICE, fixture.device()).getValueString("removed_at"));
QueryInput visible = new QueryInput().withTableName(PrintingTables.DEVICE).withFilter(new QQueryFilter().withCriteria("removed_at", QCriteriaOperator.IS_BLANK));
assertEquals(List.of(fixture.otherDevice()), new QueryAction().execute(visible).getRecords().stream().map(row -> row.getValueString("id")).toList());
RunBackendStepInput revokedOnly = prepared(PrintingDeviceProcesses.Action.REVOKE, fixture.otherDevice());
revokedOnly.addValue(PrintingDeviceProcesses.CONFIRM, true);
new PrintingDeviceProcesses.Load().runOnePage(revokedOnly, new RunBackendStepOutput());
assertEquals(1, fixture.service().devices().length());
RunBackendStepInput removeRevoked = prepared(PrintingDeviceProcesses.Action.REMOVE, fixture.otherDevice());
removeRevoked.addValue(PrintingDeviceProcesses.CONFIRM, true);
new PrintingDeviceProcesses.Load().runOnePage(removeRevoked, new RunBackendStepOutput());
assertEquals(0, fixture.service().devices().length());
assertEquals(1, fixture.service().deviceCommandsPage(fixture.otherDevice(), 50, null).getJSONArray("commands").length());
}
/***************************************************************************
** Selected late jobs produce valid, stable wire request IDs and one command.
***************************************************************************/
@@ -120,7 +120,7 @@ class PrintingExtractTest
checked++;
}
}
assertEquals(14, checked);
assertEquals(18, checked);
}
@@ -191,7 +191,7 @@ class PrintingHomeTest
try
{
String header = JsonUtils.toJson(render(instance, "printingHomeHeader"));
assertTrue(header.contains("No print devices are connected"));
assertTrue(header.contains("No print stations are connected"));
assertTrue(header.contains("/app/printingEnrollDevice/?returnTo=%2Fapp%2Fprinting%2F"));
assertEquals(PrintingTables.DEVICE, instance.getProcess("printingEnrollDevice").getTableName());
assertEquals(0, instance.getProcess("printingEnrollDevice").getMinInputRecords());
@@ -0,0 +1,364 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. 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.printing;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.temporal.ChronoUnit;
import java.util.Base64;
import java.util.List;
import java.util.Locale;
import java.util.UUID;
import com.kingsrook.qqq.backend.core.actions.tables.AggregateAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.Aggregate;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateResult;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.DateBucket;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.DateBucketUnit;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.GroupBy;
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;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.expressions.NowWithOffset;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/*******************************************************************************
** Print jobs carry their creation instant as a real UTC timestamp that QQQ
** filters, sorts and date-buckets, beside the ISO-8601 text the queue SQL uses.
*******************************************************************************/
class PrintingJobCreatedTimeTest
{
/***************************************************************************
** A submitted job stores one instant in both columns, the timestamp in UTC.
***************************************************************************/
@Test
void submittedJobStoresTheCreationInstantAsAUtcTimestamp() throws Exception
{
DriverDataSource source = source();
PrintingService service = new PrintingService(source, "tenant-a");
service.initialize();
String device = enroll(service);
Instant before = Instant.now();
String job = submit(service, device, "recent");
Instant after = Instant.now();
Instant text = Instant.parse(text(source, job));
Instant timestamp = timestamp(source, job).toInstant(ZoneOffset.UTC);
assertFalse(text.isBefore(before) || text.isAfter(after), text + " is outside " + before + " to " + after);
assertTrue(Duration.between(text, timestamp).abs().compareTo(Duration.ofNanos(1000)) <= 0, text + " and " + timestamp + " are different instants");
}
/***************************************************************************
** The job table exposes a DATE_TIME field in the default columns; the text stays available.
***************************************************************************/
@Test
void metadataExposesCreatedAsADateTimeField() throws Exception
{
QInstance instance = instance(source());
QFieldMetaData created = instance.getTable(PrintingTables.JOB).getField("created_time");
assertEquals(QFieldType.DATE_TIME, created.getType());
assertEquals("Created", created.getLabel());
assertEquals(QFieldType.STRING, instance.getTable(PrintingTables.JOB).getField("created_at").getType());
var columns = (PrintingQBitProducer.DefaultColumns) instance.getTable(PrintingTables.JOB).getSupplementalMetaData("nextUi");
assertTrue(columns.getDefaultColumns().contains("created_time"));
assertFalse(columns.getDefaultColumns().contains("created_at"));
}
/***************************************************************************
** "Created in the last day" selects, sorts and counts by format through QQQ.
***************************************************************************/
@Test
void relativeDateCriteriaSelectRecentJobsOnly() throws Exception
{
DriverDataSource source = source();
QInstance instance = instance(source);
PrintingService service = new PrintingService(source, "tenant-a");
String device = enroll(service);
String recent = submit(service, device, "recent");
String old = submit(service, device, "old");
age(source, old, Instant.now().minus(3, ChronoUnit.DAYS));
QContext.init(instance, viewer());
try
{
QQueryFilter lastDay = new QQueryFilter(new QFilterCriteria("created_time", QCriteriaOperator.GREATER_THAN_OR_EQUALS, NowWithOffset.minus(1, ChronoUnit.DAYS)));
List<QRecord> rows = new QueryAction().execute(new QueryInput().withTableName(PrintingTables.JOB).withFilter(lastDay)).getRecords();
assertEquals(List.of(recent), ids(rows));
Instant created = rows.getFirst().getValueInstant("created_time");
assertTrue(Duration.between(created, Instant.now()).abs().compareTo(Duration.ofMinutes(5)) < 0, created + " is not the current UTC instant");
QQueryFilter earlier = new QQueryFilter(new QFilterCriteria("created_time", QCriteriaOperator.LESS_THAN, NowWithOffset.minus(1, ChronoUnit.DAYS)));
assertEquals(List.of(old), ids(new QueryAction().execute(new QueryInput().withTableName(PrintingTables.JOB).withFilter(earlier)).getRecords()));
QQueryFilter newestFirst = new QQueryFilter().withOrderBy(new QFilterOrderBy("created_time", false));
assertEquals(List.of(recent, old), ids(new QueryAction().execute(new QueryInput().withTableName(PrintingTables.JOB).withFilter(newestFirst)).getRecords()));
Aggregate count = new Aggregate("id", AggregateOperator.COUNT);
GroupBy format = new GroupBy(QFieldType.STRING, "format");
List<AggregateResult> byFormat = new AggregateAction().execute(new AggregateInput(PrintingTables.JOB).withFilter(lastDay).withAggregate(count).withGroupBy(format)).getResults();
assertEquals(1, byFormat.size());
assertEquals("ZPL", byFormat.getFirst().getGroupByValue(format));
assertEquals(1, ValueUtils.getValueAsInteger(byFormat.getFirst().getAggregateValue(count)));
GroupBy day = new GroupBy(QFieldType.DATE_TIME, "created_time").withBucket(new DateBucket(DateBucketUnit.DAY));
List<AggregateResult> byDay = new AggregateAction().execute(new AggregateInput(PrintingTables.JOB).withAggregate(count).withGroupBy(day)).getResults();
assertEquals(2, byDay.size());
for(AggregateResult bucket : byDay)
{
assertNotNull(bucket.getGroupByValue(day));
assertEquals(1, ValueUtils.getValueAsInteger(bucket.getAggregateValue(count)));
}
}
finally
{
QContext.clear();
}
}
/***************************************************************************
** Rows written before the column existed receive their timestamp from the text.
***************************************************************************/
@Test
void initializeBackfillsExistingJobs() throws Exception
{
DriverDataSource source = source();
PrintingService service = new PrintingService(source, "tenant-a");
service.initialize();
String device = enroll(service);
String fractional = submit(service, device, "fractional");
String whole = submit(service, device, "whole");
dropCreatedTime(source);
execute(source, "UPDATE printing_job SET created_at=? WHERE id=?", "2026-01-02T03:04:05.123456Z", fractional);
execute(source, "UPDATE printing_job SET created_at=? WHERE id=?", "2025-12-31T23:59:59Z", whole);
service.initialize();
service.initialize();
assertEquals(LocalDateTime.parse("2026-01-02T03:04:05.123456"), timestamp(source, fractional));
assertEquals(LocalDateTime.parse("2025-12-31T23:59:59"), timestamp(source, whole));
assertNotNull(timestamp(source, submit(service, device, "after")));
}
/***************************************************************************
** A host schema without the column refuses submissions and reads; no job is stored without its timestamp.
***************************************************************************/
@Test
void schemaWithoutTheColumnFailsLoudly() throws Exception
{
DriverDataSource source = source();
QInstance instance = instance(source);
PrintingService service = new PrintingService(source, "tenant-a");
String device = enroll(service);
dropCreatedTime(source);
SQLException refused = assertThrows(SQLException.class, () -> submit(service, device, "refused"));
assertTrue(String.valueOf(refused.getMessage()).toLowerCase(Locale.ROOT).contains("created_time"), String.valueOf(refused.getMessage()));
try(Connection connection = source.getConnection(); PreparedStatement statement = connection.prepareStatement("SELECT COUNT(*) FROM printing_job"); ResultSet rows = statement.executeQuery())
{
assertTrue(rows.next());
assertEquals(0, rows.getInt(1));
}
QContext.init(instance, viewer());
try
{
assertThrows(QException.class, () -> new QueryAction().execute(new QueryInput().withTableName(PrintingTables.JOB)));
}
finally
{
QContext.clear();
}
}
/***************************************************************************
** Independent shared-connection H2 database for each test.
***************************************************************************/
private static DriverDataSource source()
{
return new DriverDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=FALSE", "sa", "");
}
/***************************************************************************
** A real RDBMS instance binds the QBit and service to one isolated database.
***************************************************************************/
private static QInstance instance(DriverDataSource source) throws Exception
{
PrintingService service = new PrintingService(source, "tenant-a");
service.initialize();
QInstance instance = new QInstance();
String backend = "printing-test-" + UUID.randomUUID();
instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("mock").withType(QAuthenticationType.MOCK));
instance.addBackend(new RDBMSBackendMetaData().withName(backend).withVendor("h2").withHostName("mem").withDatabaseName("test").withUsername("sa").withPassword("").withJdbcUrl(source.url()));
new PrintingQBitProducer().withConfig(new PrintingQBitConfig().withBackendName(backend).withService(service)).produce(instance, "printing");
new QInstanceValidator().validate(instance);
return instance;
}
/***************************************************************************
** An ordinary scoped administrator, never the all-powerful system session.
***************************************************************************/
private static QSession viewer()
{
return new QSession().withUser(new QUser().withIdReference("viewer")).withSecurityKeyValue("printing_tenant", "tenant-a").withPermission("printing_admin");
}
/***************************************************************************
** Enroll one device with an approved synthetic label queue.
***************************************************************************/
private static String enroll(PrintingService service) throws Exception
{
JSONObject code = service.createEnrollment("warehouse", "station");
String device = service.enroll(code.put("deviceName", "station").put("agentVersion", "test")).getString("deviceId");
service.inventory(device, new JSONArray().put(new JSONObject().put("printerId", "labels").put("name", "Labels").put("status", "READY").put("formats", new JSONArray(List.of("ZPL")))));
service.enablePrinter(device, "labels", true);
return device;
}
/***************************************************************************
** Submit one synthetic label through the service; nothing leaves the test database.
***************************************************************************/
private static String submit(PrintingService service, String device, String key) throws Exception
{
return service.submit(new JSONObject().put("deviceId", device).put("printerId", "labels").put("title", key + " job").put("format", "ZPL")
.put("contentBase64", Base64.getEncoder().encodeToString("^XA^XZ".getBytes(StandardCharsets.UTF_8))).put("idempotencyKey", key)).getString("jobId");
}
/***************************************************************************
** Record identifiers in result order.
***************************************************************************/
private static List<String> ids(List<QRecord> rows)
{
return rows.stream().map(row -> row.getValueString("id")).toList();
}
/***************************************************************************
** Move the creation of one job to an earlier instant in both columns.
***************************************************************************/
private static void age(DriverDataSource source, String job, Instant created) throws SQLException
{
execute(source, "UPDATE printing_job SET created_at=?, created_time=? WHERE id=?", created.toString(), LocalDateTime.ofInstant(created, ZoneOffset.UTC), job);
}
/***************************************************************************
** Return the schema to its shape before the column was added.
***************************************************************************/
private static void dropCreatedTime(DriverDataSource source) throws SQLException
{
execute(source, "DROP INDEX printing_job_created_time_idx");
execute(source, "ALTER TABLE printing_job DROP COLUMN created_time");
}
/***************************************************************************
** The stored ISO-8601 text of one job.
***************************************************************************/
private static String text(DriverDataSource source, String job) throws SQLException
{
try(Connection connection = source.getConnection(); PreparedStatement statement = connection.prepareStatement("SELECT created_at FROM printing_job WHERE id=?"))
{
statement.setString(1, job);
try(ResultSet rows = statement.executeQuery())
{
assertTrue(rows.next());
return rows.getString(1);
}
}
}
/***************************************************************************
** The stored timestamp of one job, without any time zone conversion.
***************************************************************************/
private static LocalDateTime timestamp(DriverDataSource source, String job) throws SQLException
{
try(Connection connection = source.getConnection(); PreparedStatement statement = connection.prepareStatement("SELECT created_time FROM printing_job WHERE id=?"))
{
statement.setString(1, job);
try(ResultSet rows = statement.executeQuery())
{
assertTrue(rows.next());
return rows.getObject(1, LocalDateTime.class);
}
}
}
/***************************************************************************
** Run one statement with bound values.
***************************************************************************/
private static void execute(DriverDataSource source, String sql, Object... values) throws SQLException
{
try(Connection connection = source.getConnection())
{
PrintingService.update(connection, sql, values);
}
}
}
@@ -110,6 +110,26 @@ class PrintingOperatorTest
}
/***************************************************************************
** A removed printer leaves the operator catalog and refuses submissions until
** its device shares it again and an administrator approves it.
***************************************************************************/
@Test
void removedPrintersLeaveTheCatalog() throws Exception
{
var f = PrintingProcessesTest.fixture();
f.service().removePrinter(f.device(), "laser");
QContext.getQSession().setPermissions(Set.of("printing_operator"));
assertEquals(List.of(f.otherPrinterId()), PrintingOperatorAuthorization.catalog(50, null).getJSONArray("printers").toList().stream().map(row -> ((Map<?, ?>) row).get("printerRowId")).toList());
assertThrows(QException.class, () -> PrintingOperatorAuthorization.submit(request(f.printerId(), "removed")));
f.service().inventory(f.device(), new JSONArray().put(new JSONObject().put("printerId", "laser").put("name", "Packing laser").put("formats", new JSONArray(List.of("PDF")))));
assertEquals(1, PrintingOperatorAuthorization.catalog(50, null).getJSONArray("printers").length());
f.service().enablePrinter(f.device(), "laser", true);
assertEquals(2, PrintingOperatorAuthorization.catalog(50, null).getJSONArray("printers").length());
}
/***************************************************************************
** Tenant and parent device locks apply to catalog, submit and replay reads.
***************************************************************************/
@@ -506,6 +506,66 @@ class PrintingProcessesTest
/***************************************************************************
** Remove printers archives the selection: hidden from the list, the approval
** frame, claims and the selectors, history kept; a re-shared queue returns
** exposed but awaiting approval again.
***************************************************************************/
@Test
void removedPrintersAreArchivedUntilTheDeviceSharesThemAgain() throws Exception
{
Fixture fixture = fixture();
var process = fixture.instance().getProcess("printingRemoveSelectedPrinters");
assertEquals("Remove printers", process.getLabel());
assertEquals(PrintingTables.PRINTER, process.getTableName());
assertEquals(1, process.getMinInputRecords());
assertEquals(PrintingAdminPermission.class.getName(), process.getPermissionRules().getCustomPermissionChecker().getName());
assertEquals("Removed at", fixture.instance().getTable(PrintingTables.PRINTER).getField("removed_at").getLabel());
assertEquals("Removed at", fixture.instance().getTable(PrintingTables.DEVICE).getField("removed_at").getLabel());
String queued = PrintingProcesses.submitTest(PrintingProcesses.resolvePrinter(fixture.printerId(), fixture.device()), true);
RunBackendStepOutput preview = new RunBackendStepOutput();
new PrintingProcesses.SelectedTransform().runOnePage(new RunBackendStepInput().withProcessName(process.getName())
.withRecords(List.of(new QRecord().withValue("id", fixture.printerId()))), preview);
assertEquals(1, preview.getRecords().size());
assertEquals(1, fixture.service().printers(fixture.device()).length());
var result = new RunProcessAction().execute(new RunProcessInput().withProcessName(process.getName())
.withValue("recordIds", fixture.printerId()).withFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP));
assertFalse(result.getException().isPresent(), () -> result.getException().toString());
assertEquals(0, fixture.service().printers(fixture.device()).length());
assertEquals(0, fixture.service().printerApprovals(fixture.device()).length());
assertTrue(fixture.service().claim(fixture.device()).isNull("job"));
assertEquals("QUEUED", fixture.service().getJob(queued).getString("status"));
assertEquals(409, assertThrows(PrintingException.class, () -> fixture.service().enablePrinter(fixture.device(), "laser", true)).getStatus());
assertThrows(PrintingException.class, () -> fixture.service().submit(new JSONObject().put("deviceId", fixture.device()).put("printerId", "laser").put("title", "after removal")
.put("format", "PDF").put("contentBase64", java.util.Base64.getEncoder().encodeToString(PrintingTestDocuments.pdf())).put("idempotencyKey", "after-removal")));
QueryInput visible = new QueryInput().withTableName(PrintingTables.PRINTER)
.withFilter(new com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter().withCriteria("removed_at", com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator.IS_BLANK));
assertEquals(List.of(fixture.otherPrinterId()), new QueryAction().execute(visible).getRecords().stream().map(row -> row.getValueString("id")).toList());
QRecord archived = PrintingProcesses.resolvePrinter(fixture.printerId(), fixture.device());
assertFalse(archived.getValueString("removed_at").isBlank());
assertEquals(0, archived.getValueInteger("enabled"));
assertEquals(0, archived.getValueInteger("available"));
QFieldMetaData selector = ((QFrontendStepMetaData) fixture.instance().getProcess("printingTestPdf").getStep("details")).getFormField("printerId");
var filter = selector.getPossibleValueSourceFilter().clone();
filter.interpretValues(Map.of("input", Map.of("deviceId", fixture.device())), PossibleValueSearchFilterUseCase.FORM);
assertTrue(new SearchPossibleValueSourceAction().execute(new SearchPossibleValueSourceInput().withPossibleValueSourceName(PrintingTables.PRINTER).withDefaultQueryFilter(filter)).getResults().isEmpty());
var replay = new RunProcessAction().execute(new RunProcessInput().withProcessName(process.getName())
.withValue("recordIds", fixture.printerId()).withFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP));
assertFalse(replay.getException().isPresent(), () -> replay.getException().toString());
fixture.service().inventory(fixture.device(), new JSONArray().put(new JSONObject().put("printerId", "laser").put("name", "Packing laser").put("formats", new JSONArray(List.of("PDF")))));
JSONObject shared = fixture.service().printers(fixture.device()).getJSONObject(0);
assertTrue(shared.getBoolean("available"));
assertFalse(shared.getBoolean("enabled"));
assertEquals("[{\"printerId\":\"laser\",\"enabled\":false}]", fixture.service().printerApprovals(fixture.device()).toString());
assertEquals(fixture.printerId(), PrintingProcesses.resolvePrinter(fixture.printerId(), fixture.device()).getValueString("id"));
assertEquals(null, PrintingProcesses.resolvePrinter(fixture.printerId(), fixture.device()).getValueString("removed_at"));
assertTrue(fixture.service().claim(fixture.device()).isNull("job"));
fixture.service().enablePrinter(fixture.device(), "laser", true);
assertEquals(queued, fixture.service().claim(fixture.device()).getJSONObject("job").getString("jobId"));
}
/***************************************************************************
** An altered record payload cannot select another tenant's queue.
***************************************************************************/
@@ -57,6 +57,55 @@ public class PrintingRealtimeTest
Path directory;
/** Lowercase upgrade header names (an ingress that normalizes them) must still resolve the device for every socket callback. */
@Test
void lowercaseUpgradeHeadersReachWelcomeAndFrames() throws Exception
{
String jdbc = "jdbc:h2:file:" + directory.resolve("lowercase-realtime").toString().replace('\\', '/');
PrintingLocalHost host = new PrintingLocalHost(new PrintingLocalSettings(jdbc, "sa", "", "demo", 0, "synthetic-management-token-long-enough", "operator", "synthetic-realtime-password-1234"));
AtomicReference<Javalin> app = new AtomicReference<>();
QApplicationLauncher launcher = QApplicationLauncher.run(host, new QApplicationLauncherConfig().withRegisterShutdownHook(false).withServerCustomizer(server ->
{
host.configure(server, false);
server.withJavalinConfigurationCustomizer(app::set);
}));
PrintingService service = Objects.requireNonNull(launcher.getQInstance().getQBits().values().stream().map(value -> value.getConfig()).filter(PrintingQBitConfig.class::isInstance)
.map(PrintingQBitConfig.class::cast).findFirst().orElseThrow().getService(), "The printing service was not configured");
String base = "http://127.0.0.1:" + Objects.requireNonNull(app.get(), "Test HTTP server was not started").port();
JSONObject enrolled = service.enrollForUser(service.createEnrollment("default", "Lowercase fixture").put("deviceName", "Lowercase fixture").put("agentVersion", "test"), "operator");
String device = enrolled.getString("deviceId");
String proof = enrolled.getString("deviceToken");
try(HttpClient http = HttpClient.newHttpClient())
{
String cookie = login(http, base);
// An ingress that lowercases header names must not leave the device unresolved on connect: WELCOME, an acknowledged
// inventory and the capability-gated PRINTERS push all work exactly as with the canonical spelling.
try(PrintingSocketFixture lowercase = PrintingSocketFixture.open(http, base, cookie, device, proof, true))
{
lowercase.initialize(new JSONArray().put("realtime-v1").put("REFRESH_INVENTORY").put("CANCEL_SPOOL").put("printer-approval-v1"));
lowercase.send("INVENTORY", "inventory-lower", new JSONObject().put("printers", new JSONArray().put(new JSONObject().put("printerId", "queue").put("name", "Synthetic queue")
.put("driverName", "Synthetic").put("portName", "SYNTHETIC").put("status", "READY").put("formats", new JSONArray().put("PDF")))));
assertEquals("inventory-lower", lowercase.take("ACK").getJSONObject("payload").getString("acknowledgedMessageId"));
JSONObject printers = lowercase.take("PRINTERS").getJSONObject("payload");
assertEquals("queue", printers.getJSONArray("printers").getJSONObject(0).getString("printerId"));
assertFalse(printers.getJSONArray("printers").getJSONObject(0).getBoolean("enabled"));
// A replacement socket with canonical spelling supersedes the lowercase one through the same device key.
try(PrintingSocketFixture canonical = PrintingSocketFixture.connect(http, base, cookie, device, proof))
{
assertTrue(lowercase.closed.get(10, TimeUnit.SECONDS) > 0);
canonical.send("PING", "ping-1", new JSONObject());
canonical.take("PONG");
}
}
}
finally
{
launcher.stop();
}
}
/** An old client cannot ignore media intent, including after replacing a capable connection. */
@Test
void mediaCapabilityIsReplacedOnEveryAuthenticatedHello() throws Exception
@@ -106,6 +155,103 @@ public class PrintingRealtimeTest
}
/** PRINTERS follows each accepted inventory and every approval change, only on a connection that advertised printer-approval-v1. */
@Test
void printerApprovalIsPushedOnlyToCapableConnections() throws Exception
{
String jdbc = "jdbc:h2:file:" + directory.resolve("approval-realtime").toString().replace('\\', '/');
PrintingLocalHost host = new PrintingLocalHost(new PrintingLocalSettings(jdbc, "sa", "", "demo", 0, "synthetic-management-token-long-enough", "operator", "synthetic-realtime-password-1234"));
AtomicReference<Javalin> app = new AtomicReference<>();
QApplicationLauncher launcher = QApplicationLauncher.run(host, new QApplicationLauncherConfig().withRegisterShutdownHook(false).withServerCustomizer(server ->
{
host.configure(server, false);
server.withJavalinConfigurationCustomizer(app::set);
}));
PrintingService service = Objects.requireNonNull(launcher.getQInstance().getQBits().values().stream().map(value -> value.getConfig()).filter(PrintingQBitConfig.class::isInstance)
.map(PrintingQBitConfig.class::cast).findFirst().orElseThrow().getService(), "The printing service was not configured");
String base = "http://127.0.0.1:" + Objects.requireNonNull(app.get(), "Test HTTP server was not started").port();
JSONObject enrolled = service.enrollForUser(service.createEnrollment("default", "Approval fixture").put("deviceName", "Approval fixture").put("agentVersion", "test"), "operator");
String device = enrolled.getString("deviceId");
String proof = enrolled.getString("deviceToken");
JSONArray approval = new JSONArray().put("realtime-v1").put("REFRESH_INVENTORY").put("CANCEL_SPOOL").put("printer-media-v1").put("printer-approval-v1");
JSONArray inventory = new JSONArray()
.put(new JSONObject().put("printerId", "laser").put("name", "Synthetic laser").put("status", "READY").put("formats", new JSONArray().put("PDF")))
.put(new JSONObject().put("printerId", "queue").put("name", "Synthetic queue").put("status", "READY").put("formats", new JSONArray().put("ZPL")));
try(HttpClient http = HttpClient.newHttpClient())
{
String cookie = login(http, base);
try(PrintingSocketFixture capable = PrintingSocketFixture.open(http, base, cookie, device, proof))
{
capable.initialize(approval);
capable.take("JOB_AVAILABLE");
assertTrue(deviceSnapshot(service, device).getJSONObject("control").getJSONArray("capabilities").toList().contains("printer-approval-v1"));
capable.received.clear();
capable.send("INVENTORY", "approval-inventory", new JSONObject().put("printers", inventory));
JSONObject ack = Objects.requireNonNull(capable.received.poll(10, TimeUnit.SECONDS), "Expected ACK");
assertEquals("ACK", ack.getString("type"));
assertEquals("approval-inventory", ack.getJSONObject("payload").getString("acknowledgedMessageId"));
JSONObject printers = Objects.requireNonNull(capable.received.poll(10, TimeUnit.SECONDS), "Expected PRINTERS after ACK");
assertEquals("PRINTERS", printers.getString("type"));
assertEquals(new JSONArray().put(new JSONObject().put("printerId", "laser").put("enabled", false)).put(new JSONObject().put("printerId", "queue").put("enabled", false)).toString(),
printers.getJSONObject("payload").getJSONArray("printers").toString());
assertEquals(java.util.Set.of("printers"), printers.getJSONObject("payload").keySet());
HttpResponse<String> approved = http.send(HttpRequest.newBuilder(URI.create(base + PrintingRoutes.BASE + "/admin/devices/" + device + "/printers/laser"))
.header("Authorization", "Bearer synthetic-management-token-long-enough").header("Content-Type", "application/json")
.PUT(HttpRequest.BodyPublishers.ofString(new JSONObject().put("enabled", true).toString())).build(), HttpResponse.BodyHandlers.ofString());
assertEquals(200, approved.statusCode(), approved.body());
assertEquals(new JSONArray().put(new JSONObject().put("printerId", "laser").put("enabled", true)).put(new JSONObject().put("printerId", "queue").put("enabled", false)).toString(),
capable.take("PRINTERS").getJSONObject("payload").getJSONArray("printers").toString());
service.enablePrinter(device, "queue", true);
assertTrue(capable.take("PRINTERS").getJSONObject("payload").getJSONArray("printers").getJSONObject(1).getBoolean("enabled"));
capable.received.clear();
String job = service.submit(request(device, "approval-unrelated-wakeup")).getString("jobId");
capable.take("JOB_AVAILABLE");
assertFalse(drain(capable).stream().anyMatch(frame -> "PRINTERS".equals(frame.getString("type"))), "An unrelated commit wakeup must not resend PRINTERS");
service.cancel(device, job);
service.removePrinter(device, "queue");
assertEquals(new JSONArray().put(new JSONObject().put("printerId", "laser").put("enabled", true)).toString(),
capable.take("PRINTERS").getJSONObject("payload").getJSONArray("printers").toString(), "A removed printer leaves the approval frame");
capable.received.clear();
capable.send("INVENTORY", "approval-empty-inventory", new JSONObject().put("printers", new JSONArray()));
capable.take("ACK");
assertEquals("[]", capable.take("PRINTERS").getJSONObject("payload").getJSONArray("printers").toString());
}
try(PrintingSocketFixture legacy = PrintingSocketFixture.open(http, base, cookie, device, proof))
{
legacy.initialize(new JSONArray().put("realtime-v1").put("REFRESH_INVENTORY").put("CANCEL_SPOOL").put("printer-media-v1"));
legacy.take("JOB_AVAILABLE");
assertFalse(deviceSnapshot(service, device).getJSONObject("control").getJSONArray("capabilities").toList().contains("printer-approval-v1"));
legacy.received.clear();
legacy.send("INVENTORY", "legacy-inventory", new JSONObject().put("printers", inventory));
assertEquals("legacy-inventory", legacy.take("ACK").getJSONObject("payload").getString("acknowledgedMessageId"));
service.enablePrinter(device, "laser", false);
assertFalse(drain(legacy).stream().anyMatch(frame -> "PRINTERS".equals(frame.getString("type"))), "A connection without printer-approval-v1 never receives PRINTERS");
// The re-shared queue came back awaiting approval, so with laser disabled nothing is approved.
assertEquals(new JSONArray().put(new JSONObject().put("printerId", "laser").put("enabled", false)).put(new JSONObject().put("printerId", "queue").put("enabled", false)).toString(),
service.printerApprovals(device).toString());
}
}
finally
{
launcher.stop();
}
}
/** Collect whatever the server pushes in a short bounded window; this is an absence check, not a polling loop. */
private static java.util.List<JSONObject> drain(PrintingSocketFixture socket) throws Exception
{
java.util.List<JSONObject> frames = new java.util.ArrayList<>();
JSONObject next = socket.received.poll(500, TimeUnit.MILLISECONDS);
while(next != null)
{
frames.add(next);
next = socket.received.poll(200, TimeUnit.MILLISECONDS);
}
return frames;
}
/**
* Closing the routes waits for a channel worker that is already running. A drain that outlived the host could query a stopped file
* database and recreate it empty, which previously left a test's temporary directory populated after cleanup.
@@ -236,6 +236,62 @@ class PrintingServiceTest
/***************************************************************************
** Cleanup purges removed printers and devices only when no job references
** them; a removed device must already be revoked, and its history survives.
***************************************************************************/
@Test
void purgeDeletesOnlyUnreferencedRemovedRows() throws Exception
{
DriverDataSource source = source();
PrintingService service = new PrintingService(source, "tenant-a");
service.initialize();
String device = enroll(service);
service.inventory(device, inventory().put(new JSONObject().put("printerId", "spare").put("name", "Spare").put("formats", new JSONArray(List.of("PDF")))));
service.enablePrinter(device, "zebra", true);
String job = service.submit(request(device)).getString("jobId");
service.removePrinter(device, "zebra");
service.removePrinter(device, "spare");
service.removePrinter(device, "spare");
assertEquals(404, assertThrows(PrintingException.class, () -> service.removePrinter(device, "missing")).getStatus());
assertEquals(0, service.printers(device).length());
assertEquals(409, assertThrows(PrintingException.class, () -> service.removeDevice(device)).getStatus());
String other = enroll(service);
service.inventory(other, inventory());
service.deviceAction(other, new JSONObject().put("requestId", "revoke-other").put("issuedAt", java.time.Instant.now().toString()).put("action", "REVOKE"), "tester");
service.removeDevice(other);
service.removeDevice(other);
assertEquals(1, service.devices().length());
assertEquals(2, count(source, "SELECT COUNT(*) FROM printing_device"));
assertEquals(3, count(source, "SELECT COUNT(*) FROM printing_printer"));
// Two unreferenced printers (spare, the removed device's zebra) and the removed device itself.
assertEquals(3, service.runRetention());
assertEquals(1, count(source, "SELECT COUNT(*) FROM printing_device"));
assertEquals(1, count(source, "SELECT COUNT(*) FROM printing_printer"));
assertEquals(1, count(source, "SELECT COUNT(*) FROM printing_printer WHERE printer_id='zebra' AND removed_at IS NOT NULL"));
assertEquals(0, count(source, "SELECT COUNT(*) FROM printing_printer WHERE printer_id='spare'"));
assertEquals(0, count(source, "SELECT COUNT(*) FROM printing_device_command"));
assertEquals("QUEUED", service.getJob(job).getString("status"));
assertEquals(0, service.runRetention());
assertEquals(404, assertThrows(PrintingException.class, () -> service.printers(other)).getStatus());
}
/***************************************************************************
** Direct row counts; the service deliberately hides archived rows.
***************************************************************************/
private static long count(DriverDataSource source, String sql) throws Exception
{
try(var connection = source.getConnection(); var statement = connection.prepareStatement(sql); var result = statement.executeQuery())
{
result.next();
return result.getLong(1);
}
}
/***************************************************************************
** QBit metadata exposes safe fields and real setup/test-print processes.
***************************************************************************/
@@ -55,11 +55,20 @@ final class PrintingSocketFixture implements WebSocket.Listener, AutoCloseable
/** Separate upgrade from HELLO so a commit in that interval can be tested deterministically. */
static PrintingSocketFixture open(HttpClient client, String base, String cookie, String device, String proof) throws Exception
{
return open(client, base, cookie, device, proof, false);
}
/** Lowercase header names model an ingress that normalizes them, which the device lookup must survive. */
static PrintingSocketFixture open(HttpClient client, String base, String cookie, String device, String proof, boolean lowercaseHeaders) throws Exception
{
PrintingSocketFixture fixture = new PrintingSocketFixture();
String deviceHeader = lowercaseHeaders ? PrintingAgentAuthorization.DEVICE_HEADER.toLowerCase(java.util.Locale.ROOT) : PrintingAgentAuthorization.DEVICE_HEADER;
String tokenHeader = lowercaseHeaders ? PrintingAgentAuthorization.TOKEN_HEADER.toLowerCase(java.util.Locale.ROOT) : PrintingAgentAuthorization.TOKEN_HEADER;
fixture.socket = client.newWebSocketBuilder().subprotocols(PrintingRealtimeRoutes.SUBPROTOCOL).header("Origin", base)
.header("X-QQQ-Client", "api/qqq-print-agent")
.header("Cookie", cookie).header(PrintingAgentAuthorization.DEVICE_HEADER, device).header(PrintingAgentAuthorization.TOKEN_HEADER, proof)
.header(lowercaseHeaders ? "x-qqq-client" : "X-QQQ-Client", "api/qqq-print-agent")
.header("Cookie", cookie).header(deviceHeader, device).header(tokenHeader, proof)
.buildAsync(URI.create(base.replace("http:", "ws:") + PrintingRealtimeRoutes.PATH), fixture).get(10, TimeUnit.SECONDS);
return fixture;
}
@@ -61,7 +61,7 @@ class PrintingUiApiContractTest
var model = version.generateExtensionOpenAPIModel("qqq", extension);
assertEquals(20, model.getPaths().size());
assertTrue(model.getPaths().keySet().stream().allMatch(path -> path.startsWith("/qqq/v1/qbit/printing/")));
assertEquals("2.3.0", extension.getSemanticVersion());
assertEquals("2.4.0", extension.getSemanticVersion());
assertTrue(generated.contains("X-QQQ-Device-Token"));
assertTrue(generated.contains("application/octet-stream"));
assertTrue(generated.contains("principalId"));
@@ -5,7 +5,7 @@
# Protocol fixtures used by PrintingContractTest
Unaltered copies of files from the `fixtures/` directory of the canonical `qqq-print-protocol` repository
at commit `42c0d249f81395f00408ec0f92638dcbbf5deb03` (the commit this source pins), so the contract tests run
at commit `5005f3c9e27053b6c327bb923695012832c0fdcc` (protocol 1.1.0, the commit this source pins; the files below are unchanged since `42c0d249f81395f00408ec0f92638dcbbf5deb03`), so the contract tests run
without a sibling checkout. Only the files the test reads are copied:
- `action-refresh-inventory.request.json`