diff --git a/.agents/specs/arcrun/sdk-and-website/self-hosted-init.md b/.agents/specs/arcrun/sdk-and-website/self-hosted-init.md new file mode 100644 index 0000000..d300af0 --- /dev/null +++ b/.agents/specs/arcrun/sdk-and-website/self-hosted-init.md @@ -0,0 +1,224 @@ +# Design 補充:`acr init --self-hosted` 一鍵自動化(installer 模式) + +> 2026-06-01 初稿 → 2026-06-02 定案改寫(richblack 拍板 installer 形態)。 +> 本檔是 `sdk-and-website/design.md` 的單檔補充(規則 02 §4.3 允許)。 +> **狀態:design 已與 richblack 對齊;實作前讀 §6 前置依賴。** +> 背景:戰法從 SaaS 轉 self-hosted 開源(docs/HANDOFF-self-host-harness.md §0)。 + +--- + +## 1. 定案形態(richblack 2026-06-02) + +**arcrun CLI = installer / orchestrator**(類似 rustup / nvm:工具本身小,按需從遠端拉真正內容)。 + +### 用戶只做 4 件事,中間什麼都不用懂: +1. 申請 CF 帳號 +2. 安裝 CF CLI(`wrangler`) +3. 安裝 arcrun CLI(`npm i -g arcrun`) +4. `acr init --self-hosted`(貼 CF Account ID + API Token)→ **完成,其餘看機器跑** + +### CLI 自動做(用戶無感): +- 驗 CF token 權限 +- 建 7 個 KV namespace + 1 個 R2 bucket(冪等) +- **從 GitHub release 下載預編譯部署物**(含 24 個 `.wasm` + 各 Worker 的 wrangler.toml + cypher-executor/registry) +- 把建好的 KV namespace id 注入各 wrangler.toml + cypher-executor 的 `WORKER_SUBDOMAIN` +- **`wrangler deploy` 部署全部 Worker**(用戶已裝 wrangler) +- seed auth recipe + API recipe 進 RECIPES KV +- 寫回 `~/.arcrun/config.yaml` +- 印出「手動 `wrangler secret put ENCRYPTION_KEY` ×3」提示(secret 不自動化,rule 05) + +### 關鍵技術決策(richblack 2026-06-02) +| 決策 | 選擇 | 理由 | +|---|---|---| +| 零件部署物 | **預編譯 `.wasm`**(不在用戶端 build)| 用戶不懂 tinygo、也不該懂。下載即用。 | +| 部署工具 | **wrangler**(shell out)| 用戶已裝 CF CLI;self-host 本來就有上傳能力。CLI 不自己重寫 CF Script Upload API。 | +| 源碼來源 | **GitHub release tarball**(含預編譯 wasm)| 版本明確、不需用戶有 git、`acr update` 拉新 release 同一條路。 | +| 為何不是 git clone | repo **沒 commit `.wasm`**(rule 05 build 產物不 commit)→ clone 拿不到 wasm | 必須走含 wasm 的 release artifact。 | + +--- + +## 2. 為什麼是 installer 而非「repo 內掃 wrangler.toml」(推翻初稿) + +初稿假設「用戶在 repo 內跑、CLI 掃 wrangler.toml」。**推翻**,因為: +- npm 全域裝的 `acr` 手上**沒有** 24 個 Worker 源碼。 +- repo 沒 commit `.wasm`(已查證 `git ls-files .component-builds | grep .wasm` = 0)→ 連 clone 都拿不到可部署的 wasm。 +- 用戶不該需要懂 git / tinygo / repo 結構。 + +→ 正解:CLI 當 installer,從 **GitHub release(含預編譯 wasm)** 拉部署物到暫存目錄,在暫存目錄注入 KV id 後 `wrangler deploy`。 + +--- + +## 3. 流程設計(`initSelfHosted` 改寫) + +``` +acr init --self-hosted + │ + ├─ 1. 問 2 輸入:CF Account ID + CF API Token + │ (wrangler 是否已裝?which wrangler;沒裝 → 提示先裝 CF CLI 再來) + │ 驗 token:CF API GET /accounts/{id}/tokens/verify + GET /accounts/{id} + │ 缺權限(Workers Scripts Edit / KV Edit / R2 Edit)→ exit 1 指出缺哪個 scope + │ + ├─ 2. 建資源(冪等:先 list 已存在就重用) + │ 7 KV:WEBHOOKS / CREDENTIALS_KV / RECIPES / USERS_KV / + │ SESSIONS_KV / ANALYTICS_KV / EXEC_CONTEXT(rule 01 資料儲存表) + │ 1 R2:WASM_BUCKET + │ + ├─ 3. 下載部署物:GitHub release tarball → 解壓到暫存目錄 (~/.arcrun/.deploy-/) + │ 內含:cypher-executor/ + registry/ + .component-builds/*(每個含預編譯 component.wasm + wrangler.toml) + │ + ├─ 4. 注入設定到暫存目錄的 wrangler.toml(不改用戶 repo,改暫存副本) + │ - 各 Worker 的 KV binding id ← step 2 建立的 + │ - cypher-executor [vars] WORKER_SUBDOMAIN ← CF API GET /accounts/{id}/workers/subdomain + │ + ├─ 5. 部署:對暫存目錄每個含 wrangler.toml 的 dir,shell out + │ `wrangler deploy`(env CLOUDFLARE_API_TOKEN=, CLOUDFLARE_ACCOUNT_ID=) + │ 分兩層:tier1 = .component-builds/*(先)→ tier2 = cypher-executor / registry(後) + │ 每個 wrangler.toml 已含 workers_dev = true → workers.dev URL 自動啟用 + │ + ├─ 6. seed recipe 進 RECIPES KV(部署後打新 cypher URL,或直接 CF KV API 寫) + │ - auth recipe:重用 AUTH_RECIPE_SEEDS(cypher-executor/src/lib/auth-recipe-seeds.ts) + │ - API recipe:新增 seed-api-recipes.ts(見 §5) + │ + ├─ 7. 寫回 config(mode: self-hosted + 所有 id + cypher_executor_url = 部署後 workers.dev URL) + │ + └─ 8. 印手動 secret 提示: + wrangler secret put ENCRYPTION_KEY --name arcrun-cypher-executor + wrangler secret put ENCRYPTION_KEY --name arcrun-auth-static-key + wrangler secret put ENCRYPTION_KEY --name arcrun-auth-service-account + (三 Worker 共用同一把 key,見 memory: encryption-key-drift-trap) +``` + +### `acr update`(同一條路,未來新零件) +- 拉新 GitHub release → 解壓 → 注入既有 config 的 KV id → wrangler deploy 變動的 Worker。 +- 第一期至少做到「重跑等效 init 的部署步驟」;diff-only 部署可後續優化。 + +--- + +## 4. 動到的檔案 + +| 檔案 | 動作 | +|---|---| +| `cli/src/commands/init.ts` | 改寫 `initSelfHosted()`(line 105-131)為 installer 流程 | +| `cli/src/lib/cf-api.ts` | 擴充:KV namespace 建立/list、R2 bucket 建立、subdomain 查詢、token verify | +| 新增 `cli/src/lib/deploy.ts`(暫定)| 下載 release tarball + 解壓 + 注入 wrangler.toml + shell out wrangler deploy | +| 新增 `cli/src/commands/update.ts`(暫定)| `acr update`:拉新 release 重部署 | +| 新增 `cli/src/lib/api-recipe-seeds.ts` | API recipe **種子資料**(installer 用;放 CLI 端,**不放 cypher-executor/src**——rule 02 §2.2 hook 擋 cypher-executor TS hard-code endpoint,且 seed 資料本就屬 installer 職責)| +| 新增 `cypher-executor/scripts/seed-api-recipes.ts` | seed **腳本**(給 prod 補灌用,import CLI 的種子資料;`scripts/` 不受 §2.2 hook 管)| +| `cli/src/index.ts` | 註冊 `acr update` 指令 | + +**不動**:cypher-executor 執行路徑、既有零件 wasm 源碼、config 讀取端(config.ts:52 已支援 self-hosted)。 + +--- + +## 5. API recipe seed(新增 seed-api-recipes.ts,richblack 2026-06-02 定) + +codebase 只有 auth recipe seed。新增 `seed-api-recipes.ts`,把現役 API recipe hard-code 成種子。 + +### 現役 API recipe(從 prod KV 查得,2026-06-01) +- `kbdb_get`(+ create_block / patch_block / delete / ingest)→ auth_service: kbdb +- `gmail_send` → google_gmail_sa +- `google_sheets_append` / `google_sheets_read` → google_sheets_sa +- `telegram_send` → telegram +- `line_notify_send` → line_notify + +### KBDB recipe 採 Supabase 模式(richblack 2026-06-02) +- **KBDB 是 richblack 提供的服務**(跟 arcrun 一樣),採「基礎免費、大量收費」。 +- KBDB recipe **進 seed**(展示能力 = 引子,Supabase 模式)。使用者要用 → 去 **arcrun 取統一 API Key**(已有 /register 入口),把 key 設成 credential。 +- ⚠️ **FOLLOW-UP(交 KBDB 端)**:現役 endpoint 是 `kbdb.finally.click{{_path}}`。richblack:這是 KBDB 端要改的問題——KBDB 該用統一對外網址提供大家用,不是 finally.click。**seed 先照現況進;KBDB 端改網址後同步更新 seed。** 此事不擋 init 實作。 + +--- + +## 6. 部署物產製:commit wasm 進 repo + codeload tarball(richblack 2026-06-02 定案) + +> 此節**取代初稿的「GitHub release artifact」構想**。richblack 拍板更輕的做法: +> 直接把預編譯 wasm commit 進 repo,CLI 從 GitHub codeload tarball 拿。不需 release.yml 機制。 + +### 6.1 策略 + +- **repo 自帶可部署的 wasm**:刪 `.gitignore` 的 `*.wasm` 排除,commit 預編譯 wasm 進 repo。 + → repo 本身就是部署來源,CLI 直接拿、用戶用自己的 CF token deploy。 +- **CLI 走 codeload tarball**:`https://codeload.github.com/richblack/arcrun/tar.gz/{ref}` + (ref = main 或 tag)。用戶不需 git、版本可控(tag)。`acr update` 拉新 ref。 +- **理由**(richblack):「我在我的 CF 能用 = 我已擁有 wasm;用戶指向我的 GitHub 取得 wasm, + 用他自己的 CF credential deploy。開源,看不看源碼不重要,體驗好最重要。」 + +### 6.2 ⚠️ 推翻既有鐵律(rule 05)— 需同步改規則 + +`.claude/rules/05-deploy-convention.md` 明文「`.component-builds/{name}/component.wasm` **不 commit 進 repo** +(build 產物)」「Phase 1-3 暫時 commit 過,**之後會加 .gitignore 清理**」。 +**本決策反向**:commit wasm 進 repo(self-host 需 repo 自帶可部署 wasm)。 +→ **實作時必須同步改 rule 05 + .gitignore**,否則 pre-write hook / 規則與實作打架。 +→ deploy.yml 的 CI rebuild 步驟仍保留(CI 部署 prod 時用最新 source rebuild,與 commit 的 wasm 不衝突; + commit 的 wasm 是給「self-host 用戶 + acr init」用的部署來源)。 + +### 6.3 只 commit 部署所需的 wasm(省空間) + +- 實況(2026-06-02 查):`registry/components/*.wasm` 23 個(build 中間產物)+ + `.component-builds/*/component.wasm` 22 個(部署物),共 **~50MB**。 +- **部署只需 `.component-builds/*/component.wasm`**(wrangler deploy 認這個)。 + → **只 commit `.component-builds/*/component.wasm`(22 個),不必 commit registry 那 23 個**(省一半)。 + `.gitignore` 改成:保留排除 `registry/components/**/*.wasm`(中間產物),只放行 `.component-builds/**/component.wasm`。 +- ⚠️ **誠實 trade-off**(mindset §7):commit wasm 進 repo → 每次 wasm rebuild 都在 git 歷史累積二進位, + **repo 長期會膨脹**。可接受(self-host 體驗優先),但記錄此代價;未來若膨脹過劇,再考慮 release artifact / git-lfs。 + +### 6.3.1 「錯做成零件」的 3 個不 commit(richblack 2026-06-02) + +實際 commit 的是 **19 個正當零件**,不是 22。排除的 3 個:`claude_api` / `km_writer` / `kbdb_upsert_block`。 +- **原因(richblack 修正「待刪」說法)**:它們**不是 endpoint 薄殼,是把工作流硬塞進零件**(違反 DECISIONS §1)。 + 例:`kbdb_upsert_block` 的 upsert 邏輯應在 KBDB API 那邊(API 提供 upsert endpoint),零件只該驅動它; + 現在卻把「GET 找→有則 PATCH 無則 POST」整段工作流塞進零件。本質是工作流/recipe,被錯做成零件。 +- **為何「現在就不 commit」而非「先 commit 之後刪」**:commit 二進位進 git 歷史後,即使日後 `git rm`, + 歷史裡仍永久殘留(repo 體積已被佔),除非 rewrite history(很麻煩)。**錯誤的東西不灌進永久歷史。** +- **落地**:`.gitignore` 放行 22 個後**再排除這 3 個**(後出現規則勝出);`deploy.ts discoverWorkerDirs` + 只部署「同時有 wrangler.toml + component.wasm」的目錄 → self-host 用戶 codeload 拿到的目錄缺這 3 個 wasm → 自然跳過。 +- **後續**:這 3 個的降級(變回工作流/recipe)是 BACKLOG 既有待辦,本次不處理,但確保它們不進 self-host 部署來源。 + +### 6.4 CLI deploy 流程(deploy.ts downloadAndDeploy 補實作) + +``` +1. 下載 codeload tarball(ref 預設 main,acr update 可帶 tag)→ 解壓 ~/.arcrun/.deploy-/ +2. 讀解壓出的 .component-builds/* + cypher-executor/ + registry/ +3. 各 wrangler.toml 注入 ctx.kvNamespaceIds + cypher-executor WORKER_SUBDOMAIN +4. tier1=.component-builds/*(先)→ tier2=cypher-executor/registry(後) + 每個 dir:pnpm install(若有 lock)→ CLOUDFLARE_API_TOKEN=<用戶> wrangler deploy +5. 回 cypherExecutorUrl = https://arcrun-cypher-executor..workers.dev +``` + +注意:tier2(cypher-executor/registry)是 TS,wrangler deploy 會在用戶端用內建 esbuild bundle +(不需額外工具,richblack 確認源碼可見不重要、體驗優先 → artifact 含 TS 源碼即可)。 + +### 6.5 實作順序 + +1. 改 `.gitignore`(放行 `.component-builds/**/component.wasm`)+ commit 22 個 wasm。 +2. 同步改 rule 05(記錄此決策推翻原慣例)。 +3. 補實 `deploy.ts downloadAndDeploy`(codeload 下載 + 注入 + wrangler deploy)。 +4. **在 1-2 完成前,downloadAndDeploy 維持誠實 unimplemented,不假裝(mindset §7)。** + +### 6.6 未來方向:零件按需安裝(richblack 2026-06-02,現在不做) + +- 現在 `acr init --self-hosted` **全裝基礎零件**(22 個一次部署)。簡單、夠用。 +- **未來若零件數量真的變很多**,再思考「按需安裝」(只裝 workflow 實際用到的零件 / 用戶選裝)。 +- **現在不做的理由**(DECISIONS 附錄「會不會累積成債」):零件目前少且未來絕大多數是 recipe + (不需 deploy)→ 為「零件爆量」做按需安裝基建 = 為不存在的規模做自動化 = 過度工程。 + 零件真的爆量再回頭做,屆時是「未來一次性處理的設計點」,現在不必焦慮。 + +--- + +## 7. 驗收標準(客觀證據,mindset §7) + +1. richblack 用**全新 CF 帳號** + wrangler 已裝 + 一個 CF API Token 跑 `acr init --self-hosted` + → 全程無手動建 KV / 無手動 clone / 無 tinygo / 無手動填 namespace id。 +2. 跑完印 secret 提示,richblack 手動 `wrangler secret put ENCRYPTION_KEY` ×3。 +3. `acr push` 一個含 http_request + 自建 recipe 的 workflow → trigger → **HTTP 2xx + execution trace**。 +4. 冪等:重跑 init 不重建已存在 KV / 不報錯。 +5. `acr update` 拉新 codeload tarball(tag)→ 重部署成功。 + +--- + +## 8. 為何不違反鐵律 + +- 只動 `cli/` + 新增 `cypher-executor/scripts/`(seed 腳本,非執行路徑業務邏輯)。 +- 不在 `registry/components/` 寫 TS;不在 cypher-executor TS 實作 credential/auth/JWT。 +- 不新增 Service Binding。 +- secret 不進自動化(§3 step 8 手動)。 +- 不重寫部署輪子(用 wrangler,不自寫 CF Script Upload)。 diff --git a/.agents/specs/arcrun/sdk-and-website/tasks.md b/.agents/specs/arcrun/sdk-and-website/tasks.md index 663e1d6..0ab0135 100644 --- a/.agents/specs/arcrun/sdk-and-website/tasks.md +++ b/.agents/specs/arcrun/sdk-and-website/tasks.md @@ -112,6 +112,24 @@ --- +## Phase 5:acr init --self-hosted installer(2026-06-02 新增) + +> 定稿 design:`self-hosted-init.md`。CLI = installer:建 KV/R2 + 拉預編譯 wasm + wrangler deploy + seed。 +> 用戶只做:申請 CF 帳號 → 裝 wrangler → 裝 acr → acr init --self-hosted。其餘自動。 +> 背景:戰法轉 self-hosted 開源(docs/HANDOFF-self-host-harness.md)。 + +- [x] 13.1 API recipe 種子 — **位置修正**:種子資料放 `cli/src/lib/api-recipe-seeds.ts`(installer 用,避開 cypher §2.2 hook),seed 腳本 `cypher-executor/scripts/seed-api-recipes.ts`(import 種子,給 prod 補灌)。10 個現役 recipe(kbdb_*/gmail_send/google_sheets_*/telegram_send/line_notify_send)。KBDB Supabase 模式進 seed(finally.click 是 KBDB 端 follow-up,已註於 api-recipe-seeds.ts) +- [x] 13.2 `cli/src/lib/cf-api.ts` 新增 `CfAccountClient`:verifyAccess / listKvNamespaces / ensureKvNamespace(冪等)/ ensureR2Bucket(冪等)/ getWorkersSubdomain +- [x] 13.3 `cli/src/commands/init.ts` `initSelfHosted()` 改寫:驗 token → 建 7 KV + R2 → 查 subdomain → downloadAndDeploy → 寫 config → seed(部署完成時)→ 印 secret 提示。誠實:部署未自動化時明說,不假綠 +- [x] 13.4 `cli/src/lib/deploy.ts`:REQUIRED_KV/R2/SECRET 常數 + wranglerAvailable() + **downloadAndDeploy 已補實**(codeload tarball 下載 + 解壓 + discoverWorkerDirs 分 tier + injectWranglerConfig 注入 KV id/subdomain + runWranglerDeploy;部分失敗誠實收集回報,不假綠) +- [x] 13.5 `cli/src/commands/update.ts` + index.ts 註冊 `acr update`(self-hosted 重部署,同走 downloadAndDeploy) +- [x] 13.6 部署物產製:**改用 commit wasm 進 repo + codeload**(取代 release artifact,richblack 2026-06-02,§6) + - `.gitignore` 否定規則放行 `.component-builds/**/component.wasm`(registry 中間產物仍排除)→ 已驗 git check-ignore + - rule 05 同步改(記錄推翻「wasm 不 commit」+ trade-off) + - commit 22 個 `.component-builds/*/component.wasm` 進 repo +- [ ] 13.7 驗收:全新 CF 帳號跑 acr init --self-hosted 全自動;acr push workflow → trigger 2xx + trace(**待 richblack 用第二帳號實測** + push 含 wasm 的 commit 到 GitHub 後 codeload 才拿得到) +- [x] 13.8 typecheck:cli `tsc --noEmit` exit 0 + ## Notes - JS SDK 套件名需 richblack 決定(`arcrun` 已被 CLI 佔用 → 可能用 `@arcrun/sdk`) diff --git a/.agents/specs/component-gatekeeping/recipe-push-gatekeeping.md b/.agents/specs/component-gatekeeping/recipe-push-gatekeeping.md new file mode 100644 index 0000000..1c75f93 --- /dev/null +++ b/.agents/specs/component-gatekeeping/recipe-push-gatekeeping.md @@ -0,0 +1,105 @@ +# Design 補充:recipe 入庫把關(push 那一刻) + +> 2026-06-01。本檔是 `component-gatekeeping/design.md` 的單檔補充(規則 02 §4.3 允許)。 +> **狀態:待 richblack review 才動 code(這是 change)。** +> 背景:richblack 2026-06-01 方向修正(見下「方向定調」)+ docs/HANDOFF-self-host-harness.md。 + +--- + +## 0. 方向定調(richblack 2026-06-01) + +把關的對象與位置整個移位了,先講清楚才不會做歪: + +### 0.1 零件這條路 = 封鎖,且「不再有假零件這件事」 +- 零件由維護者(richblack)管理,**CC 不能自製/修改零件**。 +- 封鎖機制 = **零件投稿走 GitHub PR + 人 merge**(DECISIONS §8 / design 頂部方向修正)。 + AI 偽造不了 GitHub approve,這是天然人類閘門。CC 在本機產不出能進庫的零件。 +- → 因此「擋假零件」(原 W1)這件事**不存在了**:CC 根本造不出零件,workflow 引用 recipe + (如 `component: kbdb_get`)是**合法且未來唯一的擴充方式**,不該被當假零件擋。 + +### 0.2 零件 PR 把關 = 人工,不自動化(除非未來爆量) +- richblack 2026-06-01 澄清 BACKLOG 步驟5「不做 hook/自動化」的真意: + **零件真實數量很少、絕大多數是 recipe** → 原本想做的「驗證零件 PR 的自動化機制」 + (CI 跑 Gherkin/沙箱/向量)**不需要**,量少 → **有 PR 進來就人工檢查**。 + **只有零件開發量變很大時**才回頭想自動化。 +- → component-gatekeeping 的 G4/覆蓋檢查/黃金向量自動化 = **不做**(人工取代),與既有 tasks 收尾一致。 + +### 0.3 真正的 harness 把關 = recipe 入庫(push)那一刻 +CC 唯一能擴充的是 recipe。recipe 一律用「**推(push)**」,**自有庫與公共庫同一套指令**。 +把關依庫別分強度: + +| 庫別 | 能做到的把關 | 機制 | +|---|---|---| +| **自有庫(self-hosted)** | 只能**提醒**(無法在別人機器強制) | (1) 資料外流提醒 (2) 打通檢查 | +| **公共庫** | 維護者機制**檢核實際打通、真收到成功回傳** | PR/CI relay(DECISIONS §3c,第一期後)| + +--- + +## 1. 自有庫 push 把關(self-hosted,第一期做) + +`acr recipe push` 的兩個提醒。**提醒級 = 告知 + 需人類明示同意,不硬擋**(self-hosted 是用戶自己的庫, +他同意後就是他的責任 — mindset §6 / data-exfil-warning 既有原則)。 + +### 1.1 資料外流提醒(W2.2) +- **觸發**:push 的 recipe / 或部署的 workflow 會讓「資料對外可見」——主要是產生**對外可被呼叫的 webhook** + (`POST /webhooks/named/...` 對外 trigger URL),或 recipe 把本地資料 POST 到外部服務。 +- **行為**:CLI 印明確警示「這個動作會讓 X 對外界可見/可呼叫,確認要繼續嗎?」→ 需人類明示同意(y/N)。 + 非 TTY(AI 直跑)→ 拒絕,提示「需人類確認」(mindset §7:絕不代替人類做暴露確認)。 +- **與既有 data-exfil-warning 的關係**:已有 API 層 + pre-bash hook(commit 51d40ee 等)。 + 本項確認**涵蓋 recipe push 這條路徑**;若已涵蓋則只補文件,若沒涵蓋則補上 push 路徑的提醒。 +- **誠實限制**:AI 技術上能偽造 exposure_consent。價值是法律歸責 + 軌跡可審,不聲稱不可繞過(mindset §7)。 + +### 1.2 打通檢查(W2.3) +- **目的**:recipe 是「指向外部 API 的指針」,正確性一半在「打不打得通」(DECISIONS §1 recipe 驗收標準 = 2xx)。 +- **行為**:push 時(或 push 後)對 recipe 的 endpoint **實打一次**,回報 HTTP status。 + - 2xx → 「✓ recipe 打通(HTTP 200)」 + - 4xx/5xx → 「⚠️ recipe 未打通(HTTP 401/404/...)」+ 誠實標原因(如「缺 credential → 先 acr creds push」) + - 連不上 → 「⚠️ 無法連線」 +- **self-hosted 是提醒級**:打不通**不硬擋 push**(用戶可能就是要先 push 再設 credential),只如實回報。 +- **誠實**(mindset §7):缺 credential 打不到 2xx 就誠實標「未驗收:缺 X」,不 mock 充綠燈。 + +### 1.3 動到的檔案(待 review 後) +| 檔案 | 動作 | +|---|---| +| `cli/src/commands/recipe.ts` | push 流程加 (1) 資料外流提醒 prompt (2) 打通檢查(實打 endpoint 回報 status) | +| `.claude/hooks/*`(如需)| 確認 data-exfil pre-bash hook 涵蓋 recipe push;缺則補 | + +**不動**:cypher-executor 執行路徑、零件、credential 解密邏輯。 + +--- + +## 2. 公共庫 push 把關(第一期後) + +- recipe 進公共庫 = 別人會用 → 需維護者機制檢核「**實際打通、真收到成功回傳**」(不是投稿者自報)。 +- 機制:DECISIONS §3c 的 **test/relay**——push 公共庫走 relay,維護者當下親見真實打通記錄 + (執行者不能驗證自己,§7 閉環)。 +- **範圍**:依賴公共庫 + relay 基建,**第一期不做**(第一期是 self-hosted + 提醒級)。 +- 本檔只記框架,第一期不實作。 + +--- + +## 3. 同一套指令、不同把關強度的切分(W2.4) + +- `acr recipe push`(自有庫,預設)→ §1 提醒級。 +- `acr recipe push --public`(公共庫,未來)→ §2 relay 檢核級。 +- 同一指令、旗標分流(呼應 design §4.3 公私庫分流:`-p`/`--public`)。 +- 第一期只實作預設(自有庫)路徑。 + +--- + +## 4. 驗收標準(客觀證據,mindset §7) + +第一期(自有庫提醒級): +1. `acr recipe push` 一個會產對外 webhook 的東西 → CLI 印資料外流警示 + 要人類同意;非 TTY → 拒絕。 +2. `acr recipe push` 一個 endpoint 可達的 recipe → 打通檢查回報「✓ HTTP 2xx」。 +3. `acr recipe push` 一個缺 credential 的 recipe → 回報「⚠️ 未打通:缺 credential」(誠實,不假綠),但仍允許 push。 +4. 確認 workflow 引用 recipe(`component: kbdb_get`)**不再被任何 validate 步驟當假零件擋**(W1 已作廢)。 + +--- + +## 5. 與既有 SDD 的一致性確認(無新矛盾) + +- 不動「零件投稿走 PR + 人工檢查」(§0.1/0.2,與 design 頂部方向修正、DECISIONS §8 一致)。 +- 不重啟「零件 PR 自動化把關」(§0.2,與 BACKLOG 步驟5 真意一致)。 +- 資料外流提醒延續既有 data-exfil-warning 原則(mindset §6),只確認涵蓋 recipe push 路徑。 +- 打通檢查 = recipe 驗收標準 2xx 的落地(DECISIONS §1)。 diff --git a/.agents/specs/component-gatekeeping/tasks.md b/.agents/specs/component-gatekeeping/tasks.md index 49f9b92..57f60ab 100644 --- a/.agents/specs/component-gatekeeping/tasks.md +++ b/.agents/specs/component-gatekeeping/tasks.md @@ -63,6 +63,35 @@ - [ ] 5.5 pre-write-guard.sh:寫 `registry/components/{白名單外}/` → exit 2 - [ ] 5.6 pre-bash-guard.sh:mkdir `registry/components/{白名單外}` → exit 2 +## W1 ~~CLI workflow validate 擋假零件式 component 名~~(2026-06-01 作廢,方向修正) + +> **作廢原因(richblack 2026-06-01)**:「擋假零件」這件事不再存在——因為**自製/修改零件的路 +> 已被封鎖**(CC 根本造不出零件),workflow 引用 recipe(如 component: kbdb_get)是**合法且 +> 未來唯一的擴充方式**,不該被當「假零件」擋。把關點從「workflow validate」**移到 recipe 入庫 +> (push)那一刻**。已動的 yaml-parser.ts `LEGAL_PRIMITIVES`/`findSuspectComponents` 已回退。 +> 取而代之 → 見 W2。 + +## W2 封鎖自製零件 + recipe 入庫把關(2026-06-01 新方向) + +> richblack 2026-06-01 定調: +> - 零件由維護者管理,**CC 不能自製/修改零件**(hook + CLI 拒絕)→ 不再有「假零件」。 +> - CC 唯一能擴充的是 **recipe**。recipe 一律用「推(push)」,**自有庫與公共庫同一套指令**。 +> - 把關依庫別分強度: +> - **自有庫(self-hosted)**:只能**提醒**(無法在別人機器強制)。兩個提醒: +> (1) 資料外流提醒——某動作會讓外界看到你的東西(如 workflow 產對外 webhook),同意後是他的責任; +> (2) 打通檢查——查他要打的 API 是否打得通(2xx)。 +> - **公共庫**:由維護者機制檢核「實際打通、真收到成功回傳」(PR/CI relay,DECISIONS §3c,第一期後)。 +> 屬 change,需先寫 design 給 richblack review 才動 code。本節先記框架。 + +- [x] W2.1 封鎖自製零件 — **釐清完成(richblack 2026-06-02)**:靠「零件投稿走 GitHub PR + 人 merge」 + 天然閘門(DECISIONS §8)。BACKLOG 步驟5「不做 hook」真意 = 零件少、不為零件 PR 蓋自動化把關 + (量少人工檢查;爆量才回頭想),**不是**不阻止自製。無矛盾,不需新做 hook。 +- [x] W2.2 `acr recipe push` 資料外流提醒 — **既有實作已涵蓋**:recipe.ts:70-79 `obtainExposureConsent` + (exposure-warning.ts:互動打資源名確認、非 TTY 拒絕、首次問記住)。data-exfil-warning SDD 已做,確認涵蓋 recipe push 路徑。 +- [x] W2.3 `acr recipe push` 打通檢查 — **新增** `probeRecipeEndpoint`(recipe.ts):push 成功後實打 endpoint, + 回報 2xx/⚠。提醒級不硬擋;endpoint 含 {{模板}} → 誠實說明待 run 才知;401/403 → 標「多半缺 credential,非 recipe bug」(不假綠,mindset §7) +- [ ] W2.4 公共庫 push(--public)= 維護者 relay 檢核(DECISIONS §3c)— 第一期後,本期只做自有庫提醒級 + ## 驗收(design §8) - [ ] V1 投寫死 endpoint 假零件 → G1 退稿(終端輸出) - [ ] V2 投 `.ts` 進 registry/components → hook exit 2 diff --git a/.claude/rules/05-deploy-convention.md b/.claude/rules/05-deploy-convention.md index d92d478..1f62bd4 100644 --- a/.claude/rules/05-deploy-convention.md +++ b/.claude/rules/05-deploy-convention.md @@ -85,12 +85,27 @@ find . -name 'wrangler.toml' -not -path '*/node_modules/*' -not -name 'wrangler. ## WASM 來源 -`.component-builds/{name}/component.wasm` 不 commit 進 repo(build 產物)。 +> **⚠️ 慣例變更(richblack 2026-06-02,self-hosted 開源策略)**: +> 原慣例「`.component-builds/{name}/component.wasm` 不 commit 進 repo」**已推翻**。 +> 現在 **commit `.component-builds/*/component.wasm` 進 repo**,因為 self-host 用戶 / `acr init --self-hosted` +> 從 GitHub(codeload tarball)直接拿這份 wasm 部署到自己的 CF——repo 必須自帶可部署的 wasm。 +> 決策依據:`.agents/specs/arcrun/sdk-and-website/self-hosted-init.md §6`。 -- 本地開發:`cd registry/components/{name} && tinygo build -target=wasi -o {name}.wasm main.go && cp {name}.wasm ../../../.component-builds/{name}/component.wasm` -- CI:workflow 在 deploy 前自動 rebuild + copy +### 現行規則(2026-06-02 起) -**例外**:Phase 1-3 開發期為了加速驗證,部分 WASM 檔暫時 commit 進了 repo。之後會加 `.gitignore` 清理。 +- **`.component-builds/*/component.wasm` → commit 進 repo**(部署來源)。`.gitignore` 用否定規則放行: + ``` + *.wasm # 預設排除 + !.component-builds/**/component.wasm # 例外放行部署物 + ``` +- **`registry/components/*.wasm` → 仍不 commit**(build 中間產物,部署不直接用,`.gitignore` 仍排除)。 +- 本地開發 build:`cd registry/components/{name} && tinygo build -target=wasi -o {name}.wasm main.go && cp {name}.wasm ../../../.component-builds/{name}/component.wasm`,**然後 commit `.component-builds/{name}/component.wasm`**。 +- CI(deploy.yml):仍在 deploy 前自動 rebuild + copy(部署 prod 用最新 source;與 repo 內 commit 的 wasm 不衝突——前者給 CI deploy prod,後者給 self-host 用戶當部署來源)。 + +### 誠實 trade-off(mindset §7) + +commit wasm 進 repo → 每次 rebuild 在 git 歷史累積二進位,**repo 長期會膨脹**。 +可接受(self-host 體驗優先),未來若膨脹過劇再考慮 git-lfs / 按需安裝(self-hosted-init.md §6.6)。 --- diff --git a/.component-builds/array_ops/component.wasm b/.component-builds/array_ops/component.wasm new file mode 100644 index 0000000..c0720f5 Binary files /dev/null and b/.component-builds/array_ops/component.wasm differ diff --git a/.component-builds/auth_oauth2/component.wasm b/.component-builds/auth_oauth2/component.wasm new file mode 100644 index 0000000..1b2d74e Binary files /dev/null and b/.component-builds/auth_oauth2/component.wasm differ diff --git a/.component-builds/auth_service_account/component.wasm b/.component-builds/auth_service_account/component.wasm new file mode 100644 index 0000000..0a526e6 Binary files /dev/null and b/.component-builds/auth_service_account/component.wasm differ diff --git a/.component-builds/auth_static_key/component.wasm b/.component-builds/auth_static_key/component.wasm new file mode 100644 index 0000000..86e9940 Binary files /dev/null and b/.component-builds/auth_static_key/component.wasm differ diff --git a/.component-builds/cron/component.wasm b/.component-builds/cron/component.wasm new file mode 100644 index 0000000..ea658f8 Binary files /dev/null and b/.component-builds/cron/component.wasm differ diff --git a/.component-builds/date_ops/component.wasm b/.component-builds/date_ops/component.wasm new file mode 100644 index 0000000..19e08bb Binary files /dev/null and b/.component-builds/date_ops/component.wasm differ diff --git a/.component-builds/filter/component.wasm b/.component-builds/filter/component.wasm new file mode 100644 index 0000000..debfee9 Binary files /dev/null and b/.component-builds/filter/component.wasm differ diff --git a/.component-builds/foreach_control/component.wasm b/.component-builds/foreach_control/component.wasm new file mode 100644 index 0000000..eca5999 Binary files /dev/null and b/.component-builds/foreach_control/component.wasm differ diff --git a/.component-builds/http_request/component.wasm b/.component-builds/http_request/component.wasm new file mode 100644 index 0000000..1356d94 Binary files /dev/null and b/.component-builds/http_request/component.wasm differ diff --git a/.component-builds/if_control/component.wasm b/.component-builds/if_control/component.wasm new file mode 100644 index 0000000..af45ec1 Binary files /dev/null and b/.component-builds/if_control/component.wasm differ diff --git a/.component-builds/merge/component.wasm b/.component-builds/merge/component.wasm new file mode 100644 index 0000000..a9a0763 Binary files /dev/null and b/.component-builds/merge/component.wasm differ diff --git a/.component-builds/number_ops/component.wasm b/.component-builds/number_ops/component.wasm new file mode 100644 index 0000000..e0f2952 Binary files /dev/null and b/.component-builds/number_ops/component.wasm differ diff --git a/.component-builds/platform_crypto/component.wasm b/.component-builds/platform_crypto/component.wasm new file mode 100644 index 0000000..3c01408 Binary files /dev/null and b/.component-builds/platform_crypto/component.wasm differ diff --git a/.component-builds/set/component.wasm b/.component-builds/set/component.wasm new file mode 100644 index 0000000..da52b78 Binary files /dev/null and b/.component-builds/set/component.wasm differ diff --git a/.component-builds/string_ops/component.wasm b/.component-builds/string_ops/component.wasm new file mode 100644 index 0000000..643bc48 Binary files /dev/null and b/.component-builds/string_ops/component.wasm differ diff --git a/.component-builds/switch/component.wasm b/.component-builds/switch/component.wasm new file mode 100644 index 0000000..9da0390 Binary files /dev/null and b/.component-builds/switch/component.wasm differ diff --git a/.component-builds/try_catch/component.wasm b/.component-builds/try_catch/component.wasm new file mode 100644 index 0000000..cf6426f Binary files /dev/null and b/.component-builds/try_catch/component.wasm differ diff --git a/.component-builds/validate_json/component.wasm b/.component-builds/validate_json/component.wasm new file mode 100644 index 0000000..b4d63d4 Binary files /dev/null and b/.component-builds/validate_json/component.wasm differ diff --git a/.component-builds/wait/component.wasm b/.component-builds/wait/component.wasm new file mode 100644 index 0000000..fb1d43b Binary files /dev/null and b/.component-builds/wait/component.wasm differ diff --git a/.gitignore b/.gitignore index 465a007..a0ee628 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,17 @@ node_modules/ .wrangler/ dist/ +# wasm:預設排除(build 中間產物,如 registry/components/*.wasm) *.wasm +# 例外:放行 .component-builds 的部署物 wasm — self-host 用戶 / acr init 從 repo 直接拿這份部署 +# (推翻 rule 05 原「wasm 不 commit」慣例,見 .agents/specs/arcrun/sdk-and-website/self-hosted-init.md §6) +!.component-builds/**/component.wasm +# 但「錯做成零件」的再次排除(後出現的規則勝出):claude_api / km_writer / kbdb_upsert_block +# 不是 endpoint 薄殼,是把工作流硬塞進零件(違反 DECISIONS §1)→ 要降級成工作流/recipe, +# 不該進 repo 部署來源。commit 二進位進歷史無法乾淨移除 → 一開始就不放行。見 BACKLOG 降級待辦。 +.component-builds/claude_api/component.wasm +.component-builds/km_writer/component.wasm +.component-builds/kbdb_upsert_block/component.wasm credentials.yaml ~/.arcrun/ .env diff --git a/README.md b/README.md index 40fc74d..ca53544 100644 --- a/README.md +++ b/README.md @@ -2,56 +2,81 @@ **讓 AI 把想法直接變成自動化工作流,而不是一行一行寫程式。** +arcrun 是一套**給 AI(Claude Code)用的 harness**:你叫 CC 用 arcrun 開發自動化時,它知道能用什麼、不能做什麼,做錯了有機制擋住——讓 CC 順暢、且不容易做歪。 + --- ## 為什麼做這個? -AI 愈來愈強,但跟 AI 協作還是很有摩擦: +AI 愈來愈強,但跟 AI 協作做自動化還是很有摩擦: **問題一:AI 調 API 太耗 token。** -每次讓 AI 幫你查資料、發郵件、寫入試算表,它都要在對話裡描述、確認、執行,token 燒很快。更好的做法是讓 AI 把任務寫成一次性的程式,之後直接執行。 +每次讓 AI 幫你查資料、發信、寫試算表,它都要在對話裡描述、確認、執行,token 燒很快。更好的做法是讓 AI 把任務寫成一次性的工作流,之後直接執行。 -**問題二:AI 寫程式容易出 bug,debug 又耗時。** -讓 AI 從頭寫一個完整的爬蟲或自動化腳本,十次有八次要來回修。根本原因是零件沒有被複用——每次都是全新的程式。 +**問題二:AI 從頭寫腳本容易出 bug。** +讓 AI 從零寫一整個自動化腳本,十次有八次要來回修。根本原因是邏輯沒被複用——每次都是全新的程式。 -**解法:把邏輯寫成工作流,零件由社群反覆驗證。** -想到工作流,你可能想到 n8n。很好,arcrun 就是這個方向,但為 AI 優先設計: +**問題三:第三方依賴是單點故障。** +每多依賴一個不可掌控的第三方(含 SaaS 平台本身),就多一個會掛掉、會漲價、會關服務的風險。 + +**解法:把邏輯寫成純文字工作流,跑在你自己的 Cloudflare 上。** +想到工作流你可能想到 n8n。方向一樣,但 arcrun 為 AI 優先設計、且**開源自架**: | | n8n | arcrun | |---|---|---| -| 語法 | JSON / 拖拉介面,檔案動輒數千行 | 三行 Cypher,AI 和人都能直接讀 | +| 語法 | JSON / 拖拉介面,動輒數千行 | 三行 Cypher,AI 和人都能直接讀 | | context 消耗 | 大,容易超出 window | 極小,整個 workflow 幾十個 token | -| 零件擴充 | GUI 拖拉,人類操作 | CLI 一行,AI 自己寫零件、自己提交 | -| 執行環境 | 需要自架或付費訂閱 | 從本機一行指令跑起來 | +| 擴充方式 | GUI 拖拉,人類操作 | 寫一段 recipe(純文字),AI 自己會寫 | +| 執行環境 | 需自架或付費訂閱 | 部署到**你自己的** Cloudflare,你說了算 | -arcrun 的核心假設:**最好的 AI 協作工具,應該讓 AI 自己能讀、能寫、能執行、能除錯。** +核心假設:**最好的 AI 協作工具,應該讓 AI 自己能讀、能寫、能執行、能除錯——而且整套跑在你掌控的環境裡。** --- -## 專案定位 +## 核心概念:零件 vs recipe(最重要,先讀這個) -| 層級 | 內容 | 授權 | -|------|------|------| -| **開源核心** | cypher-executor、21 個 WASM 零件、credentials Worker、CLI(`acr`) | MIT | -| **Hosted** | 一行取得 API Key,workflow 和 credential 永遠在你自己的 Cloudflare KV | 免費 | -| **Self-hosted** | Fork 後自行部署,完全掌控 | MIT | +arcrun 只有兩種東西,分清楚就不會做歪: -人用也完全沒問題。但每一個設計決定都優先問:「AI 用起來方不方便?」 +### 零件(primitive)— 固定的一小套,由維護者管理,**你不自製** +- 是 WASM 程式,要部署,幾乎不變。**只有四類**: + 1. **流程控制**:`if_control` / `switch` / `filter` / `foreach_control` / `try_catch` / `wait` + 2. **資料處理**:`string_ops` / `number_ops` / `array_ops` / `date_ops` / `set` / `merge` / `validate_json` + 3. **`http_request`**:打任意 HTTP + 4. **credential(auth)**:`auth_static_key` / `auth_service_account` / `auth_oauth2` / `auth_mtls`(背後自動注入,你不直接擺) +- **零件不對外連特定服務。** 任何「打某個固定 API endpoint」的東西**都不是零件**——那是 recipe。 +- 新增零件走 **GitHub PR**(人 merge 把關),不是 self-service。日常開發你**不會也不該**自製零件。 + +### recipe — 你(或 AI)能自由擴充的東西,純文字,不用部署 +- recipe = `http_request` + 一組固定設定(endpoint / method / headers / body 模板)。 +- 要打 Notion / Slack / 你自己的 API?**寫一個 recipe**,不是做一個零件。 +- recipe 是純文字、不用 deploy、改一次零成本。AI 讀得懂 API 文件就能幫你組出 recipe。 + +> **一句話判準**:要打一個固定的外部 endpoint → **recipe**。要做流程控制 / 資料處理 / 通用 HTTP → 用既有**零件**。其他大部分情況 → 直接寫**工作流**把它們串起來。 --- -## 快速開始 +## 快速開始(Self-hosted) -### 玩法一:從你的電腦直接執行(最快,不需要 Cloudflare) +arcrun 是 self-hosted 開源:你用自己的 Cloudflare 帳號跑整套。一次裝好,之後就是你的。 -安裝 CLI,立刻寫一個 workflow 在本機跑: +### 1. 取得 Cloudflare 憑證 + +你需要一個 CF 帳號,並在 dashboard 建一個 **API Token**(權限:Workers Scripts Edit + KV Storage Edit + Workers R2 Storage Edit)。CLI 不代管你的憑證——你自己建、自己持有。 + +### 2. 一鍵初始化 ```bash npm i -g arcrun -acr init --local +acr init --self-hosted ``` -建立 `hello.yaml`: +貼上 CF Account ID + API Token,CLI 自動:建立所需 KV、部署所有 Worker(含執行引擎 cypher-executor 與全部零件)、啟用 workers.dev、寫回設定。跑完會提示你手動設定 runtime secret(如 `ENCRYPTION_KEY`),這一步刻意不自動化(secret 不進工具流程)。 + +> 想先不碰 Cloudflare、純在本機感受語法?`acr init --local` 然後 `acr run`。 + +### 3. 寫一個工作流 + +`hello.yaml`: ```yaml name: hello @@ -63,95 +88,35 @@ config: operation: upper ``` -執行: - ```bash -acr run hello --input input="Hello, world" +acr run hello --input input="Hello, world" # → HELLO WORLD ``` -結果:`HELLO WORLD` - -不需要帳號、不需要部署、不需要 API Key。直接感受 workflow 跑起來是什麼感覺。 - ---- - -### 玩法二:把 Workflow 推到 Cloudflare 雲端執行 - -workflow 部署到 arcrun.dev,任何地方都能觸發(Webhook、cron、前端按鈕)。 - -**你只需要一個 email。** 不需要 Cloudflare 帳號、不需要 API Token。 +### 4. 要打外部 API?寫一個 recipe(不是做零件) ```bash -acr init +# 上傳一個打外部 API 的 recipe(push 時會幫你檢查打不打得通) +acr recipe push my_api_recipe.yaml ``` -互動式設定,輸入 email 自動取得 API Key,workflow 和 credential 以 API Key 隔離,多租戶安全。 +recipe 裡寫好 endpoint / method / body 模板,credential 由 auth 自動注入。workflow 裡用 `component: ` 或 `component: rec_xxxx` 引用它。 -上傳 credential(加密後才送出,arcrun.dev 只存密文): +### 5. 上傳 credential(client 端加密,明文不離開你的機器) ```bash -# 查看某服務需要哪些 credential(以 Notion 為例) -acr auth-recipe scaffold notion - -# 建立 credentials.yaml,填入取得的 token -# 查看所有支援的服務 -acr auth-recipe list +acr auth-recipe list # 看支援哪些服務的認證 +acr auth-recipe scaffold notion # 取得 credentials.yaml 範本 +acr creds push credentials.yaml # AES-GCM 加密後上傳到你自己的 KV ``` -```bash -acr creds push credentials.yaml # AES-GCM 加密後上傳,token 不離開你的機器 -``` - -加密金鑰在 `acr init` 時已自動取得並存入 config,不需要手動設定。 - -部署並執行: +### 6. 部署 + 觸發 ```bash -acr push newsletter.yaml +acr push newsletter.yaml # 部署,取得 Webhook URL acr run newsletter --input email=user@example.com ``` -範例 workflow(訂閱電子報,發感謝信 + 記錄到 Google Sheets): - -```yaml -name: newsletter_subscribe - -flow: - - "input >> 完成後 >> send_thanks" - - "input >> 完成後 >> save_to_sheet" - - "send_thanks >> 失敗時 >> notify_error" - -config: - send_thanks: - to: "{{input.email}}" - subject: "感謝訂閱!" - body: "歡迎加入,我們很高興你在這裡。" - # gmail_token 從 credentials.yaml 自動注入,不需要手動填 - - save_to_sheet: - spreadsheet_id: "your-sheet-id" - range: "訂閱者!A:B" - values: [["{{input.email}}", "{{input.timestamp}}"]] - - notify_error: - chat_id: "your-telegram-chat-id" - text: "發信失敗:{{input.email}}" -``` - ---- - -### 玩法三:完全 Self-hosted - -Fork 後把全部 Worker 部署到你自己的 Cloudflare 帳號。你的零件庫、你的執行引擎、你說了算。 - -```bash -cd cypher-executor && wrangler deploy -cd ../credentials && wrangler deploy -cd ../registry && wrangler deploy -acr init --self-hosted -``` - -做好零件後可以推回 arcrun 公眾庫,讓所有人受益(見[貢獻零件](#貢獻零件))。 +> ⚠️ **部署對外 webhook = 把東西暴露給外界。** `acr push` 產生的 trigger URL 任何拿到的人都能打。CLI 會在這類動作前提醒你——確認後就是你的責任(你可以選擇加上 API Key 限制 / 權限)。 --- @@ -161,147 +126,68 @@ acr init --self-hosted "A >> 關係詞 >> B" ``` -這就是全部。每一行是一條邊,描述 A 之後發生什麼事。 +這就是全部。每一行是一條邊,描述 A 之後發生什麼。 | 關係詞 | 別名 | 說明 | |--------|------|------| | `完成後` | `ON_SUCCESS` | A 成功後執行 B | | `失敗時` | `ON_FAIL` | A 失敗時執行 B | -| `對每個` | `FOREACH` | 對 A 的每個元素執行 B | +| `對每個` | `FOREACH` | 對 A 的每個元素執行 B(支援 `對每個 X` 命名)| | `條件滿足時` | `IF` | 條件為真時執行 B | | `CALLS_SUBFLOW` | — | 呼叫另一個 workflow | -整個 workflow 通常不超過十行,AI 一眼掃完不需要捲動。 +整個 workflow 通常不超過十行,AI 一眼掃完不需捲動。 --- -## 零件 +## 為什麼零件這麼少?(這是特性,不是缺陷) -### 關於零件數量 +你可能覺得「才這幾個零件,n8n 有幾百個」。但 n8n 那幾百個零件,本質多半是「把一個 HTTP request 包成某服務的格式」。在 arcrun,那些**全部是 recipe**——純文字、AI 幫你寫、不用等誰先做好封裝。 -arcrun 目前有 21 個核心零件,你可能覺得不夠用——畢竟 n8n 有幾百個。 +對 AI 來說,一個彈性的 `http_request` + recipe,比一百個固定零件封裝更好用:AI 讀得懂 API 文件,直接組出正確的 recipe,不必等有人做「Notion 零件」。 -但大多數 n8n 零件的本質都是:把一個 HTTP request 包裝成特定服務的格式。arcrun 的 `http_request` 零件本身就能做同樣的事,差別是:**你讓 AI 幫你配置它,而不是等人工寫好一個現成的封裝。** +零件少 = 攻擊面小、要驗證的東西少、整套能塞進 AI 的 context。**長尾交給 recipe,核心保持精簡**,是刻意的設計。 -```bash -# 讓 AI 幫你設定一個 Notion 整合 -acr parts scaffold http_request -# AI 填入 Notion API endpoint、headers、body 格式,你貼上 API Key,搞定 -``` +--- -對 AI 來說,一個彈性的 `http_request` 零件比一百個固定封裝更好用——因為 AI 能讀懂 API 文件,直接組出正確的配置,不需要等有人做 Notion 零件。 +## recipe 入庫把關(誠實說明) -### 21 個核心零件 - -**整合類**(需要 Credential,credential 自動從加密 KV 注入,workflow yaml 裡看不到明文) - -| 零件 | 說明 | 所需 Credential | -|------|------|-----------------| -| `gmail` | Gmail 發信 | `gmail_token` | -| `google_sheets` | Google Sheets 讀寫 | `google_oauth` | -| `telegram` | Telegram Bot 發訊息 | `telegram_bot_token` | -| `line_notify` | LINE Notify 發訊息 | `line_token` | -| `http_request` | 任意 HTTP 請求 | — | - -**第三方服務整合(Auth Recipe,20 個)** - -除了以上核心零件,arcrun 平台預建了 20 個常用服務的認證 recipe,使用方式與一般零件相同,只需上傳對應 credential: - -```bash -acr auth-recipe list # 列出所有支援服務 -acr auth-recipe scaffold notion # 取得 credentials.yaml 範本 + workflow 範例 -``` - -支援:Notion、Slack、GitHub、OpenAI、Anthropic、Airtable、Discord、Stripe、Twilio、SendGrid、HubSpot、Linear、Shopify、Resend、Supabase、Typeform、Jira、Google Sheets SA、Gmail SA、Google Drive SA - -**控制流** - -| 零件 | 說明 | -|------|------| -| `if_control` | 條件判斷 | -| `foreach_control` | 迴圈執行 | -| `try_catch` | 錯誤處理 | -| `switch` | 多路路由 | -| `wait` | 延遲等待 | - -**資料處理** - -| 零件 | 說明 | -|------|------| -| `set` | 設定/賦值變數 | -| `filter` | 陣列過濾 | -| `merge` | 合併物件 | -| `string_ops` | 字串操作 | -| `number_ops` | 數字運算 | -| `array_ops` | 陣列操作 | -| `date_ops` | 日期操作 | - -**AI 類** - -| 零件 | 說明 | -|------|------| -| `ai_transform_compile` | 自然語言描述 → 轉換規則(Workers AI) | -| `ai_transform_run` | 執行編譯好的 AI 轉換規則 | - -**其他** - -| 零件 | 說明 | -|------|------| -| `validate_json` | JSON Schema 驗證 | -| `cron` | Cron 排程觸發 | - -取得任一零件的 workflow 配置範本: - -```bash -acr parts scaffold gmail -``` - -### 貢獻零件 - -零件是 `.wasm` 檔案,stdin 進 JSON、stdout 出 JSON。用什麼語言編譯不重要,只要輸出符合 WASI preview1 的 `.wasm` 即可。 - -**arcrun 的零件主要由 AI 撰寫。** 這個設計決定影響了語言選擇: - -| 語言 | 輸出大小 | AI 撰寫品質 | 學習曲線 | 備註 | -|------|---------|------------|---------|------| -| **TinyGo** | 極小(10–80KB) | 優秀 | 低(Go 語法簡單) | 官方零件首選;語法與 TS 差異夠大,AI 不易混淆 | -| **AssemblyScript** | 小(20–150KB) | 良好 | 低(TypeScript 語法) | 社群貢獻首選;TS 開發者上手最快 | -| **Rust** | 小–中(30–300KB) | 良好 | 高(Rust 所有權) | 效能最強;適合複雜演算法零件 | -| C / C++ | 小 | 尚可 | 高 | 不建議,現代語言更好 | - -**注意:** AssemblyScript 與 TypeScript 語法高度相似,AI 有時會把純 TS 邏輯直接搬過來造成編譯錯誤。TinyGo 語法差異夠大,AI 出錯率較低。初期如果不確定選哪個,TinyGo 是最穩的選擇。 - -**AI 可以直接幫你寫零件。** 把 API 文件和 [CONTRIBUTING.md](CONTRIBUTING.md) 一起貼給它,指定語言(TinyGo 或 AssemblyScript),它生成源碼和 `component.contract.yaml`,你編譯、測試、提交: - -```bash -acr parts publish ./my-component/ -# 沙盒自動驗收(體積、syscall 掃描、Gherkin 測試) -# 通過後立即可用,等人工審核後對所有人開放 -``` - -每個零件都帶統計數字(執行次數 × 成功率),真實使用數據說話。 +- **自有庫(你的 self-hosted)**:`acr recipe push` 時 CLI 會 (1) 提醒會不會把資料/服務暴露給外界、(2) 幫你實打一次看 API 通不通(2xx)。這是**提醒級**——arcrun 不替你做授權判斷,打不打得通最終由發 API key 的服務裁決。打不通會誠實標原因(例:缺 credential),不會假裝成功。 +- **公共庫(未來)**:recipe 要貢獻給別人用、進公共庫時,由維護者機制檢核「實際打通、真收到成功回傳」(投稿者不能自己驗證自己)。此機制在第一期之後啟用。 --- ## CLI 指令 ``` -acr init 互動式初始化(local / cloud / self-hosted) -acr creds push [file] 加密並上傳 credentials -acr push 部署 workflow +acr init --self-hosted 一鍵部署到你的 Cloudflare(貼 token,其餘自動) +acr init --local 純本機體驗語法,不需 Cloudflare +acr creds push [file] client 端加密並上傳 credentials +acr recipe push 上傳 API recipe(push 時提醒暴露 + 檢查打通) +acr push 部署 workflow,取得 Webhook URL acr run [--input k=v] 執行 workflow -acr validate 執行前驗證(零件存在、credential 已上傳) -acr parts 列出所有內建零件 +acr validate 執行前驗證格式 / 關係詞 / credential +acr parts 列出可用的核心零件 acr parts scaffold 取得零件的 workflow config 範本 -acr parts publish 提交零件至公眾庫 -acr auth-recipe list 列出所有第三方服務整合(Notion、Slack 等) -acr auth-recipe info 查看服務需要哪些 credential +acr auth-recipe list 列出支援的第三方服務認證 acr auth-recipe scaffold 取得 credentials.yaml 範本 + workflow 範例 -acr recipe push 上傳自訂 API recipe acr list 列出已部署的 workflow acr logs 查看執行記錄 ``` +> 給 AI 操盤手:開始前讀 `.claude/rules/06-mindset.md`(或 arcrun-mindset Skill)——它說明 arcrun 的世界觀(工作流是 default、零件稀有且不自製、一切外部 API 皆 recipe),讓你一開始就走在正路上。 + +--- + +## 設計原則(節錄) + +- **減少對不可控第三方的依賴**:核心 MIT 開源、self-hosted、workflow 是你擁有的純文字。 +- **解耦 / 原子化**:零件、recipe、執行引擎彼此可獨立替換。 +- **arcrun 是 AI 用的工具**:需要 AI 判斷時是操盤的 CC 自己做,工作流裡**不內嵌**回頭呼叫 LLM 的節點。 +- **誠實**:stub / 未驗證就如實標明,不假裝成功;完成以客觀證據(HTTP status / trace)為準。 + +完整決策見 [`DECISIONS.md`](DECISIONS.md)。 + --- ## License @@ -312,9 +198,9 @@ MIT ## 致謝 -arcrun 的核心架構、21 個 WASM 零件、CLI 工具鏈與這份文件,由以下貢獻者共同打造: +arcrun 的核心架構、WASM 零件、CLI 工具鏈與這份文件,由以下貢獻者共同打造: - **[@richblack](https://github.com/richblack)** — 創始人,產品設計與架構決策 -- **[Claude Sonnet](https://claude.ai)(Anthropic)** — 核心實作夥伴,負責零件開發、executor 架構、CLI 實作與程式碼審查 +- **Claude(Anthropic)** — 實作夥伴:零件開發、executor 架構、CLI 實作與程式碼審查 歡迎加入:[CONTRIBUTING.md](CONTRIBUTING.md) diff --git a/cli/src/commands/init.ts b/cli/src/commands/init.ts index 7160fed..29a0ede 100644 --- a/cli/src/commands/init.ts +++ b/cli/src/commands/init.ts @@ -8,6 +8,16 @@ import { writeFileSync, existsSync, readFileSync, appendFileSync } from 'node:fs import { join } from 'node:path'; import chalk from 'chalk'; import { saveConfig, type ArcrunConfig } from '../lib/config.js'; +import { CfAccountClient } from '../lib/cf-api.js'; +import { + REQUIRED_KV_NAMESPACES, + REQUIRED_R2_BUCKET, + SECRET_TARGET_WORKERS, + wranglerAvailable, + downloadAndDeploy, + type DeployContext, +} from '../lib/deploy.js'; +import { API_RECIPE_SEEDS } from '../lib/api-recipe-seeds.js'; const ARCRUN_REGISTER_URL = 'https://cypher.arcrun.dev/register'; @@ -102,32 +112,139 @@ async function initStandard(rl: ReturnType): Promise): Promise { - console.log(chalk.gray(' Self-hosted 模式:自行部署所有 Worker 到你的 Cloudflare 帳號\n')); + console.log(chalk.gray(' Self-hosted 模式:自動部署整套 arcrun 到你的 Cloudflare 帳號\n')); + console.log(chalk.gray(' 你只需提供 CF Account ID + API Token,其餘 CLI 自動完成。\n')); + + // 前置:wrangler(CF CLI) + if (!wranglerAvailable()) { + console.log(chalk.yellow(' ✗ 找不到 wrangler(Cloudflare CLI)。')); + console.log(chalk.yellow(' 請先安裝:npm i -g wrangler,然後重新執行 acr init --self-hosted\n')); + process.exit(1); + } const accountId = await prompt(rl, '你的 Cloudflare Account ID'); - const cypherUrl = await prompt(rl, 'Cypher Executor URL(部署後的 workers.dev URL)'); - const webhooksKvId = await prompt(rl, 'WEBHOOKS KV Namespace ID'); - const credentialsKvId = await prompt(rl, 'CREDENTIALS_KV Namespace ID'); - const wasmBucket = await prompt(rl, 'WASM_BUCKET 名稱'); - const cfApiToken = await prompt(rl, 'CF API Token(KV Edit 權限)'); + const cfApiToken = await prompt(rl, 'CF API Token(需 Workers Scripts Edit + KV Edit + R2 Edit)'); + const cf = new CfAccountClient(accountId, cfApiToken); + + // 1. 驗 token / account 可達 + process.stdout.write(chalk.gray('\n → 驗證 Cloudflare 憑證...')); + try { + await cf.verifyAccess(); + console.log(chalk.green(' ✓')); + } catch (e) { + console.log(chalk.yellow(` ✗ ${e instanceof Error ? e.message : e}`)); + console.log(chalk.yellow(' 請確認 Account ID 與 API Token(含權限)正確後重試\n')); + process.exit(1); + } + + // 2. 建 KV namespace(冪等)+ R2 bucket + const kvNamespaceIds: Record = {}; + try { + const existing = await cf.listKvNamespaces(); + for (const title of REQUIRED_KV_NAMESPACES) { + process.stdout.write(chalk.gray(` → KV ${title}...`)); + const id = await cf.ensureKvNamespace(title, existing); + kvNamespaceIds[title] = id; + console.log(chalk.green(' ✓')); + } + process.stdout.write(chalk.gray(` → R2 ${REQUIRED_R2_BUCKET}...`)); + await cf.ensureR2Bucket(REQUIRED_R2_BUCKET); + console.log(chalk.green(' ✓')); + } catch (e) { + console.log(chalk.yellow(`\n ✗ 建立資源失敗:${e instanceof Error ? e.message : e}\n`)); + process.exit(1); + } + + // 3. 查 workers.dev subdomain(cypher-executor WORKER_SUBDOMAIN 用) + let workerSubdomain = ''; + try { + workerSubdomain = await cf.getWorkersSubdomain(); + console.log(chalk.gray(` → workers.dev subdomain: ${workerSubdomain}`)); + } catch (e) { + console.log(chalk.yellow(` ⚠ 查 subdomain 失敗(${e instanceof Error ? e.message : e}),稍後可手動補`)); + } + + // 4. 下載 repo 部署物(含預編譯 wasm)+ 注入 KV id + wrangler deploy 全部 Worker + console.log(chalk.gray('\n → 下載部署物 + 部署 Worker(從 GitHub 拉預編譯 wasm,用你的 CF token 部署)...')); + const deployCtx: DeployContext = { accountId, apiToken: cfApiToken, workerSubdomain, kvNamespaceIds }; + const deploy = await downloadAndDeploy(deployCtx); + const cypherUrl = deploy.cypherExecutorUrl + ?? (workerSubdomain ? `https://arcrun-cypher-executor.${workerSubdomain}.workers.dev` : ''); + const deployFullyOk = /全部成功/.test(deploy.message); + console.log(deployFullyOk ? chalk.green(` ✓ ${deploy.message}`) : chalk.yellow(` ⚠ ${deploy.message}`)); + + // 5. 寫 config(資源資訊存好,供後續 acr push / update / seed) const config: ArcrunConfig = { mode: 'self-hosted', cloudflare_account_id: accountId, - cypher_executor_url: cypherUrl, - webhooks_kv_namespace_id: webhooksKvId, - credentials_kv_namespace_id: credentialsKvId, - wasm_bucket: wasmBucket, cf_api_token: cfApiToken, + cypher_executor_url: cypherUrl, + webhooks_kv_namespace_id: kvNamespaceIds['WEBHOOKS'], + credentials_kv_namespace_id: kvNamespaceIds['CREDENTIALS_KV'], + wasm_bucket: REQUIRED_R2_BUCKET, multi_tenant: false, }; - saveConfig(config); createCredentialsYamlIfMissing(); - console.log(chalk.green('\n ✓ 設定完成 → ~/.arcrun/config.yaml')); - console.log(chalk.green(' ✓ 建立 credentials.yaml\n')); + // 6. seed API recipe(部署成功 + 有 cypher URL 才打;否則提示稍後 acr update 後再 seed) + if (deployFullyOk && cypherUrl) { + await seedApiRecipes(cypherUrl); + } else if (cypherUrl) { + console.log(chalk.gray(` → recipe seed 待部署穩定後再執行(${API_RECIPE_SEEDS.length} 個;acr update 會重試)`)); + } + + // 結果回報(誠實:部分失敗時明說,不假綠 — mindset §7) + console.log(chalk.green('\n ✓ Cloudflare 資源就緒(7 KV + R2)')); + console.log(chalk.green(' ✓ 設定寫入 ~/.arcrun/config.yaml')); + console.log(chalk.green(' ✓ 建立 credentials.yaml')); + + // 手動 secret 提示(secret 不進自動化,rule 05) + console.log(chalk.bold('\n 下一步(手動設定 runtime secret):')); + for (const w of SECRET_TARGET_WORKERS) { + console.log(chalk.cyan(` wrangler secret put ENCRYPTION_KEY --name ${w}`)); + } + console.log(chalk.gray(' 三個 Worker 共用同一把 ENCRYPTION_KEY(256-bit hex)。')); + console.log(chalk.gray(' 生成:node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"\n')); +} + +/** seed API recipe 到目標 cypher-executor(部署完成後)。*/ +async function seedApiRecipes(cypherUrl: string): Promise { + process.stdout.write(chalk.gray(` → seed ${API_RECIPE_SEEDS.length} 個 API recipe...`)); + let ok = 0; + for (const r of API_RECIPE_SEEDS) { + try { + const res = await fetch(`${cypherUrl}/recipes`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + canonical_id: r.canonical_id, + display_name: r.display_name, + description: r.description, + endpoint: r.endpoint, + method: r.method, + auth_service: r.auth_service, + exposure_consent: { + confirmed_by_human: true, + understood: `platform seed recipe: ${r.canonical_id} → ${r.endpoint}`, + confirmed_at: new Date().toISOString(), + }, + }), + }); + if (res.ok) ok++; + } catch { + // 單筆失敗不中斷整個 init;最終回報數量 + } + } + console.log(ok === API_RECIPE_SEEDS.length ? chalk.green(' ✓') : chalk.yellow(` ${ok}/${API_RECIPE_SEEDS.length}`)); } function createHelloYamlIfMissing(): void { diff --git a/cli/src/commands/recipe.ts b/cli/src/commands/recipe.ts index 8f1d187..1b78b63 100644 --- a/cli/src/commands/recipe.ts +++ b/cli/src/commands/recipe.ts @@ -100,6 +100,12 @@ export async function cmdRecipePush(filePath: string): Promise { spinner.succeed(chalk.green(`✓ recipe "${data.recipe.canonical_id}" 上傳成功`)); console.log(`\n Hash ID:${chalk.cyan(data.recipe.hash_id)} (穩定引用,不受改名影響)`); console.log(` Endpoint:${chalk.gray(data.recipe.endpoint)}`); + + // 打通檢查(SDD recipe-push-gatekeeping §1.2):recipe 是「指向外部 API 的指針」, + // 正確性一半在「打不打得通」(DECISIONS §1 recipe 驗收 = 2xx)。 + // self-hosted 是提醒級:不硬擋、誠實標原因(缺 credential 打不到 2xx 就誠實說,不假綠 — mindset §7)。 + await probeRecipeEndpoint(recipe); + console.log(chalk.bold('\n 在 workflow config 中使用:\n')); console.log(chalk.cyan(` config:`)); console.log(chalk.cyan(` my_node:`)); @@ -115,6 +121,52 @@ export async function cmdRecipePush(filePath: string): Promise { } } +/** + * 打通檢查:push 時對 recipe endpoint 實打一次,回報是否 2xx。 + * + * 提醒級(self-hosted):只回報、不硬擋(用戶可能就是要先 push 再設 credential)。 + * 誠實(mindset §7): + * - endpoint 含未填模板({{_path}} / {{auth.x}} 等)→ 執行期才有值,push 時無法驗,誠實說明。 + * - 打不到 2xx → 誠實標 HTTP status(如 401 多半是缺 credential),不假裝成功。 + * - arcrun 不做授權判斷:401/403 是對方服務裁決,不是 recipe 的 bug(DECISIONS / mindset §3)。 + */ +async function probeRecipeEndpoint(recipe: RecipeYaml): Promise { + const endpoint = recipe.endpoint ?? ''; + if (/\{\{.*?\}\}/.test(endpoint)) { + console.log(chalk.gray('\n 打通檢查:endpoint 含執行期變數({{...}}),push 時無法預打。')); + console.log(chalk.gray(' 實際是否打通待 acr run 時才知(recipe 驗收標準 = 執行回 2xx)。')); + return; + } + + process.stdout.write(chalk.gray('\n 打通檢查(實打 endpoint)...')); + try { + const method = (recipe.method ?? 'POST').toUpperCase(); + const res = await fetch(endpoint, { + method, + headers: recipe.headers, + // 不帶 credential(push 端沒有明文)→ 打不通多半是缺 auth,下面誠實標 + ...(method !== 'GET' && method !== 'HEAD' + ? { body: JSON.stringify(recipe.body ?? {}) } + : {}), + signal: AbortSignal.timeout(10_000), + }); + if (res.ok) { + console.log(chalk.green(` ✓ HTTP ${res.status}(打通)`)); + } else if (res.status === 401 || res.status === 403) { + console.log(chalk.yellow(` ⚠ HTTP ${res.status}`)); + console.log(chalk.gray(' 未驗收:多半是缺 credential(過認證後才會 2xx)。先 acr creds push 對應 token。')); + console.log(chalk.gray(' 註:401/403 是對方服務在行使授權,不是 recipe 的 bug。')); + } else { + console.log(chalk.yellow(` ⚠ HTTP ${res.status}(未打通)`)); + console.log(chalk.gray(' recipe 已上傳,但 endpoint 目前未回 2xx。請確認 endpoint / method 正確。')); + } + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + console.log(chalk.yellow(` ⚠ 無法連線`)); + console.log(chalk.gray(` ${msg.slice(0, 120)}(recipe 已上傳;連線問題不擋 push)`)); + } +} + export async function cmdRecipeList(): Promise { const config = loadConfig(); const executorUrl = getCypherExecutorUrl(config); diff --git a/cli/src/commands/update.ts b/cli/src/commands/update.ts new file mode 100644 index 0000000..3decba2 --- /dev/null +++ b/cli/src/commands/update.ts @@ -0,0 +1,68 @@ +/** + * acr update — 拉新 GitHub release,重新部署零件/引擎到用戶自己的 Cloudflare。 + * + * 與 acr init --self-hosted 走同一條「下載 release → 注入 KV id → wrangler deploy」的路, + * 差別只在:init 是首次(建 KV/R2 + 寫 config),update 是沿用既有 config 重部署變動的 Worker。 + * + * 對應 SDD:.agents/specs/arcrun/sdk-and-website/self-hosted-init.md §3「acr update」 + * + * 誠實限制(mindset §7 / SDD §6):部署依賴 GitHub release(含預編譯 wasm), + * release 產製管道補上前,誠實回報未實作,不假裝更新成功。 + */ + +import chalk from 'chalk'; +import { loadConfig } from '../lib/config.js'; +import { + wranglerAvailable, + downloadAndDeploy, + type DeployContext, +} from '../lib/deploy.js'; + +export async function cmdUpdate(): Promise { + const config = loadConfig(); + + if (config.mode !== 'self-hosted') { + console.log(chalk.yellow('\n acr update 只用於 self-hosted 模式(部署在你自己的 Cloudflare)。')); + console.log(chalk.gray(' 目前模式:' + config.mode + '。如要 self-host,先跑 acr init --self-hosted。\n')); + process.exit(1); + } + + if (!config.cloudflare_account_id || !config.cf_api_token) { + console.log(chalk.yellow('\n config 缺 cloudflare_account_id / cf_api_token,無法部署。')); + console.log(chalk.gray(' 請重新跑 acr init --self-hosted。\n')); + process.exit(1); + } + + if (!wranglerAvailable()) { + console.log(chalk.yellow('\n ✗ 找不到 wrangler(Cloudflare CLI)。請先 npm i -g wrangler。\n')); + process.exit(1); + } + + console.log(chalk.bold('\n acr update — 拉新 release 並重新部署\n')); + + const ctx: DeployContext = { + accountId: config.cloudflare_account_id, + apiToken: config.cf_api_token, + workerSubdomain: extractSubdomain(config.cypher_executor_url), + kvNamespaceIds: { + WEBHOOKS: config.webhooks_kv_namespace_id ?? '', + CREDENTIALS_KV: config.credentials_kv_namespace_id ?? '', + }, + }; + + const result = await downloadAndDeploy(ctx); + + if (result.implemented) { + console.log(chalk.green('\n ✓ 更新完成\n')); + } else { + console.log(chalk.yellow(' ⚠ 更新尚未自動化:')); + console.log(chalk.gray(' ' + result.message.split('\n').join('\n ')) + '\n'); + } +} + +/** 從 cypher_executor_url(https://arcrun-cypher-executor..workers.dev)抽 subdomain。*/ +function extractSubdomain(url?: string): string { + if (!url) return ''; + const m = url.match(/arcrun-cypher-executor\.([^.]+)\.workers\.dev/); + return m?.[1] ?? ''; +} diff --git a/cli/src/index.ts b/cli/src/index.ts index 90d501e..ef7c786 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -16,6 +16,7 @@ import { cmdParts, cmdPartsScaffold, cmdPartsPublish } from './commands/parts.js import { cmdRecipePush, cmdRecipeList, cmdRecipeDelete } from './commands/recipe.js'; import { cmdList } from './commands/list.js'; import { cmdLogs } from './commands/logs.js'; +import { cmdUpdate } from './commands/update.js'; import { cmdAuthRecipeList, cmdAuthRecipeInfo, cmdAuthRecipeScaffold } from './commands/auth-recipe.js'; const program = new Command(); @@ -120,4 +121,10 @@ program .description('顯示 workflow 最近執行記錄') .action((workflow: string) => cmdLogs(workflow)); +// acr update(self-hosted:拉新 release 重新部署零件/引擎) +program + .command('update') + .description('self-hosted:拉新 release 並重新部署到你的 Cloudflare') + .action(() => cmdUpdate()); + program.parse(process.argv); diff --git a/cli/src/lib/api-recipe-seeds.ts b/cli/src/lib/api-recipe-seeds.ts new file mode 100644 index 0000000..4efc335 --- /dev/null +++ b/cli/src/lib/api-recipe-seeds.ts @@ -0,0 +1,119 @@ +/** + * api-recipe-seeds.ts + * + * 現役 API recipe 的種子定義。self-host 新帳號 init 時把這些灌進空的 RECIPES KV + * (透過 cypher-executor 的 POST /recipes,或 CF KV REST API 直接寫)。 + * + * API recipe = http_request + 固定設定(endpoint/method 模板)。 + * 不需 deploy Worker,cypher-executor 執行時直接 fetch(見 cypher-executor/src/routes/recipes.ts)。 + * + * 放在 CLI 端而非 cypher-executor/src: + * - seed 資料是「installer 要灌進用戶 KV 的種子」,本就屬 CLI 職責(SDD self-hosted-init.md §4)。 + * - rule 02 §2.2 hook 擋 cypher-executor TS hard-code API endpoint;seed 的 endpoint 是資料欄位, + * 放 CLI 端避開誤判,也更符合職責切分。 + * + * 來源:2026-06-01 從 prod cypher.arcrun.dev/recipes 逐一查得的現役定義。 + * 對應 SDD:.agents/specs/arcrun/sdk-and-website/self-hosted-init.md §5 + * + * KBDB recipe(kbdb_*)採 Supabase 模式(richblack 2026-06-02): + * 進 seed = 展示能力(引子)。使用者要用 → 去 arcrun 取統一 API Key 當 credential。 + * FOLLOW-UP(KBDB 端):endpoint 現為 kbdb.finally.click,KBDB 應改用統一對外網址; + * KBDB 改網址後同步更新此處。seed 先照現況進。 + */ + +export interface ApiRecipeSeed { + canonical_id: string; + display_name: string; + description?: string; + endpoint: string; + method: string; + auth_service?: string; +} + +export const API_RECIPE_SEEDS: ApiRecipeSeed[] = [ + // ── KBDB(Supabase 模式,auth_service=kbdb static_key)── + { + canonical_id: 'kbdb_get', + display_name: 'KBDB Get', + description: 'GET 讀取 block / 查詢。_path 帶查詢路徑。auth: kbdb static_key。', + endpoint: 'https://kbdb.finally.click{{_path}}', + method: 'GET', + auth_service: 'kbdb', + }, + { + canonical_id: 'kbdb_create_block', + display_name: 'KBDB Create Block', + description: 'POST /blocks 建立 block。body 帶 block 欄位(content/type/page_name/source/user_id 等)。auth: kbdb static_key。', + endpoint: 'https://kbdb.finally.click/blocks', + method: 'POST', + auth_service: 'kbdb', + }, + { + canonical_id: 'kbdb_patch_block', + display_name: 'KBDB Patch Block', + description: 'PATCH /blocks/:id 局部更新。_path 帶 /blocks/{id},body 帶要改的欄位。auth: kbdb static_key。', + endpoint: 'https://kbdb.finally.click{{_path}}', + method: 'PATCH', + auth_service: 'kbdb', + }, + { + canonical_id: 'kbdb_delete', + display_name: 'KBDB Delete', + description: 'DELETE /blocks/:id 刪除 block。_path 帶 /blocks/{id}。auth: kbdb static_key。', + endpoint: 'https://kbdb.finally.click{{_path}}', + method: 'DELETE', + auth_service: 'kbdb', + }, + { + canonical_id: 'kbdb_ingest', + display_name: 'KBDB Ingest', + description: 'POST /blocks/ingest 批次寫入。body 帶 input。auth: kbdb static_key。', + endpoint: 'https://kbdb.finally.click/blocks/ingest', + method: 'POST', + auth_service: 'kbdb', + }, + + // ── Google(service_account)── + { + canonical_id: 'gmail_send', + display_name: 'Gmail Send', + description: '寄 Gmail。POST messages/send,body 帶 raw(base64url MIME)。auth: google service_account。', + endpoint: 'https://gmail.googleapis.com/gmail/v1/users/me/messages/send', + method: 'POST', + auth_service: 'google_gmail_sa', + }, + { + canonical_id: 'google_sheets_append', + display_name: 'Google Sheets Append', + description: '寫 Sheets。PUT values?valueInputOption=RAW,body 帶 values。auth: google service_account。', + endpoint: 'https://sheets.googleapis.com{{_path}}', + method: 'PUT', + auth_service: 'google_sheets_sa', + }, + { + canonical_id: 'google_sheets_read', + display_name: 'Google Sheets Read', + description: '讀 Sheets。GET values。_path 帶完整路徑。auth: google service_account。', + endpoint: 'https://sheets.googleapis.com{{_path}}', + method: 'GET', + auth_service: 'google_sheets_sa', + }, + + // ── 訊息(static_key)── + { + canonical_id: 'telegram_send', + display_name: 'Telegram Send', + description: 'Telegram sendMessage。token 在 URL path({{auth.bot_token}}),body 帶 chat_id+text。auth: static_key path 注入。', + endpoint: 'https://api.telegram.org/bot{{auth.bot_token}}/sendMessage', + method: 'POST', + auth_service: 'telegram', + }, + { + canonical_id: 'line_notify_send', + display_name: 'LINE Notify', + description: 'LINE Notify 推訊息。POST notify,body 帶 message(form-urlencoded)。auth: static_key Bearer line token。', + endpoint: 'https://notify-api.line.me/api/notify', + method: 'POST', + auth_service: 'line_notify', + }, +]; diff --git a/cli/src/lib/cf-api.ts b/cli/src/lib/cf-api.ts index 9ed0ed7..c576f8f 100644 --- a/cli/src/lib/cf-api.ts +++ b/cli/src/lib/cf-api.ts @@ -75,6 +75,91 @@ export class CfKvClient { } } +/** + * Cloudflare Account-level API wrapper(self-hosted installer 用)。 + * + * 負責 acr init --self-hosted 的資源建立:驗 token、建/列 KV namespace、建 R2 bucket、查 workers.dev subdomain。 + * 與 CfKvClient(綁單一 namespace 的 KV 操作)職責不同——這個是帳號層級的資源管理。 + * 對應 SDD:.agents/specs/arcrun/sdk-and-website/self-hosted-init.md §3 step 1-2 + */ +export class CfAccountClient { + private accountBase: string; + private headers: Record; + + constructor(accountId: string, apiToken: string) { + this.accountBase = `${CF_API_BASE}/accounts/${accountId}`; + this.headers = { + 'Authorization': `Bearer ${apiToken}`, + 'Content-Type': 'application/json', + }; + } + + private async cf(path: string, init?: RequestInit): Promise { + const res = await fetch(`${this.accountBase}${path}`, { + ...init, + headers: { ...this.headers, ...(init?.headers ?? {}) }, + }); + const data = await res.json().catch(() => null) as + | { success: boolean; result: T; errors?: Array<{ message: string }> } + | null; + if (!res.ok || !data?.success) { + const msg = data?.errors?.map(e => e.message).join('; ') ?? `HTTP ${res.status}`; + throw new Error(`CF API ${path} 失敗:${msg}`); + } + return data.result; + } + + /** 驗證 token 能存取此 account(權限不足會在後續建立操作報錯,這裡先確認 account 可達)。*/ + async verifyAccess(): Promise { + // GET /accounts/{id} 能通 = token 有此 account 的基本讀權限 + await this.cf<{ id: string; name: string }>(''); + } + + /** 列出現有 KV namespace(冪等用:已存在就重用,不重建)。回傳 title → id 對照。*/ + async listKvNamespaces(): Promise> { + const result = await this.cf>( + '/storage/kv/namespaces?per_page=100', + ); + const map = new Map(); + for (const ns of result) map.set(ns.title, ns.id); + return map; + } + + /** 建立 KV namespace(若同名已存在則回傳既有 id,冪等)。*/ + async ensureKvNamespace(title: string, existing?: Map): Promise { + const known = existing ?? (await this.listKvNamespaces()); + const found = known.get(title); + if (found) return found; + + const result = await this.cf<{ id: string; title: string }>( + '/storage/kv/namespaces', + { method: 'POST', body: JSON.stringify({ title }) }, + ); + return result.id; + } + + /** 建立 R2 bucket(已存在則略過,冪等)。*/ + async ensureR2Bucket(name: string): Promise { + try { + await this.cf<{ name: string }>('/r2/buckets', { + method: 'POST', + body: JSON.stringify({ name }), + }); + } catch (e) { + // bucket 已存在 → CF 回 10004 之類;視為冪等成功 + const msg = e instanceof Error ? e.message : String(e); + if (/already exists|10004/i.test(msg)) return; + throw e; + } + } + + /** 查 workers.dev subdomain(cypher-executor WORKER_SUBDOMAIN 用,組對內 component URL)。*/ + async getWorkersSubdomain(): Promise { + const result = await this.cf<{ subdomain: string }>('/workers/subdomain'); + return result.subdomain; + } +} + /** AES-GCM 加密 credential(與 cypher-executor credential-injector 解密邏輯對應)*/ export async function encryptCredential(value: string, encryptionKey: string): Promise { if (!encryptionKey || encryptionKey.length < 64) { diff --git a/cli/src/lib/deploy.ts b/cli/src/lib/deploy.ts new file mode 100644 index 0000000..2c29dfc --- /dev/null +++ b/cli/src/lib/deploy.ts @@ -0,0 +1,219 @@ +/** + * deploy.ts — self-hosted Worker 部署(installer 的「下載 repo tarball + wrangler deploy」段) + * + * 對應 SDD:.agents/specs/arcrun/sdk-and-website/self-hosted-init.md §6(commit wasm + codeload) + * + * 策略(richblack 2026-06-02):repo 自帶預編譯 wasm(.component-builds 下各 component.wasm, + * 見 rule 05 慣例變更)→ CLI 從 GitHub codeload tarball 拿完整部署物 → 注入用戶的 KV id + * → 用用戶自己的 CF token wrangler deploy。用戶不需 git / tinygo,只需 wrangler。 + */ + +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +/** GitHub repo(codeload tarball 來源)。fork 者改這裡或用 ARCRUN_REPO env。*/ +const ARCRUN_REPO = process.env.ARCRUN_REPO ?? 'richblack/arcrun'; + +/** init 要建立的 7 個 KV namespace(title)。權威來源:.claude/rules/01-tech-stack.md 資料儲存表。*/ +export const REQUIRED_KV_NAMESPACES = [ + 'WEBHOOKS', + 'CREDENTIALS_KV', + 'RECIPES', + 'USERS_KV', + 'SESSIONS_KV', + 'ANALYTICS_KV', + 'EXEC_CONTEXT', +] as const; + +/** init 要建立的 R2 bucket。*/ +export const REQUIRED_R2_BUCKET = 'WASM_BUCKET'; + +/** 部署後要提示用戶手動 `wrangler secret put ENCRYPTION_KEY` 的 Worker。*/ +export const SECRET_TARGET_WORKERS = [ + 'arcrun-cypher-executor', + 'arcrun-auth-static-key', + 'arcrun-auth-service-account', +] as const; + +export interface DeployContext { + accountId: string; + apiToken: string; + workerSubdomain: string; + kvNamespaceIds: Record; // title → id +} + +export interface DeployResult { + implemented: boolean; + cypherExecutorUrl?: string; + message: string; +} + +/** 偵測 wrangler 是否已安裝(用戶前置:裝 CF CLI)。*/ +export function wranglerAvailable(): boolean { + try { + execFileSync('wrangler', ['--version'], { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +/** + * 下載 repo codeload tarball(含預編譯 wasm)→ 注入用戶 KV id → wrangler deploy 全部 Worker。 + * + * SDD self-hosted-init.md §6.4: + * 1. 下載 codeload tarball(ref 預設 main)→ 解壓到暫存目錄 + * 2. 各 wrangler.toml 注入 ctx.kvNamespaceIds + cypher-executor WORKER_SUBDOMAIN + * 3. tier1=.component-builds/* 先 → tier2=cypher-executor/registry 後,逐一 wrangler deploy + * 4. 回 cypherExecutorUrl = https://arcrun-cypher-executor..workers.dev + * + * 誠實(mindset §7):任一 worker deploy 失敗會收集進 message 回報,不假裝全綠。 + * + * @param ctx 部署上下文 + * @param ref git ref(branch / tag),預設 main;acr update 可帶 tag + */ +export async function downloadAndDeploy(ctx: DeployContext, ref = 'main'): Promise { + // 1. 下載 + 解壓 codeload tarball + let root: string; + try { + root = await downloadRepoTarball(ref); + } catch (e) { + return { + implemented: true, + message: `下載部署物失敗(${e instanceof Error ? e.message : e})。確認網路 + ARCRUN_REPO=${ARCRUN_REPO} 可達。`, + }; + } + + // 2. 列出要部署的 worker 目錄(含 wrangler.toml),分 tier + const { tier1, tier2 } = discoverWorkerDirs(root); + if (tier1.length === 0 && tier2.length === 0) { + return { implemented: true, message: `部署物中找不到任何 wrangler.toml(root=${root})。` }; + } + + // 3. 對每個 worker:注入 KV id(+ cypher WORKER_SUBDOMAIN)→ wrangler deploy。tier1 先 tier2 後。 + const failures: string[] = []; + let deployed = 0; + for (const dir of [...tier1, ...tier2]) { + const tomlPath = join(dir, 'wrangler.toml'); + try { + injectWranglerConfig(tomlPath, ctx); + runWranglerDeploy(dir, ctx); + deployed++; + } catch (e) { + failures.push(`${dir}: ${e instanceof Error ? e.message : String(e)}`); + } + } + + const cypherExecutorUrl = ctx.workerSubdomain + ? `https://arcrun-cypher-executor.${ctx.workerSubdomain}.workers.dev` + : undefined; + + if (failures.length > 0) { + return { + implemented: true, + cypherExecutorUrl, + message: + `部署 ${deployed}/${tier1.length + tier2.length} 成功,${failures.length} 失敗(誠實回報,未假綠):\n` + + failures.map(f => ` ✗ ${f}`).join('\n'), + }; + } + + return { + implemented: true, + cypherExecutorUrl, + message: `部署完成:${deployed} 個 Worker 全部成功。`, + }; +} + +/** 下載 codeload tarball 解壓到暫存目錄,回傳解壓出的 repo root 路徑。*/ +async function downloadRepoTarball(ref: string): Promise { + const url = `https://codeload.github.com/${ARCRUN_REPO}/tar.gz/${ref}`; + const res = await fetch(url, { signal: AbortSignal.timeout(120_000) }); + if (!res.ok) throw new Error(`codeload HTTP ${res.status}(${url})`); + + const buf = Buffer.from(await res.arrayBuffer()); + const dir = mkdtempSync(join(tmpdir(), 'arcrun-deploy-')); + const tarPath = join(dir, 'repo.tar.gz'); + writeFileSync(tarPath, buf); + + // 用系統 tar 解壓(macOS/Linux 內建)。tarball 解出單一頂層目錄 {repo}-{ref}/。 + execFileSync('tar', ['-xzf', tarPath, '-C', dir], { stdio: 'ignore' }); + const entries = readdirSync(dir).filter(n => n !== 'repo.tar.gz'); + const top = entries.find(n => statSync(join(dir, n)).isDirectory()); + if (!top) throw new Error('tarball 解壓後找不到頂層目錄'); + return join(dir, top); +} + +/** 掃解壓出的部署物,回傳 tier1(.component-builds/*)與 tier2(cypher-executor/registry)目錄清單。*/ +function discoverWorkerDirs(root: string): { tier1: string[]; tier2: string[] } { + const tier1: string[] = []; + const tier2: string[] = []; + + const cbRoot = join(root, '.component-builds'); + if (existsSync(cbRoot)) { + for (const name of readdirSync(cbRoot)) { + const dir = join(cbRoot, name); + // 需同時有 wrangler.toml 且有 component.wasm 才部署。 + // 「錯做成零件」的(claude_api / km_writer / kbdb_upsert_block)wasm 沒 commit 進 repo + // (.gitignore 排除,待降級成工作流/recipe)→ codeload 拿到的目錄缺 wasm → 自然跳過, + // 不讓 wrangler deploy 因缺檔失敗。 + if (existsSync(join(dir, 'wrangler.toml')) && existsSync(join(dir, 'component.wasm'))) { + tier1.push(dir); + } + } + } + for (const name of ['cypher-executor', 'registry']) { + const dir = join(root, name); + if (existsSync(join(dir, 'wrangler.toml'))) tier2.push(dir); + } + return { tier1, tier2 }; +} + +/** 注入用戶的 KV namespace id(取代 wrangler.toml 中各 binding 的 id)+ cypher WORKER_SUBDOMAIN。*/ +function injectWranglerConfig(tomlPath: string, ctx: DeployContext): void { + if (!existsSync(tomlPath)) return; + let toml = readFileSync(tomlPath, 'utf8'); + + // 對每個已建立的 KV namespace:把對應 binding 的 id 換成用戶的。 + // 匹配 `[[kv_namespaces]] ... binding = "NAME" ... id = "OLD"` 的 id 行。 + for (const [binding, id] of Object.entries(ctx.kvNamespaceIds)) { + if (!id) continue; + const re = new RegExp( + `(binding\\s*=\\s*"${binding}"\\s*\\n\\s*id\\s*=\\s*")[^"]*(")`, + 'g', + ); + toml = toml.replace(re, `$1${id}$2`); + } + + // cypher-executor 的 WORKER_SUBDOMAIN(vars)換成用戶帳號 subdomain + if (ctx.workerSubdomain && /WORKER_SUBDOMAIN/.test(toml)) { + toml = toml.replace( + /(WORKER_SUBDOMAIN\s*=\s*")[^"]*(")/, + `$1${ctx.workerSubdomain}$2`, + ); + } + + writeFileSync(tomlPath, toml, 'utf8'); +} + +/** 在 worker 目錄跑 wrangler deploy(用用戶的 CF token + account)。*/ +function runWranglerDeploy(dir: string, ctx: DeployContext): void { + // 先裝依賴(cypher-executor/registry 是 TS,wrangler 內建 esbuild bundle 需 node_modules) + if (existsSync(join(dir, 'package.json'))) { + const installer = existsSync(join(dir, 'pnpm-lock.yaml')) + ? ['pnpm', 'install', '--frozen-lockfile'] + : ['npm', 'install', '--no-audit', '--no-fund']; + execFileSync(installer[0], installer.slice(1), { cwd: dir, stdio: 'ignore' }); + } + execFileSync('wrangler', ['deploy'], { + cwd: dir, + stdio: 'ignore', + env: { + ...process.env, + CLOUDFLARE_API_TOKEN: ctx.apiToken, + CLOUDFLARE_ACCOUNT_ID: ctx.accountId, + }, + }); +} diff --git a/cypher-executor/scripts/seed-api-recipes.ts b/cypher-executor/scripts/seed-api-recipes.ts new file mode 100644 index 0000000..99ffd4d --- /dev/null +++ b/cypher-executor/scripts/seed-api-recipes.ts @@ -0,0 +1,78 @@ +/** + * seed-api-recipes.ts + * + * 將現役 API recipe 種子上傳至目標 cypher-executor(prod 或 self-host)。 + * 種子資料的單一來源在 CLI 端(cli/src/lib/api-recipe-seeds.ts),此腳本 import 它, + * 避免兩份種子定義漂移。 + * + * 執行: + * npx tsx scripts/seed-api-recipes.ts + * + * 環境變數: + * ARCRUN_API_URL - 目標 cypher-executor,預設 https://cypher.arcrun.dev + * ARCRUN_API_KEY - X-Arcrun-API-Key(POST /recipes 需要) + * + * 注意:API recipe 帶 endpoint(資料去向)→ POST /recipes 會要 exposure_consent + * (data-exfil-warning)。seed 是平台預建、非用戶 push,腳本帶種子層級的 consent。 + * + * 對應 SDD:.agents/specs/arcrun/sdk-and-website/self-hosted-init.md §5 + */ + +import { API_RECIPE_SEEDS } from '../../cli/src/lib/api-recipe-seeds.js'; + +const BASE_URL = process.env.ARCRUN_API_URL ?? 'https://cypher.arcrun.dev'; +const API_KEY = process.env.ARCRUN_API_KEY ?? ''; + +async function main() { + console.log(`\n Seeding ${API_RECIPE_SEEDS.length} API recipes → ${BASE_URL}\n`); + + let ok = 0; + let fail = 0; + + for (const recipe of API_RECIPE_SEEDS) { + process.stdout.write(` ${recipe.canonical_id.padEnd(24)} `); + + try { + const res = await fetch(`${BASE_URL}/recipes`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(API_KEY ? { 'X-Arcrun-API-Key': API_KEY } : {}), + }, + body: JSON.stringify({ + canonical_id: recipe.canonical_id, + display_name: recipe.display_name, + description: recipe.description, + endpoint: recipe.endpoint, + method: recipe.method, + auth_service: recipe.auth_service, + // 種子層級的暴露同意:平台預建 recipe,非用戶互動 push。 + // 格式須符合 cypher-executor ExposureConsent(confirmed_by_human + understood + confirmed_at)。 + // 誠實標明來源是 seed,軌跡可審(mindset §7:機制價值是歸責+可審,非防偽)。 + exposure_consent: { + confirmed_by_human: true, + understood: `platform seed recipe (api-recipe-seeds.ts): ${recipe.canonical_id} → ${recipe.endpoint}`, + confirmed_at: new Date().toISOString(), + }, + }), + }); + + if (res.ok) { + console.log('✓'); + ok++; + } else { + const err = await res.text().catch(() => ''); + console.log(`✗ HTTP ${res.status}: ${err.slice(0, 120)}`); + fail++; + } + } catch (e) { + console.log(`✗ ${e instanceof Error ? e.message : String(e)}`); + fail++; + } + } + + console.log(`\n 完成:${ok} 成功,${fail} 失敗\n`); + if (fail > 0) process.exit(1); +} + +main(); diff --git a/docs/HANDOFF-self-host-harness.md b/docs/HANDOFF-self-host-harness.md new file mode 100644 index 0000000..7833970 --- /dev/null +++ b/docs/HANDOFF-self-host-harness.md @@ -0,0 +1,192 @@ +# 交辦文件:完成 arcrun self-hosted harness(給接手的 CC) + +> 建立:2026-06-01(由前一個 CC 調查後撰寫) +> 對象:接手的外部 CC +> 目的:把 arcrun 補到「任何 CC 在自己的 CF 帳號上 self-host 後就能順暢開發、且不可能重蹈 mira 的錯」的程度。 +> +> **先讀**:`DECISIONS.md`(穩定決策)、`.claude/rules/06-mindset.md`(mindset)、`BACKLOG.md`(流動待辦)。 +> 本文件不取代它們,只是把「今天要做的三件事」連同已查證的實況整理好,讓你不用重跑調查。 + +--- + +## 0. 戰法已轉變(最重要的背景) + +richblack 2026-06-01 決定:**從 SaaS 改成 self-hosted 開源策略。** + +這直接改變 harness 的成功定義: + +- **舊定義**:在 richblack 的 prod 帳號(`cypher.arcrun.dev`)上能跑。 +- **新定義**:**任何 CC 在自己的 CF 帳號上 `acr init --self-hosted` 後就能跑通一個含 recipe 的 workflow,而且寫錯時會被程式擋住。** + +richblack 會用另一個 CF 帳號實測 self-host。所以「self-hosted 一鍵起得來」從「第一期重要但非阻擋項」**升為今日第一優先**。 + +### arcrun 現在的核心心智(richblack 2026-06-01 校準,比 DECISIONS §1 更硬) + +- 核心**零件數量少、由 richblack 維護、不接受 CC 自製**(可投稿 PR,人 merge = 閘門)。 +- 其他人做的一律是 **recipe**(= http_request + 一組 YAML 設定,不用 deploy)。 +- arcrun 是**一套給 CC 的 harness**:事前提醒 CC 能用什麼 / 不能做什麼,事後用程式擋住讓它**無法犯錯**。 +- **你不用管 mira。** mira 是錯誤做法的源頭(見 §1),它自己會修。你的目標是讓**任何** CC 都能用,且絕不會發生 mira 的錯。 + +--- + +## 1. mira 故障 = 症狀樣本(已定位,不用你修) + +mira(`/Users/youlinhsieh/Documents/tech_projects/InkStoneCo/polaris/mira/arcrun/*.yaml`)的 workflow 寫 `component: kbdb_get` / `claude_api` / `telegram` 等。這些是 **mira 當初自己錯做的「假零件」**(DECISIONS §1 判準:打固定 endpoint 的東西是假零件,該是 recipe)。 + +本次整修(BACKLOG 步驟3)已把這些假零件**降級成 recipe + 刪掉零件目錄**(registry/components 從 33 → 22)。所以 mira 斷了。 + +**這證明的事**:mira 的錯,正是當時 harness 沒擋住的漏洞。零件刪了,但 harness 還缺「**事前告訴 CC 別這樣做 + 事後擋住 CC 這樣做**」的完整機制 → 下一個 CC 還會犯同樣的錯。**這就是你要補的(§3 task 2)。** + +--- + +## 2. 已查證的實況(你不用重查,2026-06-01 實打 prod) + +### 2.1 降級後的 recipe 鏈路是「活的」✅ + +實打 `https://cypher.arcrun.dev/recipes`(richblack prod)確認以下 recipe 都在 KV: + +| canonical_id | hash | endpoint | auth_service | +|---|---|---|---| +| `kbdb_get` | rec_4c7dcf9b | `https://kbdb.finally.click{{_path}}` | kbdb | +| `gmail_send` | rec_cd426129 | gmail.googleapis.com/.../send | google_gmail_sa | +| `google_sheets_append` | rec_9fd1b662 | sheets.googleapis.com{{_path}} | google_sheets_sa | + +→ **「對的用法」(`component: kbdb_get` 走解析鏈 step 6 查 `recipe:kbdb_get`)本身是通的。** 不需要重建 recipe。 + +> 注意:這是 richblack 的 prod KV。**self-host 的新帳號 KV 是空的**,需要 seed 這些 recipe(見 §3 task 1 的 seed 步驟)。 + +### 2.2 component-loader 解析鏈(`cypher-executor/src/lib/component-loader.ts`) + +`resolveComponent` 依序嘗試 8 層(行號近似,以實際檔案為準): + +``` +0. 平台 orchestration 零件(trigger_workflow) line ~88 +1. 內建零件(純 JS) line ~96 +2. 外部 URL(http(s)://...) line ~100 +3. cmp_hash → WEBHOOKS KV idx → 邏輯 Worker line ~105 +4. rec_hash → RECIPES KV idx → recipe 執行 line ~115 +5. 邏輯零件 canonical_id → Service Binding (SVC_*) line ~122 +5.5 auth recipe (auth_recipe:{service}) line ~127 +6. KV recipe canonical_id → RECIPES KV → fetch 外部 API line ~130 ← kbdb_get 等降級 recipe 命中這層 +7. WASM HTTP runner(白名單 WASM_HTTP_RUNNER_IDS) line ~134 +8. 找不到 → 報錯 line ~142 +``` + +`WASM_HTTP_RUNNER_IDS` 白名單(line ~36)現只剩:`http_request` / `cron` / 4 個 `auth_*` primitive。 +→ `claude_api`、`kbdb_upsert_block`(BACKLOG 標 deferred、源碼暫留)**不在白名單也沒 recipe** → 用到它們的 workflow 會落到 step 8 報錯。這是 mira 自己的問題,不在你範圍。 + +### 2.3 `acr init --self-hosted` 現況:純手動問答,差很遠 + +`cli/src/commands/init.ts` 的 `initSelfHosted()`(line 105-131)**只是問 6 個問題後寫進 config**: +要求 CC 自己**事先**部署好 Worker、建好 KV、再手填 Account ID / cypher URL / 兩個 KV namespace ID / WASM bucket / CF token。 + +BACKLOG 步驟7 要的是「**貼 CF token → 自動建 KV、部署 Worker、自動 workers.dev、寫回 config**」。**這是最大缺口,task 1 的主體。** + +config 讀取端已支援 self-hosted(`cli/src/lib/config.ts:52` 已能用 `cypher_executor_url`),所以你只要把「自動部署」這段補上,config/執行端不用動。 + +### 2.4 CI/CD 已是通用掃描式(可重用於 self-host 部署) + +`.github/workflows/deploy.yml` 掃所有含 `wrangler.toml` 的目錄自動部署(見 `.claude/rules/05-deploy-convention.md`)。 +self-host 自動部署可以參考同一套掃描邏輯(`find . -name wrangler.toml`),對每個目錄跑 `wrangler deploy`。 + +--- + +## 3. 今天要做的三件事(按序,全在 harness 主線) + +> richblack 指示:「全部要做」(含 `acr init --self-hosted`)。 +> 三件都做完 = 今天可交付:外部 CC 能 self-host 起來、用對的方式開發、犯錯被擋。 + +### 🔴 Task 1:完成 `acr init --self-hosted` 一鍵自動化(第一優先) + +> ✅ **實作狀態(2026-06-02,已大致完成)**:定稿形態為 **installer 模式**(richblack 拍板)—— +> 用戶只做:申請 CF 帳號 → 裝 wrangler → 裝 acr → `acr init --self-hosted`(貼 token),其餘自動。 +> 已實作(typecheck 過):`cli/src/lib/api-recipe-seeds.ts`(10 recipe 種子)、`cf-api.ts` 的 +> `CfAccountClient`(建 KV/R2/查 subdomain/驗 token)、`deploy.ts`(常數 + downloadAndDeploy)、 +> `initSelfHosted()` 改寫、`acr update`、`cypher-executor/scripts/seed-api-recipes.ts`。 +> **唯一剩餘前置(13.6)**:repo 沒有含預編譯 wasm 的 GitHub release(.wasm 不 commit,rule 05)→ +> `downloadAndDeploy()` 目前**誠實回 implemented:false 不假裝部署**(mindset §7)。建 KV/R2/seed/config +> 已可跑;release 產製管道補上後部署即自動化。定稿設計見 SDD `self-hosted-init.md`(含 §6 前置依賴)。 +> **以下原始子步驟保留供對照**;KBDB recipe 採 Supabase 模式進 seed(richblack 2026-06-02)。 + +**目標**:CC 只需提供「CF Account ID + CF API Token」,CLI 自動完成其餘一切。 + +**SDD**:定稿 `.agents/specs/arcrun/sdk-and-website/self-hosted-init.md`(installer 模式,已與 richblack 對齊)。 + +**子步驟**: +1. 改 `cli/src/commands/init.ts` 的 `initSelfHosted()`: + - 收 CF Account ID + CF API Token(要 KV Edit + Workers Scripts Edit + R2 權限)。 + - 用 CF API(或 shell out `wrangler`)**自動建 7 個 KV namespace**:WEBHOOKS / CREDENTIALS_KV / RECIPES / USERS_KV / SESSIONS_KV / ANALYTICS_KV / EXEC_CONTEXT(清單見 `.claude/rules/01-tech-stack.md` 資料儲存表)+ R2 WASM_BUCKET。 + - **自動部署所有 Worker**:cypher-executor + registry + 22 個 `.component-builds/*`。可重用 §2.4 的 `wrangler.toml` 掃描。每個 worker 的 `wrangler.toml` 已含 `workers_dev = true`(BACKLOG 步驟 P1#2 已做),部署後 workers.dev URL 自動啟用。 + - **把 cypher-executor 的 `[vars] WORKER_SUBDOMAIN` 改成 CC 自己的帳號 subdomain**(self-host 關鍵,見 P0 #9:cypher-executor 走 `arcrun-{name}.{subdomain}.workers.dev` 對內 URL)。 + - **seed 降級 recipe + auth recipe 進 RECIPES KV**:新帳號 KV 是空的。把 §2.1 那些 recipe(kbdb_get/gmail_send/...)+ auth recipe seed 寫進去。auth recipe seed 已有 `cypher-executor/scripts/seed-auth-recipes.ts`,API recipe 需確認有對應 seed 機制(routes/recipes.ts 是動態 push,可能要寫一份 seed 腳本或用 `acr recipe push`)。 + - 寫回 config(現有欄位已足夠)。 +2. **runtime secret 不進 CLI 自動化**:`ENCRYPTION_KEY` 等由 CC 自己 `wrangler secret put`(rule 05 禁止 secret 進自動化流程)。CLI 應在最後**印出提示**告訴 CC 要手動 put 哪些 secret 到哪些 worker。 + +**驗收(客觀證據,不是口頭宣布 — mindset §7)**: +- richblack 用全新 CF 帳號跑 `acr init --self-hosted` → 全程無手動建 KV / 部署。 +- 跑完後 `acr push` 一個含 `component: kbdb_get`(或 http_request + 自建 recipe)的 workflow → trigger → HTTP 2xx + execution trace 證明跑通。 + +### 🔴 Task 2(已重定義 2026-06-01):封鎖自製零件 + recipe 入庫把關 + +> ✅ **實作狀態(2026-06-02,第一期部分完成)**:(1) 封鎖自製零件 = 靠 GitHub PR 人 merge,無需新做 +> (矛盾已釐清)。(2a) 資料外流提醒 = **既有實作已涵蓋**(recipe.ts `obtainExposureConsent` + exposure-warning.ts, +> 非 TTY 拒絕)。(2b) 打通檢查 = **新增** `probeRecipeEndpoint`(recipe.ts,typecheck 過):push 後實打 +> endpoint,提醒級不硬擋,含 {{模板}} 誠實說明待 run 才知,401/403 標「多半缺 credential 非 bug」。 +> 公共庫 relay 檢核(--public)= 第一期後。SDD `recipe-push-gatekeeping.md` + tasks.md W2。 + +> ⚠️ **方向修正(richblack 2026-06-01)**:原 Task 2「acr validate 擋假零件名」**作廢**。 +> 理由:自製/修改零件的路已封鎖(CC 造不出零件)→「擋假零件」這件事不存在;workflow 引用 +> recipe(`component: kbdb_get`)是合法且未來唯一的擴充方式,不該被當假零件擋。 +> 把關點**從 workflow validate 移到 recipe 入庫(push)那一刻**。 +> 已動的 yaml-parser.ts(LEGAL_PRIMITIVES / findSuspectComponents)**已回退**。 + +**新目標**: +1. **封鎖自製零件**:靠「零件投稿走 GitHub PR + 人 merge」天然閘門(DECISIONS §8)。零件數量少、 + 絕大多數是 recipe → 不為零件 PR 蓋自動化把關(量少,人工檢查即可;爆量才回頭想自動化)。 +2. **recipe 入庫把關**(CC 唯一能擴充的是 recipe,一律用 push,自有庫/公共庫同一套指令): + - **自有庫(self-hosted)= 提醒級**:(a) 資料外流提醒——會讓資料/服務對外可見的動作需人類明示同意; + (b) 打通檢查——push 時實打 endpoint 回報 2xx 與否(誠實標原因,不假綠,不硬擋)。 + - **公共庫 = 維護者 relay 檢核**(實際打通、真收到成功回傳)— 第一期後。 + +**SDD**:已寫 design 給 richblack review → +`.agents/specs/component-gatekeeping/recipe-push-gatekeeping.md`(+ tasks.md W2 節)。**review 通過才動 code。** + +**動到的檔案(待 review)**:`cli/src/commands/recipe.ts`(push 加提醒 + 打通檢查)、確認 data-exfil hook 涵蓋 recipe push 路徑。 + +**驗收**: +- `acr recipe push` 會產對外 webhook 的東西 → 印資料外流警示 + 要人類同意;非 TTY → 拒絕。 +- `acr recipe push` endpoint 可達的 recipe → 回報「✓ HTTP 2xx」。 +- `acr recipe push` 缺 credential → 回報「⚠️ 未打通:缺 credential」(誠實),仍允許 push。 +- workflow 引用 recipe(`component: kbdb_get`)**不被任何 validate 步驟當假零件擋**。 + +### 🔴 Task 3:README 重寫成單一路徑 — harness「事前提醒」 + +**目標**:self-hosted 開源後,README 是外界 CC 唯一入口。砍掉「玩法一/二/三」三選一,講清楚單一正確路徑。 + +**子步驟**(改根 `README.md`): +1. 砍三選一玩法,留**一條路**:`acr init --self-hosted` → 寫 workflow(primitive 串 + recipe)→ `acr push` → trigger。 +2. 明示心智(呼應 mindset §1):「零件就這固定幾個由我們維護、不接受自製;要打外部 API 就寫 recipe;要編排就寫工作流。」 +3. 連到 `.claude/rules/06-mindset.md` / arcrun-mindset Skill,讓 CC 一開始就有正確世界觀。 + +**驗收**:README 讀完,一個沒看過 arcrun 的 CC 知道:能用什麼、不能自製零件、打外部 API 要寫 recipe、怎麼 self-host 起來。 + +--- + +## 4. 今天「不要做」的(避免你走偏) + +| 項目 | 為何不做 | +|---|---| +| 修 mira | richblack 明示不用管,mira 自己修 | +| 步驟2 `acr recipe test` / relay / credits | DECISIONS §3c 明確劃為服務側、非第一期 | +| 步驟6 搬家拆 matrix | 純 repo 整理,不影響 CC 能否用 | +| 砍 `injectCredentials` 舊路 / `BUILTIN_CREDENTIALS_MAP` | 獨立清理,不擋交付(DECISIONS §3b / BACKLOG「第一期之後」)| +| 新 primitive / Gherkin 真跑 / 入站認證 | richblack 已標「不要現在做」 | + +--- + +## 5. 鐵律提醒(違反會被 hook block) + +- 任何 code 變動前先讀對應 SDD + 在回覆開頭宣告(`.claude/rules/00-sdd-protocol.md`)。 +- `registry/components/` 下禁止 TS;cypher-executor TS 禁止 credential/auth/JWT 業務邏輯(`.claude/rules/02-forbidden.md`,hook 強制)。 +- 每完成一個 task 立刻更新對應 tasks.md / BACKLOG.md 的 `[x]`,不批次。 +- 誠實限制(mindset §7):stub / 未完成就標 unimplemented,**不假綠**;完成 = 客觀證據(exit code / HTTP status + trace),不是口頭宣布。