* 🐛 Stop the export-shapes modal event from dumping all exports
The `:export-shapes` modal event carried the full `:exports` vector,
each export embedding its shape (path data, geometry, text content).
One event reached 1.7 MB, which made the audit archive cron POST a
chunk past the proxy body limit and wedge the queue.
Build a compact body for that modal instead: total exports, distinct
shapes and one count per export type, plus the page name. Other modals
keep their props unchanged.
Move the modal `:origin` prop to `::ev/origin` so `make-proto-event`
records it on the event context as `:event-origin`; the UI still reads
the plain prop from the raw state.
Add tests for the summary and for the modal origin.
AI-assisted-by: deepseek-v4.1-flash
* 🐛 Bound exception messages in reports and audit events
A frontend crash whose message embeds a whole payload (a serialized
file) produced a 9.4 MB audit event: the message was repeated in the
`:hint` prop, in the report context and in the stack trace. A single
event that big wedged the audit archive queue.
Bound exception messages at the source: `get-hint` clamps to
`max-message-length` (1000 chars), and the CLJS `format-throwable`
clamps the message line of the stack trace and honors `:trace-length`
(parity with the CLJ version). `report-context` now prints `get-hint`
instead of the raw `:hint` from `ex-data`.
Fix `first-line` on the JVM: `cuerdas/index-of` returns nil when the
substring is missing and `(pos? nil)` throws there (it is false in
CLJS), so single-line messages crashed the backend path.
AI-assisted-by: deepseek-v4.1-flash
The color tokens tab of the color picker did not focus its search
field, so users had to click it before typing a token name. The token
dropdown of numeric inputs already focuses its search on open.
Closes#12344
AI-assisted-by: claude-opus-5-5
* 🐛 Fix font family space in tokens
* 🐛 Resolve token merge conflicts
Keep the font-family token fix and the newer
background-plugin change together in the
unreleased changelog and plugin API test suite.
AI-assisted-by: gpt-6-luna
---------
Co-authored-by: niwinz <843689+niwinz@users.noreply.github.com>
* 🐛 Close the font selector when clicking outside it
The font selector had no click-outside handling. It only closed with
Esc, or when its menu was unmounted, which is why a viewport click
(that deselects the text) closed it and a click on the left sidebar,
the rulers or the header did not.
Listen to click and contextmenu on the document, as the dropdowns do,
and close when the target is outside the selector. The listener starts
on the next tick so the opening click does not close it, and a target
no longer in the document (a font row removed after selecting it) is
not taken as an outside click. The token font combobox uses the same
component, so it gets the fix too.
Closes#12246
AI-assisted-by: claude-opus-5-5
* 🐛 Hide the empty Recent section in the font selector
The "Recent" heading showed with `(and recent-fonts show-recent)`, but
the filtered recent fonts are a vector and an empty one is truthy. So
a search with no matches showed the heading over a blank list, and
any search showed it over results that are not recent.
- Show the "Recent" section only when it has fonts.
- Show "No matching fonts" when the search finds nothing.
- Square the list's top corners only when it sits under "Recent".
Closes#12249
AI-assisted-by: claude-opus-5-5
* 🐛 Separate the applied font from the focused one in font selector
The tick marked the first search result when the applied font was
filtered out, since one value drove both the tick and the font Enter
applies. So the tick showed a font that was not applied.
The tick and the focus are now two states:
- The tick marks the font the text had when the selector opened.
- A green border, as in the select dropdowns, marks the font Enter
applies. Typing moves it to the first result without previewing it.
Arrow keys move it and, once they stop for a moment, preview the
font. Previewing every font of a held key flooded the store until
React stopped with "Maximum update depth exceeded".
- Recent fonts get no border: they are reached with the mouse or by
filtering, not with the arrow keys.
- Enter applies the focused font. Esc and an outside click undo the
preview.
Also make the rows fill the list width, so the border is not clipped,
and scroll to the focused row only through the virtualized list.
Closes#12247
AI-assisted-by: claude-opus-5-5
* ♻️ Refactor typography.scss to the Design System
Move typography.scss to the DS tokens and mixins. No visual change
intended, except that the rows that took their typography from
%asset-element now use the DS body-small (line-height 1.3 instead of
1.4).
- Inline the %asset-element and %input-base placeholders, resolving
their design-token aliases to DS tokens and using local CSS
variables for state colors.
- Keep the spacing inputs on %input-element and %button-icon-small,
with a TODO: those placeholders style the icon svg and the input
elements, and go away when the controls move to the DS
numeric-input*, out of scope here.
- Use logical properties and DS spacing and size tokens.
- Merge the duplicated .typography-sample rule.
- Note why the "Ag" samples have no DS typography, and leave TODOs
where no DS typography matches exactly.
AI-assisted-by: claude-opus-5-5
The layout item min and max size inputs passed :tooltip-class to
numeric-input*, which never reads that prop. It stayed in the rest
props and reached the DOM <input>, so React warned about an unknown
tooltipClass prop. The class never styled anything.
Remove the prop from the four callers and drop the unused
.tooltip-wrapper rule.
Closes#12311
AI-assisted-by: claude-opus-5-5
Relax the subject limit from 70 to 80 characters and the body
wrap limit from 76 to 80. Merge/Revert/Reapply commits skip the
subject length check since git prepends those prefixes to an
existing subject. Update the checker, its tests, and all related
docs and memories accordingly.
AI-assisted-by: muse-spark-1.3-contributor
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Bump opencode to 2.0.29 in the devenv image and refresh
the published checksums for both supported architectures.
AI-assisted-by: muse-spark-1.3-contributor
After the unified jobs migration, cron submitted empty params that
failed validation because the job schema still required Integrant
system keys. Narrow the schema to optional REPL overrides, wire
:app.nitrate/client onto the archive job-def, and drop the unused
::jobs/defs ref from nitrate that created an Integrant cycle.
AI-assisted-by: composer
Add a check-commit-guidelines job to commit-checker.yml
that runs scripts/check-commit on every new commit, so
the Co-authored-by ban holds in CI too. PR events check
out the base ref, keeping the executed script trusted.
AI-assisted-by: muse-spark-1.3-contributor
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
The Co-authored-by trailer is now forbidden. The
commit checker fails any message holding it, whatever
the case, and the docs record the ban.
AI-assisted-by: muse-spark-1.3-contributor
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Document stroke per side and its tokens, path editor changes, image
bounds resize, configurable shortcuts, tokens from a shared library
as tokens source, canvas background color tokens, Penpot board
presets, the 3x export scale, the text editing context menu, the
organization and team selector, the hide resolved comments option,
account security and the Check for updates option.
Fix outdated notes about WebGL exports, background blur exports,
library token import and path editor shortcuts. Add the images for
these sections and remove the ones no longer used.
AI-assisted-by: claude-opus-5-5
State in the contributing guide that issues under `needs triage` and
`internal` are not open to contributions. Every new issue starts under
`needs triage` until the product team reviews it, so contributors could
mistake a fresh issue for an invitation and send a PR that we then turn
down.
A PR opened on one of those issues may be closed without review,
without an explanation and without a reply. The wording follows the
existing note about AI-assisted content.
The help center page keeps its current wording for now.
AI-assisted-by: deepseek-v4.1-flash
* 🔧 Fail CI when render-wasm JS glue exports invoke_*V
Add check-no-invoke-v (path from _build_env) and run it in CI: WASM
after a production frontend build so JS longjmp + SIMD ABI
regressions are caught in the pipeline.
* 🐛 Fail closed in render-wasm invoke_* glue check
* 🔧 Check exporter wasm glue for any invoke_* wrapper
---------
Co-authored-by: Elena Torro <elenatorro@gmail.com>
Complete ru.po from 1460 to 2275 keys (100%),
add words.ru.txt catalog, fix two dropped %s
placeholders and normalize board/layout/shortcut
terms. Documents Russian conventions in the
translations memory.
AI-assisted-by: muse-spark-1.3-contributor
The merge of main into staging brought the closed storage metadata
schema, which requires file-id and id on file-data objects. The
restore test built one without those keys, so it failed with an
invalid metadata error. Its helper now takes extra metadata and
the test passes the ids of the file and of its file-data row, as
production does.
The republish test asserted the exact list of waiting-gauge
publications, but the component's sampler starts its first tick right
away and under load lands inside the recorded window, publishing the
same count twice. The test now builds the counters alone, so the
republish call is the only publisher.
AI-assisted-by: deepseek-v4.1-flash
Mousetrap matched keydown shortcuts against event.key, the glyph the
key types, but bindings name the key. Modifiers change the glyph: on
any OS with a US layout Shift+0 types ")" and Shift+5 types "%", and
macOS Option changes it further (Option+Shift+5 types "fi", Option+A
types "å"). So shortcuts like Alt+Shift+5 for strikethrough or
Shift+0/1/2 for zoom never fired, on every platform. Read letters and
digits from keyCode on keydown, and record them the same way when a
modifier is held.
Closes#12217
AI-assisted-by: claude-opus-5-5
Repair scripts run against production with a plain SQL client, not the
devenv psql: no `\set`, `\if` or `\copy` there. The SQL datasource the
agent can query is a read-only replica that rejects writes (a temporary
table included) and cuts long queries, so its queries are plain SELECTs
bounded by a list of ids: an unbounded scan over tens of millions of rows
does not come back.
AI-assisted-by: deepseek-v4.1-flash
The queue gauge was written by whichever limiter of a name ran last, so a
per-profile pile-up never showed: a healthy profile writing 0 overwrote the
15 of the profile that was queueing (4 of 1441 samples in production), and
a limiter whose requests never come back read 0 while 72 threads stayed
parked.
A queue is an entry and an exit, so the new gauge is accumulated: one
counter per limiter name and instance, up when a call starts waiting for a
permit, down when it stops, so a call that never leaves stays visible. It
is always written with `:val`, and a daemon sampler republishes every count
every 15s so a name whose traffic died does not go stale in the scrape;
halting the component stops that thread.
Rejections had no metric at all: penpot_rpc_climit_rejected_total counts
them by name and reason (queue-full or timeout), and the log lines now
carry queue-max, permits, code and reason, so an operator can tell whether
the limiter did its job or was saturated.
The gauges that could not tell the truth are gone: queue and permits (same
last-write-wins problem, and waiting plus the wait histogram replace them)
and http_worker_busy_threads (reads 0 on an instance with 72 blocked
threads; pool_size is the signal). No other dashboard or alert rule reads
them.
AI-assisted-by: deepseek-v4.1-flash
Add a per-process availability flag (app.health) that /readyz
reports as 503 OFFLINE while offline, so the load balancer
drains the instance and in-flight requests finish untouched.
Operator surface is app.srepl.main/set-offline and
app.srepl.main/set-online; a restart comes back online.
Covered by backend-tests.health-test; HA docs updated.
AI-assisted-by: muse-spark-1.3-contributor
`file_object_thumbnail` was replaced by `file_tagged_object_thumbnail`
and nothing refers to it anymore: no query in the backend touches it,
no table has a foreign key pointing at it, no view depends on it, and
it is empty in production. It also lacked `deleted_at`, which is why
restoring files had to leave it out. Only legacy naming survives in two
helpers (`get-file-object-thumbnails` and
`delete-file-object-thumbnails!`, which operate on
`file_tagged_object_thumbnail`).
The storage bucket named "file-object-thumbnail", the one that
`file_tagged_object_thumbnail` uses, is a different thing and stays.
AI-assisted-by: deepseek-v4.1-flash
Restoring a file only un-marked the database rows (`file`,
`file_media_object`, `file_change`, `file_data`, `file_thumbnail`,
`file_tagged_object_thumbnail`), not the storage objects those rows
reference. An object that was already marked for deletion stayed
marked while a live row pointed at it, and those foreign keys are `NO
ACTION`: the storage GC cannot delete it and skips it forever (and
before the GC learned to skip, it cost the whole nightly run).
Restoring a team had the same gap with its font variants.
Both restores now unmark the storage objects of the rows they bring
back: the media and the thumbnail of the file media objects, the media
of the file and object thumbnails, the `storage-ref-id` of the file
data, and the four files of a font variant. Objects referenced by other
rows are left alone.
AI-assisted-by: deepseek-v4.1-flash
objects-gc deleted every kind of object inside one transaction per proc,
looping until empty, so a single row that a foreign key still references
aborted the whole run: the task died on its first file every night, after
the earlier steps had already spent around twenty minutes. The per-file
savepoint only saved the files proc and left the same shape everywhere
else.
Now every proc streams its pending keys with db/plan inside a read-only
transaction of its own and deletes them chunk by chunk, each chunk in a
transaction of its own with FOR UPDATE SKIP LOCKED. A chunk that fails is
reported with l/err and skipped, so the run reaches the end and the next
run retries the skipped rows.
AI-assisted-by: muse-spark-1.3-contributor
`storage-gc-deleted` ran its whole batch inside one transaction: the
handler opened it and `delete-in-bulk` reused that connection, so the
"separate transaction" its comment promised did not exist. A single
row that a foreign key still references aborted that transaction, the
cursor then died on its next fetch ("current transaction is aborted")
and the task ended up throwing `unhandled exception on running task`
after rolling back the whole night of work. That is what has kept
production from deleting a single object since late April.
Now `clean-deleted` walks the pending objects with the cursor in its
own read-only transaction and every chunk runs in its own one, so a
blocked chunk is logged with its ids and skipped while the rest is
committed. `task finished` is reachable again.
The private functions also drop the `!` suffix: the namespace is all
mutation, so the mark adds nothing and reads as noise.
The test builds a blocked object (a `file_media_object` pointing at it
as its thumbnail) plus a full chunk of pending objects and one more in
the next chunk, and checks that the run finishes and that the object of
the next chunk is deleted.
AI-assisted-by: deepseek-v4.1-flash
The four `process-deleted-*-cascade` helpers of `app.srepl.main`
loaded every soft-deleted row of a table into memory (`db/exec!`)
and ran `:delete-object` inline (`wrk/invoke!`), so they could not
be used against production: `file` alone has 2.1M deleted rows.
They now share a single `submit-deleted-cascade!` that reads the
rows with a server-side cursor (`db/plan`, in a read-only
transaction) and submits one task per row, so the work goes
through the workers like any other task. The row id goes as
`label` with `dedupe` on, and the helpers accept `:limit`, `:ids`
and `:dry-run?` to run bounded batches. Public names and their
zero-arity form are unchanged.
Tests cover the task params, the row's own date (not `now`), the
`limit`/`ids`/`dry-run?` options and that each helper reads its own
table.
AI-assisted-by: deepseek-v4.1-flash
An instance left the audit events pipeline dead: every push-audit-events
answered with 429 and 69 worker threads parked for good inside a limiter.
Two code paths of one profile acquired the same two limiters in opposite
order: a command declaring its own limits (upload-file-media-object) took
process-image/by-profile and then root/by-profile, while a command with no
limits of its own took the roots first and then re-entered climit from
inside its handler with the same ids (create-file-media-object-from-url).
Each path held what the other one waited for, and no limiter had a timeout
to break the wait.
Now wrap puts the root limiters after the command's own, so they end up
outermost and every path meets them first: the nested chains carry only the
command's own limits and run inside them, which puts both paths in the same
order. impl-invoke also keeps, per thread, the set of limiters that thread
is inside of and runs the handler inline when a call asks for one it
already holds, so a handler can no longer queue behind itself.
The shipped climit.edn sets a timeout on every limiter (60s for operations
that can be slow, 30s otherwise), so no wait can park a thread for ever.
The production file lives outside the repository and has to be mirrored.
Tests cover the acquisition order, the deadlock, the reentrancy and the
timeouts of the shipped config.
AI-assisted-by: deepseek-v4.1-flash
The command declared its limit as a plain keyword next to
`::climit/key-fn`, metadata that `climit/wrap` does not read: it
takes the key only from the positional `key-fn` of the vector form.
The limit was a single shared one, so when one call got stuck it
held the only permit and every audit batch of that instance was
rejected with 429 from then on.
The test builds the limiter from the command metadata and checks
that two profiles end up with different limiters.
AI-assisted-by: deepseek-v4.1-flash
Ctrl+click (Cmd+click on macOS) on a dashboard file card, a project
title, the "Show all files" button, or a project or Drafts item in the
sidebar now opens it in a new browser tab. It reuses the same
`::rt/new-window` navigation as the "Open in new tab" file menu option.
On file cards the modifier click does not change the selection, and a
modifier double click does not navigate the current tab. Files in the
trash are not opened.
Closes#12228
AI-assisted-by: claude-opus-5-5
Forward Cargo arguments as an array and honor explicit profiles.
Build and copy each renderer target once under the repository lock,
with watch rebuilds using the same wrapper.
Select this checkout's compiler artifact and matching generated enum
table from Cargo JSON. Record actual features, profile and output paths,
and allow callers to override publication destinations.
Run the existing Cargo parser tests before the renderer's native tests
and stop on parser failures.
AI-assisted-by: gpt-6.1-sol
* 🐛 Detect and repair children missing from their parent's list
A shape could point to an existing parent that did not list it in its
:shapes. The validator missed it, since it only walks the tree from
the root plus the shapes whose parent does not exist. Operations that
walk :shapes missed it too: deleting a variant also deleted its
container, which looked empty, and left the other variants without a
parent, so the backend rejected the change and the file stopped
saving.
- get-orphan-shapes also returns shapes not listed by their parent,
so :child-not-in-parent is reported.
- The :child-not-in-parent repair adds every unlisted child of the
parent. All repairs share the same file data and each one sets the
whole :shapes, so with several children only the last one was kept.
- New migration 0032-fix-children-not-in-parent repairs the affected
files when they are opened.
Closes#11758
AI-assisted-by: claude-opus-5-5
* 🐛 List shapes moved to root by the parent-not-found repair
The :parent-not-found repair set the shape's parent to the root but
did not add it to the root's :shapes, so it left the shape in the
child-not-in-parent state that the validator now reports. It now
adds every shape without parent to the root at once, since all
repairs are built from the same file data and each one sets the
whole :shapes.
Also cover the missing parent case in the repair tests.
AI-assisted-by: claude-opus-5-5
* ♻️ Simplify unlisted children validation and repair
Address review comments on the validation and repair of shapes that
their parent does not list.
- get-orphan-shapes no longer rebuilds *children-sets*: both callers
bind it. The lookup moves to a listed-in-parent? helper shared with
check-parent-children, which scans :shapes when the var is unbound.
- The :child-not-in-parent and :parent-not-found repairs no longer
walk every shape of the page. Each one moves only its own shape with
pcb/change-parent. The :mov-objects change adds the shape to the
parent's :shapes when applied, so several repairs on one parent no
longer overwrite each other.
- The :child-not-in-parent repair test uses plain shapes instead of
variants.
AI-assisted-by: claude-opus-5-5
* 🐛 Raise not-found when the viewer gets no valid file-id
The viewer route parses the `file-id` query param with `uuid/parse*`, so
a URL that lacks the param, or carries a value that is not a uuid, hands
the viewer component a nil file-id. The viewer then emitted
`dv/initialize` with that nil value and its `dm/assert!` on the params
failed, so the error boundary showed the internal error page for what is
in fact a missing file.
`viewer-page*` now runs a `check-file-id!` guard before it renders
`viewer*`. The guard raises `ex/raise :type :not-found :code
:missing-file-id`, which the error boundary already maps to the
not-found page, matching how a missing page id is handled a few lines
above. Rendering the guard before the effect means the viewer never
emits `dv/initialize` with a nil file-id, so no store watcher runs at
all on that path.
Add frontend-tests.ui.viewer-test to cover the guard: a nil id and a
malformed id both raise the not-found error, and a real uuid passes.
Register the namespace in the frontend test runner.
AI-assisted-by: space-bunny-free
* 🐛 Read the screen from the query param on the not-found page
The route name travels in the `screen` query param, and the match map
carries an empty `:path`, so searching the path for the screen name
always failed. Read the query param instead, so the page can tell a
workspace, dashboard or viewer not-found apart.
AI-assisted-by: space-bunny-free
* ♻️ Rewrite browser history in CLJS and guard route ids
`goog.history.Html5History` is a fragment (hash) router. Once fragment
navigation was dropped, its token, URL and popstate logic reduced to
`location.search` plus `pushState`/`replaceState`, while it kept a
`hashchange` listener, gated popstate on a fragment that was then
always `null`, and asserted on `history.pushState` in its constructor.
That last assert made the module unusable outside a browser, so it had
no test at all. The router also kept the instance in the store under
`:history`, and `logged-out` carried it across a sign-out.
Replace it with `app.util.browser-history`: the token is
`location.search`, the URL of a token is the base path plus the token,
and every change lands on a `token-changes` subject the router
subscribes to. `pushState` and `replaceState` never fire `popstate`,
so the module reports its own writes there too, and the router no
longer needs `goog.events`, an `enable!`/`disable!` pair, or the
`rx/create` teardown that wrapped them.
`set-token!` and `replace-token!` keep their deliberate asymmetry.
`set-token!` is a total no-op when the token already is the current
one, so navigating to the screen you are on cannot grow the history
stack. `replace-token!` always writes and always reports, because it
is how the app corrects the URL in place (legacy `#/…` translation, a
cleaned share link) and that correction has to reach the router even
when the token did not change.
Without an instance, `:history` leaves the store and `logged-out`
stops carrying it. The single `popstate` listener now lives for the
whole session; before, every re-init built a new `Html5History` and
left the previous one listening.
Add the History API to the mocked `window` in `app.util.globals`. It
was missing, so a test driving a real `navigate` effect met a nil
instance and silently no-opped; the writes now land on the mock.
Add frontend-tests.browser-history-test for the token read, the URL
composition, the no-op and the set/replace asymmetry, and register
the namespace in the frontend test runner.
Two route entries hardened the same class of problem, a bad URL
reaching a screen that cannot render it:
- `team-page*` raises `ex/raise :type :not-found :code :invalid-team-id`
when the team id is not a uuid, rather than doing nothing and
rendering an empty page.
- The not-found page reloads after `go-to-dashboard-recent`, so a
stale URL left behind by a bad route does not stay in front of the
dashboard. Drop the `:path` binding and the `cuerdas` require that
edit left unused.
AI-assisted-by: space-bunny-free
* 🐛 Clear the caught render error when the screen changes
Two error layers answer to different things. `:exception` in the store
renders `exception-page*` directly and is cleared by `rt/navigated`. A
render error is caught by the React error boundary, which keeps it in
internal React state that only `reset-error-boundary` clears. Every
exit from the error page — the logo, the no-permission dialogs, the SSO
dialog, the feedback link — only navigates, so all of them changed the
URL and left the error page on screen. The user was stuck until a
manual reload.
Give the boundary `:resetKeys` holding the current screen: a navigation
is a fresh attempt at rendering, so changing screen clears the error on
its own. Every one of those exits now works, with no callback to thread
`reset-error-boundary` through `error-container*`.
Three traps in that value, each found by running it:
- It is a real JS array. `react-error-boundary` reads `.length` and
`.some` in `componentDidUpdate`, and a ClojureScript vector has
neither, so it threw `some is not a function`.
- It holds the screen name as a string, not as a keyword. The library
compares entries with `Object.is`, which compares strings by value
and objects by identity, and ClojureScript does not intern keywords:
two equal keywords are two objects. A keyword key therefore read as
new on every route rebuilt, so a param change inside one screen reset
the boundary and replayed the screen that had just crashed.
- It is the screen, not the params. A param change is not a new screen.
The not-found page no longer forces a reload after
`go-to-dashboard-recent`; navigating is enough now. That reload was the
blunt workaround for the same trap.
This does not touch the libraries modal that raises the error, only
whether the app can be left afterwards.
Add frontend-tests.ui.error-boundary-test to pin the three traps: the
value is a JS array, the same screen keeps an `Object.is`-identical key
across a param change, and a different screen changes it. Register the
namespace in the frontend test runner.
AI-assisted-by: space-bunny-free
* 🔥 Remove the viewer test for a guard that does not exist
The test called `viewer/check-file-id!` three times, but no such
function exists: the viewer raises the not-found error inline inside
`viewer-page*`, from an `mf/with-effect`, so there is nothing to call.
Calling the missing name threw a TypeError, `ex-data` on it came back
nil, and the four assertions comparing `:type` and `:code` against nil
failed.
The test was asserting an API that was never built, so it cannot be
made to pass without inventing a function that is not wanted. Remove
it and drop its registration from the frontend test runner.
The suite is green again: 1149 tests, 4442 assertions, 0 failures.
AI-assisted-by: space-bunny-free
* ✨ Add skip-if-active option to jobs submit
Producers that need at most one live job per key can now pass
::jobs/skip-if-active: the submit counts live jobs for the same
tenant, name, queue and label in the same transaction and skips
the insert (returning nil) when one lives. The option requires an
explicit label and documents the same best-effort race caveat as
the dedupe it complements.
Closes#12265
AI-assisted-by: muse-spark-1.3-contributor
* ✨ Record skipped job submissions as a metric
Skip-if-active submissions withheld nothing observable: the skip
now records penpot_jobs_skipped_total (name, queue) instead of the
submitted counter, so dashboards can tell withheld work apart from
new work. The insert path is untouched.
AI-assisted-by: muse-spark-1.3-contributor
* ♻️ Drop jobs counters duplicated by histogram counts
The completed and requests counters counted exactly the same events
as the _count of their timing histograms, so they go away: terminal
totals now come from penpot_jobs_total_ms_count by outcome and
request totals from penpot_jobs_request_ms_count by outcome. One
event, one family from here on: counters only cover events without
latency, and the dispatched vs queue-wait pair stays because pushes
and claims are different events.
AI-assisted-by: muse-spark-1.3-contributor
Clients that only need a file's design tokens, such as a CI job that
syncs them to a git repository after an update-file webhook, had to
download the whole file with get-file.
get-file-tokens returns the file's tokens as a DTCG document, the same
format the token export produces, or nil when the file has none. Active
themes and sets come from the file's tokens status, so they reflect
what is currently active.
The document is returned as a plain map. A bare TokensLib is not a
collection, so the response middleware would hand it to ring unencoded.
Relates to #7091
Signed-off-by: Filip Sajdak <filip.sajdak@siili.com>
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
* 🐛 Log errors readably in MCP execute_code console output
The console captured during execute_code rendered every object argument
with JSON.stringify. An Error has no enumerable fields, so logging a
caught error printed "{}" and the agent lost the message. A circular
object or a BigInt made JSON.stringify throw, so a console.log call
aborted the executed code with an unrelated serialization error.
Format console arguments in a small pure module: errors by their string
form (name and message), other objects as indented JSON, falling back to
the string form when JSON cannot serialize the value.
AI-assisted-by: claude-opus-5.5
Signed-off-by: breken-ai <312387581+breken-ai@users.noreply.github.com>
* ♻️ Keep console formatting within ExecuteCodeTaskConsole
---------
Signed-off-by: breken-ai <312387581+breken-ai@users.noreply.github.com>
Co-authored-by: breken-ai <312387581+breken-ai@users.noreply.github.com>