From 405a73e8bad48d14ed72588418d9a4d1f2d342ed Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 19 May 2026 14:30:44 +0200 Subject: [PATCH 1/8] :sparkles: Add climit impl and config for file snapshot methods (#9722) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :sparkles: Add dedicated concurrency limit for restore-file-snapshot This adds a dedicated climit configuration for the restore-file-snapshot RPC method with :permits 1 per profile (plus queue of 2 and 60s timeout) and a global limit of 3. Previously the method only used the generic root/by-profile and root/global limits, allowing up to 7 concurrent restore operations per profile which caused database row lock contention on FOR UPDATE and connection pool exhaustion. * :sparkles: Skip locking on restore! to avoid blocking other operations Changes the row lock acquisition in restore! from a blocking FOR UPDATE to FOR UPDATE SKIP LOCKED. If the file row is already locked by another concurrent operation (e.g., another restore or an update-file), the query returns no rows and the caller fails fast with a clear conflict error instead of blocking indefinitely holding a database connection. * :sparkles: Add queue and timeout limits to root/by-profile concurrency limit Previously root/by-profile had no queue limit (unbounded Integer/MAX_VALUE) and no timeout, allowing requests to pile up indefinitely behind a profile whose permits were exhausted by long-running operations. This could lead to memory pressure and cascading failures. Now limited to 30 queued requests with a 30-second timeout so excess requests fail fast. * :sparkles: Move backup snapshot creation outside restore transaction The backup snapshot (fsnap/create!) is now created in its own short-lived connection before the actual restore transaction begins. This ensures the backup is persisted independently of the restore outcome and reduces the restore transaction window. The restore itself runs inside a db/tx-run! block with an optimistic locking check: it reads the file with FOR UPDATE and compares its revn against the value captured at backup time. If the file was edited concurrently, the restore aborts with a conflict error to prevent data loss. Co-dependent with the SKIP LOCKED change in restore! — the FOR UPDATE acquired here is in the same transaction as restore!, so the SKIP LOCKED inside restore! correctly sees the row as unlocked (same transaction). * :recycle: Remove unused private function get-minimal-file The local get-minimal-file function in file_snapshots.clj is no longer used since restore! switched to direct exec-one! with FOR UPDATE SKIP LOCKED. The sql:get-minimal-file SQL constant is still used directly. * :sparkles: Add minor improvements on db connection management * :recycle: Refactor create-file-snapshot to use explicit transaction management Remove automatic transaction wrapping (`::db/transaction true`) and pass `cfg` through the call chain instead of destructured `conn`. Wrap `fsnap/create!` in an explicit `db/tx-run!` for clearer transaction boundaries. Signed-off-by: Andrey Antukh * :sparkles: Add dedicated concurrency limit for create-file-snapshot This adds a dedicated climit configuration for the create-file-snapshot RPC method with :permits 1 per profile (plus queue of 2 and 60s timeout) and a global limit of 3. Previously the method only used the generic root/by-profile and root/global limits, allowing up to 10 concurrent snapshot creation operations per profile which could cause database contention and connection pool exhaustion. Signed-off-by: Andrey Antukh --------- Signed-off-by: Andrey Antukh --- backend/resources/climit.edn | 16 ++- backend/src/app/db.clj | 32 ++++- backend/src/app/features/file_snapshots.clj | 135 +++++++++--------- backend/src/app/main.clj | 4 +- .../src/app/rpc/commands/files_snapshot.clj | 58 +++++--- backend/test/backend_tests/db_test.clj | 43 ++++++ 6 files changed, 190 insertions(+), 98 deletions(-) create mode 100644 backend/test/backend_tests/db_test.clj diff --git a/backend/resources/climit.edn b/backend/resources/climit.edn index 34d2184153..7d8234499b 100644 --- a/backend/resources/climit.edn +++ b/backend/resources/climit.edn @@ -19,7 +19,7 @@ {:permits 40} :root/by-profile - {:permits 10} + {:permits 10 :queue 30 :timeout 30000} :file-thumbnail-ops/global {:permits 20} @@ -27,4 +27,16 @@ {:permits 2} :submit-audit-events/by-profile - {:permits 1 :queue 3}} + {:permits 1 :queue 3} + + :restore-file-snapshot/global + {:permits 3} + + :restore-file-snapshot/by-profile + {:permits 1 :queue 2 :timeout 60000} + + :create-file-snapshot/global + {:permits 3} + + :create-file-snapshot/by-profile + {:permits 1 :queue 2 :timeout 60000}} diff --git a/backend/src/app/db.clj b/backend/src/app/db.clj index c23ea07524..de80175040 100644 --- a/backend/src/app/db.clj +++ b/backend/src/app/db.clj @@ -27,7 +27,9 @@ [next.jdbc.transaction]) (:import com.zaxxer.hikari.HikariConfig + com.zaxxer.hikari.HikariConfigMXBean com.zaxxer.hikari.HikariDataSource + com.zaxxer.hikari.HikariPoolMXBean com.zaxxer.hikari.metrics.prometheus.PrometheusMetricsTrackerFactory io.whitfin.siphash.SipHasher io.whitfin.siphash.SipHasherContainer @@ -67,9 +69,8 @@ (def defaults {::name :main - ::min-size 0 ::max-size 60 - ::connection-timeout 10000 + ::connection-timeout 30000 ::validation-timeout 10000 ::idle-timeout 120000 ; 2min ::max-lifetime 1800000 ; 30m @@ -82,7 +83,7 @@ (defmethod ig/init-key ::pool [_ cfg] (let [{:keys [::uri ::read-only] :as cfg} - (merge defaults cfg)] + (merge defaults (d/without-nils cfg))] (when uri (l/info :hint "initialize connection pool" :name (d/name (::name cfg)) @@ -90,7 +91,8 @@ :read-only read-only :credentials (and (contains? cfg ::username) (contains? cfg ::password)) - :min-size (::min-size cfg) + :min-size (or (::min-size cfg) + (::max-size cfg)) :max-size (::max-size cfg)) (create-pool cfg)))) @@ -111,7 +113,9 @@ [{:keys [::uri] :as cfg}] ;; (app.common.pprint/pprint cfg) - (let [config (HikariConfig.)] + (let [config (HikariConfig.) + max-size (::max-size cfg) + min-size (or (::min-size cfg) max-size)] (doto config (.setJdbcUrl (str "jdbc:" uri)) (.setPoolName (d/name (::name cfg))) @@ -121,8 +125,8 @@ (.setValidationTimeout (::validation-timeout cfg)) (.setIdleTimeout (::idle-timeout cfg)) (.setMaxLifetime (::max-lifetime cfg)) - (.setMinimumIdle (::min-size cfg)) - (.setMaximumPoolSize (::max-size cfg)) + (.setMinimumIdle min-size) + (.setMaximumPoolSize max-size) (.setConnectionInitSql initsql) (.setInitializationFailTimeout -1)) @@ -180,6 +184,20 @@ :code :invalid-connection :hint "invalid connection provided"))) +(defn pool-stats + "Given a HikariDataSource instance, returns a map with current pool + statistics: active/idle connections, threads awaiting connection, + total connections, maximum pool size, and minimum idle connections." + [^HikariDataSource pool] + (let [^HikariPoolMXBean pool-mxbean (.getHikariPoolMXBean pool) + ^HikariConfigMXBean cfg-mxbean (.getHikariConfigMXBean pool)] + {:active-connections (.getActiveConnections pool-mxbean) + :idle-connections (.getIdleConnections pool-mxbean) + :threads-awaiting-connection (.getThreadsAwaitingConnection pool-mxbean) + :total-connections (.getTotalConnections pool-mxbean) + :maximum-pool-size (.getMaximumPoolSize cfg-mxbean) + :minimum-idle (.getMinimumIdle cfg-mxbean)})) + (defn create-pool [cfg] (let [dsc (create-datasource-config cfg)] diff --git a/backend/src/app/features/file_snapshots.clj b/backend/src/app/features/file_snapshots.clj index 192030cbf8..e4c2985f10 100644 --- a/backend/src/app/features/file_snapshots.clj +++ b/backend/src/app/features/file_snapshots.clj @@ -66,11 +66,6 @@ LEFT JOIN file_data AS fd ON (fd.file_id = f.id AND fd.id = f.id) WHERE f.id = ?") -(defn- get-minimal-file - [cfg id & {:as opts}] - (-> (db/get-with-sql cfg [sql:get-minimal-file id] opts) - (d/update-when :metadata fdata/decode-metadata))) - (def ^:private sql:get-snapshot-without-data (str "WITH snapshots AS (" sql:snapshots ")" "SELECT c.id, @@ -319,79 +314,87 @@ (defn restore! [{:keys [::db/conn] :as cfg} file-id snapshot-id] - (let [file (get-minimal-file conn file-id {::db/for-update true}) - vern (rand-int Integer/MAX_VALUE) + (let [lock-sql (str sql:get-minimal-file " FOR UPDATE OF f SKIP LOCKED") + row (db/exec-one! conn [lock-sql file-id])] - storage - (sto/resolve cfg {::db/reuse-conn true}) + (when-not row + (ex/raise :type :conflict + :code :file-locked + :hint "the file is currently locked by another operation, retry later")) - snapshot - (get-snapshot cfg file-id snapshot-id)] + (let [file (d/update-when row :metadata fdata/decode-metadata) + vern (rand-int Integer/MAX_VALUE) - (when-not snapshot - (ex/raise :type :not-found - :code :snapshot-not-found - :hint "unable to find snapshot with the provided label" - :snapshot-id snapshot-id - :file-id file-id)) + storage + (sto/resolve cfg {::db/reuse-conn true}) - (when-not (:data snapshot) - (ex/raise :type :internal - :code :snapshot-without-data - :hint "snapshot has no data" - :label (:label snapshot) - :file-id file-id)) + snapshot + (get-snapshot cfg file-id snapshot-id)] - (let [;; If the snapshot has applied migrations stored, we reuse - ;; them, if not, we take a safest set of migrations as - ;; starting point. This is because, at the time of - ;; implementing snapshots, migrations were not taken into - ;; account so we need to make this backward compatible in - ;; some way. - migrations - (or (:migrations snapshot) - (fmg/generate-migrations-from-version 67)) + (when-not snapshot + (ex/raise :type :not-found + :code :snapshot-not-found + :hint "unable to find snapshot with the provided label" + :snapshot-id snapshot-id + :file-id file-id)) - file - (-> file - (update :revn inc) - (assoc :migrations migrations) - (assoc :data (:data snapshot)) - (assoc :vern vern) - (assoc :version (:version snapshot)) - (assoc :has-media-trimmed false) - (assoc :modified-at (:modified-at snapshot)) - (assoc :features (:features snapshot)))] + (when-not (:data snapshot) + (ex/raise :type :internal + :code :snapshot-without-data + :hint "snapshot has no data" + :label (:label snapshot) + :file-id file-id)) - (l/dbg :hint "restoring snapshot" - :file-id (str file-id) - :label (:label snapshot) - :snapshot-id (str (:id snapshot))) + (let [;; If the snapshot has applied migrations stored, we reuse + ;; them, if not, we take a safest set of migrations as + ;; starting point. This is because, at the time of + ;; implementing snapshots, migrations were not taken into + ;; account so we need to make this backward compatible in + ;; some way. + migrations + (or (:migrations snapshot) + (fmg/generate-migrations-from-version 67)) - ;; In the same way, on reseting the file data, we need to restore - ;; the applied migrations on the moment of taking the snapshot - (bfc/update-file! cfg file ::bfc/reset-migrations? true) + file + (-> file + (update :revn inc) + (assoc :migrations migrations) + (assoc :data (:data snapshot)) + (assoc :vern vern) + (assoc :version (:version snapshot)) + (assoc :has-media-trimmed false) + (assoc :modified-at (:modified-at snapshot)) + (assoc :features (:features snapshot)))] - ;; FIXME: this should be separated functions, we should not have - ;; inline sql here. + (l/dbg :hint "restoring snapshot" + :file-id (str file-id) + :label (:label snapshot) + :snapshot-id (str (:id snapshot))) - ;; clean object thumbnails - (let [sql (str "update file_tagged_object_thumbnail " - " set deleted_at = now() " - " where file_id=? returning media_id") - res (db/exec! conn [sql file-id])] - (doseq [media-id (into #{} (keep :media-id) res)] - (sto/touch-object! storage media-id))) + ;; In the same way, on reseting the file data, we need to restore + ;; the applied migrations on the moment of taking the snapshot + (bfc/update-file! cfg file ::bfc/reset-migrations? true) - ;; clean file thumbnails - (let [sql (str "update file_thumbnail " - " set deleted_at = now() " - " where file_id=? returning media_id") - res (db/exec! conn [sql file-id])] - (doseq [media-id (into #{} (keep :media-id) res)] - (sto/touch-object! storage media-id))) + ;; FIXME: this should be separated functions, we should not have + ;; inline sql here. - vern))) + ;; clean object thumbnails + (let [sql (str "update file_tagged_object_thumbnail " + " set deleted_at = now() " + " where file_id=? returning media_id") + res (db/exec! conn [sql file-id])] + (doseq [media-id (into #{} (keep :media-id) res)] + (sto/touch-object! storage media-id))) + + ;; clean file thumbnails + (let [sql (str "update file_thumbnail " + " set deleted_at = now() " + " where file_id=? returning media_id") + res (db/exec! conn [sql file-id])] + (doseq [media-id (into #{} (keep :media-id) res)] + (sto/touch-object! storage media-id))) + + vern)))) (defn delete! [cfg & {:keys [id file-id deleted-at]}] diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index 940775bdf0..2c154ea95c 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -154,8 +154,8 @@ ::db/username (cf/get :database-username) ::db/password (cf/get :database-password) ::db/read-only (cf/get :database-readonly false) - ::db/min-size (cf/get :database-min-pool-size 0) - ::db/max-size (cf/get :database-max-pool-size 60) + ::db/min-size (cf/get :database-min-pool-size) + ::db/max-size (cf/get :database-max-pool-size) ::mtx/metrics (ig/ref ::mtx/metrics)} ;; Default netty IO pool (shared between several services) diff --git a/backend/src/app/rpc/commands/files_snapshot.clj b/backend/src/app/rpc/commands/files_snapshot.clj index 8325772361..e4f7d3d18d 100644 --- a/backend/src/app/rpc/commands/files_snapshot.clj +++ b/backend/src/app/rpc/commands/files_snapshot.clj @@ -17,6 +17,7 @@ [app.main :as-alias main] [app.msgbus :as mbus] [app.rpc :as-alias rpc] + [app.rpc.climit :as-alias climit] [app.rpc.commands.files :as files] [app.rpc.commands.teams :as teams] [app.rpc.doc :as-alias doc] @@ -43,9 +44,10 @@ (sv/defmethod ::create-file-snapshot {::doc/added "1.20" ::sm/params schema:create-file-snapshot - ::db/transaction true} - [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id file-id label]}] - (files/check-edition-permissions! conn profile-id file-id) + ::climit/id [[:create-file-snapshot/by-profile ::rpc/profile-id] + [:create-file-snapshot/global]]} + [cfg {:keys [::rpc/profile-id file-id label]}] + (files/check-edition-permissions! cfg profile-id file-id) (let [file (bfc/get-file cfg file-id :realize? true) project (db/get-by-id cfg :project (:project-id file))] @@ -57,10 +59,10 @@ (quotes/check! {::quotes/id ::quotes/snapshots-per-file} {::quotes/id ::quotes/snapshots-per-team})) - (fsnap/create! cfg file - {:label label - :profile-id profile-id - :created-by "user"}))) + (db/tx-run! cfg fsnap/create! file + {:label label + :profile-id profile-id + :created-by "user"}))) (def ^:private schema:restore-file-snapshot [:map {:title "restore-file-snapshot"} @@ -70,29 +72,43 @@ (sv/defmethod ::restore-file-snapshot {::doc/added "1.20" ::sm/params schema:restore-file-snapshot - ::db/transaction true} - [{:keys [::db/conn ::mbus/msgbus] :as cfg} {:keys [::rpc/profile-id ::rpc/session-id file-id id] :as params}] - (files/check-edition-permissions! conn profile-id file-id) + ::climit/id [[:restore-file-snapshot/by-profile ::rpc/profile-id] + [:restore-file-snapshot/global]]} + [{:keys [::db/pool ::mbus/msgbus] :as cfg} {:keys [::rpc/profile-id ::rpc/session-id file-id id] :as params}] + + ;; Check permissions and read current file state (short-lived, outside restore transaction) + (files/check-edition-permissions! pool profile-id file-id) (let [file (bfc/get-file cfg file-id) - team (teams/get-team conn + team (teams/get-team pool :profile-id profile-id :file-id file-id) - delay (ldel/get-deletion-delay team)] + delay (ldel/get-deletion-delay team) + file-revn (:revn file)] + ;; Create backup snapshot of the current state (committed immediately + ;; independently of the restore outcome) (fsnap/create! cfg file {:profile-id profile-id :deleted-at (ct/in-future delay) :created-by "system"}) - (let [vern (fsnap/restore! cfg file-id id)] - ;; Send to the clients a notification to reload the file - (mbus/pub! msgbus - :topic (:id file) - :message {:type :file-restored - :session-id session-id - :file-id (:id file) - :vern vern}) - nil))) + ;; Restore snapshot inside its own transaction; the revn check + ;; ensures no data is lost if the file was edited concurrently + (db/tx-run! cfg + (fn [{:keys [::db/conn] :as cfg}] + (let [current (bfc/get-minimal-file conn file-id {::db/for-update true})] + (when (not= (:revn current) file-revn) + (ex/raise :type :conflict + :code :file-modified + :hint "the file was modified during the restore process, please retry"))) + (let [vern (fsnap/restore! cfg file-id id)] + (mbus/pub! msgbus + :topic (:id file) + :message {:type :file-restored + :session-id session-id + :file-id (:id file) + :vern vern}) + nil))))) (def ^:private schema:update-file-snapshot [:map {:title "update-file-snapshot"} diff --git a/backend/test/backend_tests/db_test.clj b/backend/test/backend_tests/db_test.clj new file mode 100644 index 0000000000..b61ce6c920 --- /dev/null +++ b/backend/test/backend_tests/db_test.clj @@ -0,0 +1,43 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns backend-tests.db-test + (:require + [app.db :as db] + [backend-tests.helpers :as th] + [clojure.test :as t]) + (:import + com.zaxxer.hikari.HikariConfig + com.zaxxer.hikari.HikariDataSource + java.sql.Connection)) + +(t/use-fixtures :once th/state-init) + +(t/deftest pool-stats-returns-expected-keys + (let [stats (db/pool-stats th/*pool*)] + (t/testing "all expected keys are present" + (t/is (contains? stats :active-connections)) + (t/is (contains? stats :idle-connections)) + (t/is (contains? stats :threads-awaiting-connection)) + (t/is (contains? stats :total-connections)) + (t/is (contains? stats :maximum-pool-size)) + (t/is (contains? stats :minimum-idle))) + + (t/testing "values are non-negative integers" + (t/is (>= (:active-connections stats) 0)) + (t/is (>= (:idle-connections stats) 0)) + (t/is (>= (:threads-awaiting-connection stats) 0)) + (t/is (>= (:total-connections stats) 0)) + (t/is (>= (:maximum-pool-size stats) 0)) + (t/is (>= (:minimum-idle stats) 0))) + + (t/testing "total connections equals active + idle" + (t/is (= (:total-connections stats) + (+ (:active-connections stats) + (:idle-connections stats))))) + + (t/testing "maximum pool size is reasonable" + (t/is (pos? (:maximum-pool-size stats)))))) From 0b0bd72dce0a6776ddee1b1ea53b6f1894a7e1b4 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 19 May 2026 17:47:00 +0200 Subject: [PATCH 2/8] :paperclip: Backport opencode skills from staging --- .gitignore | 1 + .opencode/skills/update-changelog/SKILL.md | 40 +++- tools/gh.py | 21 +- tools/taiga.py | 261 +++++++++++++++++++++ 4 files changed, 301 insertions(+), 22 deletions(-) create mode 100755 tools/taiga.py diff --git a/.gitignore b/.gitignore index 8586839ba0..9d64190075 100644 --- a/.gitignore +++ b/.gitignore @@ -84,3 +84,4 @@ /**/node_modules /**/.yarn/* /.pnpm-store +/tools/__pycache__ diff --git a/.opencode/skills/update-changelog/SKILL.md b/.opencode/skills/update-changelog/SKILL.md index 5752b65aa3..29c3d295b4 100644 --- a/.opencode/skills/update-changelog/SKILL.md +++ b/.opencode/skills/update-changelog/SKILL.md @@ -45,12 +45,12 @@ python3 tools/gh.py issues "2.16.0" --state all python3 tools/gh.py issues "2.16.0" --exclude "release blocker,no changelog" ``` -**Label exclusion rules:** -- `release blocker` — Internal release-blocking bugs not relevant to end users -- `no changelog` — Chore/refactor work that doesn't need a changelog entry +**Exclusion rules:** +- `no changelog` label — Chore/refactor work that doesn't need a changelog entry +- `Task` issue type — Internal chores are not user-facing; filter these out after fetching The script outputs JSON with each entry containing `number`, `title`, `state`, -`labels`, and `closing_prs` (the PRs that fix each issue). +`issue_type`, `labels`, and `closing_prs` (the PRs that fix each issue). ### 3. Identify missing entries (optional) @@ -84,15 +84,27 @@ The `prs` command returns JSON with `number`, `title`, `body`, `state`, `merged_at`, `author`, `labels`, and `closing_issues`. PRs are fetched in batches of 50 via GraphQL to stay within API limits. -### 5. Categorize entries +### 5. Categorize entries — strictly by issue type, never by labels or emoji -Check the labels on each issue to determine which section it belongs to: +Use the **Issue Type** field (GitHub's native issue type, exposed as +`issue_type` in the `gh.py` JSON output) to determine which section an entry +belongs to. -| Label / Title prefix | Changelog section | -|----------------------|-------------------| -| `bug` label or `:bug:` title prefix | `### :bug: Bugs fixed` | -| `enhancement` label or `:sparkles:` prefix | `### :sparkles: New features & Enhancements` | -| No label | Infer from title convention, default to bug fix | +> **⚠️ CRITICAL: Never use labels or title emoji prefixes for categorization.** +> Labels like `bug` and `enhancement`, as well as title prefixes like `:bug:` +> and `:sparkles:`, are frequently inaccurate, missing, or contradictory to the +> actual issue type. The `issue_type` field from `gh.py` is the single source +> of truth. + +| `issue_type` value | Changelog section | +|--------------------|-------------------| +| `Bug` | `### :bug: Bugs fixed` | +| `Feature` or `Enhancement` | `### :sparkles: New features & Enhancements` | +| `Task` | **Exclude** — internal chores are not user-facing | +| `null` (not set) | Check labels as a fallback: `bug` label → bugs, otherwise enhancements | + +The `gh.py` issues command already includes `issue_type` in every entry's +output. **No separate GraphQL query is needed.** **Community contribution attribution:** If the issue or its fix PR has the `community contribution` label, add an attribution `(by @)` @@ -205,6 +217,7 @@ Read the top of `CHANGES.md` and confirm: can find the code changes. - **Latest version first.** New sections are inserted at the top of the changelog, below the `# CHANGELOG` header. +- **Issue Type determines section — exclusively.** Use the `issue_type` field from `gh.py` output (Bug → `:bug:`, Feature/Enhancement → `:sparkles:`). **Do not** use labels (`bug`, `enhancement`) or title emoji prefixes (`:bug:`, `:sparkles:`) — they are frequently wrong or contradictory. The `issue_type` is the single source of truth. - **User-facing descriptions.** Write from the user's perspective — describe what broke and what was fixed, not internal implementation details. - **Community attribution.** When the issue or fix PR has the @@ -213,8 +226,9 @@ Read the top of `CHANGES.md` and confirm: issue author) for the attribution. - **Only closed issues.** An issue must have `state: "closed"` to appear in the changelog. Open unresolved issues are omitted. -- **Excluded labels.** Issues with `release blocker` or `no changelog` labels - must be excluded from the changelog. +- **Excluded issues.** Issues with `no changelog` label must be excluded. + Issues with `issue_type: "Task"` must also be excluded — they are internal + chores, not user-facing changes. - **Multiple PRs per issue.** If multiple PRs fix the same issue, list them comma-separated inline: `(PR: [#A](url), [#B](url))`. - **Duplicate removal.** If an entry already exists in a prior version section, diff --git a/tools/gh.py b/tools/gh.py index 2aa7ac4da8..afd81da619 100755 --- a/tools/gh.py +++ b/tools/gh.py @@ -80,14 +80,15 @@ query($owner: String!, $repo: String!, $milestone: Int!, $cursor: String) { issues(first: 100, after: $cursor, states: __STATES__) { totalCount pageInfo { hasNextPage endCursor } - nodes { - ... on Issue { - number - title - state - labels(first: 20) { nodes { name } } - closedByPullRequestsReferences(first: 5) { nodes { number } } - } + nodes { + ... on Issue { + number + title + state + issueType { name } + labels(first: 20) { nodes { name } } + closedByPullRequestsReferences(first: 5) { nodes { number } } + } } } } @@ -120,7 +121,7 @@ def fetch_milestone_issues(milestone_num: int, states: str) -> list[dict]: states: GraphQL states enum array literal, e.g. ``"[CLOSED]"`` or ``"[OPEN CLOSED]"`` Returns: - List of {number, title, state, labels: [str], closing_prs: [int]} + List of {number, title, state, issue_type: str|None, labels: [str], closing_prs: [int]} """ query = GQL_ISSUES_QUERY.replace("__STATES__", states) all_nodes: list[dict] = [] @@ -140,10 +141,12 @@ def fetch_milestone_issues(milestone_num: int, states: str) -> list[dict]: for node in issues["nodes"]: if node is None: continue + issue_type = node.get("issueType") all_nodes.append({ "number": node["number"], "title": node["title"], "state": node["state"], + "issue_type": issue_type["name"] if issue_type else None, "labels": [lbl["name"] for lbl in node["labels"]["nodes"]], "closing_prs": [pr["number"] for pr in node["closedByPullRequestsReferences"]["nodes"]], }) diff --git a/tools/taiga.py b/tools/taiga.py new file mode 100755 index 0000000000..1e96364a31 --- /dev/null +++ b/tools/taiga.py @@ -0,0 +1,261 @@ +#!/usr/bin/env python3 +""" +Taiga API client — fetch public issues, user stories, and tasks from the +Penpot project (id 345963) without authentication. + +Usage: + python3 tools/taiga.py + python3 tools/taiga.py + python3 tools/taiga.py [--json] + python3 tools/taiga.py [--json] + +Examples: + python3 tools/taiga.py https://tree.taiga.io/project/penpot/issue/13714 + python3 tools/taiga.py --json https://tree.taiga.io/project/penpot/us/14128 + python3 tools/taiga.py task 13648 +""" + +import argparse +import json +import re +import sys +import urllib.error +import urllib.request + +API_BASE = "https://api.taiga.io/api/v1" +PROJECT_ID = 345963 + +ENDPOINT_MAP = { + "issue": "issues", + "us": "userstories", + "task": "tasks", +} + +TYPE_LABELS = { + "issue": "Issue", + "us": "User Story", + "task": "Task", +} + + +# ── URL Parsing ────────────────────────────────────────────────────────────── + +def parse_taiga_url(url: str) -> tuple[str, int] | None: + """Extract (type, ref) from a tree.taiga.io URL. + + Supported patterns: + .../project/penpot/issue/13714 + .../project/penpot/us/14128 + .../project/penpot/task/13648 + """ + m = re.search(r"/project/penpot/(issue|us|task)/(\d+)", url) + if not m: + return None + return m.group(1), int(m.group(2)) + + +# ── API call ───────────────────────────────────────────────────────────────── + +def fetch_item(endpoint: str, ref: int) -> dict | None: + """Fetch a single item by ref using the 'by_ref' endpoint.""" + url = f"{API_BASE}/{endpoint}/by_ref?ref={ref}&project={PROJECT_ID}" + try: + with urllib.request.urlopen(url, timeout=15) as resp: + return json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + print(f"Error: HTTP {e.code} — {e.reason}", file=sys.stderr) + if e.code == 404: + print( + f" Item (ref={ref}) not found in project {PROJECT_ID}.", + file=sys.stderr, + ) + return None + except urllib.error.URLError as e: + print(f"Error: {e.reason}", file=sys.stderr) + return None + except json.JSONDecodeError as e: + print(f"Error: invalid JSON response — {e}", file=sys.stderr) + return None + + +# ── Output formatting ──────────────────────────────────────────────────────── + +def _val(value, default="—"): + return value if value is not None else default + + +def _tag_list(tags): + """Pretty-print tag list. Tags are arrays of [name, color] pairs.""" + if not tags: + return "—" + names = [t[0] if isinstance(t, list) else str(t) for t in tags] + return ", ".join(names) + + +def _extra_name(extra_info): + """Extract a display name from an *_extra_info dict.""" + if not extra_info: + return "—" + return extra_info.get("full_name_display") or extra_info.get("username") or "—" + + +def _status_name(status_info): + """Extract status name from status_extra_info.""" + if not status_info: + return "—" + return status_info.get("name", "—") + + +def _project_name(proj_info): + """Extract project name from project_extra_info.""" + if not proj_info: + return "—" + return proj_info.get("name", "—") + + +def _assignee(item): + """Return the assignee display name.""" + return _extra_name(item.get("assigned_to_extra_info")) + + +def _owner(item): + return _extra_name(item.get("owner_extra_info")) + + +def format_summary(item: dict, item_type: str) -> str: + """Build a printable summary matching the requested format.""" + label = TYPE_LABELS.get(item_type, item_type.capitalize()) + subject = item.get("subject", "(no subject)") + ref = item.get("ref", "?") + status = _status_name(item.get("status_extra_info")) + assignee = _assignee(item) + owner = _owner(item) + created = item.get("created_date", "")[:10] if item.get("created_date") else "" + tags = _tag_list(item.get("tags", [])) + + # Title line + title = f"{label} #{ref} — {subject}" + + # Fields section (no indent) + fields = [] + fields.append(f"Status: {status}") + + if item_type == "us": + milestone = item.get("milestone_slug") or "" + points = item.get("points") or {} + point_count = len(points) + fields.append(f"Milestone: {milestone}") + fields.append(f"Points: {point_count} role(s)") + elif item_type == "task": + milestone = item.get("milestone_slug") or "" + parent = item.get("user_story") + fields.append(f"Milestone: {milestone}") + fields.append(f"Parent US: {parent if parent else '—'}") + elif item_type == "issue": + issue_type_id = item.get("type", "") + severity_id = item.get("severity", "") + priority_id = item.get("priority", "") + fields.append(f"Type ID: {issue_type_id}") + fields.append(f"Severity ID: {severity_id}") + fields.append(f"Priority ID: {priority_id}") + + fields.append(f"Assignee: {assignee}") + fields.append(f"Author: {owner}") + fields.append(f"Created: {created}") + fields.append(f"Tags: {tags}") + + url = f"https://tree.taiga.io/project/penpot/{item_type}/{ref}" + fields.append(f"URL: {url}") + + # Assemble output + sep = "================================" + parts = [title, sep] + parts.extend(fields) + + # Full description after second separator + desc = item.get("description") or "" + if desc.strip(): + parts.append(sep) + parts.append(desc) + + return "\n".join(parts) + + +# ── CLI ─────────────────────────────────────────────────────────────────────── + +def build_parser(): + parser = argparse.ArgumentParser( + description="Fetch public items from the Penpot Taiga project.", + epilog=( + "Examples:\n" + " %(prog)s https://tree.taiga.io/project/penpot/issue/13714\n" + " %(prog)s --json us 14128\n" + " %(prog)s task 13648" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "--json", + action="store_true", + dest="raw_json", + help="Output raw JSON instead of formatted summary.", + ) + parser.add_argument( + "args", + nargs="+", + help='Either a Taiga URL, or " " (e.g. issue 13714).', + ) + return parser + + +def main(): + parser = build_parser() + opts = parser.parse_args() + + # Determine (type, ref) from arguments + item_type: str | None = None + ref: int | None = None + + if len(opts.args) == 1: + # Single argument — must be a Taiga URL + parsed = parse_taiga_url(opts.args[0]) + if parsed is None: + print( + "Error: could not parse Taiga URL. " + 'Expected format: https://tree.taiga.io/project/penpot//', + file=sys.stderr, + ) + sys.exit(1) + item_type, ref = parsed + elif len(opts.args) == 2: + item_type, ref_str = opts.args + if item_type not in ENDPOINT_MAP: + print( + f"Error: unknown type '{item_type}'. " + f"Expected one of: {', '.join(ENDPOINT_MAP)}", + file=sys.stderr, + ) + sys.exit(1) + try: + ref = int(ref_str) + except ValueError: + print(f"Error: ref must be a number, got '{ref_str}'", file=sys.stderr) + sys.exit(1) + else: + parser.print_help() + sys.exit(1) + + endpoint = ENDPOINT_MAP[item_type] + item = fetch_item(endpoint, ref) + + if item is None: + sys.exit(1) + + if opts.raw_json: + print(json.dumps(item, indent=2, ensure_ascii=False)) + else: + print(format_summary(item, item_type)) + + +if __name__ == "__main__": + main() From 5f30704b2809b529094887f253ba051e93d2a236 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 19 May 2026 17:55:41 +0200 Subject: [PATCH 3/8] :books: Update changelog --- CHANGES.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index d1a6f7562c..fbef87a713 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,10 @@ # CHANGELOG -## 2.15.4 (Unreleased) +## 2.15.4 + +### :sparkles: New features & Enhancements + +- Add rate limiting and concurrency safety for file snapshot operations [#9723](https://github.com/penpot/penpot/issues/9723) (PR: [#9722](https://github.com/penpot/penpot/pull/9722)) ### :bug: Bugs fixed @@ -9,6 +13,7 @@ - Fix API doc endpoint returning HTML as text/plain [#9680](https://github.com/penpot/penpot/issues/9680) (PR: [#9681](https://github.com/penpot/penpot/pull/9681)) - Fix unexpected error when opening the export dialog [#9721](https://github.com/penpot/penpot/issues/9721) (PR: [#9704](https://github.com/penpot/penpot/pull/9704)) + ## 2.15.3 ### :bug: Bugs fixed From 2ad85db016b341a97672c504b0188071429da44e Mon Sep 17 00:00:00 2001 From: Francis Santiago Date: Tue, 19 May 2026 13:27:41 +0200 Subject: [PATCH 4/8] :whale: Fix frontend startup on hosts without IPv6 support Signed-off-by: Francis Santiago --- docker/images/docker-compose.yaml | 3 ++- docker/images/files/nginx-entrypoint.sh | 6 +++++- docker/images/files/nginx.conf.template | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/docker/images/docker-compose.yaml b/docker/images/docker-compose.yaml index 25d419a354..63f7a8cbbc 100644 --- a/docker/images/docker-compose.yaml +++ b/docker/images/docker-compose.yaml @@ -106,7 +106,8 @@ services: environment: << : [*penpot-flags, *penpot-http-body-size, *penpot-public-uri] - + # Set to "true" on hosts where IPv6 is disabled at kernel boot level. + # PENPOT_DISABLE_IPV6_LISTEN: "true" penpot-backend: image: "penpotapp/backend:${PENPOT_VERSION:-2.15}" restart: always diff --git a/docker/images/files/nginx-entrypoint.sh b/docker/images/files/nginx-entrypoint.sh index e791ac64f0..2d4b5543de 100644 --- a/docker/images/files/nginx-entrypoint.sh +++ b/docker/images/files/nginx-entrypoint.sh @@ -37,7 +37,11 @@ export PENPOT_NITRATE_URI=${PENPOT_NITRATE_URI:-http://penpot-nitrate:3000} export PENPOT_MCP_URI=${PENPOT_MCP_URI:-http://penpot-mcp:4401} export PENPOT_MCP_URI_WS=${PENPOT_MCP_URI_WS:-http://penpot-mcp:4402} export PENPOT_HTTP_SERVER_MAX_BODY_SIZE=${PENPOT_HTTP_SERVER_MAX_BODY_SIZE:-367001600} # Default to 350MiB -envsubst "\$PENPOT_BACKEND_URI,\$PENPOT_EXPORTER_URI,\$PENPOT_NITRATE_URI,\$PENPOT_MCP_URI,\$PENPOT_MCP_URI_WS,\$PENPOT_HTTP_SERVER_MAX_BODY_SIZE" \ +export PENPOT_IPV6_LISTEN_DIRECTIVE=${PENPOT_IPV6_LISTEN_DIRECTIVE:-"listen [::]:8080 default_server;"} +if [ "${PENPOT_DISABLE_IPV6_LISTEN}" = "true" ]; then + export PENPOT_IPV6_LISTEN_DIRECTIVE="" +fi +envsubst "\$PENPOT_BACKEND_URI,\$PENPOT_EXPORTER_URI,\$PENPOT_NITRATE_URI,\$PENPOT_MCP_URI,\$PENPOT_MCP_URI_WS,\$PENPOT_HTTP_SERVER_MAX_BODY_SIZE,\$PENPOT_IPV6_LISTEN_DIRECTIVE" \ < /tmp/nginx.conf.template > /etc/nginx/nginx.conf PENPOT_DEFAULT_INTERNAL_RESOLVER="$(awk 'BEGIN{ORS=" "} $1=="nameserver" { sub(/%.*$/,"",$2); print ($2 ~ ":")? "["$2"]": $2}' /etc/resolv.conf)" diff --git a/docker/images/files/nginx.conf.template b/docker/images/files/nginx.conf.template index c182856e6b..6efcbc47f3 100644 --- a/docker/images/files/nginx.conf.template +++ b/docker/images/files/nginx.conf.template @@ -73,7 +73,7 @@ http { server { listen 8080 default_server; - listen [::]:8080 default_server; + ${PENPOT_IPV6_LISTEN_DIRECTIVE} server_name _; client_max_body_size $PENPOT_HTTP_SERVER_MAX_BODY_SIZE; From f3053fc8440fb926cd040a09209f23945fec0d8e Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 21 May 2026 12:09:43 +0200 Subject: [PATCH 5/8] :bug: Fix 2.15 release notes crash on invalid slide index (#9805) --- frontend/src/app/main/ui/releases/v2_15.cljs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/main/ui/releases/v2_15.cljs b/frontend/src/app/main/ui/releases/v2_15.cljs index 8c2f61580f..5880a63de6 100644 --- a/frontend/src/app/main/ui/releases/v2_15.cljs +++ b/frontend/src/app/main/ui/releases/v2_15.cljs @@ -77,7 +77,7 @@ [:& c/navigation-bullets {:slide slide :navigate navigate - :total 4}] + :total 3}] [:button {:on-click next :class (stl/css :next-btn)} "Continue"]]]]]] @@ -118,7 +118,7 @@ [:& c/navigation-bullets {:slide slide :navigate navigate - :total 4}] + :total 3}] [:button {:on-click next :class (stl/css :next-btn)} "Continue"]]]]]] From d574ec4ed2a869bcefb36c54ecae54784f80e33a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Tue, 19 May 2026 20:51:37 +0200 Subject: [PATCH 6/8] :wrench: Change the path to the cache directories in the custom runner --- .github/workflows/tests.yml | 44 ++++++++++++++++++------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e4b2d49efc..9e2c4b18d9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -27,8 +27,8 @@ jobs: container: image: penpotapp/devenv:latest volumes: - - /tmp/.m2:/root/.m2 - - /tmp/.gitlibs:/root/.gitlibs + - /var/cache/github-runner/m2:/root/.m2 + - /var/cache/github-runner/gitlib:/root/.gitlibs steps: - name: Checkout repository @@ -91,8 +91,8 @@ jobs: container: image: penpotapp/devenv:latest volumes: - - /tmp/.m2:/root/.m2 - - /tmp/.gitlibs:/root/.gitlibs + - /var/cache/github-runner/m2:/root/.m2 + - /var/cache/github-runner/gitlib:/root/.gitlibs steps: - name: Checkout repository @@ -110,8 +110,8 @@ jobs: container: image: penpotapp/devenv:latest volumes: - - /tmp/.m2:/root/.m2 - - /tmp/.gitlibs:/root/.gitlibs + - /var/cache/github-runner/m2:/root/.m2 + - /var/cache/github-runner/gitlib:/root/.gitlibs steps: - uses: actions/checkout@v6 @@ -165,8 +165,8 @@ jobs: container: image: penpotapp/devenv:latest volumes: - - /tmp/.m2:/root/.m2 - - /tmp/.gitlibs:/root/.gitlibs + - /var/cache/github-runner/m2:/root/.m2 + - /var/cache/github-runner/gitlib:/root/.gitlibs steps: - name: Checkout repository @@ -191,8 +191,8 @@ jobs: container: image: penpotapp/devenv:latest volumes: - - /tmp/.m2:/root/.m2 - - /tmp/.gitlibs:/root/.gitlibs + - /var/cache/github-runner/m2:/root/.m2 + - /var/cache/github-runner/gitlib:/root/.gitlibs steps: - name: Checkout repository @@ -220,8 +220,8 @@ jobs: container: image: penpotapp/devenv:latest volumes: - - /tmp/.m2:/root/.m2 - - /tmp/.gitlibs:/root/.gitlibs + - /var/cache/github-runner/m2:/root/.m2 + - /var/cache/github-runner/gitlib:/root/.gitlibs services: postgres: @@ -264,8 +264,8 @@ jobs: container: image: penpotapp/devenv:latest volumes: - - /tmp/.m2:/root/.m2 - - /tmp/.gitlibs:/root/.gitlibs + - /var/cache/github-runner/m2:/root/.m2 + - /var/cache/github-runner/gitlib:/root/.gitlibs steps: - name: Checkout repository @@ -283,8 +283,8 @@ jobs: container: image: penpotapp/devenv:latest volumes: - - /tmp/.m2:/root/.m2 - - /tmp/.gitlibs:/root/.gitlibs + - /var/cache/github-runner/m2:/root/.m2 + - /var/cache/github-runner/gitlib:/root/.gitlibs steps: - name: Checkout repository @@ -308,8 +308,8 @@ jobs: container: image: penpotapp/devenv:latest volumes: - - /tmp/.m2:/root/.m2 - - /tmp/.gitlibs:/root/.gitlibs + - /var/cache/github-runner/m2:/root/.m2 + - /var/cache/github-runner/gitlib:/root/.gitlibs needs: build-integration @@ -344,8 +344,8 @@ jobs: container: image: penpotapp/devenv:latest volumes: - - /tmp/.m2:/root/.m2 - - /tmp/.gitlibs:/root/.gitlibs + - /var/cache/github-runner/m2:/root/.m2 + - /var/cache/github-runner/gitlib:/root/.gitlibs needs: build-integration @@ -380,8 +380,8 @@ jobs: container: image: penpotapp/devenv:latest volumes: - - /tmp/.m2:/root/.m2 - - /tmp/.gitlibs:/root/.gitlibs + - /var/cache/github-runner/m2:/root/.m2 + - /var/cache/github-runner/gitlib:/root/.gitlibs needs: build-integration From f1c78945c41bb9f5dcfc0468e2008ff87c8b7d0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Tue, 26 May 2026 13:16:31 +0200 Subject: [PATCH 7/8] :whale: Start penpot-frontend always after penpot-mcp --- docker/images/docker-compose.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/docker/images/docker-compose.yaml b/docker/images/docker-compose.yaml index 63f7a8cbbc..cd5c9bf113 100644 --- a/docker/images/docker-compose.yaml +++ b/docker/images/docker-compose.yaml @@ -89,6 +89,7 @@ services: depends_on: - penpot-backend - penpot-exporter + - penpot-mcp networks: - penpot From e43703c368d06e6b870d04bc86602e6aae5b9f9e Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 27 May 2026 10:57:06 +0200 Subject: [PATCH 8/8] :paperclip: Update root dependencies --- package.json | 4 +- pnpm-lock.yaml | 196 ++++++++++++++++++++++++++++--------------------- 2 files changed, 116 insertions(+), 84 deletions(-) diff --git a/package.json b/package.json index 543592511c..d4a799d6f3 100644 --- a/package.json +++ b/package.json @@ -16,10 +16,10 @@ "fmt": "./scripts/fmt" }, "devDependencies": { - "@github/copilot": "^1.0.44", + "@github/copilot": "^1.0.54", "@types/node": "^25.6.2", "esbuild": "^0.28.0", "nrepl-client": "^0.3.0", - "opencode-ai": "^1.15.4" + "opencode-ai": "^1.15.11" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aa7c8ea720..8d8107a7af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: devDependencies: '@github/copilot': - specifier: ^1.0.44 - version: 1.0.44 + specifier: ^1.0.54 + version: 1.0.54 '@types/node': specifier: ^25.6.2 version: 25.6.2 @@ -21,8 +21,8 @@ importers: specifier: ^0.3.0 version: 0.3.0 opencode-ai: - specifier: ^1.15.4 - version: 1.15.4 + specifier: ^1.15.11 + version: 1.15.11 packages: @@ -182,44 +182,60 @@ packages: cpu: [x64] os: [win32] - '@github/copilot-darwin-arm64@1.0.44': - resolution: {integrity: sha512-9NqA5sT2spmNsehxhs51GhXRZIZga5nq+WcMl4LG2QrUPJRDwvHf1bDKqETJUBbYvBY8jONGuTKMRofkMI68YQ==} + '@github/copilot-darwin-arm64@1.0.54': + resolution: {integrity: sha512-ZRiKkxCvDccdGSNB/gmge4UkqMsWWZNIOr0pcim4/x2YUdHbh9cex9RZRjEMXijtUkBTzW5DP/cACuoAqTCyEg==} cpu: [arm64] os: [darwin] hasBin: true - '@github/copilot-darwin-x64@1.0.44': - resolution: {integrity: sha512-QPD8KtXx07SIKILGBl4JDhPyL2Qo0FMmaTYVxR6nkyHkHnFPsUZD6VWGR+T/KMLkcUXFM85Xc1ba9Y27s4nRrQ==} + '@github/copilot-darwin-x64@1.0.54': + resolution: {integrity: sha512-DGqs8x5r4y+SebMco890lNsPrqe6L4v2hCmV1IQ1pvYPvD1o1NMVSZPAQhkdvUeR5bqusOg8+0ugIZOQGTFpFQ==} cpu: [x64] os: [darwin] hasBin: true - '@github/copilot-linux-arm64@1.0.44': - resolution: {integrity: sha512-Z8ScIUP433xS18f68NP9jM9zW320Xzpi2wf7Nig/VyfrwupBy25UTezydQMT0KQHLWTEleHOPcYnASY3HgJXnQ==} + '@github/copilot-linux-arm64@1.0.54': + resolution: {integrity: sha512-waVKu6RuG8YBvCoGrOgtsOxmnfLaUywvbqZXRgvMya1m4akRkMi5r9B2UDr3+egjChp+FIUJVbGIoXN6ZST0rQ==} cpu: [arm64] os: [linux] + libc: [glibc] hasBin: true - '@github/copilot-linux-x64@1.0.44': - resolution: {integrity: sha512-KUl6lvJt0HNKaXSx0T0bIWJ3rvrGwgZYMlkDfqMbuMnZatEQJbjPwxmL/IDfp/c0DyKd7K+ajl17wHYcN/hJIQ==} + '@github/copilot-linux-x64@1.0.54': + resolution: {integrity: sha512-u/ltZa+HDIuhMivkIwkkuylRdEMk5Lp0XjE9w/OityW+BPKjZ+VKAmJ1/1Xm/uUx1IUlZaE3TJnka52wVNOD0A==} cpu: [x64] os: [linux] + libc: [glibc] hasBin: true - '@github/copilot-win32-arm64@1.0.44': - resolution: {integrity: sha512-JVJxZJwAc95ZfapgOXjNFwSqrWlvC3heo128L+CDkdZ6lwpD1dTGMHT/6rMMEeo3xjZmMm8tiynfwsHLDgTtvQ==} + '@github/copilot-linuxmusl-arm64@1.0.54': + resolution: {integrity: sha512-21LLjoQnD57Y1fvO56G1FGVbkt/ffZNDpHqVe2NW7C4r78Gn0hOTqwp+xWRUMpdmxrGZyKeFjX8jK6qox2uF5w==} + cpu: [arm64] + os: [linux] + libc: [musl] + hasBin: true + + '@github/copilot-linuxmusl-x64@1.0.54': + resolution: {integrity: sha512-sbeATKa9vaIetsY1vhQJO0PN/5FgoK48wkGBWCy4BpO8ER/kGYczT22qv6n96gBYrVmC2IZuTFTM4GFpC3bbBw==} + cpu: [x64] + os: [linux] + libc: [musl] + hasBin: true + + '@github/copilot-win32-arm64@1.0.54': + resolution: {integrity: sha512-muOX8qrJSi56BWQejkH0TgXpZYRO8Y9k1qIfMuRojZyLyATn1P4lIKb67ZqDCXJLkcPfVJ5eJYsSAeGwU3Qpww==} cpu: [arm64] os: [win32] hasBin: true - '@github/copilot-win32-x64@1.0.44': - resolution: {integrity: sha512-Yj3KQ/DqwS50PwRtyQITX2mWIVZeJeX+y0faVSMwUUzG1qxmMcme7wimhKOyc4LSV11DpgVB9MSiBw2xys7iww==} + '@github/copilot-win32-x64@1.0.54': + resolution: {integrity: sha512-BheXmqrYFmfRXA0iveKkjKks/2wgK5glrEOARomzy3JCbvVMSPIE8YeK+3YysiOh2SUkWjahwJc09cxaBq4+qQ==} cpu: [x64] os: [win32] hasBin: true - '@github/copilot@1.0.44': - resolution: {integrity: sha512-wr/GmNOUaJK/giJK5abyB1oTpEowgFKLi+NJnlyAymKiK/GKCaRlJqiX23H2RetM8vD2hDYUFUFm9lTCooGy0g==} + '@github/copilot@1.0.54': + resolution: {integrity: sha512-gxiWEQFWxJ3J2Rh67CxKEfER/zayB1z2qaSBUz3RZ0u1iDNJdGPry/1vOQ72X/yHmpGNm+9egucN5VMzyedsIg==} hasBin: true '@types/node@25.6.2': @@ -228,6 +244,10 @@ packages: bencode@2.0.3: resolution: {integrity: sha512-D/vrAD4dLVX23NalHwb8dSvsUsxeRPO8Y7ToKA015JQYq69MLDOMkC0uGZYA/MPpltLO8rt8eqFC2j8DxjTZ/w==} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + esbuild@0.28.0: resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} engines: {node: '>=18'} @@ -236,69 +256,69 @@ packages: nrepl-client@0.3.0: resolution: {integrity: sha512-EcROXUrzlGHKOdu/E/5WB0OESCI0iGHhdXeYk9cULYtd72eFJrM/Q1umvjTBfKWlT62y76cnyLG/3CmSCqT12w==} - opencode-ai@1.15.4: - resolution: {integrity: sha512-Z5PeJwFNUW4sFW+jYHQTnJm6858dECvWOATvnG0S66Nn46zwjaaZJEJMKQEPOW7Yog99j6k7xRKvJPPAjKDXfQ==} + opencode-ai@1.15.11: + resolution: {integrity: sha512-i3DYIATyWT3ukP+5OCyEuXvbCEq7PgBAlVA4yp01+W5BaYeoj/f0bpXdDPP5q9B/Yl8drtyEhWh0YC9UAHF06A==} cpu: [arm64, x64] os: [darwin, linux, win32] hasBin: true - opencode-darwin-arm64@1.15.4: - resolution: {integrity: sha512-d+0sFAAhrDtjQbxRZvYSzy3g/xj/xUDRWUVBWGfkJAx0QEWc/v7cksmnYj3p3l88Gxm/rWeLCh6H32pw1/En8A==} + opencode-darwin-arm64@1.15.11: + resolution: {integrity: sha512-XuiTIkBj0YKpfT8KHNan4JaX686vROCwXQHDzsZ55g/I7rdyQXG2wdt2CsUhRDaPyQTOkhrM+VqC3uYsT+kZzw==} cpu: [arm64] os: [darwin] - opencode-darwin-x64-baseline@1.15.4: - resolution: {integrity: sha512-Lj9wsEPFyEOgLO6J3DsCXdSu/IAJnW/RjtD1oojAao6uHvhs5uXyj1mrsmK8GrtAfCT4JUh8W38o3YYXGjItSw==} + opencode-darwin-x64-baseline@1.15.11: + resolution: {integrity: sha512-itb1FRgGyve89/W+sQBqmTVWUoldb0TdH1Qm805c52UbY+nkEW6oE40vveHJEDDPxFHypyzSymYFcJ7wqYBisw==} cpu: [x64] os: [darwin] - opencode-darwin-x64@1.15.4: - resolution: {integrity: sha512-H412BUw5O+bmXfzLo6UMCWVc3DOYEM0RCI5Kt+Ynqh+Q9878bXK6mwRR7VXgGVBkkH2U4GtT1uDgY0BzSK185Q==} + opencode-darwin-x64@1.15.11: + resolution: {integrity: sha512-PimsC+uaSmVxszQ3sbrIEjDoba9jUyAwAbydEukY3EoQ7cgLFd8tn9H/8yeA0aY7bh3uiCttDCctoQfMpV+S9Q==} cpu: [x64] os: [darwin] - opencode-linux-arm64-musl@1.15.4: - resolution: {integrity: sha512-TO2IVSoYolGKJahf73/hRsJBGxLKOdP/akYPzI0hQQvW4oVrmQkZ3s13jU1+LXIEn4Zbj/TB18QvLzvXrnrEhA==} + opencode-linux-arm64-musl@1.15.11: + resolution: {integrity: sha512-wLaAM12H0mH93XpdXuFz4+oeNA9+CDj8WEvdL8NNrz/Sfgi/zWIo8g3UMH+lp+pfO0s7PTM84RzxGQTOvcejXg==} cpu: [arm64] os: [linux] - opencode-linux-arm64@1.15.4: - resolution: {integrity: sha512-V+x/u9JnPOLPEfqbePSCL0OQdin5gs1V35VsVxj19WaZDEwxlMVjOe6HjVKEY64/O6htkPxCCZohmnMU4dVBMQ==} + opencode-linux-arm64@1.15.11: + resolution: {integrity: sha512-SM+xMU8pUd5p6KD2wdIR2d0q3IRw6axKSGUqlcVrit7ZhFlTjdr3Ca0KqVv3JsUny8Bu8N8Z5b3MHvqRf4nTSQ==} cpu: [arm64] os: [linux] - opencode-linux-x64-baseline-musl@1.15.4: - resolution: {integrity: sha512-xOJ3aHg2+2GrT9F/KmAF0JLB1D6K3SCY/626n+fLjs/AEFvLdmE3TYhoXPEyGH2I9F4kF+4p2xk0pg2b+LVlZQ==} + opencode-linux-x64-baseline-musl@1.15.11: + resolution: {integrity: sha512-N+geBY9Zv2kfoMKYMnPxJZQ1R9xOrgA55BMa7aMtMHr2x9tkjI5mCT7ese6Igna/cnoicxo216YhkSzyY1+p9A==} cpu: [x64] os: [linux] - opencode-linux-x64-baseline@1.15.4: - resolution: {integrity: sha512-dTlV8tAVN8nFdPb7527GR6/BpyIVavAcXJmZ2VbS1daXu4C6k6bpmjiS/ZFKlphRZiKKiEzFrHlimao4BMchVQ==} + opencode-linux-x64-baseline@1.15.11: + resolution: {integrity: sha512-MX+eaLOkFVO5IA8jA0QLUJma3KBwRzUrzZrCFuv6+vq9U/SsD+F9Jz2Bnfw9iE9v3QwnJ+8Yf/vKCsb5LrrWvQ==} cpu: [x64] os: [linux] - opencode-linux-x64-musl@1.15.4: - resolution: {integrity: sha512-IbMaM6zrakdtDD55GUhlT/WeXomXmKsVqo3XQuOaGXprBg3W5alsxXh60SZpV3ftbdcMD/eiB/PYtN/ZN8Fa5w==} + opencode-linux-x64-musl@1.15.11: + resolution: {integrity: sha512-/xbhh8aDFR5E8Ggc00ZG3qXXAwWxosEfWaPXiP04/Y7kDbz8T28a1cSIWniNmY426rYdnYLXgwJzOgpD/ZhDDg==} cpu: [x64] os: [linux] - opencode-linux-x64@1.15.4: - resolution: {integrity: sha512-2c20aldKLfNkg6N6nABvvK1fuaCwYLo/HNeL8ikellkFMeGalCGDhkL/VQ8R8KPV3ohVZJtZwG0nkFiA8MeHCg==} + opencode-linux-x64@1.15.11: + resolution: {integrity: sha512-RuXo1XFiAqW6ypnP4V+rPDmTrdDKh8FuO+3whMpw2qIYs8eVil72QwX1Rv6W96FDCFQkyUZW9R6MK/MGbGWCUQ==} cpu: [x64] os: [linux] - opencode-windows-arm64@1.15.4: - resolution: {integrity: sha512-kr3nIWmYH7NC0Vgrhgjp9EmCuy5MuxjIRrSjzlfRLMaML6U/a0Hsr3AahBwI1KjT+HEhz5u6xpodZeeEDY3nPQ==} + opencode-windows-arm64@1.15.11: + resolution: {integrity: sha512-sdWtLGq1aoaCzbTQY8NR7+g56lzYREBBcT7Na81FKg4H/ZybLQHXV+lwbaw+cK/d0aPpM8EAB+TV6Wbe5nEzGw==} cpu: [arm64] os: [win32] - opencode-windows-x64-baseline@1.15.4: - resolution: {integrity: sha512-2/elQ163r4Q97bYJRrY09IG+bpqh0AKpfutDGCaokFdLWIWQN/cFvjzb4C+BKzLFsU9LRfoyvPhe4nXMm1+S4A==} + opencode-windows-x64-baseline@1.15.11: + resolution: {integrity: sha512-Lj9TkJIeUD++idJWIKK5z+k5hvNubAEXItdxzBLM8LlWArSd2tXCGbxbBktsM5URlhFBdAN05ghyiUtAVOcMPg==} cpu: [x64] os: [win32] - opencode-windows-x64@1.15.4: - resolution: {integrity: sha512-f6p40u3yLEbiq4pzBOXAwtW/NP/dL8uTurHfraPcfezA4ua5DEm4vSoSePUY0CHtubUPuDe0wRUA1s53sysjPQ==} + opencode-windows-x64@1.15.11: + resolution: {integrity: sha512-d5jJqLA+d2DmbEzVrriePxoOjLfqjKZar0OYkNgvmUcWHCHeyn1NcL5JH6T7L19s/X3qY1tEDvZZ3uWFqNMdGQ==} cpu: [x64] os: [win32] @@ -389,32 +409,42 @@ snapshots: '@esbuild/win32-x64@0.28.0': optional: true - '@github/copilot-darwin-arm64@1.0.44': + '@github/copilot-darwin-arm64@1.0.54': optional: true - '@github/copilot-darwin-x64@1.0.44': + '@github/copilot-darwin-x64@1.0.54': optional: true - '@github/copilot-linux-arm64@1.0.44': + '@github/copilot-linux-arm64@1.0.54': optional: true - '@github/copilot-linux-x64@1.0.44': + '@github/copilot-linux-x64@1.0.54': optional: true - '@github/copilot-win32-arm64@1.0.44': + '@github/copilot-linuxmusl-arm64@1.0.54': optional: true - '@github/copilot-win32-x64@1.0.44': + '@github/copilot-linuxmusl-x64@1.0.54': optional: true - '@github/copilot@1.0.44': + '@github/copilot-win32-arm64@1.0.54': + optional: true + + '@github/copilot-win32-x64@1.0.54': + optional: true + + '@github/copilot@1.0.54': + dependencies: + detect-libc: 2.1.2 optionalDependencies: - '@github/copilot-darwin-arm64': 1.0.44 - '@github/copilot-darwin-x64': 1.0.44 - '@github/copilot-linux-arm64': 1.0.44 - '@github/copilot-linux-x64': 1.0.44 - '@github/copilot-win32-arm64': 1.0.44 - '@github/copilot-win32-x64': 1.0.44 + '@github/copilot-darwin-arm64': 1.0.54 + '@github/copilot-darwin-x64': 1.0.54 + '@github/copilot-linux-arm64': 1.0.54 + '@github/copilot-linux-x64': 1.0.54 + '@github/copilot-linuxmusl-arm64': 1.0.54 + '@github/copilot-linuxmusl-x64': 1.0.54 + '@github/copilot-win32-arm64': 1.0.54 + '@github/copilot-win32-x64': 1.0.54 '@types/node@25.6.2': dependencies: @@ -422,6 +452,8 @@ snapshots: bencode@2.0.3: {} + detect-libc@2.1.2: {} + esbuild@0.28.0: optionalDependencies: '@esbuild/aix-ppc64': 0.28.0 @@ -456,55 +488,55 @@ snapshots: bencode: 2.0.3 tree-kill: 1.2.2 - opencode-ai@1.15.4: + opencode-ai@1.15.11: optionalDependencies: - opencode-darwin-arm64: 1.15.4 - opencode-darwin-x64: 1.15.4 - opencode-darwin-x64-baseline: 1.15.4 - opencode-linux-arm64: 1.15.4 - opencode-linux-arm64-musl: 1.15.4 - opencode-linux-x64: 1.15.4 - opencode-linux-x64-baseline: 1.15.4 - opencode-linux-x64-baseline-musl: 1.15.4 - opencode-linux-x64-musl: 1.15.4 - opencode-windows-arm64: 1.15.4 - opencode-windows-x64: 1.15.4 - opencode-windows-x64-baseline: 1.15.4 + opencode-darwin-arm64: 1.15.11 + opencode-darwin-x64: 1.15.11 + opencode-darwin-x64-baseline: 1.15.11 + opencode-linux-arm64: 1.15.11 + opencode-linux-arm64-musl: 1.15.11 + opencode-linux-x64: 1.15.11 + opencode-linux-x64-baseline: 1.15.11 + opencode-linux-x64-baseline-musl: 1.15.11 + opencode-linux-x64-musl: 1.15.11 + opencode-windows-arm64: 1.15.11 + opencode-windows-x64: 1.15.11 + opencode-windows-x64-baseline: 1.15.11 - opencode-darwin-arm64@1.15.4: + opencode-darwin-arm64@1.15.11: optional: true - opencode-darwin-x64-baseline@1.15.4: + opencode-darwin-x64-baseline@1.15.11: optional: true - opencode-darwin-x64@1.15.4: + opencode-darwin-x64@1.15.11: optional: true - opencode-linux-arm64-musl@1.15.4: + opencode-linux-arm64-musl@1.15.11: optional: true - opencode-linux-arm64@1.15.4: + opencode-linux-arm64@1.15.11: optional: true - opencode-linux-x64-baseline-musl@1.15.4: + opencode-linux-x64-baseline-musl@1.15.11: optional: true - opencode-linux-x64-baseline@1.15.4: + opencode-linux-x64-baseline@1.15.11: optional: true - opencode-linux-x64-musl@1.15.4: + opencode-linux-x64-musl@1.15.11: optional: true - opencode-linux-x64@1.15.4: + opencode-linux-x64@1.15.11: optional: true - opencode-windows-arm64@1.15.4: + opencode-windows-arm64@1.15.11: optional: true - opencode-windows-x64-baseline@1.15.4: + opencode-windows-x64-baseline@1.15.11: optional: true - opencode-windows-x64@1.15.4: + opencode-windows-x64@1.15.11: optional: true tree-kill@1.2.2: {}