FR-069 抽取 SOP checklist——「把一個模組抽成 jedi 插件套件」逐步指南

狀態:P1(jedi-ai-bot)實跑驗證後定稿|建立日期:2026-08-29|對應卡:CM-1437 適用範圍:FR-069 P2–P5 的每一支套件抽取。照著做,遇到不一致以本文為準、並回頭更新本文。

變更紀錄

日期 變更 對應
2026-08-29 初版——P1 jedi-ai-bot 實跑一遍後寫成,含 D6 插件契約、D7 harness/README、四道防線簽名 CM-1437
2026-08-29 P4 jedi-remote-agent 實跑後補:§4.2 migration 隨包機制定案(首例)、§1 盤點加「消費端建構點」與「signature 變更」兩項、§6.1 harness 三道前提、§5 大模組的 DI container 保留判準 CM-1440
2026-08-30 P3 jedi-log-forwarding 實跑後補:§3.4 logger 名三連坑(抽走 logging 相關模組必踩)、§1 盤點加「模組有沒有背景常駐元件」、§5 加「core/ 不可 import api/」、§6 加「跑 logging 的模組要驗『真的送到對面』」 CM-1439
2026-08-30 P5 jedi-license-runtime 實跑後補:§1 盤點加「共用 error code 歸屬」、§3.2 斷法表加「多語系文案」、§4 加 HANDLE_KEY 慣例、§4.2 加「seed 列與非 RLS 表」兩條、§6.2 加「平行 session 共用檔」的 staging 手法、§8 加「schema 對照 DEV 實況」 CM-1441

0. 先讀這段(30 秒版)

抽一支套件=七個階段、一條不可倒的順序

① 盤點  →  ② 建套件骨架  →  ③ 搬檔+斷線  →  ④ 定 register 簽名
      →  ⑤ 主專案改接線  →  ⑥ 本地驗證(path dependency)  →  ⑦ 發版+pin

⑥ 之前不要碰 ⑦,⑦ 之前不要 commit「主專案移除目錄」——順序倒了會出現 「committed 狀態沒源碼、又裝不到套件」的死結。

三個「等 user 明示才做」的動作:發版推 Nexuspush收尾類文件。 其餘(commit、Notion 回寫子卡)做完就做,不用問。


1. 階段 ①:盤點(動手前,約 20 分鐘)

盤點產出:一張「要搬哪些檔 / 要斷哪幾條線 / 每條線怎麼斷」的清單。斷法選擇見 §3.2。


2. 階段 ②:建套件骨架

在 monorepo 根目錄下 mkdir jedi-<name> 後建立:

jedi-<name>/
├── jedi_<name>/
│   ├── __init__.py                  # logger = logging.getLogger("jedi_<name>")
│   ├── api/                         # ★ 插件模式:route 進套件(與舊 jedi-* 慣例不同)
│   ├── app/service/
│   ├── common/                      # 套件自帶的小工具(如回應信封)
│   ├── domain/repository/           # port(ABC)放這
│   ├── infra/                       # 有 DB 表才需要(ORM model / mapper / repo impl)
│   └── plugin.py                    # ★ register() / adapters / config,見 §4
├── harness/                         # ★ D7:standalone 最小宿主
│   ├── dev_app.py
│   └── docker-compose.yml
├── tests/                           # ★ D7:零外部相依,能離開主專案獨立跑
├── README.md                        # ★ D7:接入說明書,見 §6
├── pyproject.toml
├── poetry.toml                      # [virtualenvs] in-project = true
└── publish.sh                       # rm -rf dist/ && poetry publish --build -r nexus

pyproject 要點(照抄 jedi-ai-bot):


3. 階段 ③:搬檔+斷線

3.1 搬檔

3.2 斷線:四種手法,按差異性質選

