703 Commits

Author SHA1 Message Date
Álvaro Tejero Cantero
0cc2e97338
Merge develop into iop-vc-preview
Bring iop-vc-preview up to develop 43f3bda8b5, which carries the
final squash of penpot#12420 (93d36d04d8).

iop-vc-preview carried an earlier copy of penpot#12420 (42a2e33ffe
and a6c20b3e95). Every file the early or the final copy touches was
merged three-way with a6c20b3e95 as the base, so develop's side wins
for every penpot#12420 hunk and iop-vc-preview keeps only its own
deltas. Changes from other develop PRs (#12524, #12192, #12731,
#12686, #12442) are kept.

Adaptations to develop:
- files_update.clj: apply-changes returns the triple
  [file op-changes media-index] on the #12524 auto-repair path too,
  and a repaired branch save logs the repair changes ahead of the
  client's ones.
- files.clj: absorb-library-by-file takes develop's update-file! map.
- migrations.cljc: migrate keeps its target arity and develop's
  ordered :migrations set.
- climit.edn: the branch global limits declare a queue, as #12686's
  example-config-bounds-every-wait requires.
- Tests: the repair tests destructure the triple, the branch import
  test uses the #12192 export cfg keys, and the ref-paths test
  classifies :set-own-tokens-status.
- Dashboard comments and placeholder take develop's design-system
  icons around iop-vc-preview's review list; en.po and es.po keep the
  union of both sides.

AI-assisted-by: claude-opus-5-5
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-11-03 12:47:08 +01:00
Andrey Antukh
93d36d04d8
✨ Stabilize fragmented file data across snapshots and GC (#12420)
* ✨ Stabilize fragmented file data across snapshots and GC

Snapshots, automatic and user, now store pointer-based rows that share
fragments with the live file instead of duplicating page content.
`get-file-snapshot` resolves those pointers on the server so version
previews keep rendering plain data, and `restore!` reuses the shared
rows. Export still materializes (the package carries no fragments) and
import regenerates them.

`file-gc` treats the main file and every unpurged snapshot as liveness
roots, reads the file unrealized, and reattaches the original pointer
wherever the cleaned value is unchanged, so a pass over an untouched
file writes no fragments and a pass over an edited file keeps the ids
of the pages it did not touch. Snapshot creation and the restore
backup take the file row `FOR SHARE`, which serializes them with the
pass and closes the race that let a new snapshot reference fragments
the pass was about to mark.

All backend pointer bindings go through the `fdata/with-pointers`,
`with-tracked`, `with-tracked-pointers` and `with-untracked` macros.
The loader reads fragments a previous pass marked until `objects-gc`
purges them, and the frontend fails loudly with retry instead of
dropping an unresolved fragment.

Review fixes in this branch: the automatic snapshot attaches before
pending migrations, `realize-pointers` is the single verb,
`reattach-unchanged` takes the identity fast path, the restore guard
takes its revn from the backup read, and `PointerMap.withMeta`
generates a fresh id when metadata changes.

Tests cover both snapshot writers (red without the lock), snapshot
sharing and pin release through the real delete and purge paths,
reattach against orphan marking, preview after offload, the pointer
binding contracts and the with-meta id change.

Closes #12410

AI-assisted-by: muse-spark-1.3-contributor
AI-assisted-by: deepseek-v4.1-flash

♻️ Pin no-churn behavior for untouched pages

Pin the behavior the churn verification measured: a save keeps the
fragment id of every page it does not touch, with a page of eleven keys
(PersistentHashMap), a 46-shape objects map and a nine-page index (hash
map). Also pin the mixed case: a data-level objects-map flip rewrites
the old pointer pages once and the next save keeps the new ids.

The memory records the identity rule behind it: re-assoc with the same
value returns the same map in array and hash maps alike.

Closes #12410

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

⚡ Stop loading untouched pages when enabling objects-map

`enable-objects-map` wrapped every page on every save, and reading a
page's objects forced its PointerMap to load the fragment. A save of
one page in a seven-page file therefore made seven fragment reads.

Skip unloaded pointers: their fragment was already wrapped by the
encode-file that wrote it, so loading it only to wrap it again is a
no-op. The save now loads only the pages it edits. A page with a plain
fragment (a data-level feature flip) keeps its plain fragment until it
is first loaded or edited; a migration that enables the feature must
rewrite the data itself.

Cover the change with load-count tests for save and restore, update the
flip test to the new contract, and drop the stale restore comment.

Closes #12410

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

⚡ Keep page fragment ids through pending migrations

Applying a pending migration on read realized the file, ran the
migration and persisted it again; the persist wrapped every plain page
in a new PointerMap, so every page got a new fragment id even when the
migration did not change it. That leaves the fragment cache cold and
rewrites every fragment on the first read after a deploy.

Capture the original pointers before realizing, reattach the unchanged
positions after migrating (same value comparison file-gc already uses),
persist the reattached file and return the realized one: the read
contract is plain data. Read-only connections skip the persist and the
reattach. A migration that adds objects-map skips the reattach so the
pages get wrapped.

Move the three helpers to fdata so file-gc and the migration path share
them, and cover the path with a real 0031 migration test: the unchanged
page keeps its id, the changed page gets a new one, the caller gets
realized data and a second read does not migrate again.

Closes #12410

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

* 🐛 Realize library pointers with each library's own loader

Fragment rows are keyed by file id, and every caller binds the
fragment loader of the file it works on. get-resolved-file-libraries
returned libraries with their pages still as pointers, so the first
read of a library page under fdata/pointer-map asked the consuming
file for the fragment and failed with :fragment-not-found. That
broke migrations and file validation for every file using a
pointer-mapped library.

The weak-map loader now realizes each library's pointers with the
library's own id. The test fails without the change.

AI-assisted-by: claude-opus-5-5
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>

* 🐛 Restore the pointer-map require the repair path still needs

The branch dropped `app.util.pointer-map` from the namespace while the
auto-repair path (added to develop before this branch) still rebinds
`pmap/*tracked*` around validation and repair in
`repair-preexisting-damage` and `process-changes-and-validate`. The
namespace did not compile, so the whole backend failed to load.

Those toggles do not fit the fdata macros: `with-untracked` binds nil
and `with-tracked` creates a fresh atom, while the repair path has to
turn tracking off and restore the existing atom (and read it before the
repair starts). Restore the require and keep the bindings explicit.

Also pin the feature-off path with a test: with `fdata/pointer-map` off
(the default), a save creates no fragments, file-gc writes nothing and
the snapshot preview still returns plain data. Drop a stray `\n` char
literal left at the end of one deftest.

Update the memories with the exception to the macro rule, the snapshot
FOR SHARE locking and the pre-migration automatic snapshot.

Closes #12410

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

* 🐛 Validate debug files with their own pointer loader

The `/dbg/actions` file validation passed the file id where
`get-resolved-file-libraries` expects the file map, so the library map
was seeded with a nil key. It also validated a file read without
realizing it, so the first page lookup of a pointer-mapped file raised
"load is not supported when *load-fn* is not bind" and the endpoint
could not validate exactly the files the feature touches.

Read the file realized (validation walks every page and component
anyway) and build the library map from the file. The library loader
already resolves each library's pointers with its own id, so the
realized file and its libraries validate as plain data. Covered by a
handler-level test that fails before the change.

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

* ♻️ Bind the snapshot loader through the fdata helper

`reduce-snapshots` hand-rolled the same `binding` that
`fdata/with-pointers` builds (it also loaded the function's own
pointer loader before entering the reduction). Use the macro so the
rule holds there too and the namespace no longer needs
`app.util.pointer-map`; the loader is identical (same cfg and file-id)
and snapshot walking behaves the same.

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

* 📚 Note the pointer-binding exceptions and snapshot ordering

Record what the feature review settled:

- Two direct `binding`s stay on purpose: the fine-grained `*tracked*`
  toggles of `app.rpc.commands.files-update` (validation/repair turn
  tracking off and restore the existing atom) and
  `binfile.common/encode-file` (fresh tracked set inheriting the
  current one). Atom-scoped macros would only be worth it if the set
  grows.
- The automatic snapshot is taken before the pending migration, and
  the pre-migration window is narrow: the `get-file` RPC migrates on
  read and its ETag hashes the available migrations, so a post-deploy
  read cannot be a 304 from the pre-deploy state. The old-format
  snapshot only appears when the save is the first request to touch
  the file after the deploy, and `get-file-snapshot` previews it as
  stored.

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

* 🐛 Apply pending migrations when previewing a version

Automatic snapshots are attached before the save applies pending file
migrations, so a version can store the old format. The preview RPC
handed that data to the UI as stored, and the viewport renders it with
the current code.

Realize the snapshot with the live file id (fragments are keyed by
file, not by snapshot), normalize its migrations to a set (the decoder
keeps the pgarray as a vector, and `set/difference` would compare
indexes) and apply the pending migrations with the pure
`fmg/migrate-file` and the live libraries. Nothing is persisted: the
stored version keeps its data and restoring it re-migrates on the next
read. The response now carries the snapshot's migrations.

Use `:features` the way `get-file` does for `fdata/objects-map`: a
client that does not declare it gets plain objects. The historical
feature set is not checked against the enabled one on purpose: a
version may predate a feature change.

Cover the preview of a snapshot with a pending migration and the
objects realization; both pin that the stored row is untouched. Update
the file-data memory with the preview contract and the vector/set
trap.

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

* 🐛 Decode snapshot migrations as an ordered set

`file-snapshots/decode-snapshot` handed the `file_change.migrations`
pgarray as a vector. `set/difference` falls back to index lookups when
the first collection is shorter than the second, so the pending set was
right only by accident: a stored subset happens to work, while a
stored set larger than `available-migrations` (a snapshot taken by a
newer build) marks every available migration as pending and re-applies
it to already migrated data.

Decode it as a `d/ordered-set`, the type the rest of the code and
`schema:file` expect. This also covers `restore!` and the snapshot
list, and it lets `get-file-snapshot` drop the `(into #{} ...)`
normalization it was carrying. Re-wrap the union in `fmg/migrate` so
the invariant holds even when a caller hands a plain set.

A snapshot with NULL `migrations` predates SQL migration 0140 and
keeps the format version of its time. Pin that legacy path: the
preview regenerates the applied set from that version, migrates in
memory and leaves the stored row untouched. Update the file-data
memory with the invariant and the legacy path.

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

* 🐛 Fix flaky waiting-gauge assertion in climit test

The throwable-while-waiting test asserted the exact list of
waiting-gauge publications, but the component sampler republishes
the same count when its first tick lands inside the recorded
window under load. Assert only the last value, which is the
contract the helper documents.

AI-assisted-by: muse-spark-1.3

---------

Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
Co-authored-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-11-02 15:02:55 +01:00
Andrey Antukh
29373fff9f
✨ Rework the progress of the import and export dialogs (#12192)
* ✨ Rework the import dialog

The import wizard paints a Check/Upload/Import stepper derived from the
dialog status, plus a phase title, a description and a progress bar per
phase, and its rows follow the new design: folder icon, file name and a
size subtitle. While checking, the dialog shows no rows and no spinner,
because the phase header already carries the message.

The wizard writes the job milestone once, in the header
(`jp/current-milestone-text`), since every file of a container reports
the same one. A row keeps its name and its size, says "Ready" once its
container finished uploading, and shows the error text when it fails;
the button that drops a file only appears while the wizard still offers
its import step. The `jobs.queued` label now reads "Waiting job slot".

The bar measures the whole import: determinate while the containers are
uploaded, weighted by their size (`imp/upload-pct`), and animated
otherwise, because the stages of a job are not comparable. The phase
title counts the files, and `imp/imported-count` counts them done with
the job's own counter, so the number moves while a container rebuilds
its files one by one.

The analysis reads the entries of a version 3 package in one pass: a
file shows what it occupies inside its container, its own entries plus
the storage objects its media and thumbnails reference, while what the
container weighs stays in `:container-size`, the size the upload bar is
weighed on. The ready step waits for the end of the analysis
(`imp/ready-to-import?`), not for every known file, so Continue never
leaves out the files that arrive later.

Tests cover the file sizes, the upload percentage, the done counter,
the wizard step and the milestone in course. The change also adds
`-XX:NativeMemoryTracking=summary` to the backend devenv so `jcmd`
shows native memory, and documents the wizard, the promise call styles
(`mem:clojure/promesa`) and two CSS pitfalls: a logical `inline-size`
wins over an inline physical `width`, and Rumext leaves a plain
`:style` map raw, so inline styles leave CLJS as `#js` objects.

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

* ✨ Rework the export dialog

Every exported file is its own job, so each row reports its own state:
folder icon, name and a size-plus-`.penpot` subtitle; waiting for a
worker; running with the milestone of the job and an animated bar; or
exported with a tick. A failed row paints red, takes the close icon and
shows the hint of the job, or a generic label, through
`files-export-modal.export-error`.

Success, failure and cancellation replace each other, so a row never
reports two outcomes at once. The ids of the messages are canonicalized
to string before they match a row, because they cross serialization
boundaries. The export result now carries `:size`, so the subtitle says
the format until the job produces the artifact and the size once it
exists; a transport failure builds a synthetic error, so a nil
`ex-data` no longer leaves the row hanging.

The header counts the files and says whether the export runs or ended;
since every file has its own job, it paints no global bar.

Closing changes too: the Close button is gone. While files are in
flight the footer offers Cancel export and the X cancels as well, which
marks the loading rows cancelled and leaves them in place, so their
outcome can be read; once every row reached a terminal state the footer
offers Done, which dismisses the dialog.

The retry button of the finished design is left out, because the client
has no retry mechanism yet. Styles come along: the rows scroll while
the header and the footer stay put, and the new phase header, row and
bar follow the tokens of the import wizard. The title key becomes
`files-export-modal.title`, and the new keys cover the header, the row
states and the buttons.

AI-assisted-by: muse-spark-1.3

* ♻️ Inline the binfile job adapters into their task handlers

The wrappers in app.binfile.jobs only forwarded to the core, so each
task handler now composes its own run: frozen cfg, active check,
progress bridge and format dispatch. export-cfg freezes the whole
export (file-ids, export-type, output) and v3/export-files! takes it
as its single argument; ::bfc/ids becomes ::bfc/file-ids. The bridge
keeps one job: bfj/run forwards the cfg to its callback, and
check-active answers with it to thread the run.

AI-assisted-by: muse-spark-1.3

* 🐛 Migrate binfile tests to the single-arg export-files!

The refactor made v3/export-files! take a single cfg carrying
::bfc/file-ids and ::bfc/output, but six calls in binfile_test
(added by the tokens handling change) still used the old two-arg
form with ::bfc/ids. Move them to the new form, as the rest of
the file already does.

AI-assisted-by: muse-spark-1.3

* 🐛 Fix export artifact expectations to include file size

The export flow now reports the artifact size, but the stale
expectations dropped it and failed the frontend suite. Include the
size in both assertions so they match the emitted message.

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

* 🐛 Migrate remaining export-files! test callers to the single-arg form

rpc_binfile_test and offload_file_data_test still passed the output apart
and used ::bfc/ids; move both into the cfg as ::bfc/file-ids/::bfc/output.

* 🐛 Hide the downloads hint when no file was exported

After Cancel or when every job failed, the export header said
"0 files exported · Check your downloads folder."; drop the hint then.

* ♻️ Read the object of a container entry once

The index of a container asked twice for the object of the same zip
entry: once to test it and once as the key of the update. Resolve it
once, in the branch that owns it, so the entries of a file never pay
for a question that does not apply to them.

Addresses a review note on #12192.

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

* ♻️ Never size an import row with its container's weight

A row of a version 3 package took the size of the container whenever the
container held no entry for its file, while the comment beside it
promised the opposite. An unknown size now stays nil, which the row
already understands: it paints no subtitle instead of a number that
belongs to the whole package.

The test carries a file the manifest lists and the container does not
hold, and the memory records what the row does then.

Addresses a review note on #12192.

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

* 🐛 Retry pool stats read until snapshot is consistent

Hikari exposes each pool counter through a separate MXBean call, so a
borrow or return between the reads can leave one snapshot off by one.
The test read once and failed in CI with total 2 vs active + idle 3.

Read up to 20 times with a 10ms pause until total equals active +
idle, then assert on the stable snapshot. The invariant stays strict;
only the transient race is tolerated.

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

---------

Co-authored-by: Elena Torro <elenatorro@gmail.com>
2026-11-02 12:12:39 +01:00
Andrey Antukh
65a76a21cd Merge remote-tracking branch 'origin/staging' into develop 2026-10-29 16:31:26 +01:00
Álvaro Tejero Cantero
87c1b640fa
♻️ Pin which fragment a one-page save loads and keeps
save-loads-only-edited-page counted fragment loads only. Assert as well
that the one load is the edited page's fragment, that the untouched
pages keep their fragment ids, that the rewritten page is stored as an
objects map, and that the file reloads equal to the edit applied to the
file before the save.

Under the old enable-objects-map, which wrapped every page, the test
fails with three loads instead of one. Replaying 1,000 recorded saves
of a 49-page file on a pointer-map copy found no other load the save
path adds beyond the edited page and the components map.

AI-assisted-by: claude-opus-5-5
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-10-29 12:43:15 +01:00
Dr. Dominik Jain
c0f0b14540
🎉 Add direct MCP feedback channel via dedicated tool (#12551)
* ♻️ Move devenv-only tools to a separate folder

* 🎉 Add an MCP feedback channel

Add a user-approved feedback tool and an MCP-token-authenticated backend
endpoint that queues plain-text email. Enable the tool through the
configured backend URI in both local and multi-user mode.

Validate subject and feedback limits, enforce per-profile rate limits,
and document configuration. Cover authentication, queueing, tool
availability, and failure handling with backend and MCP tests.

Closes #12490

AI-assisted-by: gpt-6

* ♻️ Move MCP feedback submission into RPC

Use the standard RPC wrappers for feedback and remove the dedicated
HTTP endpoint, route wiring, and custom rate limiter.

Restrict MCP tokens to opted-in routes and commands. Add a shared RPC
call helper to the MCP backend client and adapt the existing tests.

AI-assisted-by: gpt-6

* 🐛 Harden MCP feedback auth, validation and messages

Accept MCP tokens when either the mcp or the access-tokens flag is on, so
instances that enable MCP without personal access tokens can send
feedback. Personal tokens still need access-tokens. Keep the issuer in one
constant, decode tokens without raising to reject unknown issuers, and
name the issuer and type checks.

Reject control characters in the subject, and in the feedback all but
tabs and line breaks, in both the command and the tool, so a NUL answers
400 instead of failing when the mail job is stored. Answer 403 with
profile-blocked or profile-not-active for unusable profiles, record only
who sent feedback in the audit event, and add doc metadata.

Replace on OAuth approval an MCP token issued before MCP tokens had their
own issuer, or one this instance cannot decrypt, since the backend rejects
both.

In the MCP server, mention disabled access tokens on 401, drop the unused
503 case, and say that delivery happens in the background. Document the
flags in the README.

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

* 📚 Document the MCP feedback tool and MCP token rules

List submit_feedback among the MCP tools and add an MCP feedback section to
the configuration guide covering PENPOT_MCP_BACKEND_URI,
PENPOT_MCP_FEEDBACK_EMAIL, the flags and the email settings. Add commented
examples of both variables to the compose file.

In the agent memories, record when OAuth approval replaces an MCP token,
how the MCP server reaches the backend, and which flags, routes and
commands accept MCP tokens.

AI-assisted-by: claude-opus-5-5
2026-10-29 10:35:46 +01:00
Andrey Antukh
3cc91e8ba2
♻️ Take the command limits before the root ones in the RPC limiter (#12686)
* ♻️ Take the command limits before the root ones in the RPC limiter

The RPC concurrency limiter took the root limiters (the ones every request
passes through) before the ones a command declares, so a request waiting for
the limits of its own command held the root permits of its profile while it
waited: a profile with a pile-up of heavy requests saw its other requests
queue behind them and be rejected with 429.

That order was forced by two commands that entered the limiter from inside
their handler: a command that declares no limits of its own can only take
them deeper than the root ones, so two paths of one profile could take the
same limiters in opposite order and park for good.

The commands declare their limits in their metadata now and their handlers
call the work directly, so no command enters the limiter from inside its
handler and every path takes the limiters in the same order. The root
limiters are appended to the sequence first, so the limits a command
declares end up outermost and are taken first: a request waiting for them
does not hold the root permits of its profile. The photo of a team keeps its
limits, because it declares them now that the helper does not.

A call from inside a handler can only reenter the limits that thread
already holds, and it is refused when it asks for a new one, because that
would take it after the root ones and invert the order against the other
paths. The key of a limit is resolved the same way in every path, so both
paths point at the same limiter and the reentrancy check sees it.

Every entry of the shipped climit.edn declares a queue, so the number of
requests waiting for the same limit is bounded instead of piling up until
the timeout. The production file lives outside the repository and has to be
mirrored to pick them up.

The tests cover the acquisition order of a command with and without limits
of its own, the order of the limits as a whole (a ring of any size fails,
not only a pair in opposite order), the nested call that asks for a limit
the thread does not hold, and the queues and timeouts of the shipped
configuration. The import of a binfile declares its request as multipart in
its test, the way the commands that take an upload map do.

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

* 🔧 Keep the commands' limits and the shipped config in sync

`profile-plugin-ops` guarded only an UPDATE on the caller's own profile row,
which the row lock already serializes, so its global cap could only reject a
burst of different profiles toggling plugins. Both commands declare the
by-profile limit alone now, and the example config drops the entry.

A declared id without an entry does not limit anything: `wrap` logs a warning
and runs the handler with no limiter at all, which is how a command ends up
unbounded in production. A test fails now when a command declares an id the
config does not list, and the header of the example says the production file
mirrors the shape and lists every declared id, not the values.

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

* 📚 Record that background work is not waited for with a sleep

A long command moved to the background reports its own completion, so
polling it with a sleep inside a foreground command only blocks the session
and the notification arrives anyway. The rule says to keep working on
something else, or to end the response and wait for it.

AI-assisted-by: minimax-m3.1-flash
2026-10-28 18:44:36 +01:00
Dr. Dominik Jain
e20a0d748e
✨ Add OAuth support for MCP server (#12480)
* 🎉 Add OAuth support for remote MCP connections

Add MCP-specific discovery, client registration, and approval endpoints.
Exchange single-use authorization codes with PKCE for the fixed MCP user
token, enabling MCP and creating the token on approval when needed.

Use self-contained client IDs without new database tables or stored client
records. Keep temporary authorization state in Redis/Valkey.

Accept bearer credentials while preserving query-parameter connections.
Enable OAuth through PENPOT_MCP_AUTH_URI in multi-user mode. Document the
environment variable in the README and implementation details in Serena.

Explain protocol and concurrency decisions in the Clojure code. Cover token
provisioning, OAuth state, client registration, HTTP flows, and the approval
page with tests.

AI-assisted-by: gpt-6

* 🐛 Wait for devenv initialization before starting builds

Docker reports a container as Running as soon as its main process starts,
while the entrypoint may still be preparing the development environment.
manage.sh used that status to launch tmux jobs, allowing frontend builds
to race with ownership changes to /opt/rustup and Cargo setup.

On a fresh container, rustup could fail with Permission denied before the
frontend watcher started. Nginx would later serve the existing JavaScript,
leaving source changes absent from the running app despite restarting
devenv.

Wait for nginx's local health endpoint before launching the jobs. Nginx
starts after entrypoint setup, so its response confirms that setup has
finished. Allow up to 120 seconds and report a timeout without starting
the jobs if initialization does not complete.

Validation: shell syntax and whitespace checks pass.

AI-assisted-by: gpt-6

* ♻️ Share the login modal with MCP authorization

Extract the viewer login modal and its styles into a shared auth
component, retaining the viewer wrapper and its close behavior.

Use the modal on the MCP approval page and preserve the pending request
through login. Closing it clears the saved return URL and shows a
cancellation message without further actions.

Cover return URL handling and cancellation with frontend tests.

AI-assisted-by: gpt-6

* ✨ Show client icons in MCP authorization

Preserve HTTPS logo URLs from client registration and published metadata
and show the icon beside the application name on the approval page.
Ignore unusable URLs and hide failed images without blocking approval.

Update the access description to refer to MCP connections established
from Penpot. Cover logo metadata and approval responses in backend tests.

AI-assisted-by: gpt-6

* 🐛 Follow the system theme when no preference is configured

Default to the system theme in the shared resolver used by the app and
plugins. Preserve explicit light and dark choices, including the legacy
default value that means dark.

Reuse the reactive theme hook in progress widgets and show the system
preference in settings for accounts without a configured theme.

Verify preference resolution with regression tests and browser checks.
All 1,474 frontend tests pass.

AI-assisted-by: gpt-6

* 🐛 Replace expired MCP tokens during OAuth approval

Settings can give an MCP token an expiry date. OAuth approval reused
the newest MCP token without checking it, so an expired token reached
the client and never matched a plugin connection.

Approval now treats an expired token as missing and creates a new one.
Token exchange rejects a code whose token expired after approval with
invalid_grant.

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

* 🐛 Log unexpected errors on MCP OAuth endpoints

The OAuth routes sit outside the shared HTTP middleware, so the shared
error handler never saw them, and their own handler turned unexpected
failures into a 503 without logging anything. Failures in the session
middleware of the approval endpoints had no handler at all.

Unexpected failures now also go through the shared error handler for
its logging, while clients still get an OAuth error body. The OAuth
error handler also wraps the session middleware of the approval
endpoints.

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

* 🔧 Add devenv script to run the MCP server with OAuth

start-mcp-devenv-oauth starts the MCP server in multi-user mode with
PENPOT_MCP_AUTH_URI derived from PENPOT_PUBLIC_URI, to test OAuth in
the devenv. Agentic tooling still uses the single-user
start-mcp-devenv.

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

* 🐛 Fix failing test case

---------

Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
2026-10-28 18:40:04 +01:00
Álvaro Tejero Cantero
10c603dbee
✨ List copies that follow through a nested component
The copies summary now follows the same cascade the post-merge sync
does: when a copy that follows a changed component sits inside another
component's main, that outer component's copies are listed too, marked
with the component they follow through. The summary is computed only
when a caller asks for it (the compare view and the conflict
responses), so the ahead and behind counts skip it.

AI-assisted-by: claude-opus-5-5
2026-10-28 17:27:17 +01:00
Álvaro Tejero Cantero
95ad841ed5
✨ Report what a merge does to component copies
The merge summary now says, for each component whose main the incoming
side changed, which copies will follow it after the integration and
which keep an override, per sync group and token slot. Each copy entry
lists what the copy rule settled, with the side and the reason, and
what the user still decides. A copy the rule settles entirely stays in
the summary as an informational entry instead of disappearing, and
applies nothing. A conflict on a component's main counts the copies
its resolution also decides.

AI-assisted-by: claude-opus-5-5
2026-10-28 16:59:34 +01:00
Álvaro Tejero Cantero
f598e73bec
✨ Make tokens on copies follow their component through a merge
A copy's applied tokens were one attribute outside every sync group,
so when both sides applied different tokens to a component, every
untouched copy showed a conflict of its own.

The copy rule now covers applied tokens one slot at a time, as
Penpot's own component sync does: a slot follows the component unless
the sync group of the attribute it drives is overridden. A token
applied to a copy by hand on one side wins, and only a slot both sides
applied differently by hand stays a conflict. The side the user picks
for that conflict decides only the open slots.

AI-assisted-by: claude-opus-5-5
2026-10-28 16:19:41 +01:00
Álvaro Tejero Cantero
0632188950
✨ Prove a reset of copy overrides survives a branch merge
A copy whose override the base holds can be reset on one side while
the other side leaves it alone. The tests check that the reset reaches
the other file in both directions: the copy loses its override, takes
the component's value, and follows a later component edit. The merge
direction failed before copies followed their component.

AI-assisted-by: claude-opus-5-5
2026-10-28 15:20:14 +01:00
Álvaro Tejero Cantero
d7cce4a930
✨ Make copies follow their component through a branch merge
A merge or an update from main compared a component's main and each
copy as separate shapes. A copy added on the other side kept the old
look, and when both sides changed the component the dialog showed one
conflict per copy, so a copy could end up disagreeing with its
component without being marked as overridden.

Copies now follow the merged component, as they do when the component
is edited. Per copy attribute: if no side overrode it, it raises no
conflict and follows the component; if one side overrode it, that
side wins; only overrides on both sides that differ stay a conflict.
After the merge or update applies, the copies of every local component
whose main changed are synced, nested copies included, and the sync
changes are validated and recorded with the rest. A clean merge of a
copy no longer marks it as overridden on the target.

AI-assisted-by: claude-opus-5-5
2026-10-28 14:29:34 +01:00
Álvaro Tejero Cantero
8444ee5595
Merge branch 'iop-vc-preview' into branch-id-reroute 2026-10-28 12:40:25 +01:00
Álvaro Tejero Cantero
939eb1a9d7
🐛 Copy the inherited images into a branch made an ordinary file
A branch reads the images it inherited through main's media rows. When
materialize turned it into an ordinary file, the file kept pointing at
those rows and released the base that kept main's file-gc away from
them, so an image main later dropped, or main's deletion, broke the
materialized file.

Materialize now copies every media row the document uses that another
file owns into the branch file under a fresh id, and re-points the
document at the copies before it persists it.

AI-assisted-by: claude-opus-5-5
2026-10-28 12:31:16 +01:00
Álvaro Tejero Cantero
87d61cbdb4
🐛 Give a branch file its own id in the document it derives
A branch derives its document from main's base snapshot, and the derive
never re-pointed main's ids: :data :id stayed main's, and so did every
inherited reference to the file itself (component instances, library
colour and typography refs). The branch workspace then took its own
components for an external library it never loads and read its own
tokens through main: validation reported :component-main-external and
:component-not-found on a fresh branch, the local component sync
skipped inherited copies, an update from main refused once the branch
held its own copy of a base component, and materialize detached the
main instance of every inherited component.

The derive now sets the branch's id on the document before the replay,
so a change or migration comparing a reference with the document's id
sees the branch, and re-routes main's id to the branch's after it, so
op log rows written before this fix are covered too. The base keeps
main's ids because it shares main's fragments; comparisons already map
the branch's id back to main's.

relink-refs rebuilds a collection only when something inside it
changed (median 803 ms instead of 1506 ms on a 90,612-shape file), and
it now relinks the :ref-file of shadow and grid colours, the key
migration 0015 gave them, which it had missed since.

AI-assisted-by: claude-opus-5-5
2026-10-28 12:31:12 +01:00
Álvaro Tejero Cantero
6e3acf7482
♻️ Delete every merged branch, as penpot#12596 decided
The merge into iop-vc-preview kept the "Archive this branch" option of
the later merge dialog. Juan's decision in penpot#12596 holds instead: a
merged branch is always deleted, never archived. The merge and conflict
dialogs lose the option, merge-file-branch no longer takes keep-branch,
merged branches leave the branch list and count again, and the tests
for kept merged branches go, as in the pull request. The merge comment
stays. The warning about left-out changes no longer suggests archiving.

AI-assisted-by: claude-opus-5-5
2026-10-28 12:27:38 +01:00
Álvaro Tejero Cantero
74e33d05b6
Merge branch 'juan-vc-ui-tweaks-01' into iop-vc-preview
Takes penpot#12596: branch access by team role alone (no file role on
a branch, the owner role on materialise, branches move with their
source file, migrations 0807 and 0808), the merge record with its
comment (migration 0806) and the reviews dialog, the new git icons, and
the workspace tidy-up.

Where Juan's later redesign of the compare, conflict and merge dialogs
(c68daed816, e4e237346e) already changed the same code, the redesign
wins: the old dialogs stay in branch_review/, the redrawn compare
icon stays, and the merge keeps its "Archive this branch" option. So
merge-file-branch accepts both `comment` and `keep-branch`, a kept
merged branch is listed and counted again, and the keep-branch tests
the PR removed are restored. The merge dialog asks for the comment
beside the archive option, and the conflicts dialog sends it on.

AI-assisted-by: claude-opus-5-5
2026-10-28 12:06:11 +01:00
Alejandro Alonso
4dc684dd9c Merge remote-tracking branch 'origin/staging' into develop 2026-10-28 11:50:56 +01:00
Alejandro Alonso
506923e2e9 Merge remote-tracking branch 'origin/main' into staging 2026-10-28 11:50:36 +01:00
Álvaro Tejero Cantero
7e5099727a
🐛 Make the base the only snapshot a file-gc test relies on
The save in file-gc-keeps-the-fragments-a-base-names took an automatic
snapshot of pre-edit main, which names the same fragments as the branch
base, so the test passed even with bases left out of liveness. It now
drops every other snapshot before the pass.

AI-assisted-by: claude-opus-5-5
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-10-28 11:48:12 +01:00
Álvaro Tejero Cantero
e53719f2b7
🐛 Keep a page reorder on main visible to a bounded compare
The page order lives in the document root, not in a page fragment, so
a reorder on main moves no fragment, and a compare bounded to the moved
pages compared the order of no page at all. moved-pages now leaves the
whole-file comparison in place when main orders the pages it shares
with the base differently from the base.

AI-assisted-by: claude-opus-5-5
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-10-28 11:42:49 +01:00
Álvaro Tejero Cantero
709a92b9c0
⚡ Bound the main side of a branch compare to the pages main moved
A page whose fragment id main shares with the branch's base holds the
same document on both sides, because a fragment row never changes after
it is written. The derive now hands back the fragment ids of the base
pages, and a comparison loads only the pages of main whose id differs:
the base's realized page stands in for every other page. The direction
that reports main's changes (the compare's main->branch and the reverse
pass of the diff counts) is bounded to those pages with :only-pages.

A base that was migrated, or that holds no pointers, gives no ids and
keeps the whole-file comparison. On a copy of the scaling corpus rung L7
(69 pages, 47,514 objects) the main->branch compare drops from 5,690 ms
to 1,965 ms and the branch->main compare from 3,254 ms to 1,981 ms; what
remains is the branch derive, which still realizes the whole base.

The compare's size gate now runs after the derive, because main's
document is assembled from the base. The depth gate still runs before
it, and the base passed the size gate when it was written.

AI-assisted-by: claude-opus-5-5
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-10-28 11:36:52 +01:00
Álvaro Tejero Cantero
885099326a
✨ Store branch bases as pointers into the source file
A branch base was a fully realized copy of main. With fdata/pointer-map,
creation now stores main's fragment pointers: the base costs one pointer
per page and shares main's fragment rows.

Fragment rows are keyed by (file_id, id), so the base readers (the branch
derive and get-base-data) realize the base with the source file id
through realize-branch-base. The branch file's own loader finds none of
main's fragments.

Every writer of a snapshot that names main's fragments reads main's row
FOR SHARE, so it serializes with file-gc, which takes the row FOR UPDATE
and marks every fragment no committed snapshot names. Creation, the
pre-merge snapshot, and the base repositioned by an update from main
follow that rule. The merge and the update put main's stored pointer
back on every page they left unchanged, so the merge rewrites only the
pages it changed and a repositioned base shares main's fragments.

The hand-written pmap bindings move to fdata/with-tracked-pointers.

AI-assisted-by: claude-opus-5-5
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-10-28 11:20:26 +01:00
María Valderrama
ff9506c551
✨ Add member profile support for organization attributes (#12652) 2026-10-28 11:02:42 +01:00
Alejandro Alonso
a8e8a54a2b Merge remote-tracking branch 'origin/staging' into develop 2026-10-28 10:35:31 +01:00
Alejandro Alonso
00dec9143d
🎉 Add SREPL helpers to update emails and clear bounce reports (#12668)
Support currently fixes wrong profile emails and false bounce marks with
raw SQL. Add update-profile-email! (rejects collisions) and
clear-email-complaint-reports! (deletes global and profile reports and
unmutes the matching profile) so these ops run through the REPL.

Closes #12663
2026-10-28 10:25:43 +01:00
Álvaro Tejero Cantero
607920fad3
Merge branch 'issue-12410' into iop-vc-preview
Brings in the fragmented file data stabilization across snapshots and
GC (penpot#12410) and the per-library pointer loader fix. Four files
conflicted.

file_gc.clj `process-file!` keeps the branch's `:is-branch` skip and
its warning; ordinary files run the new body under `with-pointers`
(snapshot liveness, reattach of unchanged fragments, fragment
cleaning).

files_snapshot.clj `restore-file-snapshot` takes the new backup
transaction, which reads the file FOR SHARE and gives the guard revn.
`fsnap/check-restorable!` now runs on that read, before the backup
snapshot is created, so a branch file is still refused without a
committed backup.

binfile_test.clj and rpc_file_snapshot_test.clj keep the tests both
sides appended.

AI-assisted-by: claude-opus-5-5
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-10-28 06:34:39 +01:00
Álvaro Tejero Cantero
1342b27f6f
Merge remote-tracking branch 'origin/develop' into iop-vc-preview
Merges develop at f6f9df9a1a. Twelve files conflicted; each keeps
both intents.

files_update.clj: develop's per-stage validation timings wrap the
branch save path. `update-file` keeps the branch's early timer and
passes it to `update-file*`, inside develop's try/catch timing log.
`process-changes-and-validate` times the changes application and
still returns the file, the fixed-up change vector and the media
index. The audit props carry develop's revn and modified-at plus the
branch props. The timing test destructures the new return shape.

v3.clj keeps the branch-file overwrite guard and takes develop's
`configure-import-transaction`. migrations.clj puts develop's 0155
to 0157 before the branch's 0800 to 0805, so the branch's stay the
tail of the vector.

errors.cljs checks the merged and archived branch refusals before
develop's retrying case. left_header.cljs keeps the branch wrapper,
the review badge and the branch bar on develop's markup: the status
dot without icons and the DS library icon on the shared badge.
palette.scss and assets.scss keep the branch clearance terms on
develop's logical properties. The test runners, requires and
deftests are unions.

AI-assisted-by: claude-opus-5-5
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-10-28 06:28:08 +01:00
Álvaro Tejero Cantero
a6c20b3e95
🐛 Realize library pointers with each library's own loader
Fragment rows are keyed by file id, and every caller binds the
fragment loader of the file it works on. get-resolved-file-libraries
returned libraries with their pages still as pointers, so the first
read of a library page under fdata/pointer-map asked the consuming
file for the fragment and failed with :fragment-not-found. That
broke migrations and file validation for every file using a
pointer-mapped library.

The weak-map loader now realizes each library's pointers with the
library's own id. The test fails without the change.

AI-assisted-by: claude-opus-5-5
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-10-28 06:19:29 +01:00
Andrey Antukh
42a2e33ffe
✨ Stabilize fragmented file data across snapshots and GC
Snapshots, automatic and user, now store pointer-based rows that share
fragments with the live file instead of duplicating page content.
`get-file-snapshot` resolves those pointers on the server so version
previews keep rendering plain data, and `restore!` reuses the shared
rows. Export still materializes (the package carries no fragments) and
import regenerates them.

`file-gc` treats the main file and every unpurged snapshot as liveness
roots, reads the file unrealized, and reattaches the original pointer
wherever the cleaned value is unchanged, so a pass over an untouched
file writes no fragments and a pass over an edited file keeps the ids
of the pages it did not touch. Snapshot creation and the restore
backup take the file row `FOR SHARE`, which serializes them with the
pass and closes the race that let a new snapshot reference fragments
the pass was about to mark.

All backend pointer bindings go through the `fdata/with-pointers`,
`with-tracked`, `with-tracked-pointers` and `with-untracked` macros.
The loader reads fragments a previous pass marked until `objects-gc`
purges them, and the frontend fails loudly with retry instead of
dropping an unresolved fragment.

Review fixes in this branch: the automatic snapshot attaches before
pending migrations, `realize-pointers` is the single verb,
`reattach-unchanged` takes the identity fast path, the restore guard
takes its revn from the backup read, and `PointerMap.withMeta`
generates a fresh id when metadata changes.

Tests cover both snapshot writers (red without the lock), snapshot
sharing and pin release through the real delete and purge paths,
reattach against orphan marking, preview after offload, the pointer
binding contracts and the with-meta id change.

Closes #12410

AI-assisted-by: muse-spark-1.3-contributor
AI-assisted-by: deepseek-v4.1-flash

♻️ Pin no-churn behavior for untouched pages

Pin the behavior the churn verification measured: a save keeps the
fragment id of every page it does not touch, with a page of eleven keys
(PersistentHashMap), a 46-shape objects map and a nine-page index (hash
map). Also pin the mixed case: a data-level objects-map flip rewrites
the old pointer pages once and the next save keeps the new ids.

The memory records the identity rule behind it: re-assoc with the same
value returns the same map in array and hash maps alike.

Closes #12410

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

⚡ Stop loading untouched pages when enabling objects-map

`enable-objects-map` wrapped every page on every save, and reading a
page's objects forced its PointerMap to load the fragment. A save of
one page in a seven-page file therefore made seven fragment reads.

Skip unloaded pointers: their fragment was already wrapped by the
encode-file that wrote it, so loading it only to wrap it again is a
no-op. The save now loads only the pages it edits. A page with a plain
fragment (a data-level feature flip) keeps its plain fragment until it
is first loaded or edited; a migration that enables the feature must
rewrite the data itself.

Cover the change with load-count tests for save and restore, update the
flip test to the new contract, and drop the stale restore comment.

Closes #12410

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

⚡ Keep page fragment ids through pending migrations

Applying a pending migration on read realized the file, ran the
migration and persisted it again; the persist wrapped every plain page
in a new PointerMap, so every page got a new fragment id even when the
migration did not change it. That leaves the fragment cache cold and
rewrites every fragment on the first read after a deploy.

Capture the original pointers before realizing, reattach the unchanged
positions after migrating (same value comparison file-gc already uses),
persist the reattached file and return the realized one: the read
contract is plain data. Read-only connections skip the persist and the
reattach. A migration that adds objects-map skips the reattach so the
pages get wrapped.

Move the three helpers to fdata so file-gc and the migration path share
them, and cover the path with a real 0031 migration test: the unchanged
page keeps its id, the changed page gets a new one, the caller gets
realized data and a second read does not migrate again.

Closes #12410

AI-assisted-by: deepseek-v4.1-flash
2026-10-28 06:16:37 +01:00
Pablo Alba
e4c1b154e3
✨ Add profile name and email to nitrate audit log (#12636)
* ✨ Add profile name and email to nitrate audit log

* ✨ Mr changes
2026-10-27 09:30:28 +01:00
Eva Marco
01e0b32850
🐛 Remap library refs inside deleted components on absorb (#12232)
* 🐛 Remap library refs inside deleted components on absorb

Deleted components keep their shapes in :objects, to be restored and
to resolve their remaining copies. When a library was absorbed into a
file (export with libraries merged, unsharing or deleting the
library), absorb-assets only looked for usages in pages and in live
components. The shapes inside deleted components kept pointing to the
library, so they no longer matched their copies in the pages: the file
failed validation with :component-id-mismatch and could not be saved.

Also look for usages in deleted components, so their library
components, colors and typographies are remapped to the local file.

Relates to #10806

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

* 🐛 Persist pointer-map fragments when absorbing a library

absorb-library-by-file saved the file without its :features. Without
"fdata/pointer-map" there, encode-file does not persist the changed
fragments, so in files with pointer maps the pages and components
that absorb-assets remapped were never written. After unpublishing or
deleting a library, its consumers failed to load with "fragment not
found".

Pass :features to update-file!. Exporting with shared libraries does
not go through this path, so it was not affected.

AI-assisted-by: claude-opus-5-5
2026-10-26 17:56:31 +01:00
andrés gonzález
3f8aee7933
✨ Record imported file ids in the import-binfile audit event (#12491)
The RPC layer emitted the event as soon as the handler returned the
SSE response, before the import ran, so the event could not say which
files the import created. Emit it from inside the SSE handler once the
import ends, with the ids and count of the created files and whether
the import completed or failed.

Closes #12485

AI-assisted-by: claude-opus-5-5
2026-10-26 12:38:02 +01:00
Pablo Alba
3cd073fc6a
✨ Add auto-repair for integrity error files (#12524)
* ✨ Add auto-repair for integrity error files

* ✨ MR changes
2026-10-26 11:04:06 +01:00
Andrey Antukh
74add05226 Merge remote-tracking branch 'origin/staging' into develop 2026-10-23 13:40:44 +02:00
elhombretecla
59d5817d1b 🐛 Give branch access through the team role alone
A branch must be open to every member of the team of its source file,
by role: owners, admins and editors open and edit it, viewers read it.
Two things got in the way.

Branch creation granted its creator the owner role on the branch file
(a file_profile_rel row), so a team editor became owner and admin of
their own branch. create-file-branch grants no file role any more: a
branch takes its permissions from the team and project roles alone.
materialize-file-branch, which turns a branch into an ordinary file,
now grants the owner role to the profile that runs it, as creating a
file does. Migration 0807 deletes the file roles of existing branches;
materialised files are no longer branches and keep theirs.

move-files moved a file but left its branches in the old project. The
permissions of a branch come from the project it lives in, so after a
move to another team its members got not-found and the workspace asked
them to request access. move-files now moves the branches of every
moved file with it, deleted branch rows included since their files
live on until the GC, and the broken library links cleanup already
covers them. It refuses to move a branch file on its own
(:cant-move-a-branch), which would part it from its source. Migration
0808 puts every branch back in the project of its source file and
drops the links of those branches to libraries of their old team.

Assigning a reviewer to a design review grants no access: the email
and the dashboard notification only let the reviewer know.

Tests cover a branch created with no file role, the creator keeping
editor rights, another editor saving to the branch, a viewer reading
it, the owner role granted on materialise, a lone branch refused by
move-files and an editor of the new team opening a moved branch.
2026-10-23 13:12:11 +02:00
elhombretecla
c1dec5d4f0 ✨ Add merge reviews and tidy up the branching workspace 2026-10-23 10:32:21 +02:00
Álvaro Tejero-Cantero
dcedd40a42
✨ Tell merged and archived branches apart (#12572)
* 🐛 Refuse saves to a branch kept after its merge

A branch kept after its merge stays an ordinary file with status
"merged", so every save passed the edition check and appended ops to
an op log that no command integrates any more. Refuse such saves with
a :branch-merged validation error instead of stranding them. An
archived branch still takes saves: restoring it brings them back.

AI-assisted-by: claude-opus-5-5
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>

* ✨ Tell merged and archived branches apart

The workspace treated "merged" and "archived" as one state: an
archived branch read "Merged branch" and lost every action, and a
merged row offered Restore, which the backend always refused.

Each status now has its own presentation. A kept merged branch keeps
"Merged branch" and View in main, and opens read-only: the workspace
permissions drop :can-edit while it is the open file, the way a
viewer gets a read-only workspace. An archived branch says so in the
banner and offers Compare and Restore. The header notes each status,
the switcher lists merged and archived branches in two collapsible
groups, the all-branches dialog gets a Merged tab, and the branch info
dialog offers Restore and Compare for an archived branch.

AI-assisted-by: claude-opus-5-5
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>

* ✨ Refuse saves to an archived branch

An archived branch is now read-only, like a merged one: the user
restores it, or turns it into an ordinary file, to keep editing.
persist-branch-file! refuses its saves with a :branch-archived
validation error, distinct from :branch-merged so the client can say
which way out applies. A restored branch takes saves again, and so
does an archived branch turned into an ordinary file.

AI-assisted-by: claude-opus-5-5
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>

* ✨ Open archived branches read-only with their ways out

An archived branch now opens read-only, like a merged one. Its banner,
its branch info dialog and its row in the all-branches dialog offer
the two ways back to editing, Restore and Turn into an ordinary file,
next to Compare. The header and switcher note reads "Archived, read
only".

A save the server refuses because the branch is merged or archived
now shows a warning that names the way out, instead of the generic
autosave error with a report link.

AI-assisted-by: claude-opus-5-5
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>

---------

Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-10-22 20:54:15 +02:00
Álvaro Tejero-Cantero
d669eec0c0
✨ Load the branch list only when the switcher opens (#12571)
* ✨ Add a branch count query for the workspace header

The header shows its branch row only when the file has branches, and
it learnt that from get-file-branches, which compares every diverged
branch against main. get-file-branch-count answers the same question
with one indexed count: archived and merged branches included, counted
from main when the file is itself a branch, with no comparison and no
climit, like the branches_count subquery of the dashboard listings.

The listing keeps its climit, and :get-file-branches/global now sets
the 60 s timeout that the header of climit.edn promises, so a waiter
behind six cold listings gets an error instead of waiting without
bound. Its rationale comment names the callers it serves now.

AI-assisted-by: claude-opus-5-5
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>

* ✨ List branches only when the switcher opens

Opening a file ran the counted branch listing for every user, because
the header decided whether to show its branch row from that listing.
A cold listing runs a three-way comparison per diverged branch, and
one save to main makes every branch of the file a cache miss again.

The header now loads the branch count and the branch context with the
file. The listing loads when the branch switcher or the all-branches
dialog opens, and both show "Loading branches…" until it arrives
instead of a list from an earlier opening.

A failed listing used to read as an empty list, which on main hid the
branch row and sent the shortcut to the create dialog. It now ends in
an error state: the switcher and the dialog say "Couldn't load
branches" with a Retry action, and the row stays, since its visibility
comes from the count. A successful listing refreshes the count, so
deleting the last branch from the list still hides the row.

Creating a pull request from the branch banner looked the branch up
in the listing to decide whether to save pending edits first. Without
the listing loaded at open it would skip the save, so it now reads the
branch context of the open file.

AI-assisted-by: claude-opus-5-5
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>

* ✨ Show the branch row when the branch count fails

A failed get-file-branch-count left no count, so on main the header
hid its branch row and the shortcut opened the create dialog on a file
that may have branches. The failure is now kept as an error state
under :workspace-branch-count, and has-branches? reads it as true.

The row then shows the Main chip and, where the status note goes,
"Couldn't load branches" in the warning colour. Opening the switcher
loads the listing through the existing loading and Retry path, and a
successful listing settles the count, which replaces the note.

AI-assisted-by: claude-opus-5-5
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>

* 📚 Name the callers of the branch listing limit again

The merge of iop-vc-preview took its climit.edn, whose comment on
:get-file-branches/global still described a panel open. Restore the
comment this branch wrote: the listing runs when the switcher, the
all-branches dialog or the dashboard popover opens, and a file open
only counts branches.

AI-assisted-by: claude-opus-5-5
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>

* 🐛 Mock the branch count in the library copy tests

The header now shows its branch row from get-file-branch-count, not
from the listing, so the library copy tests mock the count as well.

AI-assisted-by: claude-opus-5-5
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>

---------

Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-10-22 20:50:58 +02:00
Álvaro Tejero-Cantero
d613972474
🐛 Merge file-level plugin data and base font size on branches (#12569)
A branch whose only change was file-level plugin data or the base font
size merged with a success response, and the change was lost. The merge
engine compared colours, typographies, components, media, pages, and
tokens only, so such a branch produced an empty summary and the merge
took the empty-changes shortcut.

The engine now classifies two more kinds three-way: file plugin data,
flattened to namespace and key as page plugin data already is, and the
file options, where an unset base font size reads as the default. Both
kinds count towards ahead, behind, and conflicts, appear in the compare
dialog, and translate into set-plugin-data and set-base-font-size
changes for merge and update from main. Both sides changing the same
key to different values is a modify-modify conflict.

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

Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-10-22 20:04:02 +02:00
Álvaro Tejero-Cantero
6ddbfcf749
🐛 Make the backend branch tests pass on iop-vc-preview (#12568)
* 🐛 Give the branch concurrency limits a timeout

Every global entry for branch and pull-request commands lacked a
:timeout, so a saturated limiter parked its waiters forever and
climit-test/example-config-bounds-every-wait failed on seven ids.

Each entry now waits at most 60 s, the value of the other slow
operations. The size gates in files_branch.clj refuse files whose
operation would exceed 30 s, so the wait covers one in-flight
operation with as much again in margin.

AI-assisted-by: claude-opus-5-5
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>

* 🐛 Make the quota racer a member of the file team

File access requires membership of the file's team since the
permission check started joining team_profile_rel. The racer in
create-checks-the-branch-quota-inside-the-lock held only a file role,
so its create failed with :object-not-found and the test never raced.

The racer now joins the file's team as an editor, so both creates
again compete for the last branch slot.

AI-assisted-by: claude-opus-5-5
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>

---------

Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-10-22 19:19:38 +02:00
Andrey Antukh
ba78ccb8c9
✨ Harden file data offload job with lock, summary and metrics (#12403)
Take the file row with FOR UPDATE SKIP LOCKED when the offload job
starts and skip the run when the row is locked or gone, so the job
never runs while the file is being edited. Every live hot-storage
row of the file moves to cold storage inside one all-or-nothing
transaction, and the handler answers a summary map with the
offloaded rows, bytes and per-type stats.

Add bounded metrics for the job: rows and bytes counters labeled by
row type on successful runs only, and a timing histogram with the
ok, skip and error outcomes. The job definition now requires
metrics, the recorders live in app.jobs.metrics, and the existing
ephemeral job timing histogram pins its buckets.

Cover the job with a new test namespace: the happy path, the locked
row, missing and deleted files, the rows and bytes summary with its
no-op rerun, the whole-file abort on storage failure, the metric
definitions and emissions, and the submit-time params validation.
An end-to-end suite reads, snapshots, writes, exports, imports and
deletes offloaded files; concurrency tests pin the stale-revn skip,
the blocked-edit retry and the reclaimable orphans of a partial
failure. The file-gc scheduler test pins one job per eligible file,
and the scheduler private helper drops its bang.

Document the lock invariant, the deliberate no-pacing decision and
the metric label sets in the backend storage memory and the
configuration guide.

Related to #12398.

AI-assisted-by: muse-spark-1.3-contributor
2026-10-22 15:31:16 +02:00
Andrey Antukh
f8f1a668f7
♻️ Use skip-if-active for cron no-overlap in backend worker (#12412)
The cron tick kept its own active-job count beside the shared
submit option. Delegate the no-overlap check to
::jobs/skip-if-active so both paths share one queue-qualified
check served by the partial index.

A tick now skips only when a live job sits on the :cron queue,
and a skip records both the cron counter and the generic skipped
counter.

Closes #12411

AI-assisted-by: muse-spark-1.3-contributor
2026-10-22 15:29:37 +02:00
Eva Marco
d8862c759b
🐛 Fix shared files 500 on path content without coordinates (#12426)
* 🐛 Fix legacy path migrations on content without coordinates

PRE still returned 500 on get-team-shared-files after #12357, now with
"expected rect instance" in legacy-6. When a path has no points, legacy-6
rebuilds its rect from the content, but content whose points are not
numbers (NaN or infinite coordinates, segments without params, only a
close path) gives no rect either, and rect->points rejects nil.

Fall back to the empty rect in legacy-6. A path with segments missing
coordinates then failed later, in 0003-convert-path-content-v2, since the
binary path data can not store them; drop those segments there.

Relates to #12343

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

* 🐛 Skip shared files that fail to load in get-team-shared-files

get-team-shared-files loads and migrates every shared file of the team,
so one file whose migration fails made the whole request return 500 and
the "Add library" modal could not open.

Catch the error per file, log it with the file and team ids, and leave
that file out of the list. The other libraries still show, and the error
report names the broken file.

Closes #12343

AI-assisted-by: claude-opus-5-5
2026-10-21 12:31:03 +02:00
Andrey Antukh
fcf9c27635 Merge remote-tracking branch 'origin/staging' into develop 2026-10-21 10:14:55 +02:00
Andrey Antukh
028547e19b Merge remote-tracking branch 'origin/main' into staging 2026-10-21 10:09:59 +02:00
Andrey Antukh
114928ea5f 🐛 Fix the key reported when a redis DEL times out
`app.redis/del` logged `:key key`, but `key` is not bound in that
`let`: it resolved to `clojure.core/key`, so the line carried
`#object[clojure.core$key ...]` instead of the keys that failed to be
deleted. Log `:keys keys`, which is always the normalized vector, so a
single key and a collection report the same shape.

The tests pin the line: the keys, the hint, the error level, the logger
and the timeout as cause, plus the `nil` the caller gets.

The prod-infra memory now notes that client-side redis timeouts mean the
JVM is paused, not that the server is slow.

AI-assisted-by: deepseek-v4.1-flash
2026-10-20 15:33:37 +00:00
Pablo Alba
a5dac03073
✨ Add organization SSO and invitation audit events for nitrate (#12461) 2026-10-20 14:08:40 +02:00
Andrey Antukh
be2e117f67
♻️ Rename audit-log archivers to consumers and use base Nexus URI (#12459)
* ♻️ Rename audit-log archivers to consumers

The pending-queue owners are no longer only external archives: a
local, non-archiving drain is about to join them, so "archiver" no
longer fits. Rename the column, identifiers, jobs, metrics and docs
to "consumer" in one release; migration 0157 never reached
production, so a single RENAME COLUMN is enough, with no backfill.

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

* ♻️ Use base Nexus URI with appended ingest path

Mirror the admin-console setup: PENPOT_NEXUS_URI holds the base
URI and the consumer appends api/audit itself, instead of a full
endpoint URI. Keeps the explicit :uri job param as override and the
:task-not-configured guard when no base is set.

AI-assisted-by: muse-spark-1.3-contributor
2026-10-19 23:25:44 +02:00