Skip to content

Instant print job delivery via Mercure (+ FrankenPHP Mercure 1.x fix) - #1628

Draft
francisrath wants to merge 18 commits into
Part-DB:masterfrom
francisrath:feat/print-agent-mercure
Draft

francisrath wants to merge 18 commits into
Part-DB:masterfrom
francisrath:feat/print-agent-mercure

Conversation

@francisrath

Copy link
Copy Markdown

Draft, not ready to merge. This hasn't been tested enough yet. Push was tested against a Mercure 1.1.5 hub in Docker on one machine; it has not been tested behind a real reverse proxy or in the built FrankenPHP image. I'm opening it to get your feedback on the approach before polishing it.

Part of #1625; the third of three alternative PRs. It includes #1626 and #1627. The commits new here start at "feat(agents): publish new print jobs to Mercure".

With #1627, agents poll every 30 s. This PR lets Part-DB notify them through a Mercure hub, so a job prints about a second after the click. Polling stays as the fallback.

What it adds

  • Publishing: when a job is created or resent, Part-DB publishes a private update on urn:part-db:agent:<id>:jobs.
    • It uses symfony/mercure (0.9, Mercure protocol 1.0) directly, not the bundle. With an empty MERCURE_URL outside FrankenPHP, the bundle's Twig extension builds the hub on every Twig boot and throws, which broke cache:clear in my test. Here the hub is only built when push is on.
  • Subscription offer: the heartbeat answer gets mercure: {hub, topic, token}. The token is a 1-hour subscriber token for that one topic.
  • Pending count: the heartbeat answer also gets pending, the number of pending jobs. Agents only list jobs when there are some, and an agent with a live subscription can check in less often. A lost event is still picked up at the next check-in.
  • Off by default: push is off unless MERCURE_PUBLIC_URL is set. Push misconfigurations (an unreachable hub, an empty or too short secret) are only logged; agents keep polling.
    • Publishing uses a 2 s / 5 s HTTP timeout, so an unreachable hub can't hold up the Print button.
    • Env: MERCURE_PUBLIC_URL, MERCURE_URL, MERCURE_JWT_SECRET, MERCURE_JWT_ISSUER. They are documented in docs/configuration.md, including the sidecar hub for the Apache image and resource_identifier behind a TLS-terminating proxy.

FrankenPHP image fix (useful on its own)

dunglas/frankenphp:1-php8.4-bookworm is now FrankenPHP 1.13.4 with Mercure 1.1.5. With the current .docker/frankenphp/Caddyfile:

  • As soon as MERCURE_PUBLISHER_JWT_KEY is set, the server refuses to start. The publisher_jwt/subscriber_jwt directives "work only in compatibility mode" in Mercure 1.x.
  • transport_url is "not available anymore" and is ignored.

This PR moves the keys into an issuer {$MERCURE_TRUSTED_ISSUERS:https://localhost} block and uses transport bolt. An issuer block needs a key, so the entrypoint sets a random publisher key when none is configured. Keyless installs keep starting, and the hub then accepts no outside tokens.

I checked both cases with frankenphp validate in that image, but I have not built and run the full Part-DB image.

Tests

  • New tests: AgentJobPusherTest (private update, publish failure doesn't break job creation, unreachable hub doesn't block, short secret), and AgentApiControllerTest (offer present or absent, token claims, misconfigured secret, pending count).
  • PHPStan passes.
  • End to end with pdb-print and a real Mercure 1.1.5 hub:
    • The job reached the agent 0.24 s after the click.
    • With the hub stopped, job creation still worked and the job printed at the next poll.
    • An idle agent only checks in once a minute.

🤖 Generated with Claude Code

francisrath and others added 18 commits October 28, 2026 21:47
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds a Tools entry for the print agent page and documents the agent contract.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The bulk lot action and hand-typed ids can contain spaces, ';' or empty entries,
which agents don't have to accept. Also: lowercase pairing key, popup without
opener, per-profile labels on the menu icons, and the docs now say which bytes
form the HMAC key and how agents should check ts, replays and src.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agents register with an API token of their owner and identify themselves by a
UUID, so renewing the token keeps them. Jobs are never re-queued automatically.
Label profiles get an optional default printer id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agents send a heartbeat with their printers, list their pending jobs, claim one
atomically and report the result. Only API tokens are accepted, and agents only
see their owner's jobs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The label dialog offers the user's agent printers (default printer of the
profile first), the label menu icon prints on the profile's default printer, and
the parts table gets "print labels" actions. Without agent printers the buttons
keep handing labels to the browser's local agent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The print agent page lists your agents with their printers and recent jobs, with
"send again", cancel and remove. Label profiles get a default printer field that
suggests the printer ids your agents report.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- reject heartbeats over 64 KiB, and require API access for agents
- the lot print action falls back to the dialog for parts without lots
- cancelling a job is atomic against an agent claiming it
- "send again" refuses printers the agent no longer reports
- redirects after printing refuse control characters
- label menus look up the user's printers once per request
- the agents page shows the reported model, media and status

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jobs are published as private updates on urn:part-db:agent:<id>:jobs when
MERCURE_PUBLIC_URL is set. Uses symfony/mercure directly; the hub is only
built when push is on, so Part-DB works unchanged without a hub.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FrankenPHP 1.13 ships Mercure 1.x, which refuses the top-level
publisher_jwt/subscriber_jwt directives as soon as a key is set, and no
longer knows transport_url. Move the keys into an issuer block
(MERCURE_TRUSTED_ISSUERS, default https://localhost) and use the bolt
transport directive. An issuer needs a key, so the entrypoint uses a
random publisher key when none is configured.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…configured

A missing or too short MERCURE_JWT_SECRET made every heartbeat fail, so
agents stopped printing; now they just poll. Publishing uses short HTTP
timeouts, so an unreachable hub can't hold up the Print button for 60 s.
Document resource_identifier for hubs behind a TLS-terminating proxy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agents only need to list jobs when there are some, which halves their
idle requests, with or without Mercure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant