裝一個 App 我的 Arcrun 就多一個功能——現在每加一個能力都要改 Portal 程式碼 #82

Open
opened 2026-08-09 13:45:58 +00:00 by Leo · 3 comments
Owner

我希望有一個功能是:裝一個 App,我的 Arcrun 就多一個功能出來——就像 WordPress 裝外掛那樣。

leo 2026-08-09:
「但要變成是一個 Arcrun App,就是含有前端、template、slots 的設置
任何人可以安裝後他的 Arcrun 就跳出筆記功能。」
Mira 的那些功能萃取都要變成可安裝。或說是像 WP Plugin 的功能。
「所以你要設計新功能跟現有的整合,例如 UI 怎麼被加入 Portal?」

這不是一個功能,是一個機制

現在的狀況是:每加一個能力,就要有人去改 Portal 的程式碼
⇒ 能力與界面焊死 ⇒ 沒有人能在不動核心的情況下加東西
⇒ 而 Mira 身上那些能力(筆記、專案管理、萃取…)每一個都要被搬出來Leo/mira#2),
如果沒有這個機制,就是把它們一個一個焊進 Portal——焊三次之後 Portal 就沒人敢動了

一個 App 要能帶什麼(leo 列的三件)

  • 前端——它自己的畫面
  • template——它要存的資料長什麼樣(KBDB 是 seed 一列 template,不加表)
  • slots 設置——它的 UI 掛在 Portal 的哪裡

🔴 要先回答的設計題(leo 直接點名)

「UI 怎麼被加入 Portal?」

這題答不出來,其餘都是空談。要回答的至少有:

  • Portal 上有哪些掛載點(導覽列一項?側欄一區?首頁一張卡?獨立頁面?)
  • 掛載是宣告的還是改程式碼的——只有前者才叫可安裝
  • 兩個 App 想掛同一個位置怎麼辦
  • App 的前端怎麼拿到資料(走既有的 KBDB/cypher API,還是另開一條?——答案應該是前者
  • 裝了之後怎麼移除,移除後資料怎麼辦

第一個實例:手機寫筆記

  • 來源:Mira 現在就有這個功能(Leo/mira#2 的萃取清單第三項「河道寫筆記」)
  • 前置Leo/InkStoneCo#2 —— leo:「這個 inkstoneco 的是那個的前置任務,可以直接提供 mira 版筆記
    短期可以先給 mira 版,讓 leo 手機上先能用
    但終局是它要變成一個裝得起來的 App,不是再焊一塊進 Portal
  • ⇒ 這一項同時是機制的第一個白老鼠:機制設計得對不對,看它能不能承載這個真實案例

怎麼驗

  1. 一個沒改過 Portal 任何一行程式碼的人,把筆記 App 裝上去 → Portal 上出現筆記功能
  2. 移除它 → Portal 回到沒有它的樣子,沒有殘骸
  3. 同一套機制再承載第二個 Mira 萃取出來的能力(不是只為筆記量身打造)
  4. 🔴 用瀏覽器實際看畫面,不是看程式碼在不在

相關

  • Leo/mira#2 —— Mira 現代化與能力萃取(本票是那些能力的落腳形態
  • Leo/InkStoneCo#2 —— 手機寫筆記(第一個實例的短期前置)
  • Leo/Arcrun#81 —— AI 開場的全景圖(同一個方向:能力要能被發現,不論發現者是人還是 AI)
  • ⚠️ Leo/arcrun-rag#40 的教訓:別為了整合而在兩處各維護一份。App 的宣告只能有一個地方。
我希望有一個功能是:**裝一個 App,我的 Arcrun 就多一個功能出來——就像 WordPress 裝外掛那樣。** > leo 2026-08-09: > 「但要變成是一個 **Arcrun App**,就是**含有前端、template、slots 的設置**, > **任何人可以安裝後他的 Arcrun 就跳出筆記功能**。」 > 「**Mira 的那些功能萃取都要變成可安裝。或說是像 WP Plugin 的功能。**」 > 「所以你要**設計新功能跟現有的整合**,例如 **UI 怎麼被加入 Portal**?」 ## 這不是一個功能,是一個機制 現在的狀況是:**每加一個能力,就要有人去改 Portal 的程式碼**。 ⇒ 能力與界面焊死 ⇒ 沒有人能在不動核心的情況下加東西 ⇒ 而 Mira 身上那些能力(筆記、專案管理、萃取…)**每一個都要被搬出來**(`Leo/mira#2`), 如果沒有這個機制,就是把它們一個一個焊進 Portal——**焊三次之後 Portal 就沒人敢動了**。 ## 一個 App 要能帶什麼(leo 列的三件) - **前端**——它自己的畫面 - **template**——它要存的資料長什麼樣(KBDB 是 seed 一列 template,不加表) - **slots 設置**——**它的 UI 掛在 Portal 的哪裡** ## 🔴 要先回答的設計題(leo 直接點名) **「UI 怎麼被加入 Portal?」** 這題答不出來,其餘都是空談。要回答的至少有: - Portal 上有哪些**掛載點**(導覽列一項?側欄一區?首頁一張卡?獨立頁面?) - 掛載是**宣告的**還是**改程式碼的**——只有前者才叫可安裝 - 兩個 App 想掛同一個位置怎麼辦 - App 的前端怎麼拿到資料(走既有的 KBDB/cypher API,還是另開一條?——**答案應該是前者**) - 裝了之後**怎麼移除**,移除後資料怎麼辦 ## 第一個實例:手機寫筆記 - **來源**:Mira 現在就有這個功能(`Leo/mira#2` 的萃取清單第三項「河道寫筆記」) - **前置**:`Leo/InkStoneCo#2` —— leo:「這個 inkstoneco 的是那個的前置任務,**可以直接提供 mira 版筆記**」 ⇒ **短期可以先給 mira 版**,讓 leo 手機上先能用 ⇒ **但終局是它要變成一個裝得起來的 App**,不是再焊一塊進 Portal - ⇒ 這一項同時是**機制的第一個白老鼠**:機制設計得對不對,看它能不能承載這個真實案例 ## 怎麼驗 1. 一個**沒改過 Portal 任何一行程式碼**的人,把筆記 App 裝上去 → Portal 上出現筆記功能 2. 移除它 → Portal 回到沒有它的樣子,**沒有殘骸** 3. 同一套機制**再承載第二個** Mira 萃取出來的能力(不是只為筆記量身打造) 4. 🔴 用瀏覽器實際看畫面,不是看程式碼在不在 ## 相關 - `Leo/mira#2` —— Mira 現代化與能力萃取(本票是那些能力的**落腳形態**) - `Leo/InkStoneCo#2` —— 手機寫筆記(第一個實例的短期前置) - `Leo/Arcrun#81` —— AI 開場的全景圖(同一個方向:**能力要能被發現**,不論發現者是人還是 AI) - ⚠️ `Leo/arcrun-rag#40` 的教訓:**別為了整合而在兩處各維護一份**。App 的宣告只能有一個地方。
Leo added the
p
high
s
doing
labels 2026-08-09 13:46:33 +00:00
Leo added a new dependency 2026-08-09 13:49:31 +00:00
Author
Owner

設計提案:Arcrun App ↔ Portal 掛載協定 v0

本輪只做設計偵察:沒有寫任何功能程式碼、沒有改 Portal、沒有部署、沒有碰 prod。
下面每一條機制結論都附 檔案:行,讓下一個人不用再翻一次源碼。

📌 定位已依 leo 2026-08-09 追加指示調整:
你現在就可以設計 Arcrun App UI 如何跟 Portal 整合的 protocol,等你搞清楚 Mira 的功能時直接改成 App 模式,不要分兩段,不然你開發好了又要一次遷移。」
⇒ 本票原先寫的「短期先給 mira 版筆記、終局再變 App」作廢,不要照那段做。
⇒ 驗收標準是「一個 subagent 拿著這份就能把手機寫筆記做成 App,不必再問一輪」,不是一份漂亮的架構文件。


一、先講結論

  • 今天「每加一個能力都要改 Portal 程式碼」不是懶,是物理上只有這條路。
    • Portal 是一個 2057 行的單檔 HTML,導覽項、頁面容器、路由表全部硬編在裡面(而且同一個事實在檔內寫了四遍)。
    • 它出貨時被整包內嵌成一顆單檔 Worker(bundle 的第 5 顆),檔案表在打包當下凍結,執行期不可能多出一個檔案。
    • 安裝器對它做的事是「整顆換掉」;安裝器裡沒有任何 UI 註冊/掛載點概念mountslotmenutab 在安裝器全樹零命中)。
    • ⇒ 加一個畫面 = 改那個檔 → 重打 bundle → 重出 release → 用戶重裝。這就是「焊死」的實體形式。
  • 所以協定的核心只有一句:Portal 只改一次,加一個泛用 loader;此後每一個 App 都是 0 行 Portal 改動、0 行 cypher 改動。
  • 要新增的東西總共六件(§五),其中真正新的只有「一列 template + 一支泛用端點 + 一個 loader」。
  • 關鍵取捨:App 不是 bundle 的第 6 顆。 App 走安裝後另外裝的路,不進基礎 bundle——進了就等於「要加能力還是得重出核心版本」,病沒治到。

二、現況實查(給下一個人省時間用)

2.1 Portal 前端

  • 真身= matrix/arcrun/console-ui/public/portal/index.html(2057 行,HTML/CSS/JS 全內嵌單檔)
    • 側欄導覽項硬編::253-259
    • 手機底部 tab 硬編::510-516(同一組項目寫第二遍
    • 頁面容器硬編::265:300:312:322:337:346:459
    • 路由表硬編:VIEWS :718HOME :719LOADERS :747
    • 顯示與否:allowedViews() :721-729showApp() :790-799
    • 「有哪些頁」這個事實在這個檔裡被寫了四遍(側欄/tab/VIEWS/allowedViews)。Leo/arcrun-rag#40 說的那個形狀,Portal 裡面已經有了。

2.2 能力旗標從哪來

  • GET /portal/sessionmatrix/arcrun/cypher-executor/src/routes/portal.ts:495-515,回 rolelibrariesgraph_allowedworkflows_visibleupload_enabled
  • 判準寫死在同檔 :371-385workflowsVisible()PORTAL_SHOW_WORKFLOWSuploadEnabled() 讀三個 env 齊不齊)
  • 該檔 :492-494 已經立好規矩,本協定完全沿用:前端旗標只是顯示提示,真閘在路由層

2.3 Portal 怎麼送到用戶手上(決定了「App 前端能不能不改 Portal 就出現」)

  • products/arcrun-rag/installer/scripts/build-ui-bundle.mjs
    • :154 const FILES = ${JSON.stringify(files)} —— 把整個 console-ui/public/ 目錄內嵌成單檔 worker arcrun-rag-ui
    • :216 找不到路徑就 SPA fallback 回 /portal/index.html
    • :172 /__versionui_fingerprint(指紋由內容算,安裝器據此判斷要不要重推)
  • App 前端絕對不能想辦法塞進這顆 worker,塞進去就回到「改 Portal 再出貨」。App 前端必須是執行期才被載入的外部資源

2.4 安裝器現況(現役那一顆,實查)

  • 現役= products/arcrun-rag/installer/oauth-prototype/worker.js(3267 行,worker 名 arcrun-installerinstall.arcrun.dev 就是它)
    ——不是 installer/src/index.js(舊工地主任,已非出貨路徑)
  • 七步定義在 :154-162;主流程 runInstall() :1324;裝完導向 …/portal/:1709:1798
  • 它裝的東西:5 顆 worker(cypher-executor/kbdb/http-request/code/rag-ui)+1 顆 D1+7 個 KV+1 個 Vectorize index+4 條 workflow+8 支 agent-skill+KBDB templates(經 /init/seed
  • 宣告有兩層
    • 版控裡:installer/scripts/bundle-components.mjs:46-79BUNDLE_COMPONENTS(「一個 bundle 該有哪幾顆」的唯一真相源,是 JS 常數不是資料檔
    • 產物端:bundle repo 的 manifest.json不在本 repo 版控裡releasefingerprintsha256release.mjs 依內容算出來)
  • workflow 怎麼被裝進去(雲端路徑,與我原本的假設不同,重要)
    • 打包期 installer/scripts/compile-workflows.mjs:讀取清單硬編:30-37(4 支),並在 :63-127 產生/保全預編圖(flow 變了就必須重編,沒有圖一律 exit 1)
    • 安裝期 worker.js:1212-1253 pushWorkflowTo()直接用預編圖,全程 0 次 /cypher/search(理由 :1218-1223:冷實例編圖 25.7s > 15s timeout),再 POST {cypher}/webhooks/named
    • 雲端安裝路徑不用 acr push;只有本機版 install/install.sh:180-203 才用 acr push
    • App 自帶的 workflow 也必須自帶預編圖,否則冷實例裝 App 會 timeout。這是照抄既有教訓,不是新規矩。
  • 安裝器裡沒有任何 UI 掛載概念mountslotmenutab 全樹零命中;navcard 的命中全是安裝器自己那幾頁的 HTML
  • schemas/ 裡沒有「可安裝單位」的 schema(只有 collector-trigger 與尚未實作的 kbdb-ingest 兩份)。現有「可安裝單位」的粒度是 worker,沒有任何欄位描述「這個單位會在 UI 露出什麼」。

2.5 後端也已經焊過一次(反面教材)

  • cypher-executor/src/routes/portal-data.ts:487-528POST /portal/data/upload
  • 這是「上傳」這個單一能力專屬的端點,開關靠三個 env;Gitea token 留在 server 側(:507)——安全形狀是對的,但它只服務一個能力
  • ⇒ 手機寫筆記若照這個做,就是第二根焊點。本協定要做的是把這個形狀泛用化一次

2.6 工作流的觸發權在誰手上

  • cypher-executor/src/routes/webhooks-named.ts:260-272/webhooks/named/:name/trigger 需要 X-Arcrun-API-Key(實例擁有者金鑰)
  • Portal 端刻意不開觸發:portal/index.html:340不能觸發執行(觸發屬系統擁有者權限)」;portal-data.ts:533-585read_only: true 且不回 webhook_url
  • ⇒ App 前端不可能自己 trigger workflow(拿不到那把金鑰,也不該拿到)⇒ 必須 server 代打(§3.6 存在的理由)

2.7 KBDB 這一層現成的路

  • template 種子宣告:cypher-executor/src/lib/portal-seeds.ts:21PORTAL_TEMPLATE_SEEDS
  • 冪等 ensure:portal.ts:99 ensurePortalTemplates(),由 /init/seedroutes/init-seed.ts:95)與 /portal/admin/bootstrapportal.ts:549)呼叫
  • API:routes/kbdb-proxy.ts:49POST /kbdb/templates)、:85POST /kbdb/records
  • 庫目錄 portal_libraryportal-seeds.ts:36;刪庫 portal.ts:1264
  • 「新資料類型 = seed 一列 template,永不加表」這條路已鋪好,App 直接走它。

