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>
`gh issue edit --add-assignee` fails with a 422 for users without
write permission on the repo, for non-members, and for people who
never commented, and the command still exits 0. The memory now
records that failure and the `gh api` check that reveals whether the
assignment actually took effect.
AI-assisted-by: step-5-preview-free
Bumps the OpenCode CLI version pinned by the devenv image from 2.0.25
to 2.0.26 and refreshes the per-architecture SHA-256 checksums used
to verify the downloaded tarballs.
AI-assisted-by: step-5-preview-free
The pino transports run in a worker thread reached through a message
port that stays referenced once a log line is in flight, so the
node:test child process kept running after every test and hook had
finished. The runner then canceled the file's root test with
"Promise resolution is still pending but the event loop has already
resolved" and the suite hung until something killed it, which
happened in roughly two runs out of five.
`logTransportTargets` returns no targets inside a node:test child
process, which `node --test` marks with `NODE_TEST_CONTEXT`, so
tests log straight to stdout and the process exit is deterministic.
Production still uses the console, file, and Loki transports.
AI-assisted-by: step-5-preview-free
The 7.x line ships only a version stub at the `typescript` entry
point and no longer exposes the synchronous compiler API, so the
`ts.transpileModule` call that `ConnectionMetadata.test.ts` uses to
compile `main.ts` and `plugin.ts` into a sandboxed browser context
failed with "Cannot read properties of undefined (reading
'CommonJS')", taking all 11 plugin connection tests down.
6.0.3 restores that API and matches the version the frontend and
plugins packages already pin.
AI-assisted-by: step-5-preview-free
The review-code flow skill now documents how to post a review as a
GitHub comment: in English, with a minimal structure (intro line,
main issues, minor suggestions, optional closing). Posting is
explicit-only: the review stays in the session unless the user
asks for the comment.
The code-review-criteria skill replaces the flat finding ID
sequence (F1, F2, ...) with severity classes: R1 for required
findings, M1 for minor ones, and S1 for suggestions. IDs stay
stable so follow-up discussion can reference findings
unambiguously.
AI-assisted-by: glm-5.3-flash
@penpot/plugin-styles loaded Work Sans from fonts.bunny.net via a remote
@import in core/fonts.css, which every plugin built with the package
fetched at runtime. That broke air-gapped deployments, leaked user IPs
to a third party, and forced deployments to widen the CSP style-src
and font-src directives introduced in #11374.
Drop the remote import and use the system-ui stack instead of
'Work Sans' in the same package (both in core/fonts.css for code
blocks and in core/generic.css for the base typography, which relied
on the same webfont), so plugins no longer reach any external domain
and 'self' is enough for both CSP directives.
Verified by bundling the package entry point with esbuild using the
same options as tools/scripts/build-css.mjs: the produced styles.css
is balanced, contains system-ui, and contains no external import.
Closes#12023
Signed-off-by: ump45nose <1729065730@qq.com>
The Plugin API can write arbitrary key/value plugin data onto the file
root, but it could only be read back by downloading and decoding the
whole file. Add a `get-file-plugin-data` read command that returns the
file-level plugin data map, optionally narrowed to a single namespace,
so external tooling can fetch metadata such as issue or commit
references cheaply.
Read-only: reuses `bfc/get-file` and `check-read-permissions!`, and
adds no data-model or change-type changes. Object/page level reads are
intentionally left out of this first command.
Relates to #10169
Signed-off-by: Filip Sajdak <filip.sajdak@siili.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
The theme selector showed "You need to be an editor to use themes"
whenever tokens could not be edited. That is also the case for editors
when the tokens source is a library, and they can still switch themes.
Base the message on file edit permission instead.
Closes#12177
AI-assisted-by: claude-opus-5-5
* 🐛 Fix inner shadow covering the shape in the SVG renderer (#12141)
* 🐛 Fix inner shadow covering the shape in the SVG renderer
The svg filter components moved to the `*` syntax (#9448), but
filter-entry* still built their props as a raw JS object with
kebab-case keys. Raw JS props skip the kebab to camel conversion, so
the components read nil for filterId and filterIn, and no filter
primitive got its result or in2. With the chain broken, an inner
shadow was blended over the empty flood instead of the shape, hiding
the fill (and a board's children) under the shadow color. Exports
using these filters broke the same way. Pass the props in camelCase.
Closes#11967
AI-assisted-by: claude-opus-5-5
* ♻️ Build filter entry props with mf/props
Use mf/props instead of a raw JS object so the props keep the
kebab-case keys used across the codebase. The macro converts them to
camelCase at compile time, so there is no runtime cost, and the
comment about the manual conversion is no longer needed.
AI-assisted-by: claude-opus-5-5
* 🐛 Translate the This file tab texts in the libraries modal
The tab name, the section title and the empty state button of the
This file tab were hardcoded in English. Add translation keys with
English and Spanish texts.
Closes#12178
AI-assisted-by: claude-opus-5-5
---------
Co-authored-by: Eva Marco <eva.marco@kaleidos.net>
Auto-grow text geometry is finalize-only to avoid typing lag, so the
Design tab kept the committed selrect (0.01 for new texts) until Esc.
Patch display W/H from live WASM layout while editing, matching the
viewport outline, without restoring per-keystroke modifiers.
Closes#12244
* 🐛 Fix inner shadow covering the shape in the SVG renderer
The svg filter components moved to the `*` syntax (#9448), but
filter-entry* still built their props as a raw JS object with
kebab-case keys. Raw JS props skip the kebab to camel conversion, so
the components read nil for filterId and filterIn, and no filter
primitive got its result or in2. With the chain broken, an inner
shadow was blended over the empty flood instead of the shape, hiding
the fill (and a board's children) under the shadow color. Exports
using these filters broke the same way. Pass the props in camelCase.
Closes#11967
AI-assisted-by: claude-opus-5-5
* ♻️ Build filter entry props with mf/props
Use mf/props instead of a raw JS object so the props keep the
kebab-case keys used across the codebase. The macro converts them to
camelCase at compile time, so there is no runtime cost, and the
comment about the manual conversion is no longer needed.
AI-assisted-by: claude-opus-5-5
Rust 1.91 links emscripten with JS exception handling, which cannot
mix with SUPPORT_LONGJMP=wasm. Bump the devenv to Rust 1.99 (native
Wasm EH) and align EMCC_CFLAGS / SKIA_BINARIES_URL with a Skia
-ljmp-wasm prebuild so +simd128 no longer hits invoke_*V TypeErrors.
* ♻️ Move some constants out of app.render-wasm.api
- Moved to aapp.render-wasm.wasm where they can be imported
without pulling half the editor with them
- Renamed one for consistency
* 🔥 Delete unused function, update docstring
`resolve-and-link-libraries` asserts `::bfc/team-id`, and
`enable-backend-asserts` is now enabled in `scripts/ci` and
`tests-backend.yml`. Without a team-id, the v3 import tests
error instead of passing. Production always provides the
team (RPC import, management and debug routes); mirror that
in the tests.
Update `mem:backend/testing` and `mem:scripts/ci` with the
`enable-backend-asserts` behavior.
Verified with `./scripts/ci backend`: 1235 tests, 6384
assertions, 0 failures.
AI-assisted-by: deepseek-v4.1-flash