24444 Commits

Author SHA1 Message Date
Andrey Antukh
49f746ddd8 📚 Never commit the plan file with the implementation
The implement-plan flow asked to commit the closed plan together with
the code, so the resulting commit looked like it was missing a piece.

State the rule in the three places that describe it: the implement-plan
skill, the commit workflow memory and the agents README. A plan is
session bookkeeping, not part of the change, and its absence from the
commit is correct rather than a missing piece.

AI-assisted-by: deepseek-v4.1-flash
2026-10-18 20:40:23 +02:00
Andrey Antukh
16ea1ab3cd 🔧 Ignore generated artifacts and local root files
Local scratch files and autogenerated build output were
showing up in git status:

- /*.html and /*.json cover local reports and token
  dumps at the repo root; !/package.json keeps the
  tracked root manifest visible to git.
- /media-processor/target/ and /scripts/__pycache__/
  cover build and Python cache output.
- render-wasm artifacts copied into the exporter go in
  their own commented block.

Record the exporter and render-wasm generated paths in
the module memories so nobody edits or commits them by
hand.

AI-assisted-by: deepseek-v4.1-flash
2026-10-18 18:01:22 +00:00
Andrey Antukh
49aeeb053b 🐛 Use multipart helper in jobs upload-chunk test
The multipart-only upload guard rejects the :content map sent by
upload-chunked! over plain command!. Send it through
multipart-command! like the other upload tests; forged-path tests
stay on plain command!.

AI-assisted-by: muse-spark-1.3-contributor
2026-10-17 21:53:29 +00:00
Andrey Antukh
b633093336 Merge remote-tracking branch 'origin/staging' into develop 2026-10-17 16:28:59 +02:00
Andrey Antukh
70b317a976
🐛 Harden upload path handling and validation error bodies (#12067)
* 🚑 Reject forged upload paths outside multipart

Transit bodies can smuggle upload maps whose path points at any
server file; the schema only checks the shape and handlers open
the path directly.

app.rpc now injects a server-side multipart flag that bodies cannot
forge, and methods declare their upload keys with the new
multipart-only-params metadata, enforced centrally after schema
validation with a params-validation error. Covers all nine upload
methods, including chunked and management flows.

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

* 🐛 Sanitize raw explain in generic validation error bodies

The generic :validation branch returned ex-data untouched, so errors
carrying the raw malli explain (live schema objects, e.g. storage
invalid-storage-metadata) broke JSON encoding mid-stream and clients
got truncated bodies. Apply the same clean treatment the known codes
already use: drop the raw explain and attach its humanized text.

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

* 🐛 Add e2e path traversal tests for binfile and media upload

Add two e2e suites that hand-write transit bodies with a forged
["~#path", ...] upload path (no transit library needed) and expect
400 validation/params-validation. They cover import-binfile :file,
upload-file-media-object :content and upload-chunk :content, plus a
plain-JSON string-path control case. Before the multipart-path fix
they failed (500 io-exception, invalid-image, 200 stored chunk);
after it they pass.

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

* 🐛 Use multipart helper in binfile SVG test setup

The SVG import setup uploaded through plain command!, so the new
multipart-only guard rejected it with params-validation. Switch
the setup to multipart-command! like the other upload tests.

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

* 🐛 Use multipart helper in remaining upload tests

The multipart-only upload guard rejects plain RPC calls carrying
upload maps. Five tests with real files still used command! and
failed with params-validation. Switch them to multipart-command!
while keeping the forged-content tests on plain command!.

AI-assisted-by: muse-spark-1.3-contributor
2026-10-17 16:01:04 +02:00
Andrey Antukh
15606a3f85 🐛 Answer 400 on a request body the parser cannot read
A body declared as JSON that clojure.data.json cannot read ended as a 500
:unexpected with the whole stack trace in the log: wrap-parse-request
classified the failure by exception class, and the library signals its
syntax errors with a bare java.lang.Exception.

Classify it where it happens instead: read-body-params reads the body and
raises :validation :malformed-json (400) for anything it cannot parse,
keeps RequestTooBigException at 413 and re-raises a structured error from
deeper untouched. The body is read outside the handler try, so the error
goes out as a throw like the other specific-exception branches.

The failure is now one log line (path, peer address, content-type, parser
message) without the stack trace, and it is counted: the middleware takes
the metrics instance as a middleware argument, so the backend exports
penpot_http_malformed_requests_total and nexus declares its own
nexus_http_malformed_requests_total.

Tests: parse-request-malformed-json-body reproduces the production body
and asserts the 400 and the counter; the existing parse-request tests now
pass a metrics stub.

AI-assisted-by: deepseek-v4.1-flash
2026-10-17 15:54:52 +02:00
Andrey Antukh
07e085c2cf :revind: Backport several updates for .github/workflows from develop 2026-10-17 14:55:22 +02:00
Andrey Antukh
9336962a77 ⏪ Backport docker changes from develop 2026-10-17 14:54:23 +02:00
Andrey Antukh
fcbc4f1769 🐛 Remove duplicate declarations in the typography sample
`.typography-sample-input` declared `font-size` and `line-height`
twice with the same values, so stylelint failed the frontend lint
with `declaration-block-no-duplicate-properties`.

The duplication came from an earlier staging into develop merge: one
side moved the pair next to its comment, the other kept it below
`block-size`, and the merge kept both. Drop the second pair.

No behaviour change: both pairs held the same values, and only the
last declaration of a block takes effect.

AI-assisted-by: deepseek-v4.1-flash
2026-10-17 12:53:13 +00:00
Andrey Antukh
8c5113b815 Merge remote-tracking branch 'origin/staging' into develop 2026-10-17 14:44:44 +02:00
penpot-renovate[bot]
1a68928aca
⬆️ Update DHI base images (#12399)
- dhi.io/debian-base trixie-debian13-dev@7b1d652 (docker/images/Dockerfile.backend)
- dhi.io/debian-base trixie-debian13-dev@7b1d652 (docker/images/Dockerfile.backend)
- dhi.io/debian-base trixie-debian13-dev@7b1d652 (docker/imagemagick/Dockerfile)
- dhi.io/debian-base trixie-debian13-dev@7b1d652 (docker/devenv/Dockerfile)
- dhi.io/nginx 1.31.7-alpine3.24@6d3811f (docker/images/Dockerfile.storybook)
- dhi.io/nginx 1.31.7-debian13-dev@10c0037 (docker/images/Dockerfile.frontend)
- dhi.io/node 24.22.0@ (docker/devenv/Dockerfile)
- dhi.io/node 24.22.0@ (.nvmrc)
- dhi.io/node 24.22.0-debian13-dev@39f5547 (docker/images/Dockerfile.media-processor)
- dhi.io/node 24.22.0-debian13@c6ddacf (docker/images/Dockerfile.mcp)
- dhi.io/node 24.22.0-debian13-dev@39f5547 (docker/images/Dockerfile.mcp)
- dhi.io/node 24.22.0-debian13-dev@39f5547 (docker/images/Dockerfile.exporter)
2026-10-17 14:06:09 +02:00
penpot-renovate[bot]
53f83c98cf
⬆️ Update GitHub Actions (#12401)
- actions/checkout v7 (.github/workflows/commit-checker.yml)
- actions/download-artifact v8.0.2 (.github/workflows/tests-e2e.yml)
- actions/upload-artifact v7.0.2 (.github/workflows/tests-e2e.yml)
- actions/upload-artifact v7.0.2 (.github/workflows/tests-e2e.yml)
- actions/upload-artifact v7.0.2 (.github/workflows/tests-e2e.yml)
- actions/upload-artifact v7.0.2 (.github/workflows/tests-e2e.yml)

Signed-off-by: penpot-renovate[bot] <338182062+penpot-renovate[bot]@users.noreply.github.com>
Co-authored-by: penpot-renovate[bot] <338182062+penpot-renovate[bot]@users.noreply.github.com>
2026-10-17 14:01:55 +02:00
Andrey Antukh
231adf4edc ✨ Add a skill to rewrite a squashed commit message
The new skill turns HEAD's stacked subjects, bodies and repeated trailers
(a typical result of squashing) into one message that follows
mem:workflow/creating-commits: a single imperative subject, a body
covering the whole branch diff, then the trailers.

It collects every distinct AI-assisted-by value before rewriting, so an
attribution is never dropped, and it requires ./scripts/check-commit to
exit 0 after the amend. It never pushes: rewriting history over commits
the user already pushed is their call, not the agent's.

AI-assisted-by: deepseek-v4.1-flash
2026-10-17 14:00:43 +02:00
Andrey Antukh
016c3976d0 🔧 Install opencode on demand instead of baking it in
The opencode build stage was already commented out, so the image carried
a dead block and a commented COPY. Drop both: opencode now comes from
scripts/setup-opencode, which installs @opencode/cli as a global pnpm
package in the penpot user's $PNPM_HOME. That dir sits in the user-data
volume, so the install survives a container re-creation and needs no
root.

pnpm 12 keeps the global binaries in $PNPM_HOME/bin, so put that dir on
PATH in the bashrc (and make the guard check the same path) instead of
$PNPM_HOME, which left the commands unreachable.

The entrypoint now also chowns ~/.local, ~/.cache and the dotfiles it
copies from /root. EXTERNAL_UID remaps penpot to the host UID on every
start, so the ownership the image set at build time stops matching; that
broke global pnpm installs and pnpm's own packageManager download.

AI-assisted-by: deepseek-v4.1-flash
2026-10-17 14:00:43 +02:00
Andrey Antukh
d1aa36b814 ⬆️ Update pnpm to 12.14.0 and Node to v24.22.0
Bump the pnpm pin to 12.14.0 in the devenv and the production images
and stamp the same version into every package.json, realigning the
drift between the devenv (12.9.1) and the rest (12.6.0).

Bump Node to v24.22.0, the newest patch of the Krypton LTS line, in
.nvmrc, the devenv and image Dockerfiles and the plugins-deploy
workflows.

The pnpm arch checksums are those of the GitHub release tarballs, not
of the npm package. Lockfile changes are metadata only.

AI-assisted-by: deepseek-v4.1-flash
2026-10-17 14:00:43 +02:00
Marina López
cec8080351
🐛 Show trial status on subscription page (#12389) 2026-10-16 18:22:51 +02:00
Alonso Torres
02e10a005a
✨ Add token source to plugins (#12183)
* ✨ Add token source to plugins

* ✨ Changes after review

* ✨ Move plugins code to library-edit
2026-10-16 17:58:51 +02:00
Andrey Antukh
da1791bf00 📎 Disable temporary the storage gc job from cron 2.18.4-RC3 2026-10-16 15:57:32 +02:00
Andrey Antukh
d0f455d521 ⚡ Chunk storage-gc-deleted reads with a keyset cursor
The storage-gc-deleted task used to stream all the pending rows with
db/plan inside one long read-only transaction, blocking the table
vacuum for the whole run and generating bloat on long deletions.

Now every chunk of 25 objects is one short read-write transaction:
the SELECT reads and locks the next objects after a (deleted_at, id)
keyset cursor with FOR UPDATE SKIP LOCKED, deletes the database rows
grouped by backend and removes their backend content. Concurrent runs
never take the same chunk, and the double SELECT of the old lock-ids
step is gone.

A chunk that fails aborts its transaction: nothing of the chunk is
deleted, its locks are released, and the run skips it; the cursor
still advances to the last row read, so a failed chunk is not visited
again in the same run.

AI-assisted-by: glm-5.3-flash
2026-10-16 15:57:32 +02:00
alonso.torres
f23eb31bd7 ✨ Send pending edits when the user leaves the tab or window
Edits wait up to 3s in a buffer before they are sent. Closing the
laptop or switching away right after an edit could leave that batch
unsent, and a crash or a discarded tab then lost it.

The batch is now sent at once when the tab is hidden (another tab, a
minimized window, a closed tab or laptop) or the window loses focus
(another app or a plugin iframe). The 3s window itself is unchanged.
pagehide and beforeunload are left out: they come too late for an
async save, and the tab is already hidden by then.
2026-10-16 15:24:22 +02:00
alonso.torres
a3fc7c8465 ✨ Save before downloading a .penpot file and warn if behind
The .penpot download is built from the server copy. Users who
downloaded a file to keep their work while saves were failing got an
old or empty file, with no warning.

Opening the download dialog now sends the pending changes of the files
and waits up to 10s for them. If some are still not saved, the dialog
says the download will not include them.
2026-10-16 15:24:22 +02:00
alonso.torres
a1e51aacba 🐛 Keep the workspace open when an export fails
A failed export raised the exporter error to the global handler, which
replaced the workspace with the error page. Users then reloaded and
lost the changes that were not saved yet.

- A failed single or multiple export now shows an error toast and
  keeps the workspace. Exporter defects still report as unhandled.
- A failed multiple export request ends its progress listeners, so the
  leave-page warning it turned on is released.
- Before exporting, the pending changes of the exported files are sent
  and the export waits up to 10s for them, instead of 400ms. Exports
  are built from the server copy, so if some changes are still unsaved
  a toast warns that the export may not include them.

force-persist-files-and-wait waits only for the given files, so a stuck
save of another file does not delay the export.
2026-10-16 15:24:22 +02:00
alonso.torres
781ac534e0 ✨ Count save failures with a save-failed audit event
We only learn how often users lose work from support tickets. Each time
the user is told a save failed, persistence now emits a save-failed
audit event with the cause type, the code, whether the queue is still
retrying and how many changes are waiting.

It follows the same rule as the failure toast: a repeated failure of
the slow retry cycle is not counted again. Failures handed to another
handler, such as an expired session, are counted too, since they also
stop the save.
2026-10-16 15:24:22 +02:00
alonso.torres
4b668acf96 🐛 Retry saves that a busy backend refused or rolled back
A save waits for the lock that another save holds on the same file. A
slow save, such as one on a large file, blocks the other saves to that
file. The waiting request also holds the user's only save slot on the
backend, so the user's saves to other files wait behind it too.

Those saves ended in answers that stopped autosave for good:

- a 429 after 60s, when no save slot freed up in time;
- a 504 written by the backend after 300s, with :statement-timeout,
  when Postgres cancelled the wait for the lock.

In both cases the backend never applied the save, so sending it again
is safe. transient-error? now treats them as temporary, and the save
takes the retry path. A statement timeout takes 300s per attempt, so
the slow failure limit still bounds it. GET requests and error reports
are unchanged: the check lives in the save pipeline, not in
retryable-types.

Both answers were reproduced in the devenv by holding the file lock
from a separate database session.
2026-10-16 15:24:22 +02:00
Elena Torró
01aacde0a8
🐛 Refresh board bounds after toggling clip content in WASM SVG export (#12363) 2026-10-16 14:01:48 +02:00
Andrey Antukh
03134d35ae
🐛 Fix silent failure when uploading custom fonts (#12315)
* 🐛 Fix silent failure when uploading custom fonts

Custom font upload crashed with re-seq must match
against a string when opentype.js returned nil
subfamily names, leaving the user with no feedback.
Unknown signatures and unparseable files were also
silently dropped.

Make variant parsing nil-safe, harden entry
preparation for missing tables, report every
per-file failure with a bad-font notification, and
show a generic error if the stream itself fails.

Closes #12304

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

* ♻️ Rename font mtype helper and mark helpers private

Avoid confusion with app.worker.import/parse-mtype,
which detects binfile signatures. The font helper now
lives as parse-font-mtype, and the upload helpers only
used internally are private, with tests reaching them
through var references.

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

* 💄 Fix formatting in font upload tests

Realign map keys shifted by the var-quote prefix so
cljfmt check passes again. No behavior change.

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

* 🐛 Fix infer warnings in fonts metrics code

Add missing ^js type hints to the hhea and os2 metric lookups in
prepare-font so shadow-cljs can infer the interop targets and the
release build reports zero warnings.

AI-assisted-by: muse-spark-1.3-contributor
2026-10-16 12:51:48 +02:00
Andrey Antukh
472ba8dde5 Merge remote-tracking branch 'origin/staging' into develop 2026-10-16 12:49:04 +02:00
Andrey Antukh
dfc55a7192 Merge remote-tracking branch 'origin/staging' into develop 2026-10-16 12:48:49 +02:00
Elena Torró
ccdfaffbef
🐛 Fix stroke to path for strokes with different widths per side (#12356) 2026-10-16 12:36:23 +02:00
Andrey Antukh
7322cd8ec5 📚 Update changelog 2026-10-16 11:43:42 +02:00
Andrey Antukh
3e1334ce97 Merge remote-tracking branch 'origin/staging' into staging 2026-10-16 11:25:45 +02:00
Andrey Antukh
9ea4c3404f Merge remote-tracking branch 'origin/main' into staging 2026-10-16 11:25:25 +02:00
Andrés Moya
a5f75c7394
🐛 Fix repair bad variants (#11983)
* 🐛 Ensure normalized names in components and variants

We have a rule of that shape names that include / separator should have
it normalized, with one space before and one after. But this rule is
being enforced only in the UI and the plugins API. There may be other
ways of creating shapes that does not follow it.

For normal shapes this is not really a problem. But for main instances
it may cause that the name of the shape does not match the joined
path+name of the component, and this may generate some problems.

We have decided to add a migration to fix this in all main instances and
variant containers and enforce it in the future with integrity
validations (normal shapes may be left as is).

* 🐛 Fix bad variant-id in the component and the main instance

* 🔧 Fix some issues detected in PR review

* 🐛 Add nil guard
2026-10-16 10:59:57 +02:00
Pablo Alba
0afe1e193d
🐛 Fix team switcher height (#12371) 2026-10-16 09:13:03 +02:00
Pablo Alba
296ad9704f
🐛 Fix bad message changing team to another organization (#12354) 2026-10-15 21:41:36 +02:00
Andrey Antukh
122d4ebcd3 🔥 Remove unused requires flagged by the Clojure linter
The dashboard required app.common.exceptions without ever using it,
and the plugin shape namespace still referred to
token-attr-plugin->token-attr after the per-side stroke width work
replaced its only use with expand-token-attrs. Both are leftovers of
earlier refactors; dropping them leaves clj-kondo with no warnings.

AI-assisted-by: deepseek-v4.1-flash
2026-10-15 20:23:25 +02:00
Eva Marco
ab7a994d6c
🐛 Fix libraries modal visual test (#12367)
The libraries modal now opens on the "This file" tab, so the visual test
timed out looking for the shared library. Open the "Libraries" tab first,
as the functional test does, mock the file with the library linked, and
check the "Added" label instead of the old "File library" text.

AI-assisted-by: claude-opus-5-5
2026-10-15 20:08:48 +02:00
Eva Marco
539983db2f
♻️ Use a DS icon button for flatten in the boolean options (#12368)
* ♻️ Use a DS icon button for flatten in the boolean options

Replace the raw flatten button and its deprecated icon with icon-button*
and the DS boolean-flatten icon. The title text moves to aria-label, so
the DS tooltip shows it. The grid column goes on the tooltip wrapper
through tooltip-class.

Add a visual test that selects two shapes so the boolean options show.

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

* ♻️ Remove deprecated button styles from the boolean options

The flatten button is a DS icon button now, so its placeholders, sizes
and icon colors go away; only the grid column stays, on the tooltip
wrapper. Use block-size and drop the refactor/ import.

AI-assisted-by: claude-opus-5-5
2026-10-15 20:07:04 +02:00
Eva Marco
d26a401637
♻️ Use DS buttons for more colors in the selected colors (#12369)
* ♻️ Use DS buttons for more colors in the selected colors

Replace the three raw "more colors" buttons (library colors, colors and
color tokens) with the DS secondary button.

Add a visual test that selects a board with more than three colors so
the button shows.

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

* ♻️ Move selected colors styles to DS tokens

The "more colors" buttons are DS buttons now, so their placeholder and
typography mixin go away; the class only centers the label, since the
column stretches the button. Delete the unused .add-fill rule, move
spacing to DS tokens and logical properties, and drop the refactor/
import.

AI-assisted-by: claude-opus-5-5
2026-10-15 20:06:29 +02:00
Eva Marco
47f0e15744
♻️ Use DS icon buttons in the align options (#12366)
* ♻️ Use DS icon buttons in the align options

Replace the eight raw align and distribute buttons with icon-button*
and DS icons. The title text moves to aria-label, so the DS tooltip
shows the same text and shortcut. Disabled distribute buttons now use
the DS disabled color.

Add a visual test that selects two shapes so the align options show.

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

* ♻️ Remove deprecated button styles from the align options

The align buttons are DS icon buttons now, so the .align-button rules
and their placeholders go away. Move the row height to a DS size token
and drop the refactor/ import.

AI-assisted-by: claude-opus-5-5
2026-10-15 20:05:58 +02:00
Andrey Antukh
414553278a
✨ Count the application logs as a metric, by logger and level (#12313)
* ✨ Count the application logs as a metric, by logger and level

The error panels had no metric to read: they counted log lines in Loki,
because the backend published nothing about the errors of the application
(the only thing alive in Prometheus was the http listener error count,
which measures another thing). Now there is
`penpot_log_events_total{logger, level}`.

The counter comes from a new component, `app.loggers.metrics/reporter`,
which hooks a watch on the log record like the database and mattermost
reporters do, but without a thread or a channel: the increment is in
memory, so it runs in the watcher itself. Only `warn`, `error` and `fatal`
are counted, and only the records that go through `app.common.logging`:
the lines a library writes straight to log4j2 are not counted (one in a
thousand today).

The body of the watcher is wrapped in a `try/catch` that swallows the
error on purpose: the watcher fires from the `swap!` of the log record,
inside the logging path, so an exception there would reach whoever was
logging and would take the logging executor down with it. A test pins that
a failing publication does not propagate.

The suite removes the reporter (it would count the logs of the tests and
the recording of their `mtx/run!`), so a test of its own pins the wiring.
The memories carry the counter, its two limits and why the `try/catch` is
not noise; the grafana one also brings the notes of this afternoon about
the service account, the dashboard search and Cloudflare, which were
waiting for a commit.

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

* 📚 Address the review findings on the log-events metric

The review of the counter found that the reason written for the
`try/catch` was not the real one, so the memory and the comments say now
what happens if it is removed: with `::l/sync?` the exception reaches
whoever was logging, and on the async path it dies in the uncaught
handler of the logging executor -- outside the application's logging --
where the pool replaces the worker, so logging continues and that line is
lost. They also say that a publication failing every time is silent: the
counter stays flat and nothing is written about it.

The rest are small things the review asked for: the test of the `fatal`
level (it was in the counted set and nothing verified it), the HELP of the
metric naming the three levels, the require of the test without the unused
alias that clj-kondo flagged, and the note that the `level` is lowercase
here and uppercase in Loki.

The grafana memory loses what was said twice: the two pairs of bullets
about reading the API (the service account 403s and the search that lies)
are merged, the CLIMIT notes are one bullet instead of two, and the four
bullets about editing dashboards are grouped in their own section. Its
host name is gone as well, which the first line of that file forbids.

AI-assisted-by: deepseek-v4.1-flash
2.18.4-RC2
2026-10-15 19:50:06 +02:00
Andrey Antukh
57fa280209
🐛 Validate and decode a job result with its declared schema (#12350)
* 🐛 Decode job results with the schema of the job-def

A job result is stored as plain JSON, so a uuid comes back as the text
it is on disk and a uuid used as a map key comes back as a keyword. The
import resolution is a map keyed by file id, and the wizard lost that
identity: choosing a library in the link step created no link at all,
with no error and with the modal closing as if it had worked.

A job-def can now declare a result schema and its precompiled decoder,
the pair the params already have, and the substrate applies them when the
row is read. A def that declares neither answers with the JSON the row
holds, as before; a row whose job-def is gone is read untouched instead
of raising.

Relates to #12339

AI-assisted-by: deepseek-v4.1-flash
Signed-off-by: Andrey Antukh <niwi@niwi.nz>

* 🐛 Type the import result so its resolution keeps uuid keys

The resolution of a version 3 import is a map keyed by the new file id,
and the result column is plain JSON: without a schema of its own every
key came back as a keyword while the :id of the entry came back as text,
so the dashboard wizard could not match the file it was linking and
created no library link at all.

Declare the shape of the resolution (files, done and pending entries and
their candidates) and give the job-def its result schema and decoder.
The check before the result is stored keeps validating the same schema.

Closes #12339

AI-assisted-by: deepseek-v4.1-flash
Signed-off-by: Andrey Antukh <niwi@niwi.nz>

* 🐛 Take the imported file id from the entry, not the map key

The wizard looked a file up in the selection with the key of the
resolution map while it had indexed the selection by the :id of the
entry. The key is keywordized on its way through the job result, so the
lookup missed and the confirm button created no library link at all,
with no error and with the modal closing as if it had worked.

Take the id from the entry, the source the rest of the wizard already
uses, and move the decision to a pure function covered by tests of both
shapes of the resolution.

Relates to #12339

AI-assisted-by: deepseek-v4.1-flash
Signed-off-by: Andrey Antukh <niwi@niwi.nz>

* 🐛 Test that an import result keeps its ids as uuids

The regression this branch fixes is only visible through the read path
of the substrate: the resolution of an import is a map keyed by the new
file id, and a test that works with the value in memory never sees the
keys coming back as keywords from the plain JSON column.

Import a package whose library needs a replacement chosen by the user,
read the result with `jobs/get-job` as the client does, and check the
keys, the file ids and the ids of the candidates. Without the result
decoder of the import job-def the test answers with text ids.

Relates to #12339

AI-assisted-by: deepseek-v4.1-flash
Signed-off-by: Andrey Antukh <niwi@niwi.nz>

* 📚 Document the result schema of a job-def

The memories and the docstring of the substrate said a job result has no
schema of its own and comes back as the JSON the row holds. A job-def can
now declare its result schema and decoder, and the substrate applies them
when the row is read.

Keep the rule next to it: a result map keyed by uuid needs its schema
declared, or every key comes back as a keyword, and the link-later step
of the import wizard takes the file id from the entry and never from the
key of the resolution map.

Relates to #12339

AI-assisted-by: deepseek-v4.1-flash
Signed-off-by: Andrey Antukh <niwi@niwi.nz>

* 📚 Fix the documented contract of a job result

The docstring of get-job said a result has no schema of its own and comes
back as the JSON the row holds, which is what this branch changes: the
result is decoded with the schema its job-def declares, and a result that
does not conform never reaches the column because the write validates it.

Say the same in the management command that stores the result of an
external worker, the other writer of the column.

Relates to #12339

AI-assisted-by: deepseek-v4.1-flash
Signed-off-by: Andrey Antukh <niwi@niwi.nz>

* 🐛 Check the result of a job against its schema before storing it

The result of a job crosses a plain JSON column, so a uuid comes back as
text and a uuid used as a map key as a keyword. The job-def now declares
the schema of its result and the substrate compiles the decoder from it,
which is what the read path uses to get the types back.

The same schema is the contract of the write path: complete refuses a
result that does not conform, with the malli explanation, so nothing
invalid reaches the column, and the answer is the same for the runner and
for the external worker that completes a job through the management RPC.
A job that is no longer completable is skipped without checking, and the
read path decodes only: a row written by another version stays readable.

Relates to #12339

AI-assisted-by: deepseek-v4.1-flash
Signed-off-by: Andrey Antukh <niwi@niwi.nz>

* 🐛 Check the result of an import inside its transaction

The check ran after the transaction of the import had committed, so a
result the schema does not accept failed the job with the files already
created in the destination project: a half imported project and an error.
The core decides whether the whole import is kept or rolled back, so the
check belongs inside its transaction, which is what turns a contract
violation into a rollback.

The substrate checks the result again when the job is completed, which is
the only net for a job that does not validate its own result.

Relates to #12339

AI-assisted-by: deepseek-v4.1-flash
Signed-off-by: Andrey Antukh <niwi@niwi.nz>

* 🐛 Cover the edges of an import result read by the client

The shape of a result with no libraries to resolve, and the one of a
version 1 import, is the most common in production and neither was covered
by the tests that read a result the way the client does.

Cover both through jobs/get-job, and the case of a registry that no longer
knows the kind of the row: it answers with the JSON of the column,
keywordized keys and uuid as text, instead of raising. Say in the two tests
that depend on the decoder what implementation without it makes them fail.

Relates to #12339

AI-assisted-by: deepseek-v4.1-flash
Signed-off-by: Andrey Antukh <niwi@niwi.nz>

* 📚 Fix the prose and the fixtures of the result contract

The result schema of a job-def is what the read decodes with and what the
write checks: say that in the memories, with the rule that any uuid of a
result needs it, whether a value or a map key.

The motive of the wizard helper was a fact this branch makes false: what a
key of the resolution map holds depends on the result schema of the job-def
that wrote it, so the rule is to read the id from the entry. Same
correction in its memory and in the test that pins the shape.

The candidates of a pending entry match by slugified name, not by name, and
every test row now states the kind of its job, because the name is what
resolves its job-def when the result is read or checked.

Relates to #12339

AI-assisted-by: deepseek-v4.1-flash
Signed-off-by: Andrey Antukh <niwi@niwi.nz>

* ♻️ Keep the wizard link helper private and cover its shape

The helper that decides which links the wizard creates belongs to its
namespace, as its neighbours do: reach it from the test with @#', the
pattern the frontend tests already use for a private helper.

Cover the shapes it had no test for: a file with two libraries pending
resolution, a file whose libraries were auto-linked, and a selection where
only one of two entries was resolved.

Relates to #12339

AI-assisted-by: deepseek-v4.1-flash
Signed-off-by: Andrey Antukh <niwi@niwi.nz>

* ♻️ Share one job-row helper across the job tests

Three test namespaces had their own copy of the same insert, differing only
in the kind of the job and in whether the row holds the resource it
consumes. Move it to the test helpers as create-job*, with the kind of the
job as its first argument: it is what resolves the job-def of the row when
its result is read or checked, so every test states it.

The bang of the name goes away with it: the design rules keep it for the
mutating API of app.db, and this is a fixture of the tests. The three
namespaces lose the requires that only their removed copy used.

Relates to #12339

AI-assisted-by: deepseek-v4.1-flash
Signed-off-by: Andrey Antukh <niwi@niwi.nz>

---------

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
2026-10-15 19:18:04 +02:00
Elena Torró
556d604e67
🐛 Refresh clip content button state after toggling it (#12353) 2026-10-15 17:09:01 +02:00
Andrés Moya
feb73f0fd8
✨ Handle tokens libraries when exporting .penpot files (#12077)
* ✨ Handle tokens libraries when exporting .penpot files

Exporting a file that takes its tokens from an external library left
the :tokens-source of the file pointing at a library that is not in the
export, and the tokens lib of that library was not exported.

With :detach-libraries, unset the :tokens-source and the tokens status
in detach-external-references. The file is exported without any link to
the library, and the shapes keep their applied tokens.

With :merge-libraries, add cfo/absorb-tokens-lib, which copies the tokens
lib of a library into a file and unsets its :tokens-source, so the file
becomes its own tokens source. The tokens status of the file is kept; if
it has none, the one of the library is copied. If the library has no
tokens lib, only the source is unset. embed-assets calls it for the
library that is the tokens source of the file, and ignores the other
linked libraries.

Add unit tests for absorb-tokens-lib, and binfile tests that export a
file with tokens from an external library with both modes, checking the
exported files, the tokens lib, the tokens source and the applied
tokens.

Also reformat the export-type docstring in binfile v3 and remove a
leftover debug prn in the export dialog.

AI-assisted-by: claude-sonnet-5-5

* 🐛 Preserve active token sets when importing linked libraries

Cache each decoded tokens library for the duration of an import so files
and their tokens source resolve active sets against the same generated IDs.
Add a round-trip regression test for exports that include linked libraries.

* 🐛 Preserve tokens when deleting or unpublishing libraries

Absorb the tokens library into files that use it as their tokens source.
Keep referencing file IDs in the deletion task before removing relations.

Embed tokens from deleted, unlinked sources during export, and clear stale
source and status data when the library no longer exists. Add regression
tests for deletion, unpublishing, and exports with unavailable libraries.

---------

Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
2026-10-15 16:53:30 +02:00
Eva Marco
98f8b7e625
🐛 Keep the picture in place when a crop crosses the opposite edge (#12361)
With the mod key, resizing a shape with an image fill crops it: the
shape is a window over a picture that keeps its size and position.
When the dragged edge crossed the opposite one, the crop mode turned
off and a normal resize with a negative scale flipped the shape. The
image transform is relative to the shape, so the picture was mirrored
and shrank a bit more on every crossing until it disappeared.

Keep the crop mode when the edge crosses: the window moves to the
other side of the fixed edge without flipping, the part outside the
picture shows empty, and moving the window back shows the picture
again. The crop math lives in app.common.geom.image-crop and replaces
the image transform code that was duplicated for fills and fill-image.

Closes #12349

AI-assisted-by: claude-opus-5-5
2026-10-15 16:23:32 +02:00
Andrey Antukh
334f727e87 🐛 Stop oversized audit events from wedging the queue
Port staging c74d415 (#12252) to main, adapted to this branch.

The :export-shapes modal event carried the full :exports vector,
each export embedding its shape, which could reach megabytes and
wedge the audit archive queue past the proxy body limit. Build a
compact body instead: totals, distinct shapes and per-type counts,
plus the page name. Other modals keep their props.

Move the modal :origin prop to ::ev/origin so make-proto-event
records it as :event-origin; the UI still reads the plain prop.

Bound exception messages at the source: get-hint clamps to
max-message-length (1000 chars) and the CLJS format-throwable
clamps the trace message line and honors :trace-length. The
Context Hint header in generate-report prints get-hint instead
of the raw hint. Also fix first-line on the JVM, where
cuerdas/index-of returns nil and (pos? nil) throws.

Adaptations to main: skip audit-log.md (absent here), skip the
generate-report huge-message test (staging async helpers are
absent), and fit the errors.cljs hunk to main's generate-report.

AI-assisted-by: muse-spark-1.3-contributor
2026-10-15 15:54:23 +02:00
Andrey Antukh
d57cdb4588 ⏪ Backport persistence related fixes from staging (#12306)
* ✨ Add async test helpers and the with-mocks* macro

Backport of the test scaffolding that the persistence retry work needs,
taken from staging. Purely additive: no existing helper changes behavior,
so the suite result is unchanged (740 tests, 0 failures).

- frontend-tests.helpers.async: ->promise, await-response, settle,
  observe and wait-for. wait-for checks immediately and then polls one
  macrotask at a time, bounded, so it fails instead of hanging.
- mock/with-mocks*: installs mocks with set! and evaluates its body in a
  generated ^:async fn, so the test reads sequentially and there is no
  done to thread. Evaluates to a promise.
- mock/asap and mock/run-mocked: run-mocked awaits the body and restores
  the originals always, in that order, reporting a rejection or a
  non-promise return as an :error.

The matching change that made with-mocks defer its body via asap is NOT
backported: it would force all 58 existing uses across 14 files into
done-chained contexts, which is a separate refactor.

AI-assisted-by: space-bunny-free

* ✨ Add an error report governor with compact payloads

One browser session could emit an unbounded number of identical error
reports, and a dropped connection was reported as an application defect
with the full payload. Both are fixed here, in one place, because the
persistence retry work that follows depends on the new submit-report
contract.

- A report governor in app.main.errors. Each report carries a
  fingerprint (report-name|type|code|hint|first stack frame); the first
  occurrence is always emitted, repeats inside a 2 minute window are
  counted, and the next emitted report carries them as :occurrences. The
  cache is bounded by evicting the oldest entry, so memory stays fixed.
- flash reserves the report before generating it, so a suppressed
  occurrence never pays the report-building cost. Its whole body now
  runs inside one ts/schedule callback, a reporting or notification
  failure is logged instead of propagating, and it returns a total
  promise so tests can await it.
- generate-report accepts :format :compact|:full. The compact payload
  keeps the context header plus type, code and uri, and skips the
  stack, the ex-data dump (it can carry request headers) and the
  last-events list.
- environment-error-types is derived from rp/retryable-types, so the
  transport classification has a single source of truth. Environment
  failures are audit-only handled-exceptions and never reach the
  exception page or the alerts. :offline gets its own handler instead
  of falling through to :default.

:occurrences travels inside the audit event :props, which the backend
schema accepts as a free-form map, so no backend change is needed.

The dedup governor test file is new: 7 tests over the fingerprint, the
pure reservation decision, and the flash pipeline.

Relates to #11726, #11743

AI-assisted-by: space-bunny-free

* ♻️ Answer commit refusals with an empty stream

commit-changes used to return nil when it refused to build a commit,
which forced every caller that wanted to observe the outcome to
special-case nil. It now returns an empty stream, so a refusal and an
empty result look the same and callers can subscribe uniformly.

Only the refusal branch changes: the accepted branch already answered
with (rx/of commit). Potok treats a nil watch result as "nothing", so
both spellings were already correct at the store level.

The 57 call sites that wrap this event in rx/of are unaffected. They
hold the ::commit-changes event itself, not the value its watch
returns, so they keep processing exactly as before.

AI-assisted-by: space-bunny-free

* ✨ Retry transient saves with backoff and a reconnect notice

Any failed update-file landed on the same path: the queue halted in
:error, the retained changes were flushed away, and the user was told
to reload. A dropped connection and a revoked permission are not the
same thing, so they now take different paths.

transient-error? splits them, checking :type and :cause-type because
persistence-failed wraps the original cause. Transient failures enter
a :retrying episode: the head commit stays queued and resends with
backoff at 2s, 8s and 20s, then falls through to today's terminal path.
Everything else is terminal and keeps the exact :error path.

- Resends rotate the ::request-id stamp only once the old request has
  left active-requests, carry the same :commit-id, and never double
  send while one request is in flight.
- Retry timers carry an episode token; a timer whose episode was
  superseded stays silent instead of sending or failing.
- One tagged reconnect notice per episode, hidden by tag on save and on
  terminal failure. Recovery is silent.
- The browser online event re-enters the runner, but only for a live
  :retrying episode, guarded by exists? js/window.
- wait-persisted-or-error waits through :retrying instead of cutting the
  wait short.

The episode keeps its :run-id, so a new local edit only joins the queue.
Re-entering the runner on every edit would bypass the backoff and spend
one attempt per batched commit.

The retry test namespace is new: 13 tests over the classification, the
backoff schedule, stamp rotation, stale tokens and the notice lifecycle.

Relates to #11724

AI-assisted-by: space-bunny-free

* 🐛 Fix stalled saves that never answer and edits lost on leave

Two holes the retry work did not close.

An update-file request that never gets an answer kept its attempt
stamp forever, so the queue could not move and the user saw a saving
state that never resolved. The request now has a 7 minute timeout that
fails it as a transient network error, which hands it to the retry path
instead of blocking. The timeout is longer than the 5 minute stall
threshold on purpose: the stalled report must fire before the retry,
not instead of it.

The leave-page warning also had gaps. It now tracks unsaved edits
directly, through sync-unload-guard, which holds :persistence in
st/ongoing-tasks while edits sit in the buffer or the queue. Edits kept
in the queue after a failed save keep the guard on too, so closing the
tab warns instead of dropping them.

store.cljs guards its ongoing-tasks watcher with exists? js/window,
because the new tests exercise it without a window.

Four new tests: three for the guard across buffering, an in-flight save
and a retained edit, and one for a hung request that must resend and
save.

Closes #12223

AI-assisted-by: space-bunny-free

* 🐛 Skip the save when a missing font cannot be remapped

fix-deleted-fonts emitted text and typography changes even when no
replacement font existed, so opening a page that uses a deleted custom
font rewrote identical content and cleared position-data on every
visit. Every visit became a save with nothing to save.

Both loops now compare the remapped value with the original and emit
nothing when they match. The remapped path is unchanged.

Two new tests cover the no-replacement case and the remapped case.

Closes #12151

AI-assisted-by: space-bunny-free

* ✨ Add a debug helper that simulates an intermediary response

A save that never reaches the backend is hard to reproduce: you need a
proxy, a captive portal or a CDN in the way. This wraps fetch and
answers the named API method with a status and a body that carries no
Penpot error code, leaving every other request alone.

Without a duration the simulation is spent after answering one call.
With one it answers every matching call for that long, which is how to
watch the save retry, warn, keep retrying and finally recover.

  debug.simulateHttpError('update-file')
  debug.simulateHttpError('get-comment-threads', 403)
  debug.simulateHttpError('update-file', 524, 120)
  debug.clearHttpErrorSimulation()

Dev-only and self-contained: no production path reads it.

AI-assisted-by: space-bunny-free

* 🌐 Add the save retry and connection error strings

Three strings the new persistence and error paths need:

- errors.save-retrying, shown once per retry episode while the queue
  resends a queued save after a connection loss.
- workspace.header.retrying, the save indicator state for an episode
  in progress.
- errors.connection-error, shown by the network and offline handlers,
  which no longer fall through to the generic message.

Added to en.po only. The translations script also normalizes every
other locale and drops 439 strings that were already stale before this
work; that belongs in its own maintenance pass, not in a backport.

AI-assisted-by: space-bunny-free

* 🐛 Report save failures as unhandled when they are our defect

flash-persistence reported every failure it did not delegate as
handled-exception, including the ones that are plainly our fault. A
validation failure means the client sent something the server refused,
yet it landed in the audit log with a name that skips the internal
reports and the alerts. Nobody was paged.

The report type now follows the cause. :validation, :server-error and
:internal report as unhandled-exception, so they alert. The rest are
unchanged.

Two exclusions are deliberate:

- Environment failures stay handled. They reach this point after the
  retry budget is spent, and the user's connection is not our bug.
- :assertion stays handled, because its own handler already reports it
  that way and the two paths should not disagree.

The cause type is read from :cause-type first, since persistence-failed
wraps the original under :type :persistence. Reading :type alone would
match the wrapper every time and change nothing.

The user still gets the same sticky toast and report link; only the
audit event name moves.

The test that asserted a validation failure is "still notified" now
asserts which event name it gets, across the bare and wrapped shapes,
the environment types that must stay handled, and an unclassified one.

AI-assisted-by: space-bunny-free

* 🐛 Unblock persistence when opening a file after a terminal failure

A terminal save failure set the state to :error and left the queue
alone. recover-persistence only resumed for :save-permission-denied, so
for every other terminal code both of its branches were false and it
emitted nothing. Leaving and reopening the same file therefore changed
nothing: the editor stayed halted until a reload, which rebuilds the
whole store and loses the queue anyway.

The path had two jobs under one name. initialize-persistence fires when
the workspace view mounts and tears down the previous subscription;
recover-persistence fires when a file is opened or the role changes.
They are now separate: initialization picks up a queue left behind by a
previous session, and deliberately does not lift a halt, so undeliverable
edits are released when the user acts rather than when the view mounts.

Recovery lifts the halt by releasing what can never be saved.
release-failed-file drops every queued commit of the file that failed,
then resume-persistence clears the error and settles an empty queue to
:saved. Retaining them until that point is deliberate: it is what keeps
wait-persisted-or-error rejecting, which is what stops
workspace/versions.cljs from building a snapshot out of unsaved edits.
Releasing at failure time would make waiters resolve instead.

Permission loss is treated as permanent too, which the previous
exception got wrong. The backend raises :not-found for a missing edit
permission, so a resend does not succeed — it routes the user to the
exception page. The old branch made that reachable: it resumed the save
whenever the cached can-edit flag said yes, and the server disagreed.
The only case where the flag can return to true is a team switch, where
the queue is gone anyway, so nothing real is lost by releasing.

Because the queue is shared, dropping the failed file is also what lets
the other files' pending edits drain. One file that cannot be saved no
longer blocks everything else.

Tests: the failure retains the queue and notifies once, recovery
releases it without replaying, a failed file does not block a second
one, permission loss is released rather than resumed, and initialization
leaves a halt alone.

AI-assisted-by: space-bunny-free

* ✨ Offer a reload on the save failure toast

A terminal save failure stops autosave for the whole session, but the
toast asked the user to reload without offering the reload. It listed
five implied actions, none of them reachable from the banner, and it
never said that the changes could not be saved.

flash now takes a :links vector of actions, appended after the download
link that :report-link? produces, so a caller can add recovery actions
without reaching into the report handling. flash-persistence passes a
reload built from labels.reload-page and location.reload, the pattern
already used elsewhere in the app for a full restart.

The message now says what happened, what is lost, and what to do about
it: autosave has stopped for this session, the changes since the last
successful save cannot be saved, and either reloading or leaving the
file and opening it again restarts it. Naming both matters, because the
toast links are easy to miss.

Saying the edits are lost is not optional: recovery releases them, so
without it the user finds out when they come back to the file.

Translated in en.po and es.po only. The translations script would also
normalize all 40 locales and drop entries that were already stale, which
is a maintenance pass of its own.

The reload callback cannot be invoked under the test runner, since
js/location does not exist there, so it is covered by its label and by
being callable. Whether the reload works is a manual check.

AI-assisted-by: space-bunny-free

* 🐛 Classify the responses the backend never wrote

A reply written by a proxy instead of the backend carries no Penpot
error code, so handle-response raised it as :internal. A 504 on a save
skipped the retry cycle and stopped autosave at once, and because
:internal reports as unhandled, it raised an alert. In a standard
deployment this is how a slow save ends: nginx answers 504 after 300s,
before the client's 7 minute timeout fires.

The status now decides the type:

- 504 and the Cloudflare 520-524 family raise :gateway-error, which
  joins retryable-types. Saves retry with backoff like 502 and 503,
  GETs retry, and a save that spends its retries reports as handled,
  because the report rules derive from the same set.
- 429 raises :rate-limit, and any other error status
  :unexpected-response. Neither retries; both show a toast instead of
  the error page.
- :internal is kept for a response below 400 that does not decode.

Bodies we cannot read are cut to 500 characters, since proxy error
pages run to tens of kilobytes.

Tests: classification and retries per status, the transport handlers
keep the page, a 504 and a 524 built by the real handle-response go
through the save path and land on a retry, and the new types stay
handled once the retries are spent.

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

* ✨ Keep retrying failed saves on a slow cycle

Once the three backoff retries were spent, a save that failed on the
connection stopped autosave like any terminal failure, and opening a
file again dropped its edits. A connection that comes back after a
minute lost the work all the same.

A transport failure that outlasts the backoff now arms a slow cycle:
one silent resend every 30s, until a save lands or 24h have passed
since the failure started. The cycle stops when persistence restarts,
the queue resumes another way, or the workspace closes.

A save that keeps failing slowly is not resent for a day. A gateway
that answers 504 after its own timeout may leave the backend applying
the save, so each resend can cost the whole save again, and the failing
file holds back every other file's queued edits. An attempt that fails
after 90s or more counts as a slow failure; at three, the slow cycle
stops sending the commit and the save fails as before. Fast failures,
such as being offline, never count.

- The first failure shows a toast that says the changes are kept and
  keep being sent, with no reload link, since a reload loses them. A
  save that lands hides it.
- Opening a file sends a queue still inside its retry window once
  more instead of releasing it. Failures outside the window, and every
  non-transport failure, are still released.
- A freshly loaded file gets back the queued edits of its version, so
  reopening it shows the changes that are still waiting to be saved. An
  edit that no longer applies to the loaded data leaves the file as the
  server sent it, so the file still opens; the edits stay queued.
- A commit whose earlier request is no longer in flight is simply sent
  again under the same commit id: changes are idempotent, so there is
  no unknown-outcome guard any more. A request still in flight still
  blocks a parallel send.

flash takes a :tag so the save failure toast can be hidden by tag.

Tests: the spent backoff arms the slow cycle, a 504 arms it, a terminal
failure does not, the cycle stops when the window closes or at the slow
failure limit, recovery resends instead of releasing, reopening restores
only the loaded file's edits and survives one that no longer applies,
and the retrying toast offers no reload.

* 🐛 Turn off the leave-page warning when failed edits are released

The leave-page warning only followed the queue on appends and discards.
Opening a file again releases the edits of a file that failed for good,
but that release changed the queue without either event, so the warning
stayed on with nothing left to save. Leaving the page asked about
unsaved changes until the next save went through.

The warning now also follows the release.

Tests: releasing the last failed edit turns the warning off.

* 🐛 Stop retrying failed saves outside the workspace

Leaving the workspace was meant to stop the slow retry cycle, but only
a cycle already armed stopped. A backoff timer kept firing after the
user left, and a backoff spent outside the workspace armed a slow cycle
that kept sending for up to a day from the dashboard. Those sends check
the edit permission of whatever team is open, so a switch to a team
where the user only views failed the queued edits for good, and the
next file they opened dropped them.

Leaving the workspace now suspends retries. A pending backoff timer
goes stale, and while suspended a transport failure schedules no
backoff, a spent backoff arms no slow cycle and the online event sends
nothing. The edits stay queued and a request in flight still lands.
Opening a file lifts the suspension and resends a retrying save whose
backoff was cut short; a role change does not lift it.

Tests: leaving stops a pending backoff, a failure after leaving
schedules no retry and ignores the online event, a backoff spent
outside the workspace arms no slow cycle, and opening a file saves the
held edit in each case.

---------

Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
2026-10-15 15:54:12 +02:00
Andrey Antukh
1783f9380d
✨ Scan upload-session chunks in storage gc-touched (#12305)
Route upload-session chunk liveness through the mark-time reference
scan instead of the delete-time cleanup in gc-deleted.

gc-touched now checks upload_session_chunk before marking a chunk,
so referenced chunks freeze and only orphans are marked. The freeze
path re-verifies references under row locks, which also closes the
stale-freeze race for every scanned bucket. gc-deleted drops its
upload-session special case: deferred rows keep their mappings until
the final give-up, and a live reference aborts the chunk loud.

Closes #12302

AI-assisted-by: muse-spark-1.3-contributor
2026-10-15 14:18:57 +02:00
Eva Marco
0d9b1c48fc
🐛 Fix legacy path migration crash on paths without points (#12357)
The legacy-6 migration read the rect of each path from its points and
checked whether the width or height was close to zero. A path with
missing or empty points has no rect, so the check got nil and threw a
NullPointerException. The migration then failed on every load of that
file, and get-team-shared-files returned 500 for the whole team, so the
"Add library" modal could not open.

Treat a missing rect like a degenerate one and rebuild the selrect and
points from the path content.

Closes #12343

AI-assisted-by: claude-opus-5-5
2026-10-15 13:23:54 +02:00
Andrey Antukh
a53481c3b7
✨ Enrich backend audit events with team and entity ids (#12233)
* ✨ Enrich backend audit events with team and entity ids

Backend audit events now carry the data a consumer needs to rebuild
state without joining production tables:

- prepare-rpc-event adds :team-id when the event carries a project or
  file id; commands that already loaded the team set it themselves.
- add-profile-to-team! emits add-team-member on every real insert.
- create-project and create-file emit the new row id.
- clone-template, import-binfile and duplicate-project emit one event
  per created entity.
- both binfile versions return {:file-ids [...]} from import-files!.

Closes #12219

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

* ✨ Emit delete and hard-delete audit events with entity data

The event stream must let Nexus rebuild local state without joining
production tables. This completes the enrichment with the delete paths:

- delete-team keeps returning nil but now carries the deleted row
  (id, name, timestamps) in the audit props, like delete-project.
- create-team adds created-at to its audit props.
- delete-profile carries id, email, fullname and deleted-at; its
  props used to be empty because every param is namespaced.
- objects-gc emits hard-delete-team/project/file per purged batch,
  with the deleted ids, only when the audit log is on.
- delete-team-member needs no change: the delete is physical and the
  params already carry team-id and member-id.

The nitrate push-audit-events tests now drop the membership events
emitted by their own profile setup before counting.

Closes #12219

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

* ✨ Announce cascade deletes and missing ids in audit events

The :delete-object worker task marked teams, projects and files without
leaving any event, so an event-sourced projection kept them alive for
weeks. It now emits one cascade-deleted event per execution with every
affected id, in the same transaction, only with :audit-log on. Rows
purged by objects-gc and snapshot cascades stay silent on purpose.

Also: create-team-with-invitations carries the new team id, and
update-team-member-role names the demoted member on owner transfers.

Closes #12219

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

* 🐛 Identify the SREPL profile delete in audit events

The SREPL delete-profile! emitted no profile-id and empty props, so the
row could not be attributed by any consumer. It now carries the profile
id in both fields. delete-team! named the wrong cause in its context.

Closes #12219

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

* 🐛 Identify the SREPL profile restore in audit events

The SREPL restore-profile! emitted no profile-id, so the consumer
keyed on the zero profile instead of the restored one. It now carries
the profile id like a normal profile event, and no consumer special
case is needed.

Closes #12219

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

* 🐛 Stop attributing SREPL events to the subject profile

profile-id names the actor, and SREPL calls have none: setting the
subject id blamed the user for an admin action and would have leaked
those rows into PostHog as user activity. The subject id stays in
props, where the consumer resolves it, and the event profile stays
zero.

Closes #12219

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

* ✨ Expose file analysis data for the nexus pipeline

The nexus file-security pipeline needs file content and scheduling
state from the backend. This adds a read-only management method,
`get-file-for-analysis`, returning the semantic projection of one
file (pages, frames, texts, links, interactions; ids as strings;
capped with a truncated flag), plus identity, revn, timestamps and
share state. No prior projection code existed, so the method ships
its own pure `file->projection`.

Audit events now carry what the `:local` consumer schedules from:
revn and modified-at on update-file, name and timestamps on every
create-file emitter, deleted-at on delete-file, and new
create/delete-share-link events with file and link ids.

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

* ♻️ Rename deletion audit events to action names

Event names are actions, not past tense: `cascade-deleted` becomes
`delete-object-with-cascade` with the same shape and transaction.
The three `hard-delete-*` events merge into `hard-delete-objects`:
one event per purged chunk carrying only that chunk's entity key
(`:team-ids`, `:project-ids` or `:file-ids`), because each GC
section purges in its own chunk transactions and a single event
could never share them all. Every submit stays inside its delete
transaction, `triggered-by` stays annotated, and silent paths stay
silent.

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

* 🐛 Read worker parallelism knobs with plain config keys

The runner wiring looked up `:app.main/worker-*-parallelism`
(namespaced) while the schema and the environment provide plain
keys, so `PENPOT_WORKER_DEFAULT_PARALLELISM` and
`PENPOT_WORKER_WEBHOOK_PARALLELISM` never applied and workers
always ran with the fallback. Read the keys the config holds.

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

* 🐛 Fix binfile audit test with a real file row

`submit-file-created-event` reads the light columns back from the
row, so the mocked importer must return an existing file id: a
phantom id emits no event and the test saw zero `create-file`
events. Create the file first and use its id. The audit-log memory
now tells both bulk-event helpers apart.

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

* 🐛 Fix audit event fidelity from review findings

The review of the audit enrichment found data-fidelity gaps; fix
them while the branch is fresh.

Membership events now belong to the actor: the flows where the
owner adds somebody else pass an explicit actor id, the role is
derived from the flags (the table has no role column), and the
event carries the request context like its siblings.

Also: team-id resolution skips read events, duplicate-project
emits the project name and timestamps, delete-profile is
idempotent again, the GC tests assert physical purge, and
get-file-for-analysis leaves an audit trace.

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

* ♻️ Nest cascade ids under :cascade in delete event

The cascade event carried every affected id flat next to the
object identity; nest them under `:cascade` so the payload reads
as header plus affected set. The event is an action like its
siblings, not a command. Drop the `:audit-log` flag re-check:
`submit` already fans out per flag.

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

* 📎 Update copyright name on file header

The company name changed; a single header in this branch still
carried the old one. Align it with the current form.

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

* 🐛 Fix nil event-id on webhook trigger audit rows

process-event generated the row id inside append-audit-entry, so the
:process-webhook-event task never received it and the trigger row
stored a nil :event-id. Assign the id up-front and share it between
the stored row and the worker params; the telemetry shadow row keeps
its own id to avoid a PK collision. Add regression tests covering
the handoff and the end-to-end trigger row.

AI-assisted-by: muse-spark-1.3-contributor
2026-10-15 13:01:18 +02:00