2.8 今天沒有任何 App/plugin 概念

  • matrix/arcrun/cli/src/commands/ 十八支指令裡沒有 app/plugin
  • 最接近的既有市場模型是 recipe 的 UUID/author/market-stat(matrix/arcrun/system-dev/wiki/cards/decisions/Recipe-UUID市場模型.md),那是零件層的市場,不含前端與掛載

三、協定 v0

3.1 一個 Arcrun App = 四件東西

  • 宣告 —— 它叫什麼、掛在哪、開放哪些動作
  • 前端 —— 一支 ES module,放在一個版本釘死的公開 URL
  • 資料型別 —— 要 ensure 哪些 KBDB template(零新表)/要不要一個庫
  • 工作流 —— 它自帶的 named workflow,含預編圖

3.2 宣告只有一份(#40 那條教訓的落點)

  • 作者手寫的唯一一份= App 原始碼根目錄的 arcrun-app.yaml
id: notes                       # [a-z0-9-]{2,32};同時是 record 主鍵與路由前綴 #/app/notes
name: 筆記
version: 1.0.0
icon: "✍️"
description: 手機隨手寫,寫完進知識庫

ui:
  # 版本釘死的公開 URL(見 §3.5「前端放哪」)。整個協定裡唯一一個對外位址。
  url: https://cdn.jsdelivr.net/gh/<org>/arcrun-app-notes@<commit-sha>/dist/app.js
  mount:
    - slot: nav                 # v0 只支援 nav
      label: 筆記
      order: 15
      visible_to: [user, admin] # 省略=所有登入者

data:
  templates:                    # 安裝時 ensure 進 KBDB;零新表
    - name: note
      description: 手機隨手筆記
      slots: [title, body, created_at, library, source]
  library: notes                # 要登記進 portal_library 的庫;不需要就省略

actions:                        # 前端唯一能觸發的後端動作,白名單制
  - name: save
    workflow: notes_save

workflows:                      # 由 `acr app build` 編成含預編圖的 JSON(§3.8)
  - workflows/notes_save.yaml

remove:
  keeps_data: true              # 移除只拿掉 App,不動 KBDB 內容
  • 安裝後機器上的那一份=一筆 KBDB record,template arcrun_app新 seed 一列,不加表),slots:
    • app_id / name / version / icon / ui_url / mount_json / actions_json / source_hash / status / installed_at
  • 這兩份為什麼不算「同一個事實兩份」
    • arcrun-app.yaml作者原稿;KBDB record = 安裝器產生的安裝態。人只維護前者。
    • source_hash(yaml 內容 sha256)綁住:yaml 變了 ⇒ hash 不同 ⇒ 判 stale ⇒ 重裝。
    • 這招不是新發明,是照抄 build-ui-bundle.mjs:150uiFingerprint:172/__version,以及 release.mjs 的「版本由內容算出來,不靠任何人記得改」。
    • 🔴 禁止第三份:Portal 不得硬編任何 App 清單;bundle-components.mjs 不得加 App;安裝器不得另存一份 app 列表。Portal 唯一的來源是 /portal/session 回的 apps(§3.5)。