主專案的東西 斷法
基礎設施(Redis / 檔案系統 / 外部服務 client) 套件定窄 ABC port(只放真的會用到的方法),主專案寫 adapter 注入
設定值(API key、host、開關) <Pkg>Config dataclass,由 consumer 傳入。套件永遠不讀 os.getenv、不 import config.config
認證 / user context 注入 decorator 與取值函式(見 §4 adapters
小工具(回應信封、常數字典) 套件自帶一份最小複製(如 common/response.pyAUTH_PARAMS),不 import 主專案。只複製真的會用到的部分
多語系文案(信件模板、通知內容) 套件自帶預設字串 + config 可整組覆寫。不可讀宿主的 config/translations/。字串逐字照搬不趁機潤稿——那是客戶看得到的東西

port 要窄:jedi-ai-bot 的 IChatHistoryStore 只有 get / set / delete 三個方法。 port 越窄,換後端越便宜、測試 mock 越省事。

3.4 🔴 logger 名三連坑(抽走任何「會發 log 或掛 log handler」的模組必踩)

P3 抽 log 轉發時連續踩到三個,症狀全是「功能其實在跑,但相關訊息全部消失」或 反過來「根本沒跑,而你也看不到任何錯誤」。三個都源自同一件事:模組搬進套件後, 它的 logger 名從 common.xxx 變成 jedi_xxx.common.xxx,而宿主的 logging 設定 只認得原本那幾棵樹。

# 症狀 解法
套件模組 logger 被 disable_existing_loggers 關掉 套件自己的維運訊息(啟用/失敗/重試)整個消失,連壞掉都不出聲 config 開一個 ops_logger_name,宿主傳自己樹裡的名字
稽核事件用套件 logger 發 事件掉出 system_logs,也不會被轉發 adapters 開一個 audit_logger_name,同上
宿主在掛載之後才跑 dictConfig 整批替換 handlers 清單,把套件掛上去的 handler 默默丟掉 套件端每輪自檢「handler 還在不在」,不在就重掛

第 ③ 個最陰險:dictConfig替換不是附加,而套件若只比「設定有沒有變」就會 判定「沒變、不用動」,於是永遠不會發現 handler 已經被拔掉——狀態查詢函式仍回 「運作中」。主專案必然踩到(jedi_issue 在 import 期呼叫 configure_logging(), 而模組註冊迴圈跑在 create_app() 之後)。

通則:套件若要在宿主的 logging 樹上掛東西或發訊息, logger 名一律做成可注入、預設值只是 fallback;且掛上去的東西要能自我檢查還在不在

3.3 守衛測試(斷線的證明)


4. 階段 ④:定 register 簽名(四道防線)

P1 已定案,P2–P5 直接照抄這個形狀

register(app, adapters, config=None, schema_extensions=None, mount_api=True) -> PluginHandle

四個插槽對應「不同產品接同一支套件時,API 可能差在哪」,差異由小到大:

插槽 解什麼差異 做法
1. config 參數值差異(預設值 / 上限 / 開關) dataclass <Pkg>Config,API 形狀不變
2. adapters 行為差異(背後邏輯不同) port 注入,即 D4 既有機制
3. schema_extensions 欄位差異(多收 / 多吐欄位) base schema 只認共通欄位+多給不炸;宿主註冊 request_context / response_enricher hook,擴充欄位經 context dict 傳遞
4. mount_api=False 端點形狀差異的逃生門 不掛路由、只回 service,宿主自己寫薄 route

四個插槽第一版就要開齊,即使該套件暫時用不到第 3 道——P2–P5 照抄同一簽名, 之後才不用回頭改五支套件的入口。

4.1 實作要點(P1 踩過的坑)


4.2 migration 隨包(P4 定案,有 DB 表的套件適用)

套件 ship 建表腳本,但絕不自己執行。

jedi-<name>/jedi_<name>/migrations/
├── 001-<模組>-tables.sql      # 建表 + 索引 + COMMENT
└── 002-<模組>-rls.sql         # RLS policy + GRANT(依賴宿主生態)

plugin.py 提供讀取入口,宿主自己決定何時套:

def iter_migrations() -> List[Tuple[str, str]]:
    """回 [(檔名, SQL), ...],依檔名序(三位數序號決定套用順序)。"""
    return [(p.name, p.read_text(encoding="utf-8"))
            for p in sorted(MIGRATIONS_DIR.glob("*.sql"))]

五條規則(P4 實跑得出,P3/P5 照抄):

  1. 套件只提供、不執行。何時套、套進哪個庫、怎麼記錄已套過,全是宿主的維運決定。 主專案有 schema_migrations + manifest.tsv + migrate.sh 一整套,套件不該取代它。 主專案的既有 scripts/sql/ 腳本不動、不重複登記——套件版是給其他 consumer 用的。
  2. 🔴 每支 SQL 必須冪等CREATE TABLE IF NOT EXISTS / DO $$ 包 policy 建立)。 宿主很可能在已有這些表的既有庫上執行——主專案就是這種情況(表早就建好, 套件版全程 no-op)。裸 DDL 會讓第二次執行整支失敗。寫個測試把這條焊死
    def test_ddl_is_idempotent():
        for name, sql in iter_migrations():
            for line in sql.splitlines():
                s = line.strip()
                if s.startswith(("CREATE TABLE ", "CREATE INDEX ")):
                    assert "IF NOT EXISTS" in s, f"{name} 有非冪等 DDL:{s}"
  3. 按「前提」拆檔,不是按「表」拆。001 只需要 schema 存在;002 需要宿主生態 (RLS 判定函式、應用角色)。不吃那套生態的 consumer 可以只套 001——harness 就是 這樣跑的,也因此證明了拆分是真的有意義而非形式。
  4. 🔴 多租戶欄位在 001 建 nullable,由 002 收緊成 NOT NULLTenantScopedMixinModel 只在 ENABLE_MULTI_TENANT=true 時才產生這兩個欄位——單租戶宿主的 ORM 根本不會 在 INSERT 帶它們,欄位若一開始就 NOT NULL 則每一筆都寫不進去(P4 harness 首跑實際 踩到 NotNullViolation)。
  5. 🔴 pyproject.toml 要顯式 include,否則 wheel 裡只有 .pyiter_migrations() 在安裝後靜默回空清單(沒有任何錯誤訊息,是最難查的那種):
    include = [{ path = "jedi_<name>/migrations/*.sql", format = ["sdist", "wheel"] }]
  6. 🔴 表若有「必須存在的 seed 列」,seed 要寫進 001(P5 新增)。license 的時鐘回撥 浮水印是全域單例(恆一列 id=1),repo 讀的是 WHERE id = 1、寫的是 UPDATE 該列—— 沒有那一列的話浮水印永遠讀到 NULL(不判回撥)、推進 UPDATE 到 0 列,整道防護靜默 失效。用 INSERT ... ON CONFLICT DO NOTHING 保持冪等,並寫一條測試斷言 SQL 內含 該 INSERT——這種缺失沒有任何執行期症狀,只有測試抓得到。
  7. 🔴 不是每張表都該掛 RLS(P5 新增)。全域單例/跨租戶事實表(如時鐘浮水印)掛了 租戶隔離,語意會直接壞掉(「系統見過的最大時間戳」變成每租戶一份)。002 對這種表 只 GRANT、不 ENABLE ROW LEVEL SECURITY,並寫測試把「002 不含該表的 ENABLE RLS」 焊死——照抄別張表的形狀是最容易犯的錯。

