mirror of
https://github.com/penpot/penpot.git
synced 2026-07-30 09:56:22 +00:00
🐛 Fix race condition between MCP initialization and plugin runtime (#10137)
* 🐛 Fix race condition between MCP init and plugin runtime Add promise-based synchronization to ensure MCP initialization waits for plugin runtime to be ready before calling global.ɵloadPlugin. - Add runtime-ready-promise in app.plugins that resolves when init-plugins-runtime completes - Add wait-for-runtime function for other modules to await readiness - MCP init now waits for runtime via rx/from before starting plugin - Add defensive guards in start-plugin!, load-plugin!, close-plugin! to check if plugin APIs exist before calling - Rename init-plugins-runtime! to init-plugins-runtime Fixes: global.ɵloadPlugin is not a function error when MCP plugin starts before async plugin runtime initialization completes. * 📎 Add 'create-pr' opencode skill
This commit is contained in:
parent
7cb7f7adb2
commit
a8d0c18c1b
81
.opencode/skills/create-pr/SKILL.md
Normal file
81
.opencode/skills/create-pr/SKILL.md
Normal file
@ -0,0 +1,81 @@
|
||||
---
|
||||
name: create-pr
|
||||
description: Create a GitHub PR following Penpot conventions, with a concise engineer-focused description
|
||||
---
|
||||
|
||||
# Create Pull Request
|
||||
|
||||
Create a GitHub PR with proper title format and a concise description that explains reasoning, not implementation details.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Opening a new pull request
|
||||
- The user asks to create a PR
|
||||
- Code changes are ready and committed
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Verify Prerequisites
|
||||
|
||||
```bash
|
||||
git branch --show-current
|
||||
git log --oneline main..HEAD
|
||||
```
|
||||
|
||||
### 2. Check if Branch is Pushed
|
||||
|
||||
```bash
|
||||
BRANCH=$(git branch --show-current)
|
||||
if git ls-remote --heads origin "$BRANCH" | grep -q "$BRANCH"; then
|
||||
echo "Branch is pushed, proceeding with PR creation"
|
||||
else
|
||||
echo "ERROR: Branch '$BRANCH' is not pushed to remote. Please push the branch first."
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
**If the branch is not pushed, STOP here and ask the user to push it. The LLM does not have push permissions.**
|
||||
|
||||
### 3. Create PR Body
|
||||
|
||||
Write to `/tmp/pr-body.md` to avoid shell quoting issues:
|
||||
|
||||
```bash
|
||||
cat > /tmp/pr-body.md << 'EOF'
|
||||
**Note:** This PR was created with AI assistance.
|
||||
|
||||
## What
|
||||
|
||||
<one paragraph: the problem or feature, user-facing impact>
|
||||
|
||||
## Why
|
||||
|
||||
<root cause or motivation, why this change was necessary>
|
||||
|
||||
## How
|
||||
|
||||
<high-level approach, key technical decisions>
|
||||
EOF
|
||||
```
|
||||
|
||||
### 4. Create the PR
|
||||
|
||||
Follow title and description format from `mem:workflow/creating-prs` and `mem:workflow/creating-commits`.
|
||||
|
||||
```bash
|
||||
gh pr create --base main --project "Main" --title "<title>" --body-file /tmp/pr-body.md
|
||||
```
|
||||
|
||||
### 5. What NOT to Include
|
||||
|
||||
- ❌ List of files changed (visible in diff)
|
||||
- ❌ Testing steps (CI handles this)
|
||||
- ❌ Screenshots unless UI-visible
|
||||
- ❌ Migration notes unless breaking changes
|
||||
- ❌ Regression fixes introduced during the PR (they're part of the development process, not the feature)
|
||||
|
||||
## Key Principles
|
||||
|
||||
- **Write for humans.** The diff shows what changed. The description explains why.
|
||||
- **Be concise.** Focus on reasoning: What was the problem? Why did it happen? How did you solve it?
|
||||
- **Skip the obvious.** Don't explain what `git diff` already shows.
|
||||
@ -9,6 +9,7 @@
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.files.changes-builder :as pcb]
|
||||
[app.common.logging :as log]
|
||||
[app.common.time :as ct]
|
||||
[app.main.data.changes :as dch]
|
||||
[app.main.data.event :as ev]
|
||||
@ -57,45 +58,57 @@
|
||||
|
||||
(defn start-plugin!
|
||||
[{:keys [plugin-id name version description host code permissions allow-background]} ^js extensions]
|
||||
(-> (.ɵloadPlugin
|
||||
^js ug/global
|
||||
#js {:pluginId plugin-id
|
||||
:name name
|
||||
:version version
|
||||
:description description
|
||||
:host host
|
||||
:code code
|
||||
:allowBackground (boolean allow-background)
|
||||
:permissions (apply array permissions)}
|
||||
nil
|
||||
extensions)
|
||||
(let [load-plugin (unchecked-get ug/global "ɵloadPlugin")]
|
||||
(if (fn? load-plugin)
|
||||
(-> (load-plugin
|
||||
#js {:pluginId plugin-id
|
||||
:name name
|
||||
:version version
|
||||
:description description
|
||||
:host host
|
||||
:code code
|
||||
:allowBackground (boolean allow-background)
|
||||
:permissions (apply array permissions)}
|
||||
nil
|
||||
extensions)
|
||||
|
||||
(p/catch (fn [cause]
|
||||
(ex/print-throwable cause :prefix "Plugin Error")
|
||||
(errors/flash :cause cause :type :handled)))))
|
||||
(p/catch (fn [cause]
|
||||
(ex/print-throwable cause :prefix "Plugin Error")
|
||||
(errors/flash :cause cause :type :handled))))
|
||||
|
||||
(log/warn :hint "Plugin runtime not initialized yet"
|
||||
:plugin-id plugin-id
|
||||
:action "start-plugin!"))))
|
||||
|
||||
(defn- load-plugin!
|
||||
[{:keys [plugin-id name version description host code icon permissions]}]
|
||||
(st/emit! (pflag/clear plugin-id)
|
||||
(save-current-plugin plugin-id))
|
||||
|
||||
(-> (.ɵloadPlugin
|
||||
^js ug/global
|
||||
#js {:pluginId plugin-id
|
||||
:name name
|
||||
:description description
|
||||
:version version
|
||||
:host host
|
||||
:code code
|
||||
:icon icon
|
||||
:permissions (apply array permissions)}
|
||||
(fn []
|
||||
(st/emit! (remove-current-plugin plugin-id))))
|
||||
(let [load-plugin (unchecked-get ug/global "ɵloadPlugin")]
|
||||
(if (fn? load-plugin)
|
||||
(-> (load-plugin
|
||||
#js {:pluginId plugin-id
|
||||
:name name
|
||||
:description description
|
||||
:version version
|
||||
:host host
|
||||
:code code
|
||||
:icon icon
|
||||
:permissions (apply array permissions)}
|
||||
(fn []
|
||||
(st/emit! (remove-current-plugin plugin-id))))
|
||||
|
||||
(p/catch (fn [cause]
|
||||
(st/emit! (remove-current-plugin plugin-id))
|
||||
(ex/print-throwable cause :prefix "Plugin Error")
|
||||
(errors/flash :cause cause :type :handled)))))
|
||||
(p/catch (fn [cause]
|
||||
(st/emit! (remove-current-plugin plugin-id))
|
||||
(ex/print-throwable cause :prefix "Plugin Error")
|
||||
(errors/flash :cause cause :type :handled))))
|
||||
|
||||
(do
|
||||
(log/warn :hint "Plugin runtime not initialized yet"
|
||||
:plugin-id plugin-id
|
||||
:action "load-plugin!")
|
||||
(st/emit! (remove-current-plugin plugin-id))))))
|
||||
|
||||
(defn open-plugin!
|
||||
[{:keys [url] :as manifest} user-can-edit?]
|
||||
@ -135,10 +148,15 @@
|
||||
|
||||
(defn close-plugin!
|
||||
[{:keys [plugin-id]}]
|
||||
(try
|
||||
(.ɵunloadPlugin ^js ug/global plugin-id)
|
||||
(catch :default e
|
||||
(.error js/console "Error" e))))
|
||||
(let [unload-plugin (unchecked-get ug/global "ɵunloadPlugin")]
|
||||
(if (fn? unload-plugin)
|
||||
(try
|
||||
(unload-plugin plugin-id)
|
||||
(catch :default e
|
||||
(.error js/console "Error" e)))
|
||||
(log/warn :hint "Plugin runtime not initialized yet"
|
||||
:plugin-id plugin-id
|
||||
:action "close-plugin!"))))
|
||||
|
||||
(defn close-current-plugin
|
||||
[& {:keys [close-only-edition-plugins?]}]
|
||||
|
||||
@ -15,7 +15,7 @@
|
||||
[app.main.data.plugins :as dp]
|
||||
[app.main.repo :as rp]
|
||||
[app.main.store :as st]
|
||||
[app.plugins.register :refer [mcp-plugin-id]]
|
||||
[app.plugins.register :as preg]
|
||||
[app.util.i18n :refer [tr]]
|
||||
[app.util.timers :as ts]
|
||||
[beicon.v2.core :as rx]
|
||||
@ -29,7 +29,7 @@
|
||||
{:code "plugin.js"
|
||||
:name "Penpot MCP Plugin"
|
||||
:version 2
|
||||
:plugin-id mcp-plugin-id
|
||||
:plugin-id preg/mcp-plugin-id
|
||||
:description "This plugin enables interaction with the Penpot MCP server"
|
||||
:allow-background true
|
||||
:permissions
|
||||
@ -195,41 +195,45 @@
|
||||
|
||||
(defn init-mcp
|
||||
[stream]
|
||||
(->> (rp/cmd! :get-current-mcp-token)
|
||||
(rx/tap
|
||||
(fn [{:keys [token]}]
|
||||
(when token
|
||||
(dp/start-plugin!
|
||||
(assoc default-manifest
|
||||
:url (str (u/join cf/public-uri "plugins/mcp/manifest.json"))
|
||||
:host (str (u/join cf/public-uri "plugins/mcp/")))
|
||||
;; Wait for plugins runtime to be initialized before starting the MCP plugin.
|
||||
;; This ensures global.ɵloadPlugin is available when start-plugin! is called.
|
||||
(->> (rx/from (preg/wait-for-runtime))
|
||||
(rx/mapcat
|
||||
(fn [_]
|
||||
(->> (rp/cmd! :get-current-mcp-token)
|
||||
(rx/tap
|
||||
(fn [{:keys [token]}]
|
||||
(when token
|
||||
(dp/start-plugin!
|
||||
(assoc default-manifest
|
||||
:url (str (u/join cf/public-uri "plugins/mcp/manifest.json"))
|
||||
:host (str (u/join cf/public-uri "plugins/mcp/")))
|
||||
|
||||
;; API extension for MCP server
|
||||
#js {:mcp
|
||||
#js
|
||||
{:getToken (constantly token)
|
||||
:getServerUrl #(str cf/mcp-ws-uri)
|
||||
:setMcpStatus
|
||||
(fn [status]
|
||||
(when (= status "connected")
|
||||
(start-reconnect-watcher!))
|
||||
(st/emit! (update-mcp-connection-status status))
|
||||
(log/info :hint "MCP STATUS" :status status))
|
||||
;; API extension for MCP server
|
||||
#js {:mcp
|
||||
#js
|
||||
{:getToken (constantly token)
|
||||
:getServerUrl #(str cf/mcp-ws-uri)
|
||||
:setMcpStatus
|
||||
(fn [status]
|
||||
(when (= status "connected")
|
||||
(start-reconnect-watcher!))
|
||||
(st/emit! (update-mcp-connection-status status))
|
||||
(log/info :hint "MCP STATUS" :status status))
|
||||
|
||||
:on
|
||||
(fn [event cb]
|
||||
(when-let [event
|
||||
(case event
|
||||
"disconnect" ::disconnect
|
||||
"connect" ::connect
|
||||
nil)]
|
||||
:on
|
||||
(fn [event cb]
|
||||
(when-let [event
|
||||
(case event
|
||||
"disconnect" ::disconnect
|
||||
"connect" ::connect
|
||||
nil)]
|
||||
|
||||
(let [stopper (rx/filter finalize-workspace? stream)]
|
||||
(->> stream
|
||||
(rx/filter (ptk/type? event))
|
||||
(rx/take-until stopper)
|
||||
(rx/subs! #(cb))))))}}))))
|
||||
(rx/ignore)))
|
||||
(let [stopper (rx/filter finalize-workspace? stream)]
|
||||
(->> stream
|
||||
(rx/filter (ptk/type? event))
|
||||
(rx/take-until stopper)
|
||||
(rx/subs! #(cb))))))}})))))))))
|
||||
|
||||
(defn init
|
||||
[]
|
||||
|
||||
@ -17,14 +17,17 @@
|
||||
[app.plugins.grid :as grid]
|
||||
[app.plugins.library :as library]
|
||||
[app.plugins.public-utils]
|
||||
[app.plugins.register :as preg]
|
||||
[app.plugins.ruler-guides :as rg]
|
||||
[app.plugins.shape :as shape]
|
||||
[beicon.v2.core :as rx]
|
||||
[potok.v2.core :as ptk]))
|
||||
|
||||
(defn init-plugins-runtime!
|
||||
(defn init-plugins-runtime
|
||||
[]
|
||||
(runtime/initPluginsRuntime (fn [plugin-id] (api/create-context plugin-id))))
|
||||
(runtime/initPluginsRuntime (fn [plugin-id] (api/create-context plugin-id)))
|
||||
;; Signal that runtime is ready
|
||||
(preg/signal-runtime-ready))
|
||||
|
||||
(defn initialize
|
||||
[]
|
||||
@ -38,7 +41,7 @@
|
||||
(rx/observe-on :async)
|
||||
(rx/filter #(features/active-feature? @st/state "plugins/runtime"))
|
||||
(rx/take 1)
|
||||
(rx/tap init-plugins-runtime!)
|
||||
(rx/tap init-plugins-runtime)
|
||||
(rx/ignore)))))
|
||||
|
||||
;; Prevent circular dependency
|
||||
|
||||
@ -15,12 +15,28 @@
|
||||
[app.main.repo :as rp]
|
||||
[app.main.store :as st]
|
||||
[app.util.object :as obj]
|
||||
[beicon.v2.core :as rx]))
|
||||
[beicon.v2.core :as rx]
|
||||
[promesa.core :as p]))
|
||||
|
||||
;; Needs to be here because moving it to `app.main.data.workspace.mcp` will
|
||||
;; cause a circular dependency
|
||||
(def mcp-plugin-id "96dfa740-005d-8020-8007-55ede24a2bae")
|
||||
|
||||
;; Promise that resolves when plugins runtime is initialized.
|
||||
;; Lives here to avoid circular dependency: workspace.mcp -> app.plugins -> app.plugins.api -> workspace
|
||||
(defonce ^:private runtime-ready-promise (p/deferred))
|
||||
|
||||
(defn wait-for-runtime
|
||||
"Returns a promise that resolves when plugins runtime is initialized."
|
||||
[]
|
||||
runtime-ready-promise)
|
||||
|
||||
(defn signal-runtime-ready
|
||||
"Signals that plugins runtime has been initialized. Called by app.plugins/init-plugins-runtime."
|
||||
[]
|
||||
(when (p/pending? runtime-ready-promise)
|
||||
(p/resolve! runtime-ready-promise true)))
|
||||
|
||||
;; Stores the installed plugins information
|
||||
(defonce ^:private registry (atom {}))
|
||||
|
||||
|
||||
@ -24,5 +24,5 @@
|
||||
|
||||
(defn ^:export plugins []
|
||||
(st/emit! (features/enable-feature "plugins/runtime"))
|
||||
(plugins/init-plugins-runtime!)
|
||||
(plugins/init-plugins-runtime)
|
||||
nil)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user