3.3 掛載點(slot)——只列真的存在的

  • nav(v0 唯一開放)
    • 落點=側欄 #sidenavportal/index.html:251-260)+ 手機底部 #tabbar:510-517
    • 這兩處邏輯上是同一個槽,只是渲染兩次;App 宣告一次,Portal 兩處都長出來
    • 配一個獨立頁:#/app/<id>,容器 <div class="view page" id="v-app-<id>">(Portal 動態建立)
  • settings_panel(之後)——落點=設定頁的 .panel 卡列(:365-455
  • search_mode(之後)——落點=搜尋頁的 .modebtn:273-276
  • home_card(不存在,別規劃)——Portal 目前沒有首頁HOME 就是搜尋頁(:719)。要開這個槽得先有首頁。
  • v0 只開 nav 就足以承載手機寫筆記,而且它是列表型、最不會撞。

3.4 兩個 App 想掛同一個位置怎麼辦

  • nav有序列表不是單一位置 ⇒ 兩個 App 都掛 nav 沒有衝突
    • 排序= order 升冪,同 order 用 app_id 字典序(穩定、可重現)
    • App 一律排在內建項之後,內建項順序不受 App 影響
  • 真正唯一會撞的是 id(record 主鍵+路由前綴)
    • id 不同來源 ⇒ 拒裝,要求改名
    • id 同來源(source_hash 不同)⇒ 視為更新,覆寫該筆 record
  • App 不得宣告自己是落地頁(HOME)——那是實例擁有者在設定頁選的。(這條我先裁了:第一天就開 HOME 宣告,第二個 App 進來必然變成搶位戰。)

3.5 UI 怎麼被加入 Portal(leo 點名那題的正面回答)

Portal 改一次,此後 0 行。 那一次要改什麼,具體到行:

  • /portal/session 多回一格 appscypher-executor/src/routes/portal.ts:495-515 的 response 物件加一個 key)
    • 內容= KBDB arcrun_appstatus='enabled' 的 records,依 visible_to × 登入者 role 過濾
    • 形狀:[{ app_id, name, icon, version, ui_url, mount:[{slot,label,order}] }]
    • 與既有旗標同構:只是顯示提示,真閘在路由層(§3.6 端點自己會再查一次 record)
  • portal/index.html 五個小改
    • VIEWS:718)→ BUILTIN_VIEWS.concat(apps.map(a => 'app-' + a.app_id))
    • allowedViews():721-729)→ 多一條泛用分支:app-* 一律可見(能不能看已在 server 過濾)
    • showApp():790-799)→ 依 apps 動態生成側欄 div.nav、tab div.tab、空的 <div class="view page" id="v-app-<id>">
    • LOADERS:747)→ 多一條泛用分支:app-<id>mountApp(app)
    • 新增 mountApp(app)import(app.ui_url).then(m => m.mount(container, host));切走時 m.unmount?.(container)
    • 估計約 60 行,而且是最後一次為了「多一個能力」而改這個檔

App 前端要實作的就這一個介面(ES module):

// app.js —— App 前端入口
export function mount(el, host) { /* el = Portal 給的空容器 */ }
export function unmount(el) { /* 選配:切走時清乾淨 */ }

host 是 Portal 交給 App 的唯一通道;App 拿不到 session token 本身