DDL 從哪來pg_dump --schema-only -t <表> 撈 DEV 的實況(唯讀操作,不算環境異動), 再改寫成冪等形式。不要憑 ORM model 反推——model 與實際 schema 常有落差(欄位型別、 索引、policy 形狀),照抄實況才不會出現「裝出來的庫跟現有的不一樣」。

5. 階段 ⑤:主專案改接線


6. 階段 ⑥:本地驗證(path dependency)

6.1 套件端獨立驗證(D7 的核心價值)

⚠️ echo $VIRTUAL_ENV 確認是空的。 Poetry 會沿用已啟用的 venv——若你剛從 主專案 venv 過來,poetry install 會把套件相依裝進主專案的 venv 並升掉它的套件 (P1 實際踩到兩次:marshmallow 被升到 4.x,弄壞 flask-apispec,得回主專案 poetry install 才修好)。已在別的 venv 裡就先 deactivate,或每個指令前綴 env -u VIRTUAL_ENV

🔴 有 DB 表的套件,harness 還要補三道前提(P4 首跑逐一炸出來,每個症狀都不直觀):

前提 不做會怎樣 做法
ENABLE_MULTI_TENANT=true 在 import model 之前設好 NotNullViolation:mixin 用 @declared_attr 在 class 定義當下決定要不要產生 tenant 欄位,晚設等於沒設 os.environ.setdefault(...) 放在檔案最上方
宿主生態的 tenants / org_units 兩張表要在 SQLAlchemy metadata 裡(不是只在 DB 裡) NoReferencedTableError: could not find table 'org_units'——mixin 的 FK 在 mapper 設定期就要解析得到;只在 DB 建樁表沒有用 harness 用 Base 宣告最小等價 model(見 declare_host_tables()
register_error_handlers(app) 套件拋的業務例外(401/403/404)全部變成 500,看起來像套件壞了 from jedi_common.handler.handler import register_error_handlers

這三道都是真實宿主本來就有的東西(產品有 jedi-auth 的 model、有錯誤處理器、有多租戶 開關),harness 補上不是作弊——反而正是它的價值:把「套件對宿主的隱性要求」一條條逼出來 寫進 README,下一個 consumer 才不用重新踩一遍。

6.2 主專案端驗證

6.3 拔掉測試(D6 驗收核心)

6.4 接入 README 走查(D7)


7. 階段 ⑦:發版+pin(等 user 明示

順序不可倒:

  1. [ ] 等 user 明示cd jedi-<name> && ./publish.sh(推 Nexus)
  2. [ ] 主專案 [project] dependencies"jedi-<name>==<版號>"移除 dev group 的 path override
  3. [ ] poetry update jedi-<name>poetry show 確認裝的是 Nexus 版本
  4. [ ] 重啟 BE + 再手測一輪(確認裝 Nexus 版與 path 版行為一致)
  5. [ ] 此時才 commit「主專案移除舊目錄 + pin 版號」
  6. [ ] 補 docs/claude/jedi-packages.md 該套件一段
  7. [ ] 等 user 明示 → push

8. 每支套件都要交的東西(收工檢查表)

9. 尚未定案(留給後續套件)

  • migration 隨包機制P4(CM-1440)已定案,見 §4.2。首例為 jedi-remote-agent 的三張表;P3/P5 有表時照 §4.2 五條規則辦理。
  • schema_extensions 的實戰驗證:插槽已開齊且有測試覆蓋,但尚無真實產品用到第 3 道。 第一個實際使用的套件要回頭確認這個形狀夠不夠用。

10. 名詞對照

意思
插件(plugin) 自帶 api 層 + 註冊入口的套件,裝上註冊一行就長出功能;拔掉一行就消失
拔掉測試 驗證「拔掉註冊行後產品照常跑」的手測流程(§6.3)
harness 套件自帶的最小宿主,讓套件不靠主產品也能跑起來
port 套件定義的窄介面(ABC),由 consumer 提供實作
adapter consumer side 對 port 的實作

邊界誠實聲明:做到的是安裝時可插拔(install-time pluggable), 不是執行中熱插拔——DB schema / RLS 不可能執行中掛載。對齊業界(GitLab / Odoo 模組)等級。