24156 Commits

Author SHA1 Message Date
Andrey Antukh
46cf000635 🔥 Retire the legacy app.* tree from the exporter
Port the last live namespaces adapted to the new tree (config,
mime, object, resources, the wasm engines) with the temp ownership
folded into tmpdir, and delete src/app/ outright: 28 legacy files
gone, every behavior covered by the new suite.

The render env (uris, management key, tmpdir, image cache) now
travels in the lease message; the worker thread reads no config
and no globals. The generated shared.js moves next to the new
enums (build env, gitignore and docs follow it). Legacy tests fall
with a case-by-case parity check against the new suite.

Parity: api->consumer-api (7/7 same), worker->consumer+worker
slices, config/plan/jobs/shell/pool suites superseded, svg fills
moved to the new tree over the real module.

AI-assisted-by: muse-spark-1.3-contributor
2026-10-09 12:20:22 +00:00
Andrey Antukh
e9346210d3 ✨ Declare the export kind instead of sniffing item types
Backend `export-assets` params gain optional `:kind` (`:shapes`/`:frames`,
declared by the frames dialog); the worker routes on it instead of
inferring frames from missing item keys, which `normalize-items` used
to overwrite. Frontend freezes fully typed pages plus the kind. The
shell moves to the new tree with an injected temp dir, and the dead
legacy entry and transit utils go away.

AI-assisted-by: muse-spark-1.3-contributor
2026-10-09 07:09:41 +00:00
Andrey Antukh
3225f1bdfd ✨ Own the temp area as the tmpdir service
The legacy shell creates its tmpdir at namespace load, out of any
lifecycle. The :exporter/tmpdir service pulls that moment into the
boot: fail fast on a bad :tempdir, ordered before the renderer that
writes to it, carrying the path for the day it becomes injected.

AI-assisted-by: muse-spark-1.3-contributor
2026-10-08 21:54:59 +00:00
Andrey Antukh
be3f7d39fd ♻️ Render as an injected service with validation at the edge
The renderer becomes a system service (:exporter/renderer) whose
instance is the render fn with its config closed over; the consumer
renders through that dependency and never touches the facade. render
takes the task map as is, and only render-fn validates, so a bad
batch is rejected at the single entry before anything is leased. The
worker names the renderer by ref, service keys follow the domain,
and main boots render workers on their own thread.

AI-assisted-by: muse-spark-1.3-contributor
2026-10-08 21:51:53 +00:00
Andrey Antukh
395ab4eefa ✨ Port the management client to the new exporter tree
exporter.consumer.api mirrors the legacy client call for call
(claim, progress, session, multipart settle) with ^:async and await
instead of promesa chains, keeping the fetch def seam; and
exporter.consumer.config carries the queue knobs. Runner, worker
and main read the new namespaces, so nothing under exporter.*
depends on app.consumer.* anymore.

AI-assisted-by: muse-spark-1.3-contributor
2026-10-08 18:43:02 +00:00
Andrey Antukh
4eebf51041 🎉 Port the queue consumer to the new exporter tree
The runner (exporter.consumer), its plan, the local cancel registry
(exporter.jobs) and the queue poller (exporter.consumer.worker) now
live beside the renderer: same beats, watchdog and settle, rendering
through the facade with one check shared by units of work and
drivers. The worker rides the system as :exporter.consumer/worker,
so the process has something holding it. The settle also releases
the registry entry the legacy runner leaked.

AI-assisted-by: muse-spark-1.3-contributor
2026-10-08 18:41:08 +00:00
Andrey Antukh
b94174f1d1 🎉 Give each render backend its own scope namespace
The wasm lease moves to exporter.wasm.scope (run over cfg plus an
on-scope body, low-level render-on with asserted cfg), and the
browser batch gains exporter.browser.scope with the mirrored run
over cfg plus exports. The facade validates once, partitions by
backend and joins; sys is now cfg everywhere, headless? is wasm?
and the batch resolves nil. A misresolved key also stops masking
a missing timeout as the 300s default.

AI-assisted-by: muse-spark-1.3-contributor
2026-10-08 17:54:26 +00:00
Andrey Antukh
a2dd3caaf3 🎉 Unify the renderer cancel around one injected check
Both drivers take the same sys-first shape and read the job fate
from a single zero-arg check: cooperative checkpoints on the
browser, checkpoints plus a self-abort of the leased worker on
wasm, whose signal buffer is born in the lease itself. The facade
takes sys plus a closed, validated task map, so a bad task never
touches a pool. Sys stays task-free infrastructure.

AI-assisted-by: muse-spark-1.3-contributor
2026-10-08 14:38:08 +00:00
Andrey Antukh
373116fe33 🎉 Add the renderer facade and wire both pools in main
The malli-validated facade dispatches each render to the browser
or the headless driver by its wasm flag, sharing one leased
worker across the headless renders of a scope. The production
wiring declares both pools sized to the worker concurrency; the
wiring itself is asserted as data because booting it would spawn
a real render worker off the test bundle.