host = {
  app:    { id, name, version },
  user:   { display_name, role, libraries },   // 同 /portal/session,**不含租戶字串**
  search: (q, opts)       => Promise,          // 代打 GET /portal/data/search
  entry:  (id)            => Promise,          // 代打 GET /portal/data/entries/:id
  action: (name, payload) => Promise,          // 代打 POST /portal/apps/<id>/actions/<name>
  toast:  (msg)           => void,
  theme:  ()              => 'light'|'dark',
  nav:    (subpath)       => void,             // 導到 #/app/<id>/<subpath>
}
  • App 不自己 fetch、不碰 localStorage、不知道 apiBase。 所有資料進出都經過既有的 /portal/data/*(server 側 owner_id + library enforce 已經在 portal-data.ts 做好了)。
    這正面回答 leo 那題:走既有的 KBDB/cypher API,不另開一條。

前端放哪(實體)

  • 預設= App 自己的 bundle repo,用 commit sha 釘死的 CDN URL
    • 這不是新發明:基礎 bundle 本來就這樣送(installer/oauth-prototype/worker.js:86BUNDLE_BASEcdn.jsdelivr.net/gh/<org>/arcrun-rag-bundles@<sha>
    • 釘 commit sha ⇒ 內容不可變,等同完整性保證(ES module import() 不支援 SRI,所以靠不可變 URL)
    • jsDelivr 預設回 Access-Control-Allow-Origin: *import() 直接可用
    • 裝 App 完全不需要部署任何 worker、不需要用戶的 Cloudflare 憑證
  • 替代= App 自架一顆 worker(想完全自持、或地端無外網時)
    • 只要那顆 worker 回 CORS 放行 portal origin,並提供 /__version(照抄 build-ui-bundle.mjs:172
    • 宣告完全不變,ui.url 換成那顆 worker 的位址而已
  • ⚠️ 地端/離線實例與基礎 bundle 面對同一個 CDN 依賴問題,用同一種解(鏡像 base),本協定不另立一套。

為什麼是 ES module 不是 iframe(我先裁了,但這是單向門,見 §七):

  • Portal 要在手機上用(PWA 方向),iframe 的鍵盤、滾動、返回鍵、主題切換都要另外搭一套
  • module 直接吃 Portal 既有的 CSS 變數與版面,App 作者不用重畫一套殼
  • 代價:App 的 JS 跑在 Portal 的 origin,權限等同 Portal 自己
  • host 合約刻意設計成傳輸無關 ⇒ 哪天要換成 iframe + postMessage,App 那一側一行都不用改

3.6 寫入怎麼做:一支泛用端點,不是每個 App 一支

POST /portal/apps/:app_id/actions/:action
  • 認證= portal session bearer,沿用 requirePortalUser()(與 /portal/data/* 同一套)
  • 流程
    • arcrun_app record → App 存在且 status='enabled'?否則 404
    • action 在該 record 的 actions_json 白名單裡?否則 403
    • 取對應 workflow 名 → server 側用實例自己的 tenant key 打既有的 /webhooks/named/:name/triggerwebhooks-named.ts:260
    • 注入 actor(display_namerole)與 owner_id,回工作流結果
  • 為什麼一定要 server 代打:那支 trigger 要 X-Arcrun-API-Keywebhooks-named.ts:261),是實例擁有者的金鑰,portal 用戶絕不能拿到——同 portal.ts:475「絕不回租戶字串」那條
  • 形狀與既有 upload 端點一致(token 留 server 側,portal-data.ts:507),差別是這支是泛用的,不會為第 N 個能力再長第 N 支端點
  • 附帶收穫:既有 /portal/data/upload 之後可收編成 apps/uploader/actions/upload——表示這個抽象沒有為筆記量身訂做

3.7 資料層(守鐵律)

  • App 的資料型別 = ensure 一列 KBDB template,走既有冪等路徑(portal.ts:99)→ POST /kbdb/templateskbdb-proxy.ts:49
  • 差別只在:不是硬編進 portal-seeds.ts,而是安裝時從 App 宣告帶進來
  • App 若要自己的庫 → 登記進既有 portal_libraryportal-seeds.ts:36),照既有庫權限走,不另立權限模型
  • 🔴 全程 API-as-Wall,零 SQL、永不加表

3.8 安裝與移除

  • 誰在裝:v0 = 實例擁有者(admin),不是每個 portal 同仁自己裝
    • 理由:寫 arcrun_app record 與 ensure template 都需要 tenant 權限;而且「裝什麼」是實例層的決定
    • leo 說的「任何人可以安裝」= 任何擁有自己 Arcrun 的人都能裝,不需要我們改核心 —— 這個協定滿足的是這一句
  • acr app build(作者端,出貨前跑一次)
    • arcrun-app.yaml,把 workflows[] 編成含預編圖的 JSON——直接沿用 installer/scripts/compile-workflows.mjs:63-127 的保全閘邏輯(flow 變了必須重編,沒圖就 exit 1)
    • 產出 dist/app.js(前端)+ dist/app.json(宣告+預編工作流),推上 App 自己的 bundle repo,拿到 commit sha
    • ⇒ 為什麼一定要預編:worker.js:1218-1223 的實測——冷實例即時編圖 25.7s > 15s timeout。裝 App 會踩同一顆地雷。
  • acr app install <url|path>(實例端)
    • app.json、算 source_hash
    • ensure templates / 登記 library(既有 API)
    • 逐條推 workflow:POST {cypher}/webhooks/named 帶預編圖(形狀同 worker.js:1212-1253,0 次 /cypher/search
    • arcrun_app record(status=enabled
    • ⇒ 前三步全是既有能力的組合,真正新的只有最後一步全程不部署任何 worker、不需要 CF 憑證
  • acr app remove <id>
    • record → status='removed'(或刪 record)⇒ 下一次 /portal/session 就不再回它 ⇒ Portal 上乾淨消失,沒有殘骸
    • 撤它的 workflows(既有 /webhooks/named 刪除路徑)
    • 資料留著(筆記是用戶的知識,不是 App 的財產)
    • 要一起清 = --purge,走既有 DELETE /portal/admin/libraries/by-name/:nameportal.ts:1264
    • template 不刪——KBDB template 是全域 schema,刪了會讓既有資料孤兒化。這條要寫進文件,否則會有人以為沒清乾淨。
  • 🔴 App 不進 bundle-components.mjs。那份清單是「基礎 bundle 該有哪幾顆 worker」的真相源(installer/scripts/bundle-components.mjs:46-79),ship.mjs:625 會機械核對它與 manifest 名字集合恰好相等。把 App 加進去 = 每加一個能力就要重出核心版本 = 病原封不動搬家。

四、拿「手機寫筆記」走一遍(檢驗抽象夠不夠用)

  • leo 的原始需求Leo/mira#2 工作項目第三項「手機寫筆記:河道寫筆記」;細節在 Leo/InkStoneCo#2,本機留底 system-dev/docs/issues-log/Leo-InkStoneCo-2.md
    • 「手機開啟即可直接在 Arcrun 書寫,支援 MD 即可,寫完直接萃並 ingest 到 KBDB」
    • 「剛開始只需要河道,每天的筆記一直寫下去,新的在上,用小日曆切換」
  • 它現在長什麼樣(實查)
    • 唯一實作是舊 Mira 的河道頁:matrix/arcrun/landing/app/mira/feed/page.tsx(2542 行 Next.js client component)
    • 輸入= Composer 的 <textarea>:560-566),送出 submit():567-610
    • 寫入= 前端直打 POST https://kbdb-create-block.arcrun.dev:576-590),body {type:"note", user_id:"inkstone_mira_post", source:"km-writer-direct", …}
    • 讀回河道= GET https://kbdb.finally.click/blocks/documents?limit=50:109:135
    • 寫完萃取= fire-and-forget 打 wiki_synthesis:430)與 project_detector:454
    • 後端掃描器= polaris/mira/arcrun/mira_feed_watcher.yaml:29-31每 5 分鐘 cron)撈 source=km-writer-direct:34-40
    • 現況= 已停用且明文不恢復system-dev/docs/3-specs/mira-dissolve/requirements.md:100-102polaris/mira/CLAUDE.md:3 角色已蒸發)。四個硬編端點全是舊世代 URL。
  • 照本協定,它變成什麼
    • arcrun-app.yaml = §3.2 那份範例,一字不改
    • 前端 = 一支 app.jsmount(el, host) 裡畫 composer + 河道時間軸 + 小日曆
    • 寫 = host.action('save', { title, body })POST /portal/apps/notes/actions/save → server 代打 notes_save workflow → 走既有 KBDB API 寫一筆 note record 並掛 library
    • 讀 = host.search() / host.entry()(既有 /portal/data/*,server 側已 enforce owner_id + library)
    • 萃取 = notes_save workflow 末端接既有萃取鏈(不需要 cron 掃描器,寫入當下就觸發)
    • 掛載 = slot: nav,Portal 上多一個「筆記」;手機底部 tab 同步多一個
  • 舊實作的四個病,這個協定各解掉一個
    • 硬編四個舊世代 KBDB URL ⇒ App 不知道 apiBase,只認 host
    • 前端直寫 KBDB、繞過權限 ⇒ 一律走 /portal/data/* 與 action 端點,server 側 enforce
    • 靠 5 分鐘 cron 掃「有沒有新筆記」 ⇒ 寫入即觸發
    • 它是一個獨立 Next.js 站、跟 Portal 兩個世界 ⇒ 現在長在 Portal 裡,共用登入、主題、手機殼

五、要新增的東西(總清單)

  • 一列 template seed —— arcrun_app,加進 cypher-executor/src/lib/portal-seeds.ts:21PORTAL_TEMPLATE_SEEDS(零新表)
  • /portal/session 多回 apps —— routes/portal.ts:495-515,唯讀彙整
  • 一支泛用端點 —— POST /portal/apps/:id/actions/:name
  • Portal 的泛用 loader —— console-ui/public/portal/index.html,約 60 行,一次性
  • acr app build|install|remove|list —— cli/src/commands/app.tsbuild 直接複用 compile-workflows.mjs 的預編圖保全閘
  • App 樣板 + arcrun-app.yaml 的 JSON Schema —— 目前 repo 裡沒有任何描述「可安裝單位」的 schema,這會是第一份(放 matrix/arcrun/schemas/arcrun-app.v1.schema.json,形狀比照 products/arcrun-rag/schemas/collector-trigger.v1.schema.json 的凍結版本慣例)
  • ⇒ 之後每一個 App 都是 0 行 Portal 改動、0 行 cypher 改動、0 顆 worker 部署

六、對照票上三條判準的自檢

  • ① 沒改過 Portal 一行的人能不能裝上去讓它出現?
    • 能。acr app install 寫一筆 record ⇒ 下一次 /portal/session 就回它 ⇒ Portal 動態長出 nav + 頁面 ⇒ 進頁時才 import() 它的前端。
    • ⚠️ 前提是那個一次性 loader 已經在線上的 Portal 裡。在它出貨之前,任何 App 都裝不起來——這是本協定唯一的「必須先做的事」,也是唯一一次要動 arcrun-rag-ui 出貨流程。
  • ② 同一套機制能不能承載第二個能力?
    • 能,而且反向驗證得到:既有的「上傳」能力可以原樣重寫成 apps/uploaderslot: nav + 一個 upload action),不需要對協定做任何加工。
    • Leo/mira#2 另兩項也落在同一形狀:專案管理 = slot: nav + 讀 /portal/data/search + 幾個 action;RAG 萃取鏈是純後端 workflow,本來就不需要掛 UI。
  • ③ 有沒有製造「同一個事實兩份」?
    • 宣告只有一份(arcrun-app.yaml),安裝態由它算出來、用 source_hash 綁住,沒有人手動維護第二份
    • 明文禁止把 App 加進 bundle-components.mjs(那是第二份清單的入口)。
    • 順帶減少既有重複:Portal 現在把「有哪些頁」寫了四遍(側欄/tab/VIEWS/allowedViews),loader 改完之後,App 那部分只有一個來源。

七、需要 leo 拍板的(只有兩題,其餘我自己裁了)

  • ① App 前端跑在 Portal 的 origin 裡(ES module),還是關進 iframe?
    • 我先裁 module,理由見 §3.5(手機體驗、共用主題與殼)
    • 這是單向門的邊:只要 App 開放給第三方投稿,就必須改成 iframe + postMessage
    • host 合約刻意設計成傳輸無關 ⇒ 那天改的時候 App 那一側零改動
    • 要你確認的只有一句:這批 App 短期內都是我們自己寫的,對嗎?
      是 ⇒ 照 module 做;不是 ⇒ 我改設計成 iframe(多花一輪,手機體驗會差一點)
  • ② v0 只開 nav 一個掛載點,夠不夠?
    • 我裁的是「夠」——承載得了手機寫筆記,而且列表型不會撞
    • settings_panelsearch_mode 先不開(開了就要處理排序與撞位,而現在沒有需求逼它)
    • 這是取捨題不是技術題,所以列給你。回「夠」我就照 v0 往下做。

八、還缺什麼(誠實標明)

  • Leo/arcrun-rag#40 的 18 處清單我沒拿到原文——本機 system-dev/docs/issues-log/ 沒有它的留底。我照票上給的原則辦(宣告只能有一個地方),沒有逐項核對那 18 處各是什麼。
  • acr app install 的認證形式還沒定死——acr 現在打的是 cypher(X-Arcrun-API-Key),這條夠用;但「要不要同時在 Portal 管理頁做一個裝/移除的畫面」我還沒設計(v0 可以只有 CLI)。
  • 地端/離線實例的 App 前端來源——與基礎 bundle 是同一個 CDN 依賴問題,我主張用同一種解(鏡像 base),但沒有實查現在地端是怎麼鏡像 bundle 的。
  • 流程面:本協定屬規格層變更,依 D35 走 pending-changes.md 提案、等 leo 明說 confirm 才開新 SDD。
    arcrun 現行 active SDD = matrix/arcrun/system-dev/docs/3-specs/workflow-discovery/(frontmatter 實查)。
    沒有自行開 SDD,也沒有動任何功能程式碼。
## 設計提案:Arcrun App ↔ Portal 掛載協定 v0 > 本輪只做設計偵察:**沒有寫任何功能程式碼、沒有改 Portal、沒有部署、沒有碰 prod。** > 下面每一條機制結論都附 `檔案:行`,讓下一個人不用再翻一次源碼。 > > 📌 定位已依 leo 2026-08-09 追加指示調整: > 「**你現在就可以設計 Arcrun App UI 如何跟 Portal 整合的 protocol**,等你搞清楚 Mira 的功能時**直接改成 App 模式,不要分兩段**,不然你開發好了又要一次遷移。」 > ⇒ 本票原先寫的「短期先給 mira 版筆記、終局再變 App」**作廢**,不要照那段做。 > ⇒ 驗收標準是「**一個 subagent 拿著這份就能把手機寫筆記做成 App,不必再問一輪**」,不是一份漂亮的架構文件。 --- ### 一、先講結論 - **今天「每加一個能力都要改 Portal 程式碼」不是懶,是物理上只有這條路。** - Portal 是**一個 2057 行的單檔 HTML**,導覽項、頁面容器、路由表全部硬編在裡面(而且同一個事實在檔內寫了四遍)。 - 它出貨時被**整包內嵌成一顆單檔 Worker**(bundle 的第 5 顆),檔案表在打包當下凍結,執行期不可能多出一個檔案。 - 安裝器對它做的事是「**整顆換掉**」;安裝器裡**沒有任何 UI 註冊/掛載點概念**(`mount`/`slot`/`menu`/`tab` 在安裝器全樹**零命中**)。 - ⇒ 加一個畫面 = 改那個檔 → 重打 bundle → 重出 release → 用戶重裝。**這就是「焊死」的實體形式。** - **所以協定的核心只有一句:Portal 只改一次,加一個泛用 loader;此後每一個 App 都是 0 行 Portal 改動、0 行 cypher 改動。** - **要新增的東西總共六件**(§五),其中真正新的只有「一列 template + 一支泛用端點 + 一個 loader」。 - **關鍵取捨:App 不是 bundle 的第 6 顆。** App 走**安裝後另外裝**的路,不進基礎 bundle——進了就等於「要加能力還是得重出核心版本」,病沒治到。 --- ### 二、現況實查(給下一個人省時間用) #### 2.1 Portal 前端 - 真身= `matrix/arcrun/console-ui/public/portal/index.html`(2057 行,HTML/CSS/JS 全內嵌單檔) - 側欄導覽項硬編:`:253-259` - 手機底部 tab 硬編:`:510-516`(同一組項目**寫第二遍**) - 頁面容器硬編:`:265`/`:300`/`:312`/`:322`/`:337`/`:346`/`:459` - 路由表硬編:`VIEWS` `:718`、`HOME` `:719`、`LOADERS` `:747` - 顯示與否:`allowedViews()` `:721-729`、`showApp()` `:790-799` - ⇒ **「有哪些頁」這個事實在這個檔裡被寫了四遍**(側欄/tab/VIEWS/allowedViews)。`Leo/arcrun-rag#40` 說的那個形狀,Portal 裡面已經有了。 #### 2.2 能力旗標從哪來 - `GET /portal/session` = `matrix/arcrun/cypher-executor/src/routes/portal.ts:495-515`,回 `role`/`libraries`/`graph_allowed`/`workflows_visible`/`upload_enabled` - 判準寫死在同檔 `:371-385`(`workflowsVisible()` 讀 `PORTAL_SHOW_WORKFLOWS`;`uploadEnabled()` 讀三個 env 齊不齊) - 該檔 `:492-494` 已經立好規矩,本協定完全沿用:**前端旗標只是顯示提示,真閘在路由層** #### 2.3 Portal 怎麼送到用戶手上(決定了「App 前端能不能不改 Portal 就出現」) - `products/arcrun-rag/installer/scripts/build-ui-bundle.mjs` - `:154` `const FILES = ${JSON.stringify(files)}` —— 把整個 `console-ui/public/` 目錄內嵌成單檔 worker `arcrun-rag-ui` - `:216` 找不到路徑就 SPA fallback 回 `/portal/index.html` - `:172` `/__version` 回 `ui_fingerprint`(指紋由內容算,安裝器據此判斷要不要重推) - ⇒ **App 前端絕對不能想辦法塞進這顆 worker**,塞進去就回到「改 Portal 再出貨」。App 前端必須是**執行期才被載入的外部資源**。 #### 2.4 安裝器現況(現役那一顆,實查) - 現役= `products/arcrun-rag/installer/oauth-prototype/worker.js`(3267 行,worker 名 `arcrun-installer`,`install.arcrun.dev` 就是它) ——**不是** `installer/src/index.js`(舊工地主任,已非出貨路徑) - 七步定義在 `:154-162`;主流程 `runInstall()` `:1324`;裝完導向 `…/portal/`(`:1709`、`:1798`) - 它裝的東西:**5 顆 worker**(cypher-executor/kbdb/http-request/code/**rag-ui**)+1 顆 D1+7 個 KV+1 個 Vectorize index+**4 條 workflow**+8 支 agent-skill+KBDB templates(經 `/init/seed`) - **宣告有兩層** - 版控裡:`installer/scripts/bundle-components.mjs:46-79` 的 `BUNDLE_COMPONENTS`(「一個 bundle 該有哪幾顆」的唯一真相源,是 **JS 常數不是資料檔**) - 產物端:bundle repo 的 `manifest.json`(**不在本 repo 版控裡**;`release`/`fingerprint`/`sha256` 由 `release.mjs` 依內容算出來) - **workflow 怎麼被裝進去(雲端路徑,與我原本的假設不同,重要)** - 打包期 `installer/scripts/compile-workflows.mjs`:讀取清單**硬編**在 `:30-37`(4 支),並在 `:63-127` 產生/保全**預編圖**(flow 變了就必須重編,沒有圖一律 exit 1) - 安裝期 `worker.js:1212-1253` `pushWorkflowTo()`:**直接用預編圖**,全程 0 次 `/cypher/search`(理由 `:1218-1223`:冷實例編圖 25.7s > 15s timeout),再 `POST {cypher}/webhooks/named` - ⇒ **雲端安裝路徑不用 `acr push`**;只有本機版 `install/install.sh:180-203` 才用 `acr push` - ⇒ **App 自帶的 workflow 也必須自帶預編圖**,否則冷實例裝 App 會 timeout。這是照抄既有教訓,不是新規矩。 - **安裝器裡沒有任何 UI 掛載概念**:`mount`/`slot`/`menu`/`tab` 全樹零命中;`nav`/`card` 的命中全是安裝器自己那幾頁的 HTML - **`schemas/` 裡沒有「可安裝單位」的 schema**(只有 collector-trigger 與尚未實作的 kbdb-ingest 兩份)。現有「可安裝單位」的粒度是 **worker**,沒有任何欄位描述「這個單位會在 UI 露出什麼」。 #### 2.5 後端也已經焊過一次(反面教材) - `cypher-executor/src/routes/portal-data.ts:487-528` 的 `POST /portal/data/upload` - 這是「上傳」這個**單一能力專屬**的端點,開關靠三個 env;Gitea token 留在 server 側(`:507`)——**安全形狀是對的,但它只服務一個能力** - ⇒ 手機寫筆記若照這個做,就是第二根焊點。本協定要做的是把這個形狀**泛用化一次**。 #### 2.6 工作流的觸發權在誰手上 - `cypher-executor/src/routes/webhooks-named.ts:260-272`:`/webhooks/named/:name/trigger` 需要 `X-Arcrun-API-Key`(實例擁有者金鑰) - Portal 端刻意不開觸發:`portal/index.html:340`「**不能觸發執行**(觸發屬系統擁有者權限)」;`portal-data.ts:533-585` 回 `read_only: true` 且不回 webhook_url - ⇒ App 前端**不可能**自己 trigger workflow(拿不到那把金鑰,也不該拿到)⇒ 必須 server 代打(§3.6 存在的理由) #### 2.7 KBDB 這一層現成的路 - template 種子宣告:`cypher-executor/src/lib/portal-seeds.ts:21`(`PORTAL_TEMPLATE_SEEDS`) - 冪等 ensure:`portal.ts:99` `ensurePortalTemplates()`,由 `/init/seed`(`routes/init-seed.ts:95`)與 `/portal/admin/bootstrap`(`portal.ts:549`)呼叫 - API:`routes/kbdb-proxy.ts:49`(`POST /kbdb/templates`)、`:85`(`POST /kbdb/records`) - 庫目錄 `portal_library`:`portal-seeds.ts:36`;刪庫 `portal.ts:1264` - ⇒ **「新資料類型 = seed 一列 template,永不加表」這條路已鋪好,App 直接走它。** #### 2.8 今天沒有任何 App/plugin 概念 - `matrix/arcrun/cli/src/commands/` 十八支指令裡沒有 app/plugin - 最接近的既有市場模型是 recipe 的 UUID/author/market-stat(`matrix/arcrun/system-dev/wiki/cards/decisions/Recipe-UUID市場模型.md`),那是**零件層**的市場,不含前端與掛載 --- ### 三、協定 v0 #### 3.1 一個 Arcrun App = 四件東西 - **宣告** —— 它叫什麼、掛在哪、開放哪些動作 - **前端** —— 一支 ES module,放在一個**版本釘死的公開 URL** - **資料型別** —— 要 ensure 哪些 KBDB template(零新表)/要不要一個庫 - **工作流** —— 它自帶的 named workflow,**含預編圖** #### 3.2 宣告只有一份(`#40` 那條教訓的落點) - **作者手寫的唯一一份**= App 原始碼根目錄的 `arcrun-app.yaml`: ```yaml id: notes # [a-z0-9-]{2,32};同時是 record 主鍵與路由前綴 #/app/notes name: 筆記 version: 1.0.0 icon: "✍️" description: 手機隨手寫,寫完進知識庫 ui: # 版本釘死的公開 URL(見 §3.5「前端放哪」)。整個協定裡唯一一個對外位址。 url: https://cdn.jsdelivr.net/gh/<org>/arcrun-app-notes@<commit-sha>/dist/app.js mount: - slot: nav # v0 只支援 nav label: 筆記 order: 15 visible_to: [user, admin] # 省略=所有登入者 data: templates: # 安裝時 ensure 進 KBDB;零新表 - name: note description: 手機隨手筆記 slots: [title, body, created_at, library, source] library: notes # 要登記進 portal_library 的庫;不需要就省略 actions: # 前端唯一能觸發的後端動作,白名單制 - name: save workflow: notes_save workflows: # 由 `acr app build` 編成含預編圖的 JSON(§3.8) - workflows/notes_save.yaml remove: keeps_data: true # 移除只拿掉 App,不動 KBDB 內容 ``` - **安裝後機器上的那一份**=一筆 KBDB record,template `arcrun_app`(**新 seed 一列,不加表**),slots: - `app_id` / `name` / `version` / `icon` / `ui_url` / `mount_json` / `actions_json` / `source_hash` / `status` / `installed_at` - **這兩份為什麼不算「同一個事實兩份」** - `arcrun-app.yaml` = **作者原稿**;KBDB record = **安裝器產生的安裝態**。人只維護前者。 - 用 `source_hash`(yaml 內容 sha256)綁住:yaml 變了 ⇒ hash 不同 ⇒ 判 stale ⇒ 重裝。 - 這招不是新發明,是照抄 `build-ui-bundle.mjs:150` 的 `uiFingerprint` + `:172` 的 `/__version`,以及 `release.mjs` 的「**版本由內容算出來,不靠任何人記得改**」。 - 🔴 **禁止第三份**:Portal 不得硬編任何 App 清單;`bundle-components.mjs` 不得加 App;安裝器不得另存一份 app 列表。Portal 唯一的來源是 `/portal/session` 回的 `apps`(§3.5)。 #### 3.3 掛載點(slot)——只列真的存在的 - **`nav`(v0 唯一開放)** - 落點=側欄 `#sidenav`(`portal/index.html:251-260`)+ 手機底部 `#tabbar`(`:510-517`) - 這兩處**邏輯上是同一個槽**,只是渲染兩次;App 宣告一次,Portal 兩處都長出來 - 配一個獨立頁:`#/app/<id>`,容器 `<div class="view page" id="v-app-<id>">`(Portal 動態建立) - **`settings_panel`(之後)**——落點=設定頁的 `.panel` 卡列(`:365-455`) - **`search_mode`(之後)**——落點=搜尋頁的 `.modebtn`(`:273-276`) - **`home_card`(不存在,別規劃)**——Portal 目前**沒有首頁**,`HOME` 就是搜尋頁(`:719`)。要開這個槽得先有首頁。 - ⇒ **v0 只開 `nav` 就足以承載手機寫筆記**,而且它是列表型、最不會撞。 #### 3.4 兩個 App 想掛同一個位置怎麼辦 - `nav` 是**有序列表不是單一位置** ⇒ 兩個 App 都掛 nav 沒有衝突 - 排序= `order` 升冪,同 order 用 `app_id` 字典序(穩定、可重現) - App 一律排在內建項之後,**內建項順序不受 App 影響** - **真正唯一會撞的是 `id`**(record 主鍵+路由前綴) - 同 `id` 不同來源 ⇒ **拒裝**,要求改名 - 同 `id` 同來源(`source_hash` 不同)⇒ 視為**更新**,覆寫該筆 record - **App 不得宣告自己是落地頁(HOME)**——那是實例擁有者在設定頁選的。(這條我先裁了:第一天就開 HOME 宣告,第二個 App 進來必然變成搶位戰。) #### 3.5 UI 怎麼被加入 Portal(leo 點名那題的正面回答) **Portal 改一次,此後 0 行。** 那一次要改什麼,具體到行: - **`/portal/session` 多回一格 `apps`**(`cypher-executor/src/routes/portal.ts:495-515` 的 response 物件加一個 key) - 內容= KBDB `arcrun_app` 裡 `status='enabled'` 的 records,依 `visible_to` × 登入者 role 過濾 - 形狀:`[{ app_id, name, icon, version, ui_url, mount:[{slot,label,order}] }]` - **與既有旗標同構**:只是顯示提示,真閘在路由層(§3.6 端點自己會再查一次 record) - **`portal/index.html` 五個小改** - `VIEWS`(`:718`)→ `BUILTIN_VIEWS.concat(apps.map(a => 'app-' + a.app_id))` - `allowedViews()`(`:721-729`)→ 多一條泛用分支:`app-*` 一律可見(能不能看已在 server 過濾) - `showApp()`(`:790-799`)→ 依 `apps` **動態生成**側欄 `div.nav`、tab `div.tab`、空的 `<div class="view page" id="v-app-<id>">` - `LOADERS`(`:747`)→ 多一條泛用分支:`app-<id>` → `mountApp(app)` - 新增 `mountApp(app)`:`import(app.ui_url).then(m => m.mount(container, host))`;切走時 `m.unmount?.(container)` - 估計約 60 行,**而且是最後一次為了「多一個能力」而改這個檔** **App 前端要實作的就這一個介面**(ES module): ```js // app.js —— App 前端入口 export function mount(el, host) { /* el = Portal 給的空容器 */ } export function unmount(el) { /* 選配:切走時清乾淨 */ } ``` **`host` 是 Portal 交給 App 的唯一通道;App 拿不到 session token 本身**: ```js host = { app: { id, name, version }, user: { display_name, role, libraries }, // 同 /portal/session,**不含租戶字串** search: (q, opts) => Promise, // 代打 GET /portal/data/search entry: (id) => Promise, // 代打 GET /portal/data/entries/:id action: (name, payload) => Promise, // 代打 POST /portal/apps/<id>/actions/<name> toast: (msg) => void, theme: () => 'light'|'dark', nav: (subpath) => void, // 導到 #/app/<id>/<subpath> } ``` - ⇒ **App 不自己 fetch、不碰 localStorage、不知道 apiBase。** 所有資料進出都經過既有的 `/portal/data/*`(server 側 owner_id + library enforce 已經在 `portal-data.ts` 做好了)。 這正面回答 leo 那題:**走既有的 KBDB/cypher API,不另開一條。** **前端放哪(實體)**: - **預設= App 自己的 bundle repo,用 commit sha 釘死的 CDN URL** - 這不是新發明:基礎 bundle 本來就這樣送(`installer/oauth-prototype/worker.js:86` 的 `BUNDLE_BASE`=`cdn.jsdelivr.net/gh/<org>/arcrun-rag-bundles@<sha>`) - 釘 commit sha ⇒ **內容不可變**,等同完整性保證(ES module `import()` 不支援 SRI,所以靠不可變 URL) - jsDelivr 預設回 `Access-Control-Allow-Origin: *`,`import()` 直接可用 - **裝 App 完全不需要部署任何 worker、不需要用戶的 Cloudflare 憑證** - **替代= App 自架一顆 worker**(想完全自持、或地端無外網時) - 只要那顆 worker 回 CORS 放行 portal origin,並提供 `/__version`(照抄 `build-ui-bundle.mjs:172`) - 宣告完全不變,`ui.url` 換成那顆 worker 的位址而已 - ⚠️ 地端/離線實例與基礎 bundle 面對**同一個 CDN 依賴問題**,用**同一種解**(鏡像 base),本協定不另立一套。 **為什麼是 ES module 不是 iframe**(我先裁了,但這是單向門,見 §七): - Portal 要在手機上用(PWA 方向),iframe 的鍵盤、滾動、返回鍵、主題切換都要另外搭一套 - module 直接吃 Portal 既有的 CSS 變數與版面,App 作者不用重畫一套殼 - **代價**:App 的 JS 跑在 Portal 的 origin,權限等同 Portal 自己 - **`host` 合約刻意設計成傳輸無關** ⇒ 哪天要換成 iframe + postMessage,App 那一側一行都不用改 #### 3.6 寫入怎麼做:一支泛用端點,不是每個 App 一支 ``` POST /portal/apps/:app_id/actions/:action ``` - 認證= portal session bearer,沿用 `requirePortalUser()`(與 `/portal/data/*` 同一套) - 流程 - 查 `arcrun_app` record → App 存在且 `status='enabled'`?否則 404 - `action` 在該 record 的 `actions_json` 白名單裡?否則 403 - 取對應 workflow 名 → **server 側**用實例自己的 tenant key 打既有的 `/webhooks/named/:name/trigger`(`webhooks-named.ts:260`) - 注入 actor(`display_name`/`role`)與 `owner_id`,回工作流結果 - **為什麼一定要 server 代打**:那支 trigger 要 `X-Arcrun-API-Key`(`webhooks-named.ts:261`),是實例擁有者的金鑰,portal 用戶絕不能拿到——同 `portal.ts:475`「絕不回租戶字串」那條 - **形狀與既有 upload 端點一致**(token 留 server 側,`portal-data.ts:507`),差別是**這支是泛用的,不會為第 N 個能力再長第 N 支端點** - 附帶收穫:既有 `/portal/data/upload` 之後可收編成 `apps/uploader/actions/upload`——**表示這個抽象沒有為筆記量身訂做** #### 3.7 資料層(守鐵律) - App 的資料型別 = **ensure 一列 KBDB template**,走既有冪等路徑(`portal.ts:99`)→ `POST /kbdb/templates`(`kbdb-proxy.ts:49`) - 差別只在:不是硬編進 `portal-seeds.ts`,而是**安裝時從 App 宣告帶進來** - App 若要自己的庫 → 登記進既有 `portal_library`(`portal-seeds.ts:36`),照既有庫權限走,**不另立權限模型** - 🔴 全程 **API-as-Wall,零 SQL、永不加表** #### 3.8 安裝與移除 - **誰在裝**:v0 = **實例擁有者(admin)**,不是每個 portal 同仁自己裝 - 理由:寫 `arcrun_app` record 與 ensure template 都需要 tenant 權限;而且「裝什麼」是實例層的決定 - leo 說的「任何人可以安裝」= 任何**擁有自己 Arcrun 的人**都能裝,不需要我們改核心 —— 這個協定滿足的是這一句 - **`acr app build`(作者端,出貨前跑一次)** - 讀 `arcrun-app.yaml`,把 `workflows[]` 編成**含預編圖**的 JSON——**直接沿用 `installer/scripts/compile-workflows.mjs:63-127` 的保全閘邏輯**(flow 變了必須重編,沒圖就 exit 1) - 產出 `dist/app.js`(前端)+ `dist/app.json`(宣告+預編工作流),推上 App 自己的 bundle repo,拿到 commit sha - ⇒ 為什麼一定要預編:`worker.js:1218-1223` 的實測——冷實例即時編圖 25.7s > 15s timeout。裝 App 會踩同一顆地雷。 - **`acr app install <url|path>`(實例端)** - 取 `app.json`、算 `source_hash` - ensure templates / 登記 library(既有 API) - 逐條推 workflow:`POST {cypher}/webhooks/named` **帶預編圖**(形狀同 `worker.js:1212-1253`,0 次 `/cypher/search`) - 寫 `arcrun_app` record(`status=enabled`) - ⇒ 前三步全是既有能力的組合,**真正新的只有最後一步**;**全程不部署任何 worker、不需要 CF 憑證** - **`acr app remove <id>`** - record → `status='removed'`(或刪 record)⇒ 下一次 `/portal/session` 就不再回它 ⇒ **Portal 上乾淨消失,沒有殘骸** - 撤它的 workflows(既有 `/webhooks/named` 刪除路徑) - **資料留著**(筆記是用戶的知識,不是 App 的財產) - 要一起清 = `--purge`,走既有 `DELETE /portal/admin/libraries/by-name/:name`(`portal.ts:1264`) - **template 不刪**——KBDB template 是全域 schema,刪了會讓既有資料孤兒化。這條要寫進文件,否則會有人以為沒清乾淨。 - 🔴 **App 不進 `bundle-components.mjs`**。那份清單是「基礎 bundle 該有哪幾顆 worker」的真相源(`installer/scripts/bundle-components.mjs:46-79`),`ship.mjs:625` 會機械核對它與 manifest 名字集合**恰好相等**。把 App 加進去 = 每加一個能力就要重出核心版本 = 病原封不動搬家。 --- ### 四、拿「手機寫筆記」走一遍(檢驗抽象夠不夠用) - **leo 的原始需求**(`Leo/mira#2` 工作項目第三項「手機寫筆記:河道寫筆記」;細節在 `Leo/InkStoneCo#2`,本機留底 `system-dev/docs/issues-log/Leo-InkStoneCo-2.md`) - 「手機開啟即可直接在 Arcrun 書寫,支援 MD 即可,寫完直接萃並 ingest 到 KBDB」 - 「剛開始只需要河道,每天的筆記一直寫下去,新的在上,用小日曆切換」 - **它現在長什麼樣(實查)** - 唯一實作是舊 Mira 的河道頁:`matrix/arcrun/landing/app/mira/feed/page.tsx`(2542 行 Next.js client component) - 輸入= Composer 的 `<textarea>`(`:560-566`),送出 `submit()`(`:567-610`) - 寫入= **前端直打** `POST https://kbdb-create-block.arcrun.dev`(`:576-590`),body `{type:"note", user_id:"inkstone_mira_post", source:"km-writer-direct", …}` - 讀回河道= `GET https://kbdb.finally.click/blocks/documents?limit=50`(`:109`/`:135`) - 寫完萃取= fire-and-forget 打 `wiki_synthesis`(`:430`)與 `project_detector`(`:454`) - 後端掃描器= `polaris/mira/arcrun/mira_feed_watcher.yaml:29-31`(**每 5 分鐘 cron**)撈 `source=km-writer-direct`(`:34-40`) - 現況= **已停用且明文不恢復**(`system-dev/docs/3-specs/mira-dissolve/requirements.md:100-102`;`polaris/mira/CLAUDE.md:3` 角色已蒸發)。四個硬編端點全是舊世代 URL。 - **照本協定,它變成什麼** - `arcrun-app.yaml` = §3.2 那份範例,一字不改 - 前端 = 一支 `app.js`;`mount(el, host)` 裡畫 composer + 河道時間軸 + 小日曆 - 寫 = `host.action('save', { title, body })` → `POST /portal/apps/notes/actions/save` → server 代打 `notes_save` workflow → 走既有 KBDB API 寫一筆 `note` record 並掛 library - 讀 = `host.search()` / `host.entry()`(既有 `/portal/data/*`,server 側已 enforce owner_id + library) - 萃取 = `notes_save` workflow 末端接既有萃取鏈(**不需要 cron 掃描器**,寫入當下就觸發) - 掛載 = `slot: nav`,Portal 上多一個「筆記」;手機底部 tab 同步多一個 - **舊實作的四個病,這個協定各解掉一個** - 硬編四個舊世代 KBDB URL ⇒ App 不知道 apiBase,只認 `host` - 前端直寫 KBDB、繞過權限 ⇒ 一律走 `/portal/data/*` 與 action 端點,server 側 enforce - 靠 5 分鐘 cron 掃「有沒有新筆記」 ⇒ 寫入即觸發 - 它是一個獨立 Next.js 站、跟 Portal 兩個世界 ⇒ 現在長在 Portal 裡,共用登入、主題、手機殼 --- ### 五、要新增的東西(總清單) - **一列 template seed** —— `arcrun_app`,加進 `cypher-executor/src/lib/portal-seeds.ts:21` 的 `PORTAL_TEMPLATE_SEEDS`(零新表) - **`/portal/session` 多回 `apps`** —— `routes/portal.ts:495-515`,唯讀彙整 - **一支泛用端點** —— `POST /portal/apps/:id/actions/:name` - **Portal 的泛用 loader** —— `console-ui/public/portal/index.html`,約 60 行,**一次性** - **`acr app build|install|remove|list`** —— `cli/src/commands/app.ts`;`build` 直接複用 `compile-workflows.mjs` 的預編圖保全閘 - **App 樣板 + `arcrun-app.yaml` 的 JSON Schema** —— 目前 repo 裡**沒有任何描述「可安裝單位」的 schema**,這會是第一份(放 `matrix/arcrun/schemas/arcrun-app.v1.schema.json`,形狀比照 `products/arcrun-rag/schemas/collector-trigger.v1.schema.json` 的凍結版本慣例) - ⇒ 之後**每一個 App 都是 0 行 Portal 改動、0 行 cypher 改動、0 顆 worker 部署**。 --- ### 六、對照票上三條判準的自檢 - **① 沒改過 Portal 一行的人能不能裝上去讓它出現?** - 能。`acr app install` 寫一筆 record ⇒ 下一次 `/portal/session` 就回它 ⇒ Portal 動態長出 nav + 頁面 ⇒ 進頁時才 `import()` 它的前端。 - ⚠️ **前提是那個一次性 loader 已經在線上的 Portal 裡**。在它出貨之前,任何 App 都裝不起來——這是本協定唯一的「必須先做的事」,也是唯一一次要動 `arcrun-rag-ui` 出貨流程。 - **② 同一套機制能不能承載第二個能力?** - 能,而且**反向驗證得到**:既有的「上傳」能力可以原樣重寫成 `apps/uploader`(`slot: nav` + 一個 `upload` action),不需要對協定做任何加工。 - `Leo/mira#2` 另兩項也落在同一形狀:專案管理 = `slot: nav` + 讀 `/portal/data/search` + 幾個 action;RAG 萃取鏈是純後端 workflow,本來就不需要掛 UI。 - **③ 有沒有製造「同一個事實兩份」?** - 宣告只有一份(`arcrun-app.yaml`),安裝態由它算出來、用 `source_hash` 綁住,**沒有人手動維護第二份**。 - 明文禁止把 App 加進 `bundle-components.mjs`(那是第二份清單的入口)。 - 順帶**減少**既有重複:Portal 現在把「有哪些頁」寫了四遍(側欄/tab/VIEWS/allowedViews),loader 改完之後,App 那部分只有一個來源。 --- ### 七、需要 leo 拍板的(只有兩題,其餘我自己裁了) - **① App 前端跑在 Portal 的 origin 裡(ES module),還是關進 iframe?** - 我先裁 **module**,理由見 §3.5(手機體驗、共用主題與殼) - 這是**單向門的邊**:只要 App 開放給第三方投稿,就必須改成 iframe + postMessage - `host` 合約刻意設計成傳輸無關 ⇒ 那天改的時候 App 那一側零改動 - **要你確認的只有一句:這批 App 短期內都是我們自己寫的,對嗎?** 是 ⇒ 照 module 做;不是 ⇒ 我改設計成 iframe(多花一輪,手機體驗會差一點) - **② v0 只開 `nav` 一個掛載點,夠不夠?** - 我裁的是「夠」——承載得了手機寫筆記,而且列表型不會撞 - `settings_panel` 與 `search_mode` 先不開(開了就要處理排序與撞位,而現在沒有需求逼它) - 這是取捨題不是技術題,所以列給你。**回「夠」我就照 v0 往下做。** --- ### 八、還缺什麼(誠實標明) - **`Leo/arcrun-rag#40` 的 18 處清單我沒拿到原文**——本機 `system-dev/docs/issues-log/` 沒有它的留底。我照票上給的原則辦(宣告只能有一個地方),沒有逐項核對那 18 處各是什麼。 - **`acr app install` 的認證形式還沒定死**——`acr` 現在打的是 cypher(`X-Arcrun-API-Key`),這條夠用;但「要不要同時在 Portal 管理頁做一個裝/移除的畫面」我還沒設計(v0 可以只有 CLI)。 - **地端/離線實例的 App 前端來源**——與基礎 bundle 是同一個 CDN 依賴問題,我主張用同一種解(鏡像 base),但沒有實查現在地端是怎麼鏡像 bundle 的。 - **流程面**:本協定屬**規格層變更**,依 D35 走 `pending-changes.md` 提案、等 leo 明說 confirm 才開新 SDD。 arcrun 現行 active SDD = `matrix/arcrun/system-dev/docs/3-specs/workflow-discovery/`(frontmatter 實查)。 我**沒有**自行開 SDD,也**沒有**動任何功能程式碼。
Leo added this to the Mira 現代化 milestone 2026-08-09 14:11:42 +00:00
Author
Owner

leo 回 §七 ①:答案是「是,但你問錯了方向」(2026-08-09)

leo:「短期都是我們寫,但是原本 Arcrun 的設計就是零件、recipe、workflow、App,
是一層包一層,可以自製可以分享
,你應該想的是哪裏可以註冊頁面就直接掛上。」

⇒ 提案問的是「短期都是我們自己寫嗎 ⇒ 決定 module 還是 iframe」。
leo 沒有照這個框架回答,他把框架換掉了。

這句話改變什麼

App 不是一個新發明的東西,它是既有那疊的最外層:

零件 → recipe → workflow → App
一層包一層,每一層都可以自製、可以分享

不要為 App 另外發明一套「怎麼被裝進來」的機制。
前面三層已經各自有註冊與分享的做法(acr partsacr recipeacr push/公庫)——
App 應該長在同一個形狀上,而不是旁邊多開一條路。

⇒ 真正要回答的問題被 leo 換成:「哪裡可以註冊一個頁面,然後它就直接掛上?」

對 ① 那道單向門的影響

提案原本的邏輯是「短期自己寫 ⇒ 先 module,哪天開放第三方再改 iframe」。
但既然這一層從設計上就是要能分享的,「哪天」不是假設性的未來,是既定方向。

⇒ 接手的人要重新權衡:

  • 提案自己也寫了「這是單向門的邊:只要開放第三方投稿就必須改 iframe」
  • 而提案的 host 合約刻意設計成傳輸無關(那天改,App 那一側零改動)——這個設計是對的,留著
  • 要重新判斷的是:先做 module 再改,跟一開始就做 iframe,哪個總成本低
    ——這次要把「分享是既定方向」放進算式,而不是當成低機率事件

§七 ② 尚未回覆

「v0 只開 nav 一個掛載點夠不夠」——leo 還沒回。
但注意:若照上面重新權衡的結果改變了掛載模型,這一題的答案可能跟著變
接手的人先處理 ①,再回頭看 ②。

## leo 回 §七 ①:答案是「是,但你問錯了方向」(2026-08-09) > leo:「**短期都是我們寫,但是原本 Arcrun 的設計就是零件、recipe、workflow、App, > 是一層包一層,可以自製可以分享**,你應該想的是**哪裏可以註冊頁面就直接掛上**。」 ⇒ 提案問的是「短期都是我們自己寫嗎 ⇒ 決定 module 還是 iframe」。 **leo 沒有照這個框架回答,他把框架換掉了。** ## 這句話改變什麼 **App 不是一個新發明的東西,它是既有那疊的最外層:** ``` 零件 → recipe → workflow → App 一層包一層,每一層都可以自製、可以分享 ``` ⇒ **不要為 App 另外發明一套「怎麼被裝進來」的機制。** 前面三層已經各自有註冊與分享的做法(`acr parts`/`acr recipe`/`acr push`/公庫)—— App 應該長在**同一個形狀**上,而不是旁邊多開一條路。 ⇒ 真正要回答的問題被 leo 換成:**「哪裡可以註冊一個頁面,然後它就直接掛上?」** ## 對 ① 那道單向門的影響 提案原本的邏輯是「短期自己寫 ⇒ 先 module,哪天開放第三方再改 iframe」。 但既然**這一層從設計上就是要能分享的**,「哪天」不是假設性的未來,是既定方向。 ⇒ 接手的人要重新權衡: - 提案自己也寫了「這是**單向門的邊**:只要開放第三方投稿就必須改 iframe」 - 而提案的 `host` 合約**刻意設計成傳輸無關**(那天改,App 那一側零改動)——這個設計是對的,留著 - **要重新判斷的是:先做 module 再改,跟一開始就做 iframe,哪個總成本低** ——這次要把「分享是既定方向」放進算式,而不是當成低機率事件 ## §七 ② 尚未回覆 「v0 只開 `nav` 一個掛載點夠不夠」——leo 還沒回。 但注意:**若照上面重新權衡的結果改變了掛載模型,這一題的答案可能跟著變**, 接手的人先處理 ①,再回頭看 ②。
Author
Owner

👤 leo 裁決:掛載點 v0 選側欄,不選 nav(2026-08-10)

leo 問:「以後還可以增加?如果我有 20 個 app 掛載呢?會變成側邊欄 hamburger button 的列表嗎?」
leo 答:「側欄。

⇒ 本票 §七② 那題(v0 只開 nav 一個掛載點夠不夠)結案:夠,但那一個不是 nav,是側欄

為什麼這個問題比它看起來重要

真正的問題不是「開幾個掛載點」,是「一個掛載點塞爆了會怎樣」。

  • 水平 nav 大約 5–7 項就滿,第 8 個 App 裝上去畫面就爆
  • 🔴 而爆掉的那一刻要改的是 Portal 核心——那正是本票的立票理由
    (「每加一個能力就要有人去改 Portal 的程式碼⋯⋯焊三次之後 Portal 就沒人敢動了」)
  • ⇒ 不先定義塞爆的行為,這個擴充點就是假的

垂直側欄可以一直往下長、可捲動、可分組;水平不行。
而且 leo 要的是「像 WP Plugin」——WordPress 正是這樣解的
外掛用 add_menu_pageadd_submenu_page 宣告,長在垂直側欄。連解法都同一套。

v0 的形態(兩條一起才成立)

  1. 仍然只開一個掛載點(保持最小,不因為這個裁決就變成開四個)
  2. overflow 行為現在就定義(超過 N 項自動收合成 leo 說的那種列表)
    20 個 App 是設計內的情況,不是災難,不必回頭改核心

連帶

  • 掛載必須是宣告的(App 自己說「我掛在哪」),不是改 Portal 程式碼——
    只有這樣,之後要加 cardpage 這些掛載點才是純增量,已裝好的 App 一行都不用改
  • 📐 可外推的判準(已進頂層 decisions-summary.md D63):
    設計擴充點時先問「它被塞爆會怎樣」,而不是「先開幾個」。
    撐不住的那一刻要改的若是核心,這個擴充點就是假的。

— [總管] 2026-08-10

## 👤 leo 裁決:**掛載點 v0 選側欄,不選 nav**(2026-08-10) **leo 問**:「以後還可以增加?如果我有 **20 個 app** 掛載呢?會變成**側邊欄 hamburger button 的列表**嗎?」 **leo 答**:「**側欄。**」 ⇒ 本票 §七② 那題(v0 只開 `nav` 一個掛載點夠不夠)**結案**:夠,但**那一個不是 `nav`,是側欄**。 ### 為什麼這個問題比它看起來重要 **真正的問題不是「開幾個掛載點」,是「一個掛載點塞爆了會怎樣」。** - 水平 nav 大約 **5–7 項就滿**,第 8 個 App 裝上去畫面就爆 - 🔴 **而爆掉的那一刻要改的是 Portal 核心**——**那正是本票的立票理由** (「每加一個能力就要有人去改 Portal 的程式碼⋯⋯焊三次之後 Portal 就沒人敢動了」) - ⇒ 不先定義塞爆的行為,這個擴充點就是**假的** **垂直側欄可以一直往下長、可捲動、可分組;水平不行。** 而且 leo 要的是「像 WP Plugin」——**WordPress 正是這樣解的**: 外掛用 `add_menu_page`/`add_submenu_page` 宣告,長在垂直側欄。連解法都同一套。 ### v0 的形態(兩條一起才成立) 1. **仍然只開一個掛載點**(保持最小,不因為這個裁決就變成開四個) 2. **overflow 行為現在就定義**(超過 N 項自動收合成 leo 說的那種列表) ⇒ **20 個 App 是設計內的情況,不是災難**,不必回頭改核心 ### 連帶 - 掛載必須是**宣告的**(App 自己說「我掛在哪」),不是改 Portal 程式碼—— 只有這樣,之後要加 `card`/`page` 這些掛載點才是純增量,**已裝好的 App 一行都不用改** - 📐 可外推的判準(已進頂層 `decisions-summary.md` **D63**): **設計擴充點時先問「它被塞爆會怎樣」,而不是「先開幾個」。** 撐不住的那一刻要改的若是核心,這個擴充點就是假的。 — [總管] 2026-08-10
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Blocks
You do not have permission to read 1 dependency
Reference: Leo/Arcrun#82