AI-assisted-by: muse-spark-1.3-contributor
2026-10-08 11:41:29 +00:00
Andrey Antukh
77ed919997 🎉 Port the renderer services to the new tree
The wasm worker pool becomes a service with an injectable worker
factory and its creation handshake as a unit of its own. The
bitmap/pdf/svg flows merge into one browser renderer driven by an
explicit context, and the headless driver leases one worker per job
with its cancel source injected instead of reaching the registry.
Each piece ships with hermetic tests off fake workers and pages.

AI-assisted-by: muse-spark-1.3-contributor
2026-10-07 18:59:02 +00:00
Andrey Antukh
2876c92952 ♻️ Boot the exporter from the new tree entry point
shadow boots exporter.main/main now: the browser pool starts
through the system, with reload hooks on its dev metadata and the
old app.core reload path commented out. The entry installs the
process handlers (idempotent, on start rather than on load) and
enables info logging for the exporter tree, without which the
default level filters everything below fatal. Service params go
unqualified: the top-level key already scopes the component.

AI-assisted-by: muse-spark-1.3-contributor
2026-10-07 17:52:10 +00:00
Andrey Antukh
ab6d77954a 🎉 Start the parallel exporter tree with system and browser
New code grows under `exporter.*` instead of extending `app.*`:
`exporter.main` holds the future entry point, `exporter.utils.system`
is an async-aware Integrant-style lifecycle (`init-key`/`halt-key`
multimethods, `ref`s, dependency ordering, `^:async` + `await`, no
promesa chains), and `exporter.browser` ports the whole browser
namespace as a pool service with an injectable factory. Each piece
ships with its tests, registered in the test runner.

AI-assisted-by: muse-spark-1.3-contributor
2026-10-07 17:34:55 +00:00
Andrey Antukh
ade36617b3 ♻️ Drop the dead requires and bindings the linter flags
Three requires nobody reads (`app.common.uuid` in the consumer
worker, `app.common.exceptions` in the api test, `node:path` in the
worker test), the `original-fetch`/`original-render` vars left over
from before the fakes returned their own restore thunks, and the
`port` and `terminated` atoms no test ever reads.

AI-assisted-by: muse-spark-1.3-contributor
2026-10-07 17:34:53 +00:00
Andrey Antukh
42cd7d6f9e 🐛 Restore obj/set! over-renamed by the bang-drop script
The mechanical rename turned the qualified `obj/set!` call sites
into `obj/set`, but the definition keeps its `!`: the name is shared
with the frontend twin `app.util.object`, which is not ours to
rename here. Call sites go back to `obj/set!`, and the
`:refer-clojure :exclude` goes back to `set!` with them.

AI-assisted-by: muse-spark-1.3-contributor
2026-10-07 17:34:46 +00:00
Andrey Antukh
71a96351c4 ♻️ Name the temp-file boot clean for what it does
`app.jobs.utils/init` initialized nothing: its only work was the
boot-time sweep of temp files a crashed process left behind. It is
now the public `clean-orphans`, with a docstring that says it runs
once at boot, and `app.core` calls it by that name.

AI-assisted-by: muse-spark-1.3-contributor
2026-10-07 17:34:44 +00:00
Andrey Antukh
b1cd524f9b ♻️ Drop the ! suffix from the exporter namespaces
Mechanical rename, no behavior change: every function the exporter
defines loses the `!`, at the definition and at every call site, in
`src` and in the tests. Two names could not just drop it: the
mutating `merge!` shares the namespace with a persistent `merge` of
another contract, so it becomes `merge-into`; the test restore thunks
take the role as their name (`fetch-restore`, `render-restore`). Two
dead pieces go away with their bangs: the never-called `rmdir!` and
an unused `mark!` binding in the cancel test. Untouched on purpose:
the stdlib (`swap!`, `run!`, `try!`, …), the `set!` special form,
`common` (`serialize-shape!`, `write-shape-*`), and the shared-key
config the deploy still reads.

AI-assisted-by: glm-5.3-flash
2026-10-07 14:37:08 +00:00
Andrey Antukh
74f8d563b4 ✨ Show the export queued, then progress as a percentage
The widget could never say queued: the creation answers the row
status (`pending`) and the widget only knew `queued`, so every export
opened on the generic exporting wording. The widget now names a fresh
job queued, and only the first milestone moves it to running — the
worker sends that first breath right after the claim, before the
session roundtrip. The figure is a percentage once the worker said how
much there is, and nothing while it has not: a milestone without
counters names the stage but no longer blanks the figures, and the
queued and terminal states show no counter at all.

AI-assisted-by: glm-5.3-flash
2026-10-07 14:02:17 +00:00
Andrey Antukh
a63140d309 🐛 Settle a cancelled export without noise or a leaked watchdog
A cancelled export is a normal ending, but the run treated it like a
failure: an error log with a full trace for something the user asked
for. Worse, the watchdog interval outlived the run — its stopper was
bound inside the scope the failure unwinds, so a cancelled or failed
job kept beating on a dead row forever, one HTTP call a second that
only ever answers skip. The stopper now travels in an atom the catch
reaches, the run stops beating on every ending, and a cancel logs a
single info line where a failure keeps its error with the cause.

AI-assisted-by: glm-5.3-flash
2026-10-07 13:54:15 +00:00
Andrey Antukh
b22cce46f1 🐛 Parse the claim strings into renderer uuids
The claim carries the job row JSON: every id a string. The run handed
them to the renderer as-is, and the render spec only takes uuid
objects, so every export died on `render-params`. The run now parses
the item ids at the normalize boundary, threads the job-id through
the plan (it never arrived there: the render saw nil), and omits an
absent share-id instead of naming nil.

AI-assisted-by: glm-5.3-flash
2026-10-07 13:42:20 +00:00
Andrey Antukh
f785fd61c1 ⚡ Log where the exporter worker points at boot
A worker that cannot reach its backends fails every turn with a
network error, and the curing question is always where it was
looking. The boot now says it once: pollers, queue, redis host and
backend URI (host only, never credentials).

AI-assisted-by: glm-5.3-flash
2026-10-07 13:37:29 +00:00
Andrey Antukh
c9fb11470b 🐛 Declare the transit content type on management calls
Every poller turn died on `claim-job` with `missing-key` for `job-id`
and `scheduled-at`: the JSON calls never declared their content type,
so the backend never parsed the body as transit and validated an empty
map. The header goes on the transit calls only; the multipart settle
keeps the content type its own FormData settles.

AI-assisted-by: glm-5.3-flash
2026-10-07 13:33:33 +00:00
Andrey Antukh
e4bf63cf89 🔧 Point the devenv exporter at nginx and drop the old route
The worker reaches the backend through `internal-uri`, which fell back
to the host-side public URI (unreachable from inside the container),
so every poller turn died on `fetch failed`. The devenv now defines
`PENPOT_INTERNAL_URI=http://localhost:4449`: nginx is the outermost
platform layer (Caddy only emulates the external HTTPS), and no service
goes outside to come back in. The stale `/api/export` location, which
pointed at the deleted exporter HTTP port, goes away with it.

AI-assisted-by: glm-5.3-flash
2026-10-07 13:28:34 +00:00
Andrey Antukh
b456ca6fe0 🐛 Accept an empty suffix on export asset items
The export form sends `""` when the user types no suffix, and the
legacy `/api/export` surface took a plain string there. The job schema
asked for `::sm/text` (not blank) instead, so every suffix-less export
died with `:params-validation` before reaching the worker. The suffix
is a free-form filename fragment the worker concatenates as-is, so the
schema takes any string now, empty included.

AI-assisted-by: glm-5.3-flash
2026-10-07 13:13:17 +00:00
Andrey Antukh
e5a533daea 📚 Document the wake-up contract of a worker queue
An out-of-JVM process could not learn that work exists without reading
the dispatcher: the key shape lives in `app.worker/queue-key`, but the
payload format was only in two private places (`encode-payload` in the
dispatcher and the runner's local decoder), and nothing said what a
pop means or what a consumer owes. The backend memory now states the
contract as a public surface: the key with its tenant prefix, the
two-item payload and its format, mark-scheduled-then-push and why, the
scheduled-at handed back as-is on the claim (what makes the queue a
notification and not the truth), the management API as the only
authority, no owner of the queue and the runner rule that follows, and
the beats as the cancel channel (linked to the heartbeat note, not
duplicated). The ephemeral request/reply lane stays out on purpose.

The exporter memory points at it as the first external consumer.

AI-assisted-by: glm-5.3-flash
2026-10-07 14:49:53 +02:00
Andrey Antukh
b57b479618 📚 Document the exporter as a queue-only worker
The backend memory names the management methods the external worker
talks to (claim, progress, session, complete, fail, and the skip
answers of a lost write), the exporter memory describes the worker
after the deletion (local cancel arm, pools sized to the concurrency,
plan namespace, no redis of its own) with the boot and shutdown
sequence, and the testing memory notes the default secret the test
runners assume. Nothing describes the old HTTP surface anymore.

AI-assisted-by: glm-5.3-flash
2026-10-07 14:49:50 +02:00
Andrey Antukh
b7ceb561f9 🔥 Delete the exporter's HTTP surface and the old job system
The exporter is a consumer's process now, so everything that only the
HTTP surface held goes away with it: the server, the router and the
cookie auth; the export handlers (the runner up past their pieces
through `app.consumer.plan`, and `handlers/resources` keeps only the
local artifacts); the scheduler with its admission caps; the redis
job store, publish and cancel-sweeper; and with the store gone, the
redis engine too — the pollers own plain ioredis connections.

`app.jobs` is the local cancel arm only: a runtime record the claimed
job is registered under, the flag the parts of a run read between
renders, the shared-array signal a render thread reads inside a render,
the terminate callbacks, and a `mark-cancelled` that marks the record
terminal before the callbacks run. The row of the job was never this
process's business.

The pools size themselves to the worker concurrency, which is what a
process of K pollers can run at once; there is no independent pool cap
nor scheduler admission left. Docker: the nginx export proxy and the
exporter uri die with the endpoints they pointed at; the compose keeps
the exporter env thin and honest, the README describes the worker.

AI-assisted-by: glm-5.3-flash
2026-10-07 14:09:06 +02:00
Andrey Antukh
3a6ee0003f ⚡ The frontend exports assets through the jobs substrate
Every shape, frame and simple export of the UI is one `:export-assets`
job now: the backend creates it with the items frozen, the external
worker renders it, and the client follows its life on the same
websocket channel the binfile export has ridden since it became a job.
The widget shows the real milestones of the worker (`:preparing`,
`:rendering`, `:packaging`, with the `objects` and `pages` counters,
two new translated stage labels), downloads the artifact of the
completed row, and its cancel asks the backend to stop the job. The
saturation of the export queue answers as the exporter's own busy
state, so the user is told to retry.

The three doors of the legacy surface are gone: `repo` no longer
carries `:export`, `:create-export-job` or `:cancel-export-job` —
there is no `/api/export*` call left in the frontend — and their other
two callers (copy-as-image and the plugin export) fetch their blob
from the row's `result` resource now. The single-object wasm render
stays client-side: it never leaves the browser, so it keeps
downloading locally without a job.

A viewer that exports through a public share names the share in the
items, and the creation validates every item the way the share fetches
do (the same 4-arity `check-read-permissions!` `get-file-fragment`
uses); the command stays authenticated, the same as the exporter
cookie it replaces, so an anonymous visitor is refused. Also here: the
management `complete`/`fail` settle closes the named render session
even when they lose the write, the piece the exporter's cancel work
left pending.

Two new translated strings (`exporting-preparing`,
`exporting-packaging`), a share-aware permission test of the assets
creation, and the data flow tests rewritten over the jobs channel.

AI-assisted-by: glm-5.3-flash
2026-10-07 14:09:04 +02:00
Andrey Antukh
dba39c953d ♻️ The exporter boots as a queue consumer by default
Nothing of the old exporter deserves preserving: once the plan ends
there is no HTTP surface, so the default of `PENPOT_EXPORTER_ROLES`
stops being `http` and becomes `worker`. The process that dev and
deploy have always started (the tmux window's recipe and the compose
service, both untouched) now boots the K pollers of the backend queue;
`http` stays selectable by env only until the deletion task takes the
legacy surfaces away.

AI-assisted-by: glm-5.3-flash
2026-10-07 14:03:09 +02:00
Andrey Antukh
8391fbdf07 ✨ Cancel exports through the beats and boot by roles
A cancellation reaches the worker through its beats: a `skip` answer
from `report-job-progress` says the job can no longer hear it. The
runner raises `:job-cancelled` between units of work (the same
exception the legacy surfaces raised), and the render mid-Skia dies by
terminate: the watchdog of the beats fires the cancel port the wasm
pool already serves (`jobs/mark-cancelled` raises the flag, arms the
shared-array signal and runs the terminate callbacks
`renderer.wasm/with-scope` registered), and the run settles as
`fail-job` with the session id, which the backend answers skip on a row
it already cancelled.

The settle is what ends the render session's life, whatever the write
says: complete and fail now close the named session even when they
lose the write, so no settle of the worker leaves a session behind.

The process boots by roles (`PENPOT_EXPORTER_ROLES`): `http` boots the
server and its pools exactly as today, `worker` boots K pollers of the
backend queue, and both roles coexist during the transition sharing
the engines; the shutdown unwinds the worker before redis, so no poller
pops on a closing connection.

AI-assisted-by: glm-5.3-flash
2026-10-07 14:03:09 +02:00
Andrey Antukh
c0b4430293 ✨ Add the exporter worker pollers and the export jobs run
The `:exporter` queue gets its consumer: K pollers (the new
`:exporter-worker-concurrency`), each one with its own Redis
connection, so the blocking BLPOP is both the semaphore of the slot and
the backpressure: a poller claims one job and does not pop again until
the job settles. The dispatcher's `[job-id, scheduled-at]` payloads are
read per pop; a corrupt payload is logged and dropped, a Redis hiccup
is a failed turn that the loop retries after a small delay, and no
test exercises the forever loop directly.

The run of the `:export-assets` job ports the legacy export machinery
to the substrate: the item transducers and partitioning of
export-shapes, the zip assembly and the pdfunite join of export-frames,
rendering as the session the backend minted. The progress contract
dresses the beats (`:preparing`, `:rendering`, `:packaging` with the
`objects` and `pages` counters, throttled per object) and the settle is
one `complete-job` multipart that stores the artifact and closes the
session; an export failure settles as `fail-job` with the session id,
self-contained, so the loop only ever sees a resolved run.

AI-assisted-by: glm-5.3-flash
2026-10-07 14:03:09 +02:00
Andrey Antukh
fe636282cb 📚 Document promesa API shapes in memory
Two API families with inconsistent argument order, verified against
the 12.0.1 jar: fmap/mcat are fn-first (they pair with ->>), then/catch
are promise-first (they pair with ->). The bind-style ops require the
fn to return a promise; a plain value crashes with "expected
thenable". The repo convention prefers ->> composition, and legacy ->
chains stay as they are. Routes from critical-info and exporter/core.

AI-assisted-by: glm-5.3-flash
2026-10-07 14:03:09 +02:00
Andrey Antukh
a9a105509b ✨ Add the worker config and the management API client
The worker side of the exporter gets its config: `PENPOT_EXPORTER_ROLES`
(`http` by default, `http,worker` during the transition) and
`PENPOT_EXPORTER_WORKER_CONCURRENCY` (K pollers, the concurrency of the
process, floored at one), both read from the live config, never at
import time; every accessor is a plain function so the tests do not
journey through globals. The queue key it polls is
`penpot.worker.queue:<tenant>:exporter`, the same one the dispatcher
pushes.

The management client (`app.consumer.api`) is the only place of the
worker that speaks HTTP: claim, progress, session, complete and fail,
all POSTs with `X-Shared-Key: exporter <key>`, transit+json bodies and
transit answers. The artifact form of `complete-job` rides multipart
(the undici FormData the uploads already use); a non-200 answer arrives
as the failure of the call, carrying the status and the error body.

AI-assisted-by: glm-5.3-flash
2026-10-07 14:03:09 +02:00
Andrey Antukh
8328974f2e ✨ Complete jobs with the multipart result and skip status
`complete-job` accepts a second form: the worker sends the artifact
itself as a multipart body, the server stores it in the `job-resource`
bucket owned by the job profile, and the completion coalesces
`resource_id` and the result descriptor `{resource-uri, filename,
mtype, size}` in its own transaction (first terminal state wins; a
lost race releases the artifact it stored, so nothing lingers for the
storage GC). The checks run before any object is written: a lost race
against a cancel never stores bytes, the artifact type must be one the
export produces (new `cm/export-artifact-types` allowlist) and the size
must fit the new `:exporter-max-result-size` config (default 350 MiB)
below the HTTP multipart ceiling.

`complete-job` and `fail-job` take an optional `session-id`, deleted
best-effort after the commit, and every write method answers `:skip`
with the `:status` of the row it lost against, read fresh after the
failed write, so a worker tells a cancelled job apart from one it was
too late for.

AI-assisted-by: glm-5.3-flash
2026-10-07 14:03:09 +02:00
Andrey Antukh
53008ea794 ✨ Add create-job-session for external worker rendering
The management method mints a real `http_session_v2` of the owner of a
running job, so a worker renders as the user through the auth surfaces
that already exist (cookies for render.html and assets, Bearer for the
RPC). It refuses anything the login would not accept: an unknown job,
a job that is not running, a job without owner, and an owner profile
that is inactive, blocked or deleted.

Every call mints a fresh session, so worker retries never wait on one
a settle may have closed, and the token carries the short `:exp` of the
new `:job-session-ttl` config (default 1h): a worker that dies without
settling leaves nothing usable for long, and the sessions idle GC
sweeps the row. The token minting moves to a public `generate-token` in
app.http.session, with an explicit `:exp`, and the login path delegates
to it unchanged.

AI-assisted-by: glm-5.3-flash
2026-10-07 14:03:09 +02:00
Andrey Antukh
c05a05b4c3 ✨ Add the :export-assets job-def (external exporter)
The job-def freezes the export items the frontend prepares (file, page,
object, type, scale, name and the optional share-id) and routes the job
to the `:exporter` queue through the new `::jobs/queue-name` metadata,
which the creation command reads and the substrate never does. The
handler only raises: the work runs on the external worker, and a wrong
configuration that claimed the job on a JVM runner fails visibly.

Export has one command per job type: `create-export-binfile-job` keeps
the exact behavior the export command had, and `create-export-assets-job`
checks the read permission of every file named in the items before
anything is stored. The family quote still counts every export job of
either kind.

AI-assisted-by: glm-5.3-flash
2026-10-07 14:03:06 +02:00
David Barragán Merino
27ccb32ea1 👷 Fix the devenv image build workflow
AI-assisted-by: Claude
Signed-off-by: David Barragán Merino <david.barragan@kaleidos.net>
2026-10-07 13:17:13 +02:00
Marina López
d0cf399807
🐛 Show trial status on subscription page (#12158) 2026-10-07 13:14:58 +02:00
Marina López
65b248eec3
🐛 Preserve team ID in Nitrate membership URL (#12157) 2026-10-07 13:08:02 +02:00
David Barragán Merino
edd500302c ⬆️ Update ImageMagick to 7.1.2-32
Signed-off-by: David Barragán Merino <david.barragan@kaleidos.net>
2026-10-07 13:02:27 +02:00
Belén Albeza
1d7c775f72
🐛 Fix not setting mimetype in image fills (#12136) 2026-10-07 12:26:45 +02:00
Alejandro Alonso
cc5f2f57ad
⬆️ Upgrade skia-safe to 0.153.3 with wasm SIMD (#12148)
Point SKIA_BINARIES_URL at the 0.153.3 simd prebuild and adapt
fonts, path stroke expansion, and gradients to the new API.
2026-10-07 12:08:33 +02:00
Andrey Antukh
9abcfebf60
✨ Run binfile import and export as durable jobs (#12056)
* ✨ Run binfile import and export as durable jobs

Import and export of `.penpot` files become durable jobs: two commands
create them and answer at once with a job id the caller follows, while
a worker of the `binfile` queue does the work. Each job keeps its own
ledger (owner, params frozen at creation, result, progress history and
an expiry), and the legacy commands and their SSE keep working.

Why: both operations ran inside the request. A large import held it
open for minutes, progress only existed while the caller kept the
stream open, and a client that went away left work nobody recorded,
retried or could cancel. The unified `job` substrate already dispatched,
leased, retried and logged events, but it had no user-facing ledger.

How:
- The substrate gains the pieces a user-facing job needs: optional
  job-def metadata (family, resource-role), an expiry on submit, the
  owner in the runner context, and job resources in their own bucket,
  written with the profile of the job and never deduplicated.
- One generic command per family: the caller names the job and sends its
  business params, the registry checks the family, the job-def decodes
  and validates, and permission and quota are checked first.
- Progress taps of the core become milestones of the job: a shared stage
  vocabulary with self-contained counters, throttled heartbeats, and an
  interrupt that stops the run inside its own transaction on cancel.
- Cancel reaches pending and running jobs from both modals, and closing
  a modal mid-run cancels the job it was following.
- The export re-checks the read permission at run time and answers with
  the package descriptor; the import re-checks the edition permission,
  runs the core in a transaction so a cancellation rolls it back, and
  releases the consumed package and its temporary copy.

AI-assisted-by: deepseek-v4.1-flash

* ♻️ Handle externally-cancelled binfile jobs end to end

The `cancelled` outcome of a job nobody followed no longer hangs its
screens: the data layers emit an explicit terminal message for it.

- The export emits `{:cancelled true}` per file and the modal paints a
  neutral cancelled row instead of success; a message without an
  artifact never reaches the download branch.
- The import closes every entry with a dedicated cancelled message
  through the existing error rendering, so the wizard completes.
- Both flows are pinned by data-layer tests, including the red state
  they replace.

Also in this change: the progress-contract comment of the management
API, early business-params validation in create-import-job (before
assembling the upload), a `release-resource` helper without fabricated
job ids, a note on the heartbeat write cost, and a memory cleanup.

AI-assisted-by: muse-spark-1.3-contributor

* ♻️ Pre-translate the cancelled import message

The cancelled entry of the import wizard carried its message as a key
for the UI to translate, which needed a rehash marker comment. Like
its neighboring errors, it now translates eagerly in the data layer:
the literal call keeps rehash clean with no marker, and the tests
assert through the same call instead of the raw key.

AI-assisted-by: muse-spark-1.3-contributor

* ♻️ Move cancelled strings under generic labels keys

The dashboard-specific cancelled key never shipped: before use it moves
under `labels.*` next to the generic strings both flows already use,
with Spanish translations following the established wording.

AI-assisted-by: muse-spark-1.3-contributor

* ✨ Add minor improvements

* ♻️ Rename export job command to create-export-binfile-job

Export now has one command per job type, the shape the :export-assets
command needs: create-export-binfile-job freezes exactly the
:export-binfile job, so :name no longer travels in the body and only
:params does. Import keeps the generic envelope.

submit-job reads the queue from the job-def metadata ::jobs/queue-name
(falling back to :binfile), read by the command and never by the
substrate, so a job-def can route its work to another queue without a
change in the command.

The rename reaches the frontend and the tests: the binfile export is
asked for under its own name, and the exporter service keeps
create-export-job.

AI-assisted-by: deepseek-v4.1-flash

* ✨ Polish the import and export status rows

The status row of a file already carries what the file is doing, so the
generic "Uploading file…"/"Downloading file…" lines are gone: the
import wizard drops its own (the export one went with the jobs
refactor), along with the now-unused style.

In the export, every file is queued up front, even the ones whose job
is created only after the previous one is over, and a file stays queued
until its first milestone: a worker picking the job up no longer ends
the queue. The queued text is a step smaller than the milestone.

AI-assisted-by: deepseek-v4.1-flash

* 🐛 Show the cancelled export and fix the import error

The cancelled export was unreachable: mark-file-cancelled sets :loading
false and the label lived inside the loading branch, so a cancelled
file only painted a neutral notification. The terminal state now
carries the label, and the export flags lose the `?` suffix
(predicates keep it; a data prop is not a question).

The import wizard rendered (:error entry) through tr, but every
producer writes user-facing text (a hint or a translated label), never
a key: it renders the text as-is now, and the dynamic-key ignores and
the stale comment are gone.

The total-text-limit comment no longer references the 200MiB it
replaced; the value is 500MiB in develop already.

AI-assisted-by: deepseek-v4.1-flash

* ♻️ Reflect the real job params in the creation commands

The creation commands declared their params as an open empty map, so the
RPC contract and the generated docs said nothing about them. Each command
now imports the schema from the job namespace
(`export-binfile/schema:params`, `import-binfile/schema:create-params`),
and the RPC validates the business params before the command runs. The
job-def still owns the params and re-validates the assembled ones at run
time. The commands no longer decode the params again: the RPC did it.

Import joins export as one command per job type: `create-import-job`
becomes `create-import-binfile-job`, its body no longer carries `:name`
and the job-def is resolved by name like export's. Its creation schema
keeps the version optional, because the command reads it from the package
header when the caller does not say it, and fills the manifest metadata
kept for audit.

A version out of range is now refused by the schema (`:params-validation`)
instead of the command's own `:unsupported-version` check, which is gone.

AI-assisted-by: deepseek-v4.1-flash

* 🐛 Stop the test system from running job runners

The test system is main/system-config plus main/worker-config with a
few components dissoc'ed. The runners of default, webhook and cron were
listed by hand; the binfile runner added with the durable binfile jobs
was not, so a real penpot/job-runner/binfile/0 thread ran during the
whole suite.

That runner races the tests that drive the loop by hand (run-batch plus
run-worker-loop): it takes the job the test just dispatched and
completes it, and the test then reads a state it did not produce. That
is the reported flake of binfile-jobs-integration-test, whose middle
assertion waits for a runner of another queue to not take the job. It
only reproduced in CI: in the devenv PENPOT_TENANT makes the tenant
baked into main/worker-config differ from the one the test builds, so
the two used different queue keys locally.

Drop every [<profile> :app.worker/runner] by key shape instead of
listing the known queues, so a new queue cannot bring the race back.

AI-assisted-by: deepseek-v4.1-flash

* ✨ Upload the package one chunk at a time and as a percentage

The upload milestone counts chunks, not units of work, so the row wrote
"Uploading the package 3/9" over a file the user had uploaded once: it
read as if nine packages were on their way. Write the stage as a
percentage of the file instead ("Uploading file… (33%)"), and make the
English text of the stage say file, not package.

The chunks also go one at a time now (concat-all instead of merge-all
with two in flight), so the percentage moves in a straight line instead
of jumping. upload-blob-chunked is the generic uploader, so media and
font uploads become sequential too.

AI-assisted-by: deepseek-v4.1-flash

* 🌐 Complete the Spanish catalogue

es.po was 25 keys short of en.po: the 15 that the durable binfile jobs
introduced (14 jobs.progress.stage.* and labels.queued) plus 10 gaps
that predate them (errors.connection-error, errors.save-retrying, the
find shortcuts, workspace.header.retrying,
workspace.tokens.stroke-width and the mcp session labels).

sync -l es then sorts the entries, syncs the #: comments from en and
drops the 7 dashboard.import.progress.* keys en no longer has. The
msgid sets of en and es are now identical (0 missing, 0 extra).

AI-assisted-by: deepseek-v4.1-flash

* ♻️ Move the job status keys into the jobs namespace

The three status words the durable import and export rows render were
filed under labels.*, next to the generic labels bucket, while their
progress stages live under jobs.progress.stage.*: the strings of one
feature sat in two places.

Rename them so the whole job vocabulary is one namespace:

  labels.queued           -> jobs.queued
  labels.export-cancelled -> jobs.export-cancelled
  labels.import-cancelled -> jobs.import-cancelled

No other locale has these keys yet, so the rename costs no translation.
The convention (a key names the feature that renders it; labels.* holds
the generic reusable words) is written down in the frontend/translations
memory.

AI-assisted-by: deepseek-v4.1-flash

* 🔥 Remove the translation keys nothing references

Six keys of en.po (and of es.po, which now mirrors it) have no (tr ...)
call site left in frontend/src or common/src, and no literal reference
either:

  labels.uploading-file    a duplicate of jobs.progress.stage.upload
                           since the upload stage says "Uploading file…"
  labels.downloading-file  dead since the durable jobs replaced the old
                           import and export progress rows
  inspect.attributes.stroke.alignment.center
  inspect.attributes.stroke.alignment.inner
  inspect.attributes.stroke.alignment.outer
  workspace.toolbar.mcp-connect-here

The first two are leftovers of the durable binfile jobs, the other four
predate them. Both catalogues keep an identical msgid set afterwards.

AI-assisted-by: deepseek-v4.1-flash
2026-10-07 11:52:48 +02:00
Alejandro Alonso
accea1abde
⚡ Enable wasm SIMD for Skia render-wasm builds (#12145)
Link with -msimd128 and point SKIA_BINARIES_URL at the pdf+simd prebuilt archive.
2026-10-07 11:30:40 +02:00
Danny Shirely
051f01d559
🐛 Keep per-side stroke widths out of inspect geometry (#12055)
Remove per-side stroke widths from both Inspect geometry panels so
Stroke is the only section that lists them. Keep dimensions, position,
corner radii, and transforms in Size and position.

Add rendered-component regressions for boards, rectangles, multiple
selection, uniform strokes, and CSS declarations.

Closes #12026

AI-assisted-by: GPT-6

Signed-off-by: Danial Shirali <danialshirali16@gmail.com>
Signed-off-by: Eva Marco <eva.marco@kaleidos.net>
Co-authored-by: Eva Marco <eva.marco@kaleidos.net>
2026-10-07 11:18:46 +02:00
Marina López
a9bf53adbf
Revert "🐛 Preserve path segments when joining backend URIs (#12152)" (#12156)
This reverts commit 2a6e92a7d9a5520365276f7b9a6ae59d478159ee.
2026-10-07 11:10:02 +02:00
Marina López
2a6e92a7d9
🐛 Preserve path segments when joining backend URIs (#12152) 2026-10-07 10:45:17 +02:00
Andrey Antukh
a5df69f60f Merge remote-tracking branch 'origin/staging' into develop 2026-10-07 10:25:01 +02:00
Andrey Antukh
7f0aa4f38e Merge remote-tracking branch 'origin/main' into staging 2026-10-07 10:23:37 +02:00
Andrey Antukh
b2d688a04c 💄 Stringify ids in comp-processors log props
Wrap every id prop in the comp-processors warn logs
with str so the log shows plain text instead of
the #uuid reader form. Also use :hint instead of
:msg and lowercase text, as the rest of the system
does. No logic changes, logging only.

AI-assisted-by: muse-spark-1.3
2026-10-07 08:17:33 +00:00
Eva Marco
2f47be6c15
🐛 Show the size badge on paths and straight lines (#12066)
* 🐛 Show the size badge on paths and straight lines

The badge was hidden when the shape was smaller than the badge in
either side. Flat paths have a zero width or height, so they never
showed it, and straight two-point paths hid it on purpose.

Paths now only compare their longest side with the badge width. A
straight line compares its length, and the badge runs along the
line, rotated with it and kept upright. Near the bottom of the
viewport the badge moves to the other side of the line.

The badge now reads the size from the shape :selrect, like the
measures inputs, so a rotated path shows the same values in both
places. The three badge branches share a single render block.

AI-assisted-by: claude-opus-5-5

* ♻️ Extract the size badge layout and add tests for it

Move the badge geometry out of selection-size-badge* into the pure
size-badge-layout function, so it can be tested without rendering.
The component now only picks the color and draws the result.

Add unit tests for rects, flat paths and straight lines: when the
badge hides, where it sits, how it rotates and how it moves when it
reaches the bottom of the viewport. Keep the line rotation between
-90 and 90 degrees instead of returning 360 for lines drawn leftward.

The e2e test still expected straight lines to hide the badge. It now
checks that the badge shows and follows the line angle.

AI-assisted-by: claude-opus-5-5

* ✨ Show the length of straight lines in the size badge

A straight two-point path is measured by its length, not by the box
around it, so its size badge now shows the length with two decimals at
most instead of width x height. Other shapes and paths keep width x
height.

The shorter text makes the badge narrower, so it also shows on shorter
lines; the rule is the same, the badge needs the line to be at least as
long as the badge.

Relates to #12064

AI-assisted-by: claude-opus-5-5

* 🐛 Hide the path size badge when the path is lower than it

Paths other than a straight line only hid the size badge when their
longest side was narrower than it, so a wide but low path, or a narrow
but tall one, still showed it. Flat paths no longer need that special
case, since straight lines get their own length badge. Use the same
rule as any other shape: hide the badge when either the width or the
height of the path is smaller than the badge.

AI-assisted-by: claude-opus-5-5
2026-10-07 09:18:57 +02:00