diff --git a/.agents/specs/component-registry-canon/design.md b/.agents/specs/component-registry-canon/design.md new file mode 100644 index 0000000..cbc5dcb --- /dev/null +++ b/.agents/specs/component-registry-canon/design.md @@ -0,0 +1,162 @@ +# SDD: arcrun Component Registry 正典化(Component Registry Canon) + +> 2026-05-07 建立。狗糧發現的根本問題:registry 活著但 index 空的,AI 找不到零件就會繞回 Python。 +> 範圍:**讓 registry 成為零件 metadata 的 SSOT**,含 u6u → arcrun rebrand。 + +--- + +## 1. 問題 + +### 1.1 表象 +- `registry.arcrun.dev/components/search?q=*` 永遠回 0 結果 +- MCP `u6u_search_components` 找不到任何零件 +- `acr parts list` 同樣空 + +### 1.2 根因 +`matrix/arcrun/registry/components/` 下 30+ 個零件已經部署成獨立 Worker(kbdb_ingest, claude_api, kbdb_create_block, kbdb_patch_block, http_request, string_ops, ⋯),但**它們的 contract.yaml 沒有透過 `POST /components/submit` 進 registry index**。 + +部署路徑: +``` +registry/components/{name}/main.go ← TinyGo 寫的零件 + ↓ tinygo build +.component-builds/{name}/component.wasm + ↓ wrangler deploy +{name}.arcrun.dev (Worker) ← 零件可被 HTTP 呼叫了 + +registry index? ← 這步從來沒做 +``` + +### 1.3 影響(吃狗糧的觀察) +- 新 AI(Claude / Gemini / Codex)進來不知道有什麼零件 → 自己寫 Python 直打 API +- arcrun 想推「AI-first 自服務」整個破功 +- 文件寫得再好都救不了 — 因為 README 只能寫概念,零件清單必須是 API 動態查 + +--- + +## 2. 目標 + +**Registry 是零件 metadata 的 SSOT**: + +- 零件 Worker 在跑 ⇔ registry 有對應 entry(雙向綁定) +- AI 透過 MCP `search_components` 永遠找得到所有現役零件 +- README 不寫死數量,動態 badge 即時反映 +- 第三方裝完 MCP 30 秒內能找到第一個可用零件 + +--- + +## 3. 三層設計 + +### Layer 1: 一次性 backfill(Phase 1) + +掃 `matrix/arcrun/registry/components/*/component.contract.yaml`,把每個 contract POST 進 registry index。 + +工具:`matrix/arcrun/registry/scripts/backfill-index.ts` +- 讀檔 → 解析 YAML → 呼叫 registry submit endpoint +- idempotent:已存在不重複寫(registry 端要支援 upsert) +- 跳過沙盒驗收(這些零件已驗過、已部署,不用重跑 gherkin tests) + +### Layer 2: 部署即註冊(Phase 2) + +改 `.github/workflows/deploy.yml`: +- 通用掃描掃到 `.component-builds/{name}/wrangler.toml` 部署成功後 +- post-deploy step 自動呼叫 registry submit(contract 從 `registry/components/{name}/component.contract.yaml` 讀) + +零件 Worker 部署 ⇒ registry 自動更新。沒有「零件部署了但 registry 不知道」的可能。 + +### Layer 3: Discoverability(Phase 3) + +- README 移除「21 個零件」這種寫死數字,改「跑 search 看當前清單」 +- 加 badge endpoint `registry.arcrun.dev/badge/components.svg` 即時顯示數量 +- MCP `get_component_guide` 開頭加鐵律:「動工前必須先 search,不是猜」 +- onboarding kit GitHub template:CLAUDE.md / .cursor/rules / AGENTS.md 三件套,all 強制 search 優先 + +### Layer 4: u6u → arcrun Rebrand(Phase 4) + +把 `matrix/u6u-mcp/` 跟所有 `u6u_*` tool 名搬到 arcrun 命名空間。 + +理由: +- u6u 是申請 arcrun.dev 之前的暫名,現在已過時 +- 命名混亂阻礙推廣(「為什麼 arcrun 文件叫 u6u_*?」) +- 第三方看到 u6u 不知道是同一個產品 + +範圍: +1. 目錄:`matrix/u6u-mcp/` → `matrix/arcrun-mcp/` +2. Worker name:`u6u-mcp` → `arcrun-mcp` +3. Tool 前綴:`u6u_search_components` → `arcrun_search_components`(14 個 tool) +4. Hostname:`mcp.finally.click` → `mcp.arcrun.dev`(finally.click 保留 redirect 到 arcrun.dev 過渡期) +5. Repo / Worker 內部 ID:u6u-mcp-server → arcrun-mcp-server +6. README 全文:u6u → arcrun +7. user memory(CLAUDE.md / MEMORY.md)相關提及一併更新 +8. inkstone-component-registry(舊 worker)廢止 → arcrun-registry 為唯一現役 + +**Rebrand 原則:** +- 用戶端 config(claude_desktop_config.json 等)給過渡期:兩個 URL 都活,舊的回 deprecation header 提示換新 +- Tool 前綴 `u6u_*` → `arcrun_*` 沒有過渡期(一刀切,因為前綴是 AI 看的,不是用戶記憶肌肉) +- 文件 / repo 內所有 reference 立即改 + +--- + +## 4. 範圍邊界 + +**在本 SDD 範圍內:** +- ✅ Phase 1: backfill index +- ✅ Phase 2: 部署即註冊 hook +- ✅ Phase 3: README + badge + onboarding kit +- ✅ Phase 4: u6u → arcrun rebrand(含目錄 / worker / hostname / tool 前綴 / 文件) + +**不在範圍內:** +- 新零件開發(這是 polaris 業務範圍) +- registry KV schema 改動(用既有結構) +- u6u-gui 的 rebrand(u6u-mcp 同 monorepo 但獨立 SDD) +- Phase 5(用戶自製零件 R2 上傳)— 等 Phase 4 完成後另開 SDD + +**前置依賴(已完成):** +- ✅ u6u-mcp Zod 4 → Zod 3 修復(2026-05-07) +- ✅ u6u-mcp service binding 改指 arcrun-registry(2026-05-07) +- ✅ arcrun-registry Worker 部署在 registry.arcrun.dev + +--- + +## 5. 驗收標準 + +### Phase 1 驗收 +- `u6u_search_components("kbdb")` 回非空結果,含 `kbdb_ingest` / `kbdb_create_block` / `kbdb_patch_block` +- `acr parts list` CLI 端對端能列出 +- registry KV 內至少 30 entries + +### Phase 2 驗收 +- 部署任一既有零件後,registry 30 秒內 reflect 更新 +- 部署一個全新零件,無需手動 publish,registry 自動有 +- CI workflow 不會因 registry 寫入失敗就擋部署(degraded mode:寫入失敗 log warning 但不 fail) + +### Phase 3 驗收 +- README 沒有「21 個零件」「30 個零件」這種寫死數字 +- badge SVG 渲染正確、數字跟 KV 一致 +- onboarding kit clone 下來,照 README 跑能 30 秒內 list 到零件 + +### Phase 4 驗收 +- `mcp.arcrun.dev/mcp/mcp` 通,回的 tool 名都是 `arcrun_*` +- 舊 `mcp.finally.click/mcp/mcp` 仍可用但回 deprecation header +- README / docs / GUIDE 全部 u6u 字樣消除 +- `matrix/u6u-mcp/` 目錄不存在,改為 `matrix/arcrun-mcp/` +- 用戶記憶(`~/.claude/.../MEMORY.md`)arcrun MCP 設定範例已更新 + +--- + +## 6. 風險與緩解 + +| 風險 | 緩解 | +|---|---| +| backfill 把 contract 灌進去後,沙盒驗收覆蓋既有資料 | registry submit 加 `skip_acceptance=true` flag,僅 backfill 用 | +| 部署 hook 寫入失敗擋掉部署 | hook degraded mode:失敗只 warning,不 fail 部署 | +| Rebrand 把現役 client 弄壞 | 過渡期:舊 hostname 跟 worker 並存 1 個月 | +| Tool 前綴改名 AI 適應期 | 不過渡,一刀切(前綴是 system instruction 範圍,AI 一個 prompt 就學會)| +| 既有用戶 config 寫死 finally.click | 提前公告 + 過渡期 + 舊 endpoint 自動 redirect / proxy | + +--- + +## 7. 變更紀錄 + +| 版本 | 日期 | 內容 | +|---|---|---| +| v1.0 | 2026-05-07 | 初版。吃狗糧發現 registry 空的,三層設計(backfill / auto-register / discoverability)+ u6u → arcrun rebrand 一併納入。 | diff --git a/.agents/specs/component-registry-canon/tasks.md b/.agents/specs/component-registry-canon/tasks.md new file mode 100644 index 0000000..bfeda6f --- /dev/null +++ b/.agents/specs/component-registry-canon/tasks.md @@ -0,0 +1,159 @@ +# Tasks — Component Registry Canon + +> 對應 SDD:[design.md](design.md) +> 上次更新:2026-05-07 + +**狀態 legend**:`[ ]` 待辦 / `[🔄]` 進行中 / `[x]` 完成 + +--- + +## Phase 0:前置(已完成) + +- [x] 0.1 u6u-mcp Zod 4 → Zod 3 降版修 tools/list `_zod undefined` bug(2026-05-07) +- [x] 0.2 u6u-mcp service binding `inkstone-component-registry` → `arcrun-registry`(2026-05-07) +- [x] 0.3 確認 `mcp.finally.click/mcp/mcp` 端對端通,tools/list 回 14 個 tool(2026-05-07) + +--- + +## Phase 1:Backfill Index(半天,立即見效) + +- [x] 1.1 探查 registry 既有 endpoint:發現 + - 既有 `POST /components` 強制要 wasm bytes(multipart 或 base64),跑沙盒驗收 + 寫 R2 + 寫 KV + - cypher-executor 已不從 R2 動態載 wasm(line 32 標 R2 路徑作廢,零件用獨立 Worker URL) + - 結論:R2 是 legacy,registry 真正用途是 metadata 索引給 AI 搜尋 + - 決策:**加新 endpoint `POST /components/index-only`** 接 contract(無 wasm、無沙盒),專供 backfill 跟「已部署但未索引」零件用 +- [x] 1.1.1 加 `src/actions/indexOnlyComponent.ts`(metadata-only 寫 KV,冪等) +- [x] 1.1.2 加 `src/routes/components.ts` 的 `POST /index-only` route +- [x] 1.1.3 部署 + smoke test(contract 驗證 + 錯誤處理通過) +- [x] 1.2 寫 `matrix/arcrun/registry/scripts/backfill-index.mjs`(zero-build node script,用 js-yaml) +- [x] 1.3 dry-run 確認 30 個 component 全 parse 通 +- [x] 1.4 跑真 backfill(過程中發現並修了兩個 schema 問題): + - schema enum `category` 補 `auth` / `ai` / `platform`(types.ts) + - `max_cold_start_ms` 上限放寬 50 → 500(auth/ai 含 crypto 需要) + - `no_network_syscall` / `no_filesystem_syscall` 改 optional + - `max_size_kb` 上限放寬 2048 → 8192 + - index-only route 對缺 gherkin/description/tags 的零件補 placeholder(不擋索引) +- [x] 1.5 驗證:MCP `u6u_search_components("kbdb")` 回 3 個零件(kbdb_ingest / kbdb_create_block / kbdb_patch_block) +- [ ] 1.6 驗證:`acr parts list` CLI 端對端能列 +- [x] 1.7 驗證:registry KV 30 entries(30 created + 30 idx 共 60 keys) + +--- + +## Phase 1.5:砍 R2 dead storage(先於 Phase 2,清架構斷層) + +> 2026-05-07 加入。R2 wasm 路徑早已 dead(cypher-executor 不從 R2 讀),保留只會誤導 AI。 +> SDD design.md 的「Phase 5 用戶自製零件 R2 上傳」一併廢止。 + +- [x] 1.5.1 改 `submitComponent.ts`:移除 R2 寫入段落,保留 KV 寫入 +- [x] 1.5.2 移除 `wrangler.toml` 的 `[[r2_buckets]] WASM_BUCKET` binding +- [x] 1.5.3 移除 `types.ts` Bindings 的 `WASM_BUCKET: R2Bucket` +- [x] 1.5.4 既有 `wasm_r2_key` 欄位保留為 deprecated(queryComponents 仍會讀 legacy record) +- [ ] 1.5.5 廢止 `arcrun-wasm` R2 bucket(30 天觀察期後 → 2026-06-07 之後 `wrangler r2 bucket delete`) +- [x] 1.5.6 部署 + smoke test:search 端對端通過(kbdb 找到 3 個零件) + +## Phase 2:部署即註冊(1-2 天) + +- [x] 2.1 選擇方案:CI step(github actions)— 在 wrangler deploy 之後 curl `/index-only` +- [x] 2.2 寫 `registry/scripts/register-component.sh`(本地 + CI 共用 SSOT,python3 + pyyaml 解 YAML,curl POST registry) +- [x] 2.3 改 `.github/workflows/deploy.yml` tier1 deploy step 後加 "Register component in registry" step(degraded mode:失敗只 warning) +- [x] 2.4 本地驗 `bash scripts/register-component.sh kbdb_ingest` → 200 + already_indexed +- [ ] 2.5 真正 push 一個新零件驗 CI hook 端對端(需要等下次新增零件時驗) +- [ ] 2.6 文件化:`docs/contributing-components.md`「新增零件的標準流程」 +- [ ] 2.7 廢止 `u6u_publish_component` tool 的「需手動 publish」假設(rebrand 一起做) + +--- + +## Phase 3:Discoverability(半天) + +- [ ] 3.1 改 GitHub `richblack/arcrun` README + - 移除「21 個零件」這種寫死數字 + - 加「跑 `acr parts list` 或 MCP search 看當前清單」 + - 加 badge:`![components](https://registry.arcrun.dev/badge/components.svg)` +- [ ] 3.2 加 `matrix/arcrun/registry/src/routes/badge.ts` + - GET `/badge/components.svg` 回 shields.io 格式 SVG + - count 從 KV 即時 query + - cache 1 分鐘(`Cache-Control: max-age=60`) +- [ ] 3.3 改 MCP `u6u_get_component_guide` tool(之後改名 `arcrun_*`) + - 開頭加「鐵律:動工前必須先 search_components,找不到才 publish」 +- [ ] 3.4 onboarding kit GitHub template repo(建議名 `arcrun-quickstart`) + - 三件套:CLAUDE.md / `.cursor/rules/arcrun.mdc` / AGENTS.md + - 強制:「呼叫 Claude/任何 AI 前,先 list MCP tools;arcrun MCP 已連線時,**禁止用 Python 直打 HTTP API**」 + - 內附範例 hello workflow 跟 component +- [ ] 3.5 寫 onboarding doc:`docs/onboarding-third-party-engineer.md` + - 第三方工程師如何 30 秒內讓 AI 學會用 arcrun + +--- + +## Phase 4:u6u → arcrun Rebrand(1 天,最後做) + +> 規劃做完 Phase 1-3 驗證 OK 才動 rebrand,避免邊改邊驗。 + +### 4.1 Repo & Worker +- [ ] 4.1.1 `git mv matrix/u6u-mcp matrix/arcrun-mcp`(或 cp + rm,視 git history 偏好) +- [ ] 4.1.2 改 `matrix/arcrun-mcp/wrangler.toml`: + - `name = "u6u-mcp"` → `name = "arcrun-mcp"` + - 加 route `mcp.arcrun.dev/*`,舊 `studio.finally.click/mcp*` 保留 1 個月 +- [ ] 4.1.3 改 `package.json`:`@inkstone/u6u-mcp-worker` → `@arcrun/mcp-worker` + +### 4.2 Tool 前綴改名 +- [ ] 4.2.1 14 個 tool 檔案 rename:`u6u_*.ts` → `arcrun_*.ts` +- [ ] 4.2.2 每個 tool 內部 `server.tool("u6u_xxx", ...)` 改 `server.tool("arcrun_xxx", ...)` +- [ ] 4.2.3 `src/tools/registry.ts` import 路徑全改 +- [ ] 4.2.4 `src/index.ts` `serverInfo.name` 從 `u6u-mcp-server` 改 `arcrun-mcp-server` + +### 4.3 文件 +- [ ] 4.3.1 README.md 全文 u6u → arcrun +- [ ] 4.3.2 GUIDE.md 同上 +- [ ] 4.3.3 GitHub `richblack/arcrun` README 補 MCP 段落(之前沒提) +- [ ] 4.3.4 任何提到 `u6u-mcp` / `mcp.finally.click` 的 docs 更新 + +### 4.4 用戶記憶 +- [ ] 4.4.1 `~/.claude/projects/.../memory/MEMORY.md` 加 arcrun MCP entry + - URL: `https://mcp.arcrun.dev/mcp/mcp` + - tool 前綴: `arcrun_*` + - finally.click 過渡期到何時 +- [ ] 4.4.2 polaris/mira/CLAUDE.md 提到 daemon / arcrun / MCP 的部分對齊新命名 + +### 4.5 過渡期(舊 endpoint 不立刻砍) +- [ ] 4.5.1 舊 `mcp.finally.click/mcp/mcp` 加回應 header `Deprecation: true` + `Link: ; rel="successor-version"` +- [ ] 4.5.2 舊 worker 繼續服務 30 天(2026-06-07 為止) +- [ ] 4.5.3 廢止排程:2026-06-07 後舊 worker 改回 410 Gone + 提示換新 URL + +### 4.6 驗證 +- [ ] 4.6.1 `mcp.arcrun.dev/mcp/mcp` initialize + tools/list + 一個 tool call 全通 +- [ ] 4.6.2 我的 Claude Code config 切到新 URL,用 `mcp__arcrun__search_components` 端對端測 +- [ ] 4.6.3 grep `u6u` 在 `matrix/arcrun-mcp/` 結果為 0(除了 changelog 紀錄) + +--- + +## 風險追蹤 + +- 風險 1:backfill 跑進去發現某些 contract.yaml 格式跟 registry 期望不一樣 → 緩解:dry-run 先看,必要時補 contract 欄位 +- 風險 2:Phase 4 rebrand 期間用戶 client 設定亂 → 緩解:過渡期 + Deprecation header +- 風險 3:自動註冊 hook 失敗導致部署被擋 → 緩解:degraded mode(warning 不 fail) + +--- + +## Known Issues(吃狗糧發現的,先記錄) + +### KI-1:u6u-mcp README URL 寫錯 +- README 寫 `mcp.finally.click/mcp`,實際是 `mcp.finally.click/mcp/mcp`(basePath + route) +- 影響:用戶照 README 裝完試打不通 +- 解法:rebrand 時順便修 + +### KI-2:inkstone-component-registry 跟 arcrun-registry 並存 +- 兩個 worker 都活著,u6u-mcp 之前指錯 +- inkstone-component-registry 是舊版(2026-03-24)、arcrun-registry 是現役(2026-04-16) +- 解法:Phase 1 backfill 完成後,inkstone-component-registry worker 廢止 + +### KI-3:search 對自然語言不夠靈敏(吃狗糧第一個發現) +- 現象: + - `search("從 KBDB 讀取或查詢 block")` → 0 結果 + - `search("kbdb")` → 3 結果(kbdb_ingest / kbdb_patch_block / kbdb_create_block) +- 根因:搜尋走 embedding(bge-m3)相似度,但既有零件清單少(30 個)+ description 寫得正式,自然語言整句的 embedding 跟 description 距離太遠 +- 影響:**致命** — AI 第一句永遠是自然語言整句,回 0 就會放棄 search 改寫 Python +- 解法(Phase 3 處理): + 1. embedding search 之外加 keyword fallback(split query → 對 canonical_id / display_name / tags 做 ILIKE) + 2. 或 lower threshold(目前 SCORE_THRESHOLD = 0.5,可能過高) + 3. MCP get_component_guide 教 AI 「找不到時拆關鍵字再 search」 +- 優先級:P1(會擋推廣) diff --git a/.agents/specs/recipe-system/design.md b/.agents/specs/recipe-system/design.md new file mode 100644 index 0000000..e9c36ba --- /dev/null +++ b/.agents/specs/recipe-system/design.md @@ -0,0 +1,240 @@ +# SDD: arcrun Recipe System(容器 + Recipe 模式) + +> 2026-05-07 建立。吃狗糧寫 wiki 合成 workflow 時撞牆發現的平台缺口。 +> 核心原則:**一個 WASM 零件 = 容器,內容(recipe)存資料庫**。 +> n8n 為每種 API 寫獨立 node,arcrun 走「容器 + recipe」減少零件數量。 + +--- + +## 1. 問題 + +### 1.1 撞牆現場 + +寫 mira wiki 合成 workflow(7-B)時: +- 流程:`kbdb_get(stale)` → foreach → `kbdb_get(drafts)` → `claude_api(合成 prompt)` → `kbdb_ingest` +- 第三步要組 prompt:`schema 內容 + skill 模板 + drafts array + existing_entities` +- cypher binding 內建 `{{var}}` 模板太弱(只支援 top-level,不支援嵌套 / array → string) +- 沒有 `string_template` 零件、沒有 `array_to_markdown` 零件 +- 寫專用 `wiki_prompt_builder` 零件 = 走 n8n 老路,每個 AI workflow 都要寫一個 + +### 1.2 根因 + +**arcrun recipe 系統只覆蓋 HTTP / auth 兩層**: + +| Recipe 種類 | 存哪 | 容器 | 狀態 | +|---|---|---|---| +| auth_recipe | RECIPES KV (`auth_recipe:{service}`) | auth_static_key / auth_oauth2 / ... | ✅ 已有 | +| api_recipe | RECIPES KV (`rec_{hash}`) | http_request | ✅ 已有(hard-code 在 cypher-executor 待清,Phase 1-3 處理)| +| **prompt_recipe** | ❌ 不存在 | claude_api(容器) | **缺** | + +`claude_api` 零件目前吃 `prompt: string`(已組好的字串),沒有「recipe 模式」可以讓 AI 用「組合配方」的方式呼叫。 + +### 1.3 影響 + +- **致命**:寫不出第一個 wiki 合成 workflow(7-B 卡關) +- **推廣破功**:arcrun 對外 prop 是「容器 + recipe,AI 不用寫 code」,但 prompt 這層做不到 +- **未來所有 AI workflow 都會撞同樣問題**:rss-tech-news 評語、河道 AI 副駕、ai-comment、文章摘要⋯ 全部需要組 prompt + +--- + +## 2. 設計 + +### 2.1 核心:prompt_recipe 平行於 auth_recipe / api_recipe + +**儲存**:`RECIPES` KV,key 格式 `prompt_recipe:{name}` + +**結構**: +```yaml +id: prompt_recipe:wiki_synthesis +version: v1 +description: "Mira wiki 合成(抽 triplet + 寫 wiki paragraph)" +model: sonnet # haiku / sonnet / opus(claude_api 沿用既有 routing) + +# 從 KBDB / 其他來源取的 fragment(在 prompt 組合時抓並插入) +fragments: + - var: schema + source: kbdb_block + block_id: "7a4e456e-1b0f-406a-8842-5e01d1cf1eef" # mira-wiki-schema + field: content + - var: skill_template + source: kbdb_block + block_id: "85e3b81e-dca8-4131-bcdc-990bd0d3a16f" # source-skill-wiki-synthesis + field: content + +# 從 workflow context 取(input/前置節點輸出) +inputs: + - var: drafts # 草稿 array + from: "ctx.read_drafts.blocks" + transform: "json_array" # 轉成 JSON array string + - var: existing_entities + from: "ctx.read_entities.blocks" + transform: "extract_field:page_name" # 抽 array 的 page_name 欄位 join 成 list + - var: entity_name + from: "ctx.loop.item" # foreach 迴圈當前元素 + +# 最終 prompt 由 fragments + inputs 套進 skill_template 組成 +prompt_assembly: + system: "{{schema}}" # 直接用 schema 當 system prompt + user: "{{skill_template}}" # skill template 內含 {{drafts}} {{existing_entities}} {{entity_name}} 變數 + +# 期待輸出 +output: + format: json # claude_api 自動 parse 為 object + schema: # zod-style,parse 失敗回 success:false + type: object + required: [triplets, entities, paragraphs, source_summary] +``` + +### 2.2 Recipe 解析在 cypher-executor(架構選擇 B) + +**設計決策**(2026-05-07):recipe 解析跟 prompt 組裝**在 cypher-executor TS**,不改既有 claude_api WASM。 + +理由: +1. recipe 解析是 cypher-executor 既有 `api_recipe / auth_recipe` 同性質工作 +2. 既有 claude_api 已部署 + 已測試,不動影響面最小 +3. transform 邏輯(json_array / extract_field 等)TS 寫起來比 TinyGo 簡單 10 倍 +4. 不違反 §1.6 — skill 還是 KBDB block,cypher-executor 只是組合者,不寫死 prompt + +**流程:** + +``` +workflow YAML 節點 config 出現 `recipe: prompt_recipe:xxx` + │ + ▼ +cypher-executor graph-executor.ts + 在執行該節點前 → 偵測 recipe 欄位 → 走 recipe expander + │ + ▼ +recipe expander(新 module) + 1. 從 RECIPES KV 抓 `prompt_recipe:xxx` 定義 + 2. 按 fragments 規則 → 用既有 KBDB client 抓 block content + 3. 按 inputs 規則 → 從 context 取值 + 跑 transform + 4. 組 system prompt + user prompt + 5. 把 {prompt, model, mira_token, ...} 當作節點實際 input + │ + ▼ +loader 呼叫 claude_api 容器(不知道 recipe 存在,仍吃舊介面) + │ + ▼ +claude_api 容器 → Mira daemon → 回 LLM 結果 + │ + ▼ +graph-executor 取結果 → 按 recipe.output 規則 parse JSON / 驗 schema +``` + +**對 claude_api 容器的影響**:完全沒有。它仍吃 `{mira_token, prompt, model}`。 + +**對 workflow 作者的體驗**: +```yaml +config: + synthesize: + component: claude_api + recipe: "prompt_recipe:wiki_synthesis" # ← cypher-executor 偵測到這欄位,自動解析 + mira_token: "{{secret.mira_token}}" +``` + +不寫 recipe 走舊路: +```yaml +config: + reply: + component: claude_api + prompt: "{{ctx.user_message}}" # ← 沒 recipe,cypher-executor 直接透傳 + mira_token: "{{secret.mira_token}}" +``` + +### 2.3 Workflow YAML 體驗 + +```yaml +name: wiki_synthesis +flow: + - "input >> 完成後 >> read_stale" + - "read_stale >> 對每個 >> read_drafts" + - "read_drafts >> 完成後 >> synthesize" + - "synthesize >> 完成後 >> write_wiki" +config: + read_stale: + component: kbdb_get + page_name: "mira-wiki-index-stale" + read_drafts: + component: kbdb_get + page_name: "{{loop.item}}" # entity name + synthesize: + component: claude_api + recipe: "prompt_recipe:wiki_synthesis" # ← 重點:指 recipe,不寫 prompt + mira_token: "{{secret.mira_token}}" + write_wiki: + component: kbdb_ingest + text: "{{prev.paragraphs}}" +``` + +**AI 寫這 workflow 只需要:** +1. 知道有 `kbdb_get / claude_api / kbdb_ingest` 三個容器(MCP search 找得到) +2. 知道有 `prompt_recipe:wiki_synthesis` 這個配方(MCP search 找得到) +3. 不需要懂 prompt 怎麼組、不需要看 wiki schema 文字 + +### 2.4 Recipe 是 KBDB block 還是 KV? + +**選 KV**(`RECIPES` namespace),跟既有 auth_recipe / api_recipe 一致: +- key: `prompt_recipe:{name}` +- value: YAML/JSON +- CLI 跟 MCP 用既有 `recipe push` / `recipe list` 工具管理(不需新工具) + +**不選 KBDB block**: +- 雖然 polaris/mira/CLAUDE.md §1.6 說「source-skill 存 KBDB block」 +- 但 §1.6 講的是 mira 業務的 skill template(schema / skill 模板) +- recipe 是「組合配方」(指向哪些 block + 怎麼組),是 platform 層 +- recipe **裡面** 引用 KBDB block id(fragments.source: kbdb_block)— 兩層關係清楚 + +--- + +## 3. 範圍邊界 + +**在本 SDD 範圍內:** +- ✅ Phase 1: prompt_recipe schema + RECIPES KV 規範 +- ✅ Phase 2: claude_api 改吃 recipe(向後相容舊 prompt 參數) +- ✅ Phase 3: 寫第一個 recipe `prompt_recipe:wiki_synthesis` +- ✅ Phase 4: 用此 recipe 完成 mira 7-B workflow +- ✅ Phase 5: MCP 加 recipe 管理 tool(list / get / push / delete prompt_recipe) + +**不在範圍內:** +- HTTP api_recipe / auth_recipe 改造(已有,不動) +- 多模態 prompt(image input)— 等 P2 +- recipe 沙盒驗收(recipe 是資料不是 code,不需要) + +**前置依賴(已完成):** +- ✅ kbdb_get 零件(5.3) +- ✅ component-registry MCP backfill(component-registry-canon Phase 1) + +--- + +## 4. 為什麼這個設計重要 + +| n8n | arcrun | +|---|---| +| Gmail node、Slack node、OpenAI node、Anthropic node、各 LLM node ⋯(每種 API 一個 node)| `http_request` 容器 + 各 service 的 api_recipe | +| 每個 LLM 用法新 node(chat / completion / embedding)| `claude_api` 容器 + 各用途的 prompt_recipe | +| AI 要學「Gmail node 怎麼用」「Slack node 怎麼用」⋯ | AI 要學「容器 + 配方」一次學會 | +| 零件數爆炸(500+) | 容器固定(< 30),配方無限擴充 | +| 配方藏在程式碼 | 配方在 KV,AI 直接 CRUD | + +**對 AI 推廣**:第三方 AI 看到「30 個容器 + 100 個配方」遠比「500 個 node」好理解,且配方是文字資料不是 code,AI 寫配方比寫 node 簡單。 + +--- + +## 5. 風險與緩解 + +| 風險 | 緩解 | +|---|---| +| recipe 結構過度複雜,AI 寫不出來 | Phase 3 寫第一個 recipe(wiki_synthesis)作為範本,未來 AI 抄 | +| 向後相容讓 claude_api 變兩條路 | 內部統一用 recipe path,舊 prompt 參數 → 自動轉成 inline recipe | +| recipe 引用 KBDB block id 寫死,block 改 id 就壞 | KBDB block 用 `page_name` 識別比 id 穩定,recipe 支援 `block_page_name` 欄位 | +| KV 寫入頻繁的 transform 邏輯(json_array, extract_field:x)→ 變 mini DSL | 限制 transform 種類(10 個內),列白名單,超過就請寫零件 | + +--- + +## 6. 變更紀錄 + +| 版本 | 日期 | 內容 | +|---|---|---| +| v1.0 | 2026-05-07 | 初版。吃狗糧寫 wiki 合成 workflow 撞到「prompt 組裝缺口」,補 prompt_recipe 層平行於既有 auth_recipe / api_recipe。 | +| v1.1 | 2026-05-07 | 架構選擇 B:recipe 解析在 cypher-executor TS(不改 claude_api WASM)。減少改動面、可單元測試、跟既有 api_recipe 同層次。 | diff --git a/.agents/specs/recipe-system/tasks.md b/.agents/specs/recipe-system/tasks.md new file mode 100644 index 0000000..f6916cb --- /dev/null +++ b/.agents/specs/recipe-system/tasks.md @@ -0,0 +1,110 @@ +# Tasks — Recipe System (容器 + Recipe 模式) + +> 對應 SDD:[design.md](design.md) +> 上次更新:2026-05-07 + +**狀態 legend**:`[ ]` 待辦 / `[🔄]` 進行中 / `[x]` 完成 + +--- + +## Phase 1:prompt_recipe Schema + KV 規範 + +- [x] 1.1 寫 `cypher-executor/src/lib/prompt-recipe-schema.ts`(85 行 Zod schema:fragments / inputs / prompt_assembly / output + transform 白名單 7 個) +- [x] 1.2 確認 cypher-executor wrangler.toml 已有 RECIPES KV binding +- [x] 1.3 寫 recipe loader (`recipe-loader.ts` 50 行) + transforms (`recipe-transforms.ts` 58 行) + expander (`recipe-expander.ts` 127 行) + - transform 7 個:json_array / to_string / join / markdown_list / extract_field / first / pluck_content + - expander:fragments(KBDB) + inputs(context+transform) → 套 {{var}} 模板 → {prompt, model, output_*} + - type-check 全通過 + +## Phase 2:cypher-executor recipe expander(架構選擇 B,不改 claude_api) + +- [x] 2.1 寫 `recipe-expander.ts`(127 行:load → fragments → inputs+transform → 套模板 → 回傳 prompt+model+output_*) +- [x] 2.2 寫 `recipe-transforms.ts`(58 行:7 個 transform) +- [x] 2.3 改 `graph-executor.ts` Component case:偵測 `node.data.recipe` → 呼叫 expandPromptRecipe → merge 進 mergedContext +- [x] 2.4 output parser hook:執行完若 `_recipe_output_format === 'json'` 自動 parse + required_fields 驗證 +- [x] 2.5 部署 cypher-executor v426b099e +- [x] 2.6 端對端驗證:用 curl 打 `/cypher/execute` 帶 recipe,trace 顯示 recipe 展開正確 + claude_api 拿到組好 prompt(Mira daemon 端 522 timeout 是 daemon 問題,不是 recipe 系統) +- [x] 2.7 [紅利修復] cypher-executor `WASM_HTTP_RUNNER_IDS` 加 5 個 mira 零件(claude_api / kbdb_*)— 短期解,根本修法見 KI-13 + +## Phase 3:第一個 recipe — wiki_synthesis + +- [x] 3.1 寫 `polaris/mira/recipes/wiki_synthesis.json`(4 fragments + 4 inputs + system/user template + json output) +- [x] 3.2 用 `wrangler kv key put --remote` 推進 RECIPES KV (key: `prompt_recipe:wiki_synthesis`) +- [x] 3.3 確認 KV 寫入成功(wrangler kv get 驗證) +- [ ] 3.4 不適用(架構選擇 B 不改 claude_api,recipe 在 cypher-executor 解析) +- [x] 3.5 端對端測試:用 MCP `u6u_execute_workflow` 跑 wiki_synthesis 成功 + - input:1 句草稿(黃仁勳 GTC 2026 物理 AI) + - output:3 triplets + 3 entities + 1 wiki paragraph + source_summary + - 過程修了 KI-14 (service binding 指錯)、KI-15 (token 沒轉發)、KI-16 (Claude markdown fence 沒剝) + +## Phase 4:mira 7-B 用 recipe 完成 wiki workflow + +- [🔄] 4.1 寫 `polaris/mira/workflows/wiki_synthesis.yaml`(cypher binding YAML) + - 用 `recipe: prompt_recipe:wiki_synthesis` 指 recipe + - 4-5 個節點:read_stale → foreach → read_drafts → synthesize → write_wiki + log +- [ ] 4.2 用 MCP `u6u_execute_workflow` sandbox 跑(試一個 entity 不真寫 KBDB) +- [ ] 4.3 用 MCP `u6u_deploy_workflow` 部署到 cypher-executor +- [ ] 4.4 手動觸發 cron,驗 wiki page 真的出現 +- [ ] 4.5 在 mira/wiki/ 前端看到第一張 AI 合成 wiki page + +## Phase 5:MCP recipe 管理 tools + +- [ ] 5.1 加 MCP tool `arcrun_list_recipes(prefix?)`:列所有 prompt_recipe +- [ ] 5.2 加 MCP tool `arcrun_get_recipe(name)`:取單一 recipe 內容 +- [ ] 5.3 加 MCP tool `arcrun_push_recipe(name, yaml_content)`:upsert recipe +- [ ] 5.4 加 MCP tool `arcrun_delete_recipe(name)` +- [ ] 5.5 既有 auth_recipe / api_recipe 也通用同套 tool(不只 prompt_recipe) + +--- + +## 風險追蹤 + +- 風險 1:claude_api 改造跟 mira-app 同時動,可能影響河道 AI 副駕 + - 緩解:向後相容,舊 input 仍可用,mira 河道先不切 recipe +- 風險 2:recipe transform 白名單漏了某種需求 + - 緩解:發現缺什麼再加,第一版優先支援 wiki 用到的(json_array, extract_field, join) +- 風險 3:KV 跟 KBDB 都存配置,AI 困惑「該存哪邊」 + - 緩解:清楚分層 — recipe(容器組合方式) KV,data(schema 文字、skill 模板) KBDB + +--- + +## Known Issues(吃狗糧發現,記錄) + +### KI-11:MCP `u6u_execute_workflow` 不暴露 config 欄位 ✅ 修復(2026-05-07) +- 已修:tool schema 加 optional `config: Record>` +- 部署:u6u-mcp v11d7e366 +- 用戶要重啟 client session 才能看到新 schema + +### KI-12:MCP execute 路由打 `/execute` 而非 `/cypher/execute` ✅ 修復(2026-05-07) +- 已修:service binding fetch URL 改成 `http://cypher-executor/cypher/execute` +- 部署:u6u-mcp v11d7e366 + +### KI-14:u6u-mcp service binding 指向已廢棄的 inkstone-cypher-executor ✅ 修復 +- 現象:MCP 路徑跑 workflow trace 顯示 synth 變 Output、config 被忽略 +- 根因:`u6u-mcp/wrangler.toml` services binding 是舊 worker `inkstone-cypher-executor`,不是現役 `arcrun-cypher-executor` +- 解法:改 service name + redeploy + +### KI-15:u6u-mcp 沒把 partner token 轉發給 cypher-executor ✅ 修復 +- 現象:recipe expander 抓 KBDB block 401(沒 auth) +- 根因:partnerAuthMiddleware 驗完 token 但只 set org_namespace,沒留 token;execute_workflow tool fetch 沒帶 X-Arcrun-API-Key +- 解法:middleware 也 set partner_token、handleMcpRequest + registerAllTools + execute_workflow 多一個 partnerToken 參數、fetch header 加 X-Arcrun-API-Key + +### KI-16:Recipe JSON output 被 Claude 包在 ```json``` markdown fence ✅ 修復 +- 現象:JSON.parse 失敗 "Unexpected token \`" +- 根因:Claude 預設輸出 ```json\n{...}\n``` 包裝 +- 解法:cypher-executor 解析前 regex 剝 fence + +### KI-13:cypher-executor `WASM_HTTP_RUNNER_IDS` 寫死白名單 +- 現象:每加新零件要回 cypher-executor 改白名單 + 重部署 +- 影響:違反 arcrun「容器+ recipe,新零件無需改 platform」承諾 +- 短期解:手動加進白名單(claude_api / kbdb_* 已加) +- 根本解:改成從 component-registry KV 動態查 canonical_id +- 優先級:P1(推廣破口),需新 SDD `cypher-executor-dynamic-component-discovery` + +--- + +## 對外推廣(Phase 6+,本 SDD 不執行,記錄) + +- README 示範「容器 + recipe = 一個 service」(Gmail / Slack / Claude) +- onboarding kit GitHub template 內含 5 個經典 recipe 當範例 +- 「recipe market」想法:用戶分享 recipe 幫他人少寫 prompt diff --git a/.agents/specs/resumable-workflow/design.md b/.agents/specs/resumable-workflow/design.md new file mode 100644 index 0000000..0059572 --- /dev/null +++ b/.agents/specs/resumable-workflow/design.md @@ -0,0 +1,285 @@ +# SDD: Resumable Workflow(webhook callback 喚醒) + +> 2026-05-07 建立。狗糧寫 wiki 合成 workflow 時,Mira daemon 對長草稿(>2KB)切非同步模式回 `{pending, task_id, poll_url}`,cypher-executor 沒處理就直接傳下游。 +> 本 SDD 解這層:**workflow 跑到一半遇到 pending 任務 → 暫停 + 持久化狀態 → 外部 callback 進來時喚醒繼續**。 +> 範圍:兩家自家服務之間(Mira daemon ↔ cypher-executor)走 webhook 推。對外服務無 webhook 的場景留 wishlist 用 poll 解。 + +--- + +## 1. 問題 + +### 1.1 撞牆現場 + +wiki 合成 workflow 第一節點 `claude_api(recipe:wiki_synthesis)`: +- 短草稿(< 2KB)→ daemon 同步回 `{success, data: {text}}`,recipe output parser 解 JSON 成功 +- 長草稿(> 2KB)→ daemon 估 75s,切非同步模式回: + +```json +{ + "success": true, + "pending": true, + "task_id": "task_14_1778133152480", + "poll_url": "https://mira.uncle6.me/mira/execute/task_14_1778133152480", + "estimated_seconds": 75 +} +``` + +cypher-executor 拿到這個物件就當 result,但裡面沒 `data.text`,下游 recipe output parser 找不到要 parse 的東西,整個 workflow 算「success」但實際上 wiki 還沒生出來。 + +### 1.2 現有 toolkit 不夠 + +- `wait` 零件:固定 sleep N ms,沒 retry / 條件判斷 +- `http_request` 零件:通用 HTTP,不認 daemon 的 polling 協議 +- cypher-executor `visited` Set:擋住節點重訪,沒辦法做迴圈式 poll +- Worker CPU 30s 限制:同步 poll 75s 任務不可能 + +### 1.3 Push vs Pull 抉擇(2026-05-07 拍板) + +| | Webhook 推 | Poll 拉 | +|---|---|---| +| 適用 | 雙方都自家 | 對方無 callback 能力 | +| Worker 時間消耗 | 趨近 0 | 全程占用 | +| 時長限制 | 無 | Worker CPU 30s | +| 工程位置 | runtime 能力(cypher-executor)| 零件(poll_task) | + +**走 Webhook 推**(自家服務優先,poll_task 進 wishlist)。 + +--- + +## 2. 設計 + +### 2.1 三層改動 + +**A. Mira daemon 端(infra/cloud-cto)** +- `/mira/execute` 接受新欄位 `callback_url: string`(optional) +- task 完成時 POST 到 `callback_url`,body: + ```json + { + "task_id": "task_14_xxx", + "success": true, + "data": { "text": "..." } + } + ``` +- 失敗也要 callback,body 含 `error` 欄位 +- 重試策略:3 次 backoff(1s / 5s / 30s),最後失敗就放棄(task 狀態存進 daemon 自己 KV) + +**B. cypher-executor 端(resumable runtime)** + +新概念:**workflow run 可以暫停**。 + +設計: +1. 新 KV namespace(或用既有 `EXEC_CONTEXT`)存暫停的 run state: + - key: `paused_run:{task_id}` 或 `paused_run:{run_id}` + - value: `{ run_id, graph, paused_node_id, paused_node_pending_result, context, trace_so_far, kv_store_ref, expires_at }` +2. graph-executor 偵測節點 result 含 `pending: true` + `task_id` → 暫停 + 寫 KV + 回 `{paused: true, task_id, run_id}` +3. 新 endpoint `POST /workflows/resume`: + - body: `{ task_id, result }`(result 是 daemon callback 給的完整資料) + - 從 KV 拿 paused state → merge result 進 paused_node 的 output → 從下個節點繼續執行 +4. claude_api 容器呼叫 daemon 時自動帶 `callback_url`: + - `https://cypher.arcrun.dev/workflows/resume?task_id={預先派發的 task_id}` + - 但 task_id 是 daemon 自己派的,cypher-executor 不知道。需先 daemon 派完 task_id 才能組 URL + - 解:daemon 改成「先回 task_id,再啟動實際工作 + 完成時 callback」— 兩階段 hand-shake + +實際流程(兩階段): + +``` +cypher-executor Mira daemon + │ │ + │ POST /mira/execute │ + │ { prompt, │ + │ callback_url: "?run_id=R1" } + ├─────────────────────────────>│ + │ │ 立即回 task_id(決定走非同步) + │<─────────────────────────────┤ { pending, task_id: T9 } + │ │ + ├─ 看到 pending → 寫 KV │ 啟動實際 LLM 任務 + │ paused_run:T9 = {run R1, │ + │ paused_node, ctx, ...} │ + │ │ + │ 立即回 client (MCP): │ + │ { paused, task_id: T9 } │ + │ │ + ⋯⋯⋯⋯⋯ 75s 後 ⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯ + │ │ task done + │ POST /workflows/resume │ + │ { task_id: T9, result: {...} } + │<─────────────────────────────┤ + │ │ + │ 從 KV 取 paused_run:T9 │ + │ → merge result 進 paused 節點 │ + │ → 從下個節點繼續 │ + │ │ + │ run 跑完 → 寫 trace │ + │ → 通知 client (?) │ + │ │ +``` + +### 2.2 範圍邊界 + +**第一版(v1)做:** +- ✅ 單節點 pending → resume(最常見:claude_api 拿到 daemon pending) +- ✅ daemon 加 callback_url 支援 +- ✅ cypher-executor `/workflows/resume` endpoint +- ✅ run state 寫 EXEC_CONTEXT KV,含 24h TTL(避免 KV 累積) +- ✅ 整合測:用 wiki 合成跑長草稿,驗 callback 進來能繼續 + +**第一版不做:** +- ❌ 多節點都 pending 的 nested 場景(例如 claude_api → 又一個 claude_api)— v2 +- ❌ foreach 內 pending(item-level resume)— v2 +- ❌ pending 期間用戶看到「進度」的前端 UI — 走 trace 有 paused 標記,前端 polling 自己做即可 +- ❌ pending callback 失敗時的 retry / DLQ — v2,先記 log + +**前置依賴:** +- ✅ recipe-system 已部署(cypher-executor 已會解 recipe) +- ✅ Mira daemon 在 Hetzner,可改 code + +### 2.3 為什麼不用 Cloudflare Queues / Durable Objects + +- **CF Queues**:適合大量 fan-out,這裡是點對點 callback,KV 已夠 +- **Durable Objects**:long-lived state 比 KV 強,但成本高 + 複雜 +- **EXEC_CONTEXT KV**:既有 binding,工程量最小 + +未來真撞到 KV 限制(每 partner 寫入頻率上限)再升級。 + +--- + +## 3. 詳細設計 + +### 3.1 daemon 端 callback 機制 + +`infra/cloud-cto/index.js`(Mira daemon): + +```js +// /mira/execute handler +{ + // 既有 input + 新加: + callback_url: string // optional +} + +// 處理邏輯: +// 1. 啟動 task(既有邏輯) +// 2. 預估時間 > 30s → 切非同步: +// - 立即回 { success: true, pending: true, task_id, poll_url, estimated_seconds } +// - 背景 task 完成時: +// if (callback_url) POST callback_url with { task_id, success, data, error? } +// (不論用戶有沒有 poll,callback 一定會送) +``` + +callback 失敗策略: +- 3 次重試(1s / 5s / 30s) +- 全失敗:task 狀態維持完成,等 client 主動 poll(poll_url 仍有效) +- 超過 24h 沒被消化的 task:daemon GC + +### 3.2 cypher-executor 端 resumable runtime + +#### 3.2.1 偵測 pending(graph-executor) + +在 Component case,runner 回傳後: + +```ts +result = await runner(mergedContext); + +// 偵測 pending pattern(daemon 約定的回應結構) +if (isResumablePending(result)) { + await persistPausedRun(this.env.EXEC_CONTEXT, taskIdFromResult(result), { + run_id, graph, paused_node_id: node.id, paused_context: context, + paused_result: result, trace_so_far: trace, expires_at: Date.now() + 24*60*60*1000 + }); + // 提早結束此 run,回 paused 狀態 + return { paused: true, task_id, run_id }; +} + +// ... 既有的 recipe output parsing / kvSetNodeOutput / 等 +``` + +`isResumablePending(result)` = `result?.pending === true && typeof result?.task_id === 'string'` + +#### 3.2.2 callback URL 注入(claude_api 之前的 layer) + +問題:claude_api 容器發 daemon 請求時,要帶 `callback_url`。但 task_id 是 daemon 派的,URL 裡只能放 run_id,daemon 收到 callback 時填 task_id: + +`callback_url = https://cypher.arcrun.dev/workflows/resume?run_id={current_run_id}` + +但 cypher-executor 端用 task_id 找 paused state(一個 run 可能多個 pending),所以 callback URL 應該是: + +`callback_url = https://cypher.arcrun.dev/workflows/resume`(不帶 query,task_id 在 body) + +**實作位置**:在 graph-executor 呼叫 claude_api 前,自動注入 `callback_url` 到 mergedContext: + +```ts +if (node.componentId === 'claude_api' && this.env?.PUBLIC_BASE_URL) { + mergedContext.callback_url = `${this.env.PUBLIC_BASE_URL}/workflows/resume`; +} +``` + +> 暫先用「componentId 寫死匹配」是 hacky,未來 component contract 加 `supports_async_callback: true` 標記就 generic 了。 + +#### 3.2.3 resume endpoint + +`POST /workflows/resume`: + +```ts +{ + task_id: string, // daemon 給的 + success: boolean, + data?: { text: string }, // 跟同步呼叫一樣的結構 + error?: string +} +``` + +處理: +1. 從 EXEC_CONTEXT KV `paused_run:{task_id}` 拿 state +2. 沒拿到(過期 / 重複 callback)→ 回 200 + log +3. 把 callback 給的 result 當作 paused_node 的 output +4. 重建 GraphExecutor,從下個節點繼續執行 +5. 跑完寫完整 trace + +**問題:resume 後沒辦法再回給原 client。** 用戶最初打 `/cypher/execute`(同步),拿到 `{paused, task_id}` 之後就斷了;resume 跑完 result 沒地方送。 + +**v1 解法**:resume 完寫進 `analytics_kv` 或 D1,**用戶要主動 query**。簡單但 UX 差。 +**v2 想法**:resume 完發另一個 webhook 給原 client(client 在 trigger 時帶 final_callback_url)。 + +--- + +## 4. 範圍 + +**在本 SDD 範圍內:** +- 4.1 daemon `/mira/execute` 加 callback_url 支援 +- 4.2 cypher-executor 偵測 pending + 持久化 paused state +- 4.3 cypher-executor `/workflows/resume` endpoint +- 4.4 callback_url 自動注入(claude_api 場景) +- 4.5 wiki 合成 workflow 用長草稿端對端測試 + +**不在本 SDD 範圍:** +- nested pending(v2) +- foreach 內 pending(v2) +- final_callback 給原 client(v2) +- poll_task 零件(wishlist) + +--- + +## 5. 驗收標準 + +1. wiki 合成 workflow 餵 5KB+ 草稿,跑完後 wiki page 有寫進 KBDB(不再 trace `pending` 假成功) +2. trace 有 `paused` 紀錄,能看到 task_id +3. 從 daemon 觸發 callback 後 < 5s 內 cypher-executor 把 paused state 撿起來繼續 +4. 24h 沒 callback 的 paused state KV 自動 expire(看 KV TTL 列表) + +--- + +## 6. 風險 + +| 風險 | 緩解 | +|---|---| +| daemon callback 進來時 cypher-executor 重啟 → state 還在 KV,OK | KV 持久化 | +| 同 task_id 重複 callback(網路重試)→ 重複執行下游 | resume endpoint idempotent:拿到 state 後立刻刪 KV,重複 callback 找不到 state | +| daemon callback 失敗(網路)| daemon 端 3 retry + 24h GC,超過就需手動干預(v1 接受) | +| paused state 含敏感資料(partner key)| KV 有 24h TTL;不寫 plaintext secrets(既有 credential injection 在執行前才解,paused state 存的是執行前的 context,secret 還沒解)| + +--- + +## 7. 變更紀錄 + +| 版本 | 日期 | 內容 | +|---|---|---| +| v1.0 | 2026-05-07 | 初版。狗糧 wiki 合成撞 daemon 非同步 → 補 resumable workflow runtime。第一版只做單節點 pending + claude_api callback 注入。| diff --git a/.agents/specs/resumable-workflow/tasks.md b/.agents/specs/resumable-workflow/tasks.md new file mode 100644 index 0000000..045b15c --- /dev/null +++ b/.agents/specs/resumable-workflow/tasks.md @@ -0,0 +1,61 @@ +# Tasks — Resumable Workflow + +> 對應 SDD:[design.md](design.md) +> 上次更新:2026-05-07 + +**狀態 legend**:`[ ]` 待辦 / `[🔄]` 進行中 / `[x]` 完成 + +--- + +## Phase 1:Mira daemon 端 callback 支援 + +- [x] 1.1 改 `/opt/mira/mira-daemon.js`(Hetzner mira container)`/execute` 接受 `params.callback_url` +- [x] 1.2 fireCallback function:task done/failed 時 POST callback_url,body = `{task_id, success, data?, error?}` +- [x] 1.3 callback retry:4 次(立即 + 1s/5s/30s backoff),全失敗 log +- [x] 1.4 patch script 寫好 `/tmp/patch-mira-daemon.py`,docker cp 進 container(注意:rebuild image 會丟失,需重 patch 或正式 commit 進 Dockerfile/git repo) +- [x] 1.5 真實端對端驗證:daemon log 顯示 `[Mira callback] task=task_2_... POST https://cypher.arcrun.dev/workflows/resume OK 200`(2026-05-07 07:24:04 + task_3 短測試) + +## Phase 2:cypher-executor resumable runtime + +- [x] 2.1 寫 `paused-runs.ts`(81 行):persistPausedRun / loadPausedRun / consumePausedRun + isResumablePending 偵測器,24h TTL +- [x] 2.2 改 `graph-executor.ts` Component case:偵測 pending → 寫 KV + throw WorkflowPaused +- [x] 2.3 改 `cypher-handlers.ts`:catch WorkflowPaused → 回 `{success:true, paused:true, task_id, run_id, paused_node_id, trace, graph}` +- [x] 2.4 callback_url 自動注入:componentId==='claude_api' 時 mergedContext.callback_url = PUBLIC_BASE_URL 或預設 cypher.arcrun.dev/workflows/resume + +## Phase 3:resume endpoint + +- [x] 3.1 寫 `routes/resume.ts`:POST /workflows/resume,consumePausedRun → resumeFromPaused +- [x] 3.2 graph-executor 加 `resumeFromPaused()` 方法:把 callback_result 當 paused_node 輸出 + spread 進 ctx + 從下游節點繼續 +- [x] 3.3 idempotent 驗證:第二次 callback 回 `{noop:true, reason:"state 不存在或過期"}` +- [x] 3.4 cypher-executor 部署 v0580980b +- [x] 3.5 mount /workflows/resume 進 index.ts + +## Phase 4:claude_api 容器透傳 callback_url + +- [x] 4.1 改 `claude_api/main.go`:Input 加 CallbackURL;timeout 預設改 120s +- [x] 4.2 重 build wasm + redeploy claude-api.arcrun.dev (v f926e3dd) +- [x] 4.3 真實端對端驗證:daemon 收到 callback_url → task done 後 POST cypher-executor/workflows/resume → 200 OK + +## Phase 5:端對端整合測試 + +- [ ] 5.1 用 MCP `u6u_execute_workflow` 跑 wiki 合成 + 5KB+ 草稿 +- [ ] 5.2 第一次回應應為 `{paused, task_id, run_id}` +- [ ] 5.3 等 daemon callback 進來(log 看到 /workflows/resume 命中) +- [ ] 5.4 觀察 wiki page 真的寫進 KBDB(即使原 MCP call 已斷線) +- [ ] 5.5 trace 含完整節點紀錄(paused → resumed) + +--- + +## 風險追蹤 + +- 風險 1:daemon callback 進來時,cypher.arcrun.dev 還沒醒(CF Worker cold start)→ 第一次 retry 接住(daemon retry policy 涵蓋) +- 風險 2:v1 沒 final_callback 給原 client → 用戶要主動查狀態 + - 接受:mira 河道 UI 可定期 refetch wiki page,或用既有 KBDB 觸發機制 + - v2 加 final_callback 統一處理 + +## v2 已記錄 + +- nested pending(一個 run 多個 paused 節點) +- foreach 內 pending(item-level resume) +- final_callback 給原 client(trigger 時帶 final_callback_url) +- poll_task 零件(外部 API 沒 webhook 時用) diff --git a/.component-builds/kbdb_get/package.json b/.component-builds/kbdb_get/package.json new file mode 100644 index 0000000..de75e87 --- /dev/null +++ b/.component-builds/kbdb_get/package.json @@ -0,0 +1,14 @@ +{ + "name": "arcrun-kbdb-get", + "version": "1.0.0", + "private": true, + "type": "module", + "dependencies": { + "hono": "^4.7.0" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250408.0", + "typescript": "^5.4.0", + "wrangler": "^4.0.0" + } +} diff --git a/.component-builds/kbdb_get/pnpm-lock.yaml b/.component-builds/kbdb_get/pnpm-lock.yaml new file mode 100644 index 0000000..16f48dd --- /dev/null +++ b/.component-builds/kbdb_get/pnpm-lock.yaml @@ -0,0 +1,898 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + hono: + specifier: ^4.7.0 + version: 4.12.18 + devDependencies: + '@cloudflare/workers-types': + specifier: ^4.20250408.0 + version: 4.20260507.1 + typescript: + specifier: ^5.4.0 + version: 5.9.3 + wrangler: + specifier: ^4.0.0 + version: 4.88.0(@cloudflare/workers-types@4.20260507.1) + +packages: + + '@cloudflare/kv-asset-handler@0.5.0': + resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} + engines: {node: '>=22.0.0'} + + '@cloudflare/unenv-preset@2.16.1': + resolution: {integrity: sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==} + peerDependencies: + unenv: 2.0.0-rc.24 + workerd: '>1.20260305.0 <2.0.0-0' + peerDependenciesMeta: + workerd: + optional: true + + '@cloudflare/workerd-darwin-64@1.20260504.1': + resolution: {integrity: sha512-IOMjYoftNRXabFt+QzY2Bo2mR2TNl8xsGvE0HnQ+K0S2c61VOUGUkr9gpJjnwrJ65yA9Qed4xfg0RRqXHO+nfA==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@cloudflare/workerd-darwin-arm64@1.20260504.1': + resolution: {integrity: sha512-7iMXxIU0N5KklZpQm2kuwTm0XtrpHXNqhejJyGquky8gSTnm31zBdutjMekH8VRr6ckbvZIl6lvqXzXdfOEojg==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@cloudflare/workerd-linux-64@1.20260504.1': + resolution: {integrity: sha512-YLB0EH5FQV++oWlalFgPF3p2Bp3dn/D6RWNMw0ukEC8gKnNX6o61A+dlFUl8hRD35ja1zKRxGFUojs4U2+MoJA==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@cloudflare/workerd-linux-arm64@1.20260504.1': + resolution: {integrity: sha512-FAh/82jDXDArfn9xDih6f/IJfF2SHXBb4nFeQAyHyvXrn18zM6Q3yl2Vj0U7LybbNbmu7TNGghwaM2NoSQS+0A==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@cloudflare/workerd-windows-64@1.20260504.1': + resolution: {integrity: sha512-QUg/B3dfrK/KHHHhiJzdkLkTg5mG7lA3t8iplbBoUa3XKCLOHOOXhbU4WSYlLqg8YnsQ6XLZ1HVA99fmZhJh7A==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + + '@cloudflare/workers-types@4.20260507.1': + resolution: {integrity: sha512-QChtMFu8EeVKaL4dW5r5wfZzlbH5CUnZU5Ef6E1cPjXzqryQuCwmEDNr+Oj2obbKR9jsVIUHPF/pkFaKWdYl2g==} + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@esbuild/aix-ppc64@0.27.3': + resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.3': + resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.3': + resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.3': + resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.3': + resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.3': + resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.3': + resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.3': + resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.3': + resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.3': + resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.3': + resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.3': + resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.3': + resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.3': + resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.3': + resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.3': + resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.3': + resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.3': + resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.3': + resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.3': + resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.3': + resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.3': + resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.3': + resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.3': + resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.3': + resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.3': + resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + + '@poppinss/colors@4.1.6': + resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} + + '@poppinss/dumper@0.6.5': + resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==} + + '@poppinss/exception@1.2.3': + resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + + '@sindresorhus/is@7.2.0': + resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} + engines: {node: '>=18'} + + '@speed-highlight/core@1.2.15': + resolution: {integrity: sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==} + + blake3-wasm@2.1.5: + resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + error-stack-parser-es@1.0.5: + resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + + esbuild@0.27.3: + resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} + engines: {node: '>=18'} + hasBin: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + hono@4.12.18: + resolution: {integrity: sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==} + engines: {node: '>=16.9.0'} + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + miniflare@4.20260504.0: + resolution: {integrity: sha512-HeI/HLx+rbeo/UB4qb6NsNcFdUVD7xDzyCexZJTVtFMlfpfexUKEDmdeTRRpzeHrJseZFGua+v9JO1kfPublUw==} + engines: {node: '>=22.0.0'} + hasBin: true + + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici@7.24.8: + resolution: {integrity: sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==} + engines: {node: '>=20.18.1'} + + unenv@2.0.0-rc.24: + resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + + workerd@1.20260504.1: + resolution: {integrity: sha512-AQTXSHbYNP9tLPgJNn0TmizyE4aDh2VuZZXlTAL0uu4fbCY436NAnQSJIzZbaFHM3DnAtVs9G8tkiJztSdYqDg==} + engines: {node: '>=16'} + hasBin: true + + wrangler@4.88.0: + resolution: {integrity: sha512-f470QwbeT/JM1S0duq+sLtkss7UBxIFDtYHgujv9tdQUyA/dLGDq51am0rqrsuFtCi97lTM1P5sqtt8xra1AlA==} + engines: {node: '>=22.0.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^4.20260504.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + + ws@8.18.0: + resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + youch-core@0.3.3: + resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} + + youch@4.1.0-beta.10: + resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} + +snapshots: + + '@cloudflare/kv-asset-handler@0.5.0': {} + + '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260504.1)': + dependencies: + unenv: 2.0.0-rc.24 + optionalDependencies: + workerd: 1.20260504.1 + + '@cloudflare/workerd-darwin-64@1.20260504.1': + optional: true + + '@cloudflare/workerd-darwin-arm64@1.20260504.1': + optional: true + + '@cloudflare/workerd-linux-64@1.20260504.1': + optional: true + + '@cloudflare/workerd-linux-arm64@1.20260504.1': + optional: true + + '@cloudflare/workerd-windows-64@1.20260504.1': + optional: true + + '@cloudflare/workers-types@4.20260507.1': {} + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.27.3': + optional: true + + '@esbuild/android-arm64@0.27.3': + optional: true + + '@esbuild/android-arm@0.27.3': + optional: true + + '@esbuild/android-x64@0.27.3': + optional: true + + '@esbuild/darwin-arm64@0.27.3': + optional: true + + '@esbuild/darwin-x64@0.27.3': + optional: true + + '@esbuild/freebsd-arm64@0.27.3': + optional: true + + '@esbuild/freebsd-x64@0.27.3': + optional: true + + '@esbuild/linux-arm64@0.27.3': + optional: true + + '@esbuild/linux-arm@0.27.3': + optional: true + + '@esbuild/linux-ia32@0.27.3': + optional: true + + '@esbuild/linux-loong64@0.27.3': + optional: true + + '@esbuild/linux-mips64el@0.27.3': + optional: true + + '@esbuild/linux-ppc64@0.27.3': + optional: true + + '@esbuild/linux-riscv64@0.27.3': + optional: true + + '@esbuild/linux-s390x@0.27.3': + optional: true + + '@esbuild/linux-x64@0.27.3': + optional: true + + '@esbuild/netbsd-arm64@0.27.3': + optional: true + + '@esbuild/netbsd-x64@0.27.3': + optional: true + + '@esbuild/openbsd-arm64@0.27.3': + optional: true + + '@esbuild/openbsd-x64@0.27.3': + optional: true + + '@esbuild/openharmony-arm64@0.27.3': + optional: true + + '@esbuild/sunos-x64@0.27.3': + optional: true + + '@esbuild/win32-arm64@0.27.3': + optional: true + + '@esbuild/win32-ia32@0.27.3': + optional: true + + '@esbuild/win32-x64@0.27.3': + optional: true + + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.10.0 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@poppinss/colors@4.1.6': + dependencies: + kleur: 4.1.5 + + '@poppinss/dumper@0.6.5': + dependencies: + '@poppinss/colors': 4.1.6 + '@sindresorhus/is': 7.2.0 + supports-color: 10.2.2 + + '@poppinss/exception@1.2.3': {} + + '@sindresorhus/is@7.2.0': {} + + '@speed-highlight/core@1.2.15': {} + + blake3-wasm@2.1.5: {} + + cookie@1.1.1: {} + + detect-libc@2.1.2: {} + + error-stack-parser-es@1.0.5: {} + + esbuild@0.27.3: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.3 + '@esbuild/android-arm': 0.27.3 + '@esbuild/android-arm64': 0.27.3 + '@esbuild/android-x64': 0.27.3 + '@esbuild/darwin-arm64': 0.27.3 + '@esbuild/darwin-x64': 0.27.3 + '@esbuild/freebsd-arm64': 0.27.3 + '@esbuild/freebsd-x64': 0.27.3 + '@esbuild/linux-arm': 0.27.3 + '@esbuild/linux-arm64': 0.27.3 + '@esbuild/linux-ia32': 0.27.3 + '@esbuild/linux-loong64': 0.27.3 + '@esbuild/linux-mips64el': 0.27.3 + '@esbuild/linux-ppc64': 0.27.3 + '@esbuild/linux-riscv64': 0.27.3 + '@esbuild/linux-s390x': 0.27.3 + '@esbuild/linux-x64': 0.27.3 + '@esbuild/netbsd-arm64': 0.27.3 + '@esbuild/netbsd-x64': 0.27.3 + '@esbuild/openbsd-arm64': 0.27.3 + '@esbuild/openbsd-x64': 0.27.3 + '@esbuild/openharmony-arm64': 0.27.3 + '@esbuild/sunos-x64': 0.27.3 + '@esbuild/win32-arm64': 0.27.3 + '@esbuild/win32-ia32': 0.27.3 + '@esbuild/win32-x64': 0.27.3 + + fsevents@2.3.3: + optional: true + + hono@4.12.18: {} + + kleur@4.1.5: {} + + miniflare@4.20260504.0: + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.34.5 + undici: 7.24.8 + workerd: 1.20260504.1 + ws: 8.18.0 + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + path-to-regexp@6.3.0: {} + + pathe@2.0.3: {} + + semver@7.7.4: {} + + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.7.4 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + + supports-color@10.2.2: {} + + tslib@2.8.1: + optional: true + + typescript@5.9.3: {} + + undici@7.24.8: {} + + unenv@2.0.0-rc.24: + dependencies: + pathe: 2.0.3 + + workerd@1.20260504.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20260504.1 + '@cloudflare/workerd-darwin-arm64': 1.20260504.1 + '@cloudflare/workerd-linux-64': 1.20260504.1 + '@cloudflare/workerd-linux-arm64': 1.20260504.1 + '@cloudflare/workerd-windows-64': 1.20260504.1 + + wrangler@4.88.0(@cloudflare/workers-types@4.20260507.1): + dependencies: + '@cloudflare/kv-asset-handler': 0.5.0 + '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260504.1) + blake3-wasm: 2.1.5 + esbuild: 0.27.3 + miniflare: 4.20260504.0 + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.24 + workerd: 1.20260504.1 + optionalDependencies: + '@cloudflare/workers-types': 4.20260507.1 + fsevents: 2.3.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + ws@8.18.0: {} + + youch-core@0.3.3: + dependencies: + '@poppinss/exception': 1.2.3 + error-stack-parser-es: 1.0.5 + + youch@4.1.0-beta.10: + dependencies: + '@poppinss/colors': 4.1.6 + '@poppinss/dumper': 0.6.5 + '@speed-highlight/core': 1.2.15 + cookie: 1.1.1 + youch-core: 0.3.3 diff --git a/.component-builds/kbdb_get/src/index.ts b/.component-builds/kbdb_get/src/index.ts new file mode 100644 index 0000000..6528fff --- /dev/null +++ b/.component-builds/kbdb_get/src/index.ts @@ -0,0 +1,82 @@ +/** + * arcrun WASM 零件 Worker (kbdb_get) + * + * POST / → JSON input → WASM (WASI preview1 stdin/stdout) → JSON output + * + * host function: http_request(用於呼叫 KBDB API) + * SDD: polaris/mira/.agents/specs/mira-app/design.md §6 / tasks.md §5.3 + */ + +import componentWasm from '../component.wasm' assert { type: 'webassembly' }; +import { Hono } from 'hono'; +import { cors } from 'hono/cors'; +import { createWasiShim, type WasiHostFunctions } from '../../../cypher-executor/src/lib/wasi-shim'; + +const app = new Hono(); +app.use('*', cors()); + +app.get('/', (c) => c.json({ ok: true, component: 'kbdb_get' })); + +app.post('/', async (c) => { + let input: unknown; + try { + input = await c.req.json(); + } catch { + return c.json({ success: false, error: 'request body must be JSON' }, 400); + } + + try { + const result = await runWasm(input); + return c.json(result); + } catch (e) { + return c.json( + { success: false, error: e instanceof Error ? e.message : String(e) }, + 500, + ); + } +}); + +export default app; + +// ── WASM runner ────────────────────────────────────────────────────────────── + +async function runWasm(input: unknown): Promise { + const hostFunctions: WasiHostFunctions = { + http_request: async (url, method, headersJson, body) => { + const headers: Record = {}; + if (headersJson) { + try { + const parsed = JSON.parse(headersJson); + if (parsed && typeof parsed === 'object') { + for (const [k, v] of Object.entries(parsed as Record)) { + if (typeof v === 'string') headers[k] = v; + } + } + } catch { + // ignore header parse errors + } + } + const init: RequestInit = { method, headers }; + if (body && method.toUpperCase() !== 'GET' && method.toUpperCase() !== 'HEAD') { + init.body = body; + } + const res = await fetch(url, init); + return await res.text(); + }, + }; + + const shim = createWasiShim(JSON.stringify(input), hostFunctions); + + const instance = await WebAssembly.instantiate( + componentWasm as WebAssembly.Module, + shim.imports, + ); + shim.setMemory(instance.exports.memory as WebAssembly.Memory); + await shim.run(instance); + + const stdout = shim.getStdout().trim(); + const stderr = shim.getStderr().trim(); + if (stderr) console.error('[kbdb_get wasm stderr]', stderr); + if (!stdout) throw new Error('WASM component produced no output'); + return JSON.parse(stdout); +} diff --git a/.component-builds/kbdb_get/tsconfig.json b/.component-builds/kbdb_get/tsconfig.json new file mode 100644 index 0000000..b65fda7 --- /dev/null +++ b/.component-builds/kbdb_get/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "types": ["@cloudflare/workers-types"], + "strict": true, + "noEmit": true + } +} diff --git a/.component-builds/kbdb_get/wrangler.toml b/.component-builds/kbdb_get/wrangler.toml new file mode 100644 index 0000000..f916cbd --- /dev/null +++ b/.component-builds/kbdb_get/wrangler.toml @@ -0,0 +1,11 @@ +name = "arcrun-kbdb-get" +main = "src/index.ts" +compatibility_date = "2025-02-19" +compatibility_flags = ["nodejs_compat"] + +[vars] +COMPONENT_ID = "kbdb_get" + +[[routes]] +pattern = "kbdb-get.arcrun.dev/*" +zone_name = "arcrun.dev" diff --git a/cypher-executor/src/actions/cypher-handlers.ts b/cypher-executor/src/actions/cypher-handlers.ts index 4814700..c0010ec 100644 --- a/cypher-executor/src/actions/cypher-handlers.ts +++ b/cypher-executor/src/actions/cypher-handlers.ts @@ -1,5 +1,5 @@ import type { Bindings, ExecutionGraph } from '../types'; -import { ExecutionError } from '../types'; +import { ExecutionError, WorkflowPaused } from '../types'; import { GraphExecutor } from '../graph-executor'; import { graphSchema } from '../lib/schemas'; import { createComponentLoader } from '../lib/component-loader'; @@ -32,7 +32,19 @@ export async function handleCypherExecute( env: Bindings, waitUntil: (promise: Promise) => void, apiKey?: string, -): Promise<{ success: boolean; data?: unknown; error?: string; trace?: unknown; duration_ms: number; graph?: ExecutionGraph }> { +): Promise<{ + success: boolean; + data?: unknown; + error?: string; + trace?: unknown; + duration_ms: number; + graph?: ExecutionGraph; + // resumable workflow: 節點 pending 時回 paused(不算 success 也不算 fail) + paused?: boolean; + task_id?: string; + run_id?: string; + paused_node_id?: string; +}> { const parsed = parseTriplets(triplets as unknown[]); if (!parsed) { throw new Error('無法解析任何節點'); @@ -70,6 +82,22 @@ export async function handleCypherExecute( return { success: true, data: result.data, trace: result.trace, duration_ms, graph }; } catch (err) { const duration_ms = Date.now() - start; + + // Resumable workflow: 節點回 pending → 回 paused 結構,不算成功也不算失敗 + // SDD: resumable-workflow/design.md + if (err instanceof WorkflowPaused) { + return { + success: true, + paused: true, + task_id: err.task_id, + run_id: err.run_id, + paused_node_id: err.paused_node_id, + trace: err.trace_so_far, + duration_ms, + graph, + }; + } + const errMsg = err instanceof Error ? err.message : String(err); const componentId = graph.nodes.find(n => n.componentId)?.componentId ?? graphId; const runId = `${graphId}-${Date.now()}`; diff --git a/cypher-executor/src/graph-executor.ts b/cypher-executor/src/graph-executor.ts index bb8b9b0..fb66af6 100644 --- a/cypher-executor/src/graph-executor.ts +++ b/cypher-executor/src/graph-executor.ts @@ -1,8 +1,10 @@ // arcrun 圖遍歷引擎 — 支援完整 Cypher 語意關係 import type { ExecutionGraph, GraphNode, TraceStep, ComponentRunner, KVContextStore, EdgeType, Bindings } from './types'; -import { kvSetNodeOutput, kvGetNodeOutput, ExecutionError } from './types'; +import { kvSetNodeOutput, kvGetNodeOutput, ExecutionError, WorkflowPaused } from './types'; import { injectCredentials } from './actions/credential-injector'; import { tryAuthDispatch } from './actions/auth-dispatcher'; +import { expandPromptRecipe } from './lib/recipe-expander'; +import { persistPausedRun, isResumablePending } from './lib/paused-runs'; export type ComponentLoader = (componentId: string) => Promise; export type WorkflowLoader = (workflowId: string) => Promise; @@ -17,6 +19,11 @@ export class GraphExecutor { private apiKey?: string; public recordComponentReference?: (componentId: string, workflowId: string) => Promise; + // resumable workflow(SDD: resumable-workflow/design.md) + // 暫停時持久化 state 用,需在 execute 進入時設定 + private currentGraph?: ExecutionGraph; + private currentRunId?: string; + constructor(loader: ComponentLoader, workflowLoader?: WorkflowLoader, env?: Bindings, apiKey?: string) { this.loader = loader; this.workflowLoader = workflowLoader; @@ -40,6 +47,10 @@ export class GraphExecutor { ? { runId: `${graph.id}-${Date.now()}`, kv: kvNamespace } : undefined; + // resumable workflow:記住當前 graph + run_id 給 pending 暫停用 + this.currentGraph = graph; + this.currentRunId = kvStore?.runId ?? `${graph.id}-${Date.now()}`; + // 找出所有起點(沒有任何邊指向的節點) const hasIncoming = new Set(graph.edges.map(e => e.to)); const startNodes = graph.nodes.filter(n => !hasIncoming.has(n.id)); @@ -82,6 +93,92 @@ export class GraphExecutor { return { data: mergedResult, trace }; } + /** + * 從 paused state 繼續執行 workflow + * SDD: resumable-workflow/design.md §3.2 + * + * 流程: + * 1. 把 paused_node 當已執行(result = callbackResult,注入進 context) + * 2. 找出 paused_node 的所有下游節點當新起點 + * 3. 執行下游節點直到結束(或再次 paused) + */ + async resumeFromPaused(args: { + graph: ExecutionGraph; + paused_node_id: string; + paused_context: Record; // paused 當下的 context + callback_result: Record; // daemon callback 給的 result(取代 paused result) + prior_trace: TraceStep[]; + kvNamespace?: KVNamespace; + }): Promise<{ data: unknown; trace: TraceStep[] }> { + const { graph, paused_node_id, paused_context, callback_result, prior_trace, kvNamespace } = args; + + this.currentGraph = graph; + this.currentRunId = `${graph.id}-resume-${Date.now()}`; + + const trace: TraceStep[] = [...prior_trace]; + const kvStore: KVContextStore | undefined = kvNamespace + ? { runId: this.currentRunId, kv: kvNamespace } + : undefined; + + // 把 callback_result 寫進 paused_node 的 KV output(讓下游讀得到) + if (kvStore) { + await kvSetNodeOutput(kvStore, paused_node_id, callback_result); + } + + // 把 callback_result spread 進 context(替代 paused 結果) + const mergedContext: Record = { + ...paused_context, + ...(callback_result && typeof callback_result === 'object' ? callback_result : {}), + }; + if (kvStore) { + if (!mergedContext._kv_outputs) mergedContext._kv_outputs = {}; + (mergedContext._kv_outputs as Record)[paused_node_id] = callback_result; + } + + // 找下游節點 + const downstreamEdges = graph.edges.filter(e => e.from === paused_node_id); + if (downstreamEdges.length === 0) { + // paused_node 是最後一個節點 → 直接結束 + return { data: callback_result, trace }; + } + + // 重建 fanIn(針對下游可能 fan-in 的節點) + const fanIn: FanInState = new Map(); + for (const node of graph.nodes) { + const inDeg = graph.edges.filter(e => e.to === node.id).length; + if (inDeg > 1) { + fanIn.set(node.id, { ctx: { ...mergedContext }, remaining: inDeg }); + } + } + + // 對每個下游節點,建立新 visited Set 避免 paused_node 自己被再跑一次 + const visited = new Set([`${paused_node_id}:${JSON.stringify(paused_context).slice(0, 50)}`]); + + const downstreamNodes = downstreamEdges + .map(e => graph.nodes.find(n => n.id === e.to)) + .filter((n): n is GraphNode => !!n); + + const results = await Promise.all( + downstreamNodes.map(node => + this.executeNode(node, graph, mergedContext, visited, trace, fanIn, kvStore) + ) + ); + + let mergedResult: unknown; + if (results.length === 1) { + mergedResult = results[0]; + } else { + mergedResult = results.reduce( + (acc: Record, r: unknown) => ({ + ...acc, + ...(typeof r === 'object' && r !== null ? (r as Record) : {}), + }), + {} as Record, + ); + } + return { data: mergedResult, trace }; + } + private async executeNode( node: GraphNode, graph: ExecutionGraph, @@ -118,6 +215,37 @@ export class GraphExecutor { ...resolvedData, }; + // Resumable workflow callback_url 注入(SDD: resumable-workflow/design.md §2.2) + // claude_api 容器拿到後會透傳給 Mira daemon,daemon task 完成時 POST 進來 + // hostname 暫從 PUBLIC_BASE_URL 取,沒設則用 cypher.arcrun.dev 預設 + if (node.componentId === 'claude_api') { + const baseUrl = (this.env as { PUBLIC_BASE_URL?: string } | undefined)?.PUBLIC_BASE_URL + ?? 'https://cypher.arcrun.dev'; + mergedContext.callback_url = `${baseUrl.replace(/\/$/, '')}/workflows/resume`; + } + + // Recipe expansion:若 node.data.recipe 存在,展開成實際 prompt 並併進 mergedContext + // SDD: matrix/arcrun/.agents/specs/recipe-system/design.md §2.2 + if (typeof resolvedData.recipe === 'string' && this.env?.RECIPES) { + try { + const expanded = await expandPromptRecipe( + resolvedData.recipe, + ctx, + this.env as { RECIPES: { get: (k: string) => Promise }; KBDB_BASE_URL?: string }, + this.apiKey ?? '', + ); + mergedContext = { + ...mergedContext, + prompt: expanded.prompt, + model: expanded.model, + _recipe_output_format: expanded.output_format, + _recipe_output_required_fields: expanded.output_required_fields, + }; + } catch (e) { + throw new Error(`recipe 展開失敗 (${resolvedData.recipe}): ${e instanceof Error ? e.message : String(e)}`); + } + } + // Credential 注入:在 WASM 執行前自動注入 credentials_required 中宣告的 token if (this.env) { // 先試 auth dispatcher(新路徑,走 auth primitive WASM Worker via HTTP) @@ -137,6 +265,61 @@ export class GraphExecutor { nodeInput = mergedContext; result = await runner(mergedContext); + // Resumable workflow:偵測 pending,持久化 paused state 後 throw WorkflowPaused + // SDD: resumable-workflow/design.md §3.2.1 + // 注意:放在 recipe output parsing 之前 — pending 結果不該被當 JSON 解析 + const pending = isResumablePending(result); + if (pending && this.env?.EXEC_CONTEXT && this.currentGraph && this.currentRunId) { + // 把這個節點的執行紀錄寫進 trace(status=paused) + trace.push({ + nodeId: node.id, + type: node.type, + input: nodeInput, + output: result, + duration_ms: Date.now() - start, + }); + await persistPausedRun(this.env.EXEC_CONTEXT, pending.task_id, { + run_id: this.currentRunId, + graph: this.currentGraph, + paused_node_id: node.id, + paused_context: context as Record, + paused_pending_result: result as Record, + trace_so_far: trace, + api_key: this.apiKey, + expires_at: Date.now() + 24 * 60 * 60 * 1000, + }); + throw new WorkflowPaused(pending.task_id, this.currentRunId, node.id, trace); + } + + // Recipe output parsing:若 recipe 指定 format=json,把 claude_api 回傳的 text 自動 parse + // 失敗 → 包裝成 success:false,給下游清楚知道是 LLM output 解析問題 + if (mergedContext._recipe_output_format === 'json' && result && typeof result === 'object') { + const r = result as Record; + const text = (r.data as Record | undefined)?.text ?? r.text; + if (typeof text === 'string') { + // 剝除 ```json ... ``` markdown fence(Claude 常這樣包) + let jsonText = String(text).trim(); + const fenceMatch = jsonText.match(/^```(?:json)?\s*\n([\s\S]*?)\n```$/); + if (fenceMatch) jsonText = fenceMatch[1].trim(); + try { + const parsed = JSON.parse(jsonText); + const required = mergedContext._recipe_output_required_fields as string[] | undefined; + if (required && parsed && typeof parsed === 'object') { + const missing = required.filter((f) => !(f in (parsed as Record))); + if (missing.length > 0) { + result = { success: false, error: `recipe output 缺欄位: ${missing.join(', ')}`, raw: parsed }; + } else { + result = { success: true, data: parsed }; + } + } else { + result = { success: true, data: parsed }; + } + } catch (e) { + result = { success: false, error: `recipe output JSON parse 失敗: ${e instanceof Error ? e.message : String(e)}`, raw_text: text }; + } + } + } + // BUILD-006:將節點 output 存入 KV(key = {run_id}:node:{node_id}) // 這讓下游節點可以透過 KV 讀取上游的具名 output,解決同名欄位衝突 if (kvStore && result !== null && result !== undefined) { @@ -158,6 +341,10 @@ export class GraphExecutor { break; } } catch (e: any) { + // WorkflowPaused 不是錯誤,是「workflow 暫停」訊號,直接往上傳 + // SDD: resumable-workflow/design.md + if (e instanceof WorkflowPaused) throw e; + const errMsg = e.message || String(e); trace.push({ nodeId: node.id, diff --git a/cypher-executor/src/index.ts b/cypher-executor/src/index.ts index 7c1b96a..c8b29b3 100644 --- a/cypher-executor/src/index.ts +++ b/cypher-executor/src/index.ts @@ -15,11 +15,17 @@ import { recipesRouter } from './routes/recipes'; import { credentialsRouter } from './routes/credentials'; import { webhooksNamedRouter } from './routes/webhooks-named'; import { authRouter } from './routes/auth'; +import { resumeRouter } from './routes/resume'; const app = new Hono<{ Bindings: Bindings }>(); -// 全域 CORS -app.use('*', cors()); +// 全域 CORS(允許 arcrun.dev landing page 帶 credentials 存取) +app.use('*', cors({ + origin: ['https://arcrun.dev', 'https://www.arcrun.dev'], + allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + allowHeaders: ['Content-Type', 'Authorization', 'X-Arcrun-API-Key'], + credentials: true, +})); // 掛載所有路由器 app.route('/', docsRouter); @@ -35,6 +41,7 @@ app.route('/', registerRouter); app.route('/', recipesRouter); app.route('/', credentialsRouter); app.route('/', authRouter); +app.route('/', resumeRouter); // Worker 導出 export default app; diff --git a/cypher-executor/src/lib/component-loader.ts b/cypher-executor/src/lib/component-loader.ts index 50e1fe6..63e6605 100644 --- a/cypher-executor/src/lib/component-loader.ts +++ b/cypher-executor/src/lib/component-loader.ts @@ -31,6 +31,9 @@ import type { Bindings, ComponentRunner, ServiceBinding } from '../types'; * * R2 動態注入 WASM 路徑作廢(CF workerd 不支援以 R2 物件臨時 instantiate)。 */ +// TODO(架構債,2026-05-07):白名單寫死違反 arcrun 「新零件無需改 cypher-executor」承諾 +// 應改為從 component-registry KV 動態查(registry 已有 backfill index,知道所有 canonical_id) +// SDD 待開:cypher-executor-dynamic-component-discovery const WASM_HTTP_RUNNER_IDS: ReadonlySet = new Set([ // API 零件(對應 registry/components/ 下的 TinyGo WASM) 'http_request', @@ -44,6 +47,12 @@ const WASM_HTTP_RUNNER_IDS: ReadonlySet = new Set([ 'auth_service_account', 'auth_oauth2', 'auth_mtls', + // Mira 零件(2026-05-07 加,吃狗糧推 7-B 時撞到白名單擋) + 'claude_api', + 'kbdb_ingest', + 'kbdb_get', + 'kbdb_create_block', + 'kbdb_patch_block', ]); /** canonical_id → 獨立 Worker URL(慣例:snake_case → kebab-case + .arcrun.dev) */ diff --git a/cypher-executor/src/lib/paused-runs.ts b/cypher-executor/src/lib/paused-runs.ts new file mode 100644 index 0000000..0c27957 --- /dev/null +++ b/cypher-executor/src/lib/paused-runs.ts @@ -0,0 +1,81 @@ +/** + * Paused workflow runs:節點回 pending 時把 run state 持久化進 KV, + * webhook callback 進來時撿回繼續執行 + * + * SDD: matrix/arcrun/.agents/specs/resumable-workflow/design.md §2.1 + * + * KV key: paused_run:{task_id} + * TTL: 24h(避免 KV 累積,超過就 GC) + * + * 設計筆記: + * - 用 task_id 當 key(daemon 派的 unique id),不用 run_id(同 run 可能多 paused 節點 v2) + * - consume = load + delete 原子操作(避免重複 callback 重複執行) + */ + +import type { ExecutionGraph, TraceStep } from '../types'; + +export interface PausedRunState { + run_id: string; + graph: ExecutionGraph; + paused_node_id: string; + paused_context: Record; + paused_pending_result: Record; // 節點回的 {pending, task_id, ...} + trace_so_far: TraceStep[]; + api_key?: string; + expires_at: number; // unix ms +} + +const KEY_PREFIX = 'paused_run:'; +const TTL_SECONDS = 24 * 60 * 60; + +type KvBinding = { + get: (key: string) => Promise; + put: (key: string, value: string, options?: { expirationTtl?: number }) => Promise; + delete: (key: string) => Promise; +}; + +export async function persistPausedRun( + kv: KvBinding, + taskId: string, + state: PausedRunState, +): Promise { + await kv.put(`${KEY_PREFIX}${taskId}`, JSON.stringify(state), { expirationTtl: TTL_SECONDS }); +} + +export async function loadPausedRun( + kv: KvBinding, + taskId: string, +): Promise { + const raw = await kv.get(`${KEY_PREFIX}${taskId}`); + if (!raw) return null; + try { + return JSON.parse(raw) as PausedRunState; + } catch { + return null; + } +} + +/** + * 原子讀+刪:避免同 task_id 重複 callback 重複執行下游 + * (CF KV 沒真原子操作,但 delete 失敗不影響 load 已成功) + */ +export async function consumePausedRun( + kv: KvBinding, + taskId: string, +): Promise { + const state = await loadPausedRun(kv, taskId); + if (!state) return null; + await kv.delete(`${KEY_PREFIX}${taskId}`).catch(() => { + // delete 失敗不擋,最多就重複執行一次(接受) + }); + return state; +} + +/** 偵測 component result 是否為「需要 resume」的 pending pattern */ +export function isResumablePending(result: unknown): { task_id: string } | null { + if (!result || typeof result !== 'object') return null; + const r = result as Record; + if (r.pending !== true) return null; + if (typeof r.task_id !== 'string' || !r.task_id) return null; + return { task_id: r.task_id }; +} diff --git a/cypher-executor/src/lib/prompt-recipe-schema.ts b/cypher-executor/src/lib/prompt-recipe-schema.ts new file mode 100644 index 0000000..f55d15b --- /dev/null +++ b/cypher-executor/src/lib/prompt-recipe-schema.ts @@ -0,0 +1,90 @@ +/** + * prompt_recipe Zod schema + * SDD: matrix/arcrun/.agents/specs/recipe-system/design.md §2.1 + * + * 平行於既有 auth_recipe / api_recipe,存 RECIPES KV (key: `prompt_recipe:{name}`) + * 容器 + recipe 模式:claude_api 是容器,recipe 是配方 + */ + +import { z } from 'zod'; + +// ── Transform 白名單 ────────────────────────────────────────────────────────── +// 限制 transform 種類避免變 mini-DSL;超過範圍請寫零件 +export const TRANSFORM_NAMES = [ + 'json_array', // array → JSON.stringify 整體 + 'to_string', // 任意值 → String(x) + 'join', // array → join(sep),sep 預設換行 + 'markdown_list', // array → "- a\n- b\n- c" + 'extract_field', // array of object → 抽 field 後的 array(再可串其他 transform) + 'first', // array → first element(取單一) + 'pluck_content', // KBDB blocks array → 抽 content 後 join 雙換行(草稿合併常用) +] as const; + +/** transform 表示法:name 或 name:arg(如 extract_field:page_name) */ +export const TransformSchema = z.string().regex(/^[a-z_]+(:.+)?$/, 'transform 必須為 name 或 name:arg 格式'); + +// ── Fragment:從 KBDB / KV 抓固定資料 ────────────────────────────────────────── +export const KBDBBlockFragmentSchema = z.object({ + var: z.string().min(1), // prompt template 內的變數名 + source: z.literal('kbdb_block'), + block_id: z.string().optional(), // 二擇一 + block_page_name: z.string().optional(), // 比 block_id 穩定 + field: z.string().default('content'), // 抓 block 的哪個欄位 +}); + +export const KVFragmentSchema = z.object({ + var: z.string().min(1), + source: z.literal('kv'), + key: z.string().min(1), +}); + +// discriminatedUnion 對 refined zod object 不支援,故拆成驗證後 + 單獨檢查 block_id|page_name +export const FragmentSchema = z.discriminatedUnion('source', [ + KBDBBlockFragmentSchema, + KVFragmentSchema, +]).superRefine((d, ctx) => { + if (d.source === 'kbdb_block' && !d.block_id && !d.block_page_name) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'block_id 或 block_page_name 必填其一', + }); + } +}); + +// ── Input:從 workflow context 取值(含 transform) ──────────────────────────── +export const InputSchema = z.object({ + var: z.string().min(1), + from: z.string().min(1), // JSONPath-lite,如 "ctx.read_drafts.blocks" + transform: TransformSchema.optional(), + default: z.unknown().optional(), // from 取不到時的預設值(避免炸 prompt) +}); + +// ── Prompt 組裝 ────────────────────────────────────────────────────────────── +export const PromptAssemblySchema = z.object({ + system: z.string().min(1), // 模板,可含 {{var}} + user: z.string().min(1), +}); + +// ── 輸出規格 ────────────────────────────────────────────────────────────────── +export const OutputSpecSchema = z.object({ + format: z.enum(['text', 'json']).default('text'), + // 若 format=json,可選 schema 做 parse 後驗證(簡化版,列必填欄位即可) + required_fields: z.array(z.string()).optional(), +}); + +// ── 完整 prompt_recipe 定義 ──────────────────────────────────────────────────── +export const PromptRecipeSchema = z.object({ + kind: z.literal('prompt_recipe'), + name: z.string().min(1).regex(/^[a-z][a-z0-9_]*$/, 'name 為 lowercase + underscore'), + version: z.number().int().positive().default(1), + description: z.string().optional(), + model: z.enum(['haiku', 'sonnet', 'opus']).default('sonnet'), + fragments: z.array(FragmentSchema).default([]), + inputs: z.array(InputSchema).default([]), + prompt_assembly: PromptAssemblySchema, + output: OutputSpecSchema.default({ format: 'text' }), +}); + +export type PromptRecipe = z.infer; +export type Fragment = z.infer; +export type RecipeInput = z.infer; diff --git a/cypher-executor/src/lib/recipe-expander.ts b/cypher-executor/src/lib/recipe-expander.ts new file mode 100644 index 0000000..37acd73 --- /dev/null +++ b/cypher-executor/src/lib/recipe-expander.ts @@ -0,0 +1,136 @@ +/** + * Recipe expander:把 prompt_recipe 展開成 claude_api 的實際 input + * SDD: matrix/arcrun/.agents/specs/recipe-system/design.md §2.2 + Phase 2.1 + * + * 流程: + * 1. loadPromptRecipe 取定義 + * 2. fragments → 用 KBDB API 抓 block content + * 3. inputs → 從 workflow context 取值 + 跑 transform + * 4. 套進 prompt_assembly.system / .user 的 {{var}} 模板 + * 5. 回傳 { prompt, model, output_format, output_required_fields } + */ + +import { loadPromptRecipe, RecipeLoadError } from './recipe-loader'; +import { applyTransform } from './recipe-transforms'; +import type { Fragment, RecipeInput } from './prompt-recipe-schema'; + +type ExpanderEnv = { + RECIPES: { get: (key: string) => Promise }; + KBDB_BASE_URL?: string; +}; + +export interface ExpandedRecipe { + prompt: string; // user prompt(system + user 用 \n\n--- system ---\n 分隔) + model: 'haiku' | 'sonnet' | 'opus'; + output_format: 'text' | 'json'; + output_required_fields?: string[]; +} + +/** 從 path 取嵌套值,例如 "ctx.read_drafts.blocks" / "loop.item" */ +function getByPath(ctx: Record, path: string): unknown { + const parts = path.split('.'); + let cur: unknown = ctx; + for (const p of parts) { + if (cur === null || cur === undefined) return undefined; + if (typeof cur !== 'object') return undefined; + cur = (cur as Record)[p]; + } + return cur; +} + +/** {{var}} 模板替換(top-level vars 物件) */ +function interpolate(template: string, vars: Record): string { + return template.replace(/\{\{(\w+)\}\}/g, (_, key) => (vars[key] !== undefined ? vars[key] : `{{${key}}}`)); +} + +async function fetchKbdbBlock( + env: ExpanderEnv, + apiKey: string, + fragment: Extract, +): Promise { + const base = (env.KBDB_BASE_URL ?? 'https://kbdb.finally.click').replace(/\/$/, ''); + let url: string; + if (fragment.block_id) { + url = `${base}/blocks/${encodeURIComponent(fragment.block_id)}`; + } else { + url = `${base}/blocks?page_name=${encodeURIComponent(fragment.block_page_name!)}&limit=1`; + } + const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } }); + if (!res.ok) throw new Error(`KBDB fragment 抓取失敗 (${res.status}): ${url}`); + const data = (await res.json()) as Record; + + // page_name 模式回 {blocks:[]},block_id 模式直接回 block 物件 + const block: Record = fragment.block_id + ? data + : ((data.blocks as unknown[])?.[0] as Record) ?? {}; + if (!block) throw new Error(`KBDB block 不存在: ${fragment.block_id ?? fragment.block_page_name}`); + + const fieldVal = block[fragment.field]; + if (fieldVal === undefined) throw new Error(`block 缺欄位 "${fragment.field}"`); + return fieldVal; +} + +async function resolveFragment( + env: ExpanderEnv, + apiKey: string, + frag: Fragment, +): Promise<{ var: string; value: unknown }> { + if (frag.source === 'kv') { + const val = await env.RECIPES.get(frag.key); + if (val === null) throw new Error(`KV 找不到 key: ${frag.key}`); + return { var: frag.var, value: val }; + } + return { var: frag.var, value: await fetchKbdbBlock(env, apiKey, frag) }; +} + +function resolveInput(input: RecipeInput, ctx: Record): { var: string; value: unknown } { + let val = getByPath(ctx, input.from); + const beforeDefault = val; + if (val === undefined) val = input.default; + try { + if (input.transform) val = applyTransform(val, input.transform); + return { var: input.var, value: val }; + } catch (e) { + // 把 path 跟原值放進錯誤訊息,方便 debug recipe + const valType = Array.isArray(beforeDefault) ? `array(${beforeDefault.length})` + : beforeDefault === undefined ? 'undefined(default applied)' + : typeof beforeDefault; + throw new Error(`${e instanceof Error ? e.message : String(e)} [path=${input.from}, type=${valType}]`); + } +} + +/** 主入口:展開 recipe → 組 prompt */ +export async function expandPromptRecipe( + recipeRef: string, + ctx: Record, + env: ExpanderEnv, + apiKey: string, // KBDB partner key(從 workflow auth 來) +): Promise { + const recipe = await loadPromptRecipe(recipeRef, env.RECIPES); + + const vars: Record = {}; + + for (const frag of recipe.fragments) { + const { var: name, value } = await resolveFragment(env, apiKey, frag); + vars[name] = typeof value === 'string' ? value : JSON.stringify(value); + } + for (const inp of recipe.inputs) { + const { var: name, value } = resolveInput(inp, ctx); + vars[name] = typeof value === 'string' ? value : JSON.stringify(value); + } + + const system = interpolate(recipe.prompt_assembly.system, vars); + const user = interpolate(recipe.prompt_assembly.user, vars); + + // claude_api 容器目前吃單一 prompt 字串 → system + user 用分隔線拼 + const prompt = `${system}\n\n--- USER ---\n\n${user}`; + + return { + prompt, + model: recipe.model, + output_format: recipe.output.format, + output_required_fields: recipe.output.required_fields, + }; +} + +export { RecipeLoadError }; diff --git a/cypher-executor/src/lib/recipe-loader.ts b/cypher-executor/src/lib/recipe-loader.ts new file mode 100644 index 0000000..3b86e26 --- /dev/null +++ b/cypher-executor/src/lib/recipe-loader.ts @@ -0,0 +1,50 @@ +/** + * Prompt recipe loader:從 RECIPES KV 抓 prompt_recipe 定義並驗證 + * SDD: matrix/arcrun/.agents/specs/recipe-system/design.md Phase 1.3 + * + * KV key 格式:prompt_recipe:{name} + * KV value:JSON 字串(不用 YAML,避免引入 yaml parser 進 worker) + */ + +import { PromptRecipeSchema, type PromptRecipe } from './prompt-recipe-schema'; + +type KvBinding = { get: (key: string) => Promise }; + +export class RecipeLoadError extends Error { + constructor(message: string, public readonly recipe: string) { + super(message); + } +} + +/** 從 RECIPES KV 抓 + parse + validate */ +export async function loadPromptRecipe( + recipeRef: string, // 完整 key 如 "prompt_recipe:wiki_synthesis",或裸名 "wiki_synthesis" + recipesKv: KvBinding, +): Promise { + const key = recipeRef.startsWith('prompt_recipe:') + ? recipeRef + : `prompt_recipe:${recipeRef}`; + + const raw = await recipesKv.get(key); + if (!raw) { + throw new RecipeLoadError(`找不到 recipe: ${key}`, key); + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (e) { + throw new RecipeLoadError( + `recipe ${key} 不是合法 JSON: ${e instanceof Error ? e.message : String(e)}`, + key, + ); + } + + const result = PromptRecipeSchema.safeParse(parsed); + if (!result.success) { + const issues = result.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; '); + throw new RecipeLoadError(`recipe ${key} schema 驗證失敗: ${issues}`, key); + } + + return result.data; +} diff --git a/cypher-executor/src/lib/recipe-transforms.ts b/cypher-executor/src/lib/recipe-transforms.ts new file mode 100644 index 0000000..b3a83b7 --- /dev/null +++ b/cypher-executor/src/lib/recipe-transforms.ts @@ -0,0 +1,58 @@ +/** + * Recipe transform 白名單實作 + * SDD: matrix/arcrun/.agents/specs/recipe-system/design.md §2.1 + * + * 每個 transform 接 unknown,回 unknown。 + * 失敗策略:一律 throw,由 expander 包成 recipe 錯誤 + */ + +export type TransformFn = (value: unknown, arg?: string) => unknown; + +const transforms: Record = { + json_array: (v) => JSON.stringify(v ?? []), + + to_string: (v) => { + if (v === null || v === undefined) return ''; + if (typeof v === 'object') return JSON.stringify(v); + return String(v); + }, + + join: (v, sep) => { + if (!Array.isArray(v)) throw new Error('join: input 不是 array'); + return v.map((x) => (typeof x === 'string' ? x : JSON.stringify(x))).join(sep ?? '\n'); + }, + + markdown_list: (v) => { + if (!Array.isArray(v)) throw new Error('markdown_list: input 不是 array'); + return v.map((x) => `- ${typeof x === 'string' ? x : JSON.stringify(x)}`).join('\n'); + }, + + extract_field: (v, field) => { + if (!field) throw new Error('extract_field: 需要 field 參數,例如 extract_field:page_name'); + if (!Array.isArray(v)) throw new Error('extract_field: input 不是 array'); + return v.map((x) => (x && typeof x === 'object' ? (x as Record)[field] : undefined)); + }, + + first: (v) => { + if (!Array.isArray(v)) return v; + return v[0]; + }, + + pluck_content: (v) => { + if (!Array.isArray(v)) throw new Error('pluck_content: input 不是 array'); + return v + .map((b) => (b && typeof b === 'object' ? String((b as Record).content ?? '') : '')) + .filter((s) => s.length > 0) + .join('\n\n---\n\n'); + }, +}; + +/** 解析 "name" 或 "name:arg" → 執行 transform */ +export function applyTransform(value: unknown, spec: string): unknown { + const colonIdx = spec.indexOf(':'); + const name = colonIdx === -1 ? spec : spec.slice(0, colonIdx); + const arg = colonIdx === -1 ? undefined : spec.slice(colonIdx + 1); + const fn = transforms[name]; + if (!fn) throw new Error(`未知 transform: ${name}`); + return fn(value, arg); +} diff --git a/cypher-executor/src/routes/resume.ts b/cypher-executor/src/routes/resume.ts new file mode 100644 index 0000000..a62efe7 --- /dev/null +++ b/cypher-executor/src/routes/resume.ts @@ -0,0 +1,85 @@ +/** + * POST /workflows/resume + * Webhook callback 進來時,從 paused state 撿起來繼續跑下游節點 + * SDD: matrix/arcrun/.agents/specs/resumable-workflow/design.md Phase 3 + * + * 安全:因為這是 daemon 主動 callback,沒有 partner key(daemon 不知道用戶 key) + * 靠 task_id 為 nonce + 24h TTL + idempotent consume 保護 + */ + +import { Hono } from 'hono'; +import type { Bindings } from '../types'; +import { WorkflowPaused } from '../types'; +import { GraphExecutor } from '../graph-executor'; +import { createComponentLoader } from '../lib/component-loader'; +import { consumePausedRun } from '../lib/paused-runs'; + +export const resumeRouter = new Hono<{ Bindings: Bindings }>(); + +resumeRouter.post('/workflows/resume', async (c) => { + let body: Record; + try { + body = await c.req.json(); + } catch { + return c.json({ error: 'request body 必須為 JSON' }, 400); + } + + const taskId = typeof body.task_id === 'string' ? body.task_id : undefined; + if (!taskId) { + return c.json({ error: 'task_id 必填' }, 400); + } + + // consume = load + delete(idempotent:重複 callback 第二次找不到 state,回 200) + const state = await consumePausedRun(c.env.EXEC_CONTEXT, taskId); + if (!state) { + return c.json({ + success: true, + noop: true, + reason: `paused state 不存在或已過期 (task_id=${taskId})`, + }); + } + + const callbackResult = { + success: body.success ?? true, + data: body.data, + error: body.error, + }; + + const loader = createComponentLoader(c.env); + const executor = new GraphExecutor(loader, undefined, c.env, state.api_key); + const start = Date.now(); + + try { + const result = await executor.resumeFromPaused({ + graph: state.graph, + paused_node_id: state.paused_node_id, + paused_context: state.paused_context, + callback_result: callbackResult, + prior_trace: state.trace_so_far, + kvNamespace: c.env.EXEC_CONTEXT, + }); + const duration_ms = Date.now() - start; + return c.json({ + success: true, + resumed: true, + task_id: taskId, + run_id: state.run_id, + data: result.data, + trace: result.trace, + duration_ms, + }); + } catch (err) { + if (err instanceof WorkflowPaused) { + // resume 後又遇到 pending(v2 nested 情境)— v1 仍持久化但回 paused-again + return c.json({ + success: true, + paused_again: true, + task_id: err.task_id, + run_id: err.run_id, + paused_node_id: err.paused_node_id, + }); + } + const errMsg = err instanceof Error ? err.message : String(err); + return c.json({ success: false, error: errMsg, task_id: taskId, run_id: state.run_id }, 500); + } +}); diff --git a/cypher-executor/src/types.ts b/cypher-executor/src/types.ts index db20a47..82babf8 100644 --- a/cypher-executor/src/types.ts +++ b/cypher-executor/src/types.ts @@ -50,6 +50,9 @@ export type Bindings = { GITHUB_CLIENT_ID?: string; GITHUB_CLIENT_SECRET?: string; SESSION_SIGNING_SECRET?: string; // 用於 HMAC session ID(可選,也可直接用 UUID) + // KBDB 整合 + KBDB_INTERNAL_TOKEN?: string; + KBDB_BASE_URL?: string; // 預設 https://kbdb.inkstone.app }; // 圖結構定義 @@ -119,6 +122,27 @@ export async function kvGetNodeOutput(store: KVContextStore, nodeId: string): Pr } } +/** + * Workflow 暫停(resumable workflow): + * 節點回 pending → graph-executor 持久化 state + throw 此類,被頂層接住回 paused 狀態給 caller + * SDD: matrix/arcrun/.agents/specs/resumable-workflow/design.md + */ +export class WorkflowPaused extends Error { + readonly task_id: string; + readonly run_id: string; + readonly paused_node_id: string; + readonly trace_so_far: TraceStep[]; + + constructor(task_id: string, run_id: string, paused_node_id: string, trace_so_far: TraceStep[]) { + super(`workflow paused at node ${paused_node_id} waiting for task ${task_id}`); + this.name = 'WorkflowPaused'; + this.task_id = task_id; + this.run_id = run_id; + this.paused_node_id = paused_node_id; + this.trace_so_far = trace_so_far; + } +} + /** 執行失敗時拋出的自訂 Error,攜帶完整 trace 與失敗節點資訊 */ export class ExecutionError extends Error { readonly failed_node: string; diff --git a/registry/components/claude_api/main.go b/registry/components/claude_api/main.go new file mode 100644 index 0000000..b4ebcc1 --- /dev/null +++ b/registry/components/claude_api/main.go @@ -0,0 +1,180 @@ +// claude_api — 呼叫 Mira daemon(Hetzner 上跑的 Claude Agent SDK 服務) +// +// 架構決策(2026-05-06): +// 不直打 Anthropic Messages API(OAuth token 限制 system prompt 角色 → rate_limit_error) +// 改透過已部署的 cloud-cto Mira daemon (https://mira.uncle6.me/mira/execute) +// 該 daemon 用 Claude Agent SDK,已內建 Mira persona,可長執行任務 +// +// SDD: polaris/mira/.agents/specs/mira-app/design.md §6(五個 P0 零件) +// +//go:build tinygo + +package main + +import ( + "encoding/json" + "io" + "os" + "unsafe" +) + +//go:wasmimport u6u http_request +func hostHttpRequest( + urlPtr uintptr, urlLen uint32, + methodPtr uintptr, methodLen uint32, + headersPtr uintptr, headersLen uint32, + bodyPtr uintptr, bodyLen uint32, + outPtr uintptr, outLenPtr uintptr, +) uint32 + +type Input struct { + MiraURL string `json:"mira_url"` // 預設 https://mira.uncle6.me + MiraToken string `json:"mira_token"` // Mira daemon Bearer token + Prompt string `json:"prompt"` // 必填:要傳給 Mira 的訊息 + TimeoutMS int `json:"timeout_ms"` // 預設 25000(daemon 協商模式上限) + Model string `json:"model"` // 'haiku' / 'sonnet' / 'opus',預設 haiku(daemon 端) + CallbackURL string `json:"callback_url"` // optional:daemon 完成 task 時 POST 此 URL 通知(Resumable workflow,SDD: resumable-workflow) +} + +var dummy [1]byte + +func safePtr(b []byte) (uintptr, uint32) { + if len(b) == 0 { + return uintptr(unsafe.Pointer(&dummy[0])), 0 + } + return uintptr(unsafe.Pointer(&b[0])), uint32(len(b)) +} + +func main() { + raw, err := io.ReadAll(os.Stdin) + if err != nil { + writeError("failed to read stdin: " + err.Error()) + return + } + + var input Input + if err := json.Unmarshal(raw, &input); err != nil { + writeError("invalid input JSON: " + err.Error()) + return + } + + if input.MiraToken == "" { + writeError("mira_token 必填(Mira daemon Bearer token)") + return + } + if input.Prompt == "" { + writeError("prompt 必填") + return + } + + miraURL := input.MiraURL + if miraURL == "" { + miraURL = "https://mira.uncle6.me" + } + timeoutMS := input.TimeoutMS + if timeoutMS <= 0 { + // 預設 120s:daemon 協商期會在 25s 切非同步 + callback; + // callback_url 存在時,timeout 上限不重要(daemon 會 fire callback 不論多久) + timeoutMS = 120000 + } + + // Mira daemon /execute 介面 + body := map[string]interface{}{ + "prompt": input.Prompt, + "timeout_ms": timeoutMS, + } + if input.Model != "" { + body["model"] = input.Model + } + if input.CallbackURL != "" { + body["callback_url"] = input.CallbackURL + } + bodyBytes, _ := json.Marshal(body) + + headers := map[string]string{ + "Content-Type": "application/json", + "Authorization": "Bearer " + input.MiraToken, + } + headersBytes, _ := json.Marshal(headers) + + url := miraURL + "/mira/execute" + urlBytes := []byte(url) + methodBytes := []byte("POST") + + outBuf := make([]byte, 1024*1024) // 1MB + var outLen uint32 + + urlPtr, urlLen := safePtr(urlBytes) + methodPtr, methodLen := safePtr(methodBytes) + headersPtr, headersLen := safePtr(headersBytes) + bodyPtr, bodyLenU := safePtr(bodyBytes) + + result := hostHttpRequest( + urlPtr, urlLen, + methodPtr, methodLen, + headersPtr, headersLen, + bodyPtr, bodyLenU, + uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)), + ) + + if result != 0 { + writeError("Mira daemon request failed (host_http_request returned non-zero)") + return + } + + respStr := string(outBuf[:outLen]) + var resp map[string]interface{} + if err := json.Unmarshal([]byte(respStr), &resp); err != nil { + writeError("Mira returned non-JSON: " + respStr) + return + } + + // 偵測錯誤回應 + if errObj, hasErr := resp["error"]; hasErr { + errBytes, _ := json.Marshal(errObj) + writeError("Mira error: " + string(errBytes)) + return + } + + // daemon 回應格式: + // 同步完成: {"task_id":"...","status":"done","output":"...","model":"..."} + // 非同步: {"task_id":"...","status":"running","estimated_seconds":N} + + status, _ := resp["status"].(string) + if status == "running" { + // 還沒完成,回傳 task_id 給 caller 自己 polling + out, _ := json.Marshal(map[string]interface{}{ + "success": true, + "pending": true, + "task_id": resp["task_id"], + "estimated_seconds": resp["estimated_seconds"], + "poll_url": miraURL + "/mira/execute/" + toString(resp["task_id"]), + }) + os.Stdout.Write(out) + return + } + + // status == "done" 的場景 + out := map[string]interface{}{ + "success": true, + "data": map[string]interface{}{ + "text": resp["output"], + "task_id": resp["task_id"], + "model": resp["model"], + }, + } + outJSON, _ := json.Marshal(out) + os.Stdout.Write(outJSON) +} + +func toString(v interface{}) string { + if s, ok := v.(string); ok { + return s + } + return "" +} + +func writeError(msg string) { + out, _ := json.Marshal(map[string]interface{}{"success": false, "error": msg}) + os.Stdout.Write(out) +} diff --git a/registry/components/kbdb_get/component.contract.yaml b/registry/components/kbdb_get/component.contract.yaml new file mode 100644 index 0000000..f5586da --- /dev/null +++ b/registry/components/kbdb_get/component.contract.yaml @@ -0,0 +1,69 @@ +canonical_id: "kbdb_get" +display_name: "KBDB 讀取" +category: "data" +version: "v1" +wasi_target: "preview1" +stability: "floating" +runtime_compat: + - "cf-workers" + - "workerd" + - "wazero" +constraints: + max_size_kb: 2048 + max_cold_start_ms: 50 + no_network_syscall: false + no_filesystem_syscall: true + io_model: "stdin_stdout_json" +input_schema: + type: object + required: [api_key] + properties: + api_key: + type: string + description: KBDB partner key(pk_live_xxx 或 ak_xxx) + block_id: + type: string + description: 取單一 block。給 block_id 走 GET /blocks/{id},與 page_name 二擇一 + page_name: + type: string + description: 按 page_name 查列表。走 GET /blocks?page_name=...&limit=N + limit: + type: integer + description: page_name 模式下的最大筆數,預設 50 + default: 50 + kbdb_url: + type: string + description: KBDB API base(選填,預設 https://kbdb.finally.click) + default: "https://kbdb.finally.click" +output_schema: + type: object + properties: + success: + type: boolean + blocks: + type: array + description: page_name 模式回多個 block;block_id 模式回 1 個(仍包成陣列方便下游 foreach) + items: + type: object + count: + type: integer + description: blocks.length + error: + type: string +gherkin_tests: + - scenario: "缺 api_key" + given: '{"page_name":"x"}' + then_contains: '{"success":false' + - scenario: "block_id 與 page_name 都沒給" + given: '{"api_key":"pk_live_x"}' + then_contains: '{"success":false' +tags: [data, storage, kbdb, get, query, primitive] +description: "從 KBDB 讀 block。支援兩種模式:(1) block_id 取單一 block;(2) page_name 取列表。透過 host function http_request 呼叫 KBDB GET /blocks 或 /blocks/:id。Mira wiki 合成 / 各 source workflow 讀草稿 / 查 wiki schema 都走這條。" +config_example: | + read_schema: # 取單一 block + api_key: "{{secret.kbdb_key}}" + page_name: "mira-wiki-schema" + read_drafts: # 取列表 + api_key: "{{secret.kbdb_key}}" + page_name: "{{prev.entity_name}}" + limit: 100 diff --git a/registry/components/kbdb_get/main.go b/registry/components/kbdb_get/main.go new file mode 100644 index 0000000..5e822db --- /dev/null +++ b/registry/components/kbdb_get/main.go @@ -0,0 +1,184 @@ +// kbdb_get — 從 KBDB 讀 block(GET /blocks?page_name=... 或 GET /blocks/:id) +// thin wrapper:透過 host function http_request 呼叫 KBDB API +// +//go:build tinygo + +package main + +import ( + "encoding/json" + "io" + "os" + "strconv" + "unsafe" +) + +//go:wasmimport u6u http_request +func hostHttpRequest( + urlPtr uintptr, urlLen uint32, + methodPtr uintptr, methodLen uint32, + headersPtr uintptr, headersLen uint32, + bodyPtr uintptr, bodyLen uint32, + outPtr uintptr, outLenPtr uintptr, +) uint32 + +type Input struct { + KBDBUrl string `json:"kbdb_url"` // optional + APIKey string `json:"api_key"` // 必填 + BlockID string `json:"block_id"` // 與 page_name 二擇一 + PageName string `json:"page_name"` // 與 block_id 二擇一 + Limit int `json:"limit"` // optional, default 50 +} + +var dummy [1]byte + +func safePtr(b []byte) (uintptr, uint32) { + if len(b) == 0 { + return uintptr(unsafe.Pointer(&dummy[0])), 0 + } + return uintptr(unsafe.Pointer(&b[0])), uint32(len(b)) +} + +func writeError(msg string) { + out, _ := json.Marshal(map[string]interface{}{"success": false, "error": msg}) + os.Stdout.Write(out) +} + +// urlEncode:簡易 query string encoder(只處理 KBDB 會用到的字元) +// 避免引入 net/url(白名單外) +func urlEncode(s string) string { + var out []byte + for i := 0; i < len(s); i++ { + c := s[i] + if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || + c == '-' || c == '_' || c == '.' || c == '~' { + out = append(out, c) + } else { + const hex = "0123456789ABCDEF" + out = append(out, '%', hex[c>>4], hex[c&0x0f]) + } + } + return string(out) +} + +func main() { + raw, err := io.ReadAll(os.Stdin) + if err != nil { + writeError("failed to read stdin: " + err.Error()) + return + } + + var input Input + if err := json.Unmarshal(raw, &input); err != nil { + writeError("invalid input JSON: " + err.Error()) + return + } + + if input.APIKey == "" { + writeError("api_key 必填") + return + } + if input.BlockID == "" && input.PageName == "" { + writeError("block_id 或 page_name 必填其一") + return + } + + kbdbURL := input.KBDBUrl + if kbdbURL == "" { + kbdbURL = "https://kbdb.finally.click" + } + + limit := input.Limit + if limit <= 0 { + limit = 50 + } + + // 構造 URL:block_id 模式走 /blocks/:id(單一),page_name 模式走 /blocks?page_name=...&limit=N + var url string + if input.BlockID != "" { + url = kbdbURL + "/blocks/" + urlEncode(input.BlockID) + } else { + url = kbdbURL + "/blocks?page_name=" + urlEncode(input.PageName) + + "&limit=" + strconv.Itoa(limit) + } + + headers := map[string]string{ + "Authorization": "Bearer " + input.APIKey, + } + headersBytes, _ := json.Marshal(headers) + + method := "GET" + urlBytes := []byte(url) + methodBytes := []byte(method) + + outBuf := make([]byte, 1<<20) // 1MB(list 可能很大) + var outLen uint32 + + urlPtr, urlLen := safePtr(urlBytes) + methodPtr, methodLen := safePtr(methodBytes) + headersPtr, headersLenU := safePtr(headersBytes) + bodyPtr, bodyLenU := safePtr(nil) + + result := hostHttpRequest( + urlPtr, urlLen, + methodPtr, methodLen, + headersPtr, headersLenU, + bodyPtr, bodyLenU, + uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)), + ) + + if result != 0 { + writeError("KBDB GET request failed (host_http_request returned non-zero)") + return + } + + respStr := string(outBuf[:outLen]) + + // 解析回應 + if input.BlockID != "" { + // 單一 block:KBDB 直接回 block 物件,包成 array 給下游 foreach + var block map[string]interface{} + if err := json.Unmarshal([]byte(respStr), &block); err != nil { + writeError("KBDB returned non-JSON: " + respStr) + return + } + if _, hasErr := block["error"]; hasErr { + out, _ := json.Marshal(map[string]interface{}{ + "success": false, "error": block["error"], + }) + os.Stdout.Write(out) + return + } + out, _ := json.Marshal(map[string]interface{}{ + "success": true, + "blocks": []map[string]interface{}{block}, + "count": 1, + }) + os.Stdout.Write(out) + return + } + + // page_name 列表模式:KBDB 回 {"blocks": [...], "count": N} + var listResp struct { + Blocks []map[string]interface{} `json:"blocks"` + Count int `json:"count"` + Error interface{} `json:"error"` + } + if err := json.Unmarshal([]byte(respStr), &listResp); err != nil { + writeError("KBDB returned non-JSON: " + respStr) + return + } + if listResp.Error != nil { + out, _ := json.Marshal(map[string]interface{}{ + "success": false, "error": listResp.Error, + }) + os.Stdout.Write(out) + return + } + out, _ := json.Marshal(map[string]interface{}{ + "success": true, + "blocks": listResp.Blocks, + "count": listResp.Count, + }) + os.Stdout.Write(out) +} diff --git a/registry/scripts/backfill-index.mjs b/registry/scripts/backfill-index.mjs new file mode 100644 index 0000000..250dd6c --- /dev/null +++ b/registry/scripts/backfill-index.mjs @@ -0,0 +1,121 @@ +#!/usr/bin/env node +// Backfill component metadata 進 registry index +// SDD: matrix/arcrun/.agents/specs/component-registry-canon/design.md Phase 1 +// +// 用法: +// node scripts/backfill-index.mjs --dry-run # 看會送什麼 +// node scripts/backfill-index.mjs # 真的灌 +// +// 流程: +// 1. 掃 ../components/*/component.contract.yaml +// 2. 解析 YAML(用 zero-dep 簡易 parser,contract 是 well-formed YAML) +// 3. 對每個 contract POST registry.arcrun.dev/components/index-only +// 4. 印 success / already_indexed / fail 統計 + +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const COMPONENTS_DIR = join(__dirname, '..', 'components'); +const REGISTRY_URL = process.env.REGISTRY_URL ?? 'https://registry.arcrun.dev'; +const DRY_RUN = process.argv.includes('--dry-run'); + +// YAML 是 well-formed contract.yaml,用 js-yaml 解析最穩 +async function parseYaml(text) { + const { load } = await import('js-yaml'); + return load(text); +} + +function listComponents() { + return readdirSync(COMPONENTS_DIR) + .filter((name) => { + const p = join(COMPONENTS_DIR, name); + return statSync(p).isDirectory(); + }) + .sort(); +} + +async function readContract(name) { + const path = join(COMPONENTS_DIR, name, 'component.contract.yaml'); + const text = readFileSync(path, 'utf-8'); + return parseYaml(text); +} + +async function postIndexOnly(contract) { + const res = await fetch(`${REGISTRY_URL}/components/index-only`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ contract }), + }); + const body = await res.text(); + let parsed; + try { + parsed = JSON.parse(body); + } catch { + parsed = { raw: body }; + } + return { status: res.status, body: parsed }; +} + +async function main() { + console.log('=== arcrun Component Registry Backfill ==='); + console.log(`Registry: ${REGISTRY_URL}`); + console.log(`Mode: ${DRY_RUN ? 'DRY RUN' : 'LIVE'}`); + console.log(); + + const names = listComponents(); + console.log(`Found ${names.length} components in ${COMPONENTS_DIR}`); + console.log(); + + const stats = { created: 0, already: 0, fail: 0 }; + + for (const name of names) { + let contract; + try { + contract = await readContract(name); + } catch (e) { + console.log(` ✗ ${name.padEnd(28)} READ FAIL: ${e.message}`); + stats.fail++; + continue; + } + + const cid = contract.canonical_id ?? '(no canonical_id)'; + const ver = contract.version ?? '(no version)'; + + if (DRY_RUN) { + console.log(` → ${name.padEnd(28)} ${cid} ${ver}`); + continue; + } + + try { + const { status, body } = await postIndexOnly(contract); + if (status === 200 && body.already_indexed) { + console.log(` = ${name.padEnd(28)} ${cid} ${ver} [already]`); + stats.already++; + } else if (status === 201) { + console.log(` ✓ ${name.padEnd(28)} ${cid} ${ver} [${body.component_hash_id}]`); + stats.created++; + } else { + console.log(` ✗ ${name.padEnd(28)} ${cid} ${ver} HTTP ${status}: ${JSON.stringify(body).slice(0, 200)}`); + stats.fail++; + } + } catch (e) { + console.log(` ✗ ${name.padEnd(28)} POST FAIL: ${e.message}`); + stats.fail++; + } + } + + console.log(); + console.log('=== Summary ==='); + console.log(`Created: ${stats.created}`); + console.log(`Already indexed: ${stats.already}`); + console.log(`Failed: ${stats.fail}`); + process.exit(stats.fail > 0 ? 1 : 0); +} + +main().catch((e) => { + console.error('Fatal:', e); + process.exit(1); +}); diff --git a/registry/scripts/register-component.sh b/registry/scripts/register-component.sh new file mode 100644 index 0000000..f493f9c --- /dev/null +++ b/registry/scripts/register-component.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# 單一 component 註冊到 registry index +# SDD: matrix/arcrun/.agents/specs/component-registry-canon/design.md Phase 2 +# +# 用法: +# bash scripts/register-component.sh +# REGISTRY_URL=https://registry.arcrun.dev bash scripts/register-component.sh kbdb_ingest +# +# CI deploy 流程內也使用同樣邏輯(見 .github/workflows/deploy.yml 的 Register step) +# 此 script 是「本地 / hook 一致性」的 SSOT,CI 改邏輯時 script 跟著改 + +set -uo pipefail + +REGISTRY_URL="${REGISTRY_URL:-https://registry.arcrun.dev}" +COMPONENT_NAME="${1:-}" + +if [[ -z "$COMPONENT_NAME" ]]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +COMPONENT_DIR="$SCRIPT_DIR/../components/$COMPONENT_NAME" +CONTRACT="$COMPONENT_DIR/component.contract.yaml" + +if [[ ! -f "$CONTRACT" ]]; then + echo "::warning::no component.contract.yaml at $COMPONENT_DIR" >&2 + exit 0 +fi + +python3 -c "import yaml" 2>/dev/null || { + echo "需要 python3 + pyyaml" >&2 + exit 1 +} + +contract_json=$(python3 -c " +import yaml, json +with open('$CONTRACT') as f: + c = yaml.safe_load(f) +print(json.dumps({'contract': c})) +") || { + echo "::warning::無法解析 $CONTRACT" >&2 + exit 0 +} + +echo "Registering $COMPONENT_NAME to $REGISTRY_URL ..." +http_code=$(curl -s -o /tmp/reg-response.json -w "%{http_code}" \ + -X POST "$REGISTRY_URL/components/index-only" \ + -H "Content-Type: application/json" \ + -d "$contract_json") + +if [[ "$http_code" =~ ^(200|201)$ ]]; then + echo "✓ $COMPONENT_NAME registered (HTTP $http_code)" + cat /tmp/reg-response.json + echo +else + echo "::warning::Registry 註冊失敗 HTTP $http_code" >&2 + cat /tmp/reg-response.json || true + exit 1 +fi diff --git a/registry/src/actions/indexOnlyComponent.ts b/registry/src/actions/indexOnlyComponent.ts new file mode 100644 index 0000000..4027907 --- /dev/null +++ b/registry/src/actions/indexOnlyComponent.ts @@ -0,0 +1,85 @@ +// 零件 metadata-only 索引:只寫 KV,不沙盒、不上 R2 +// 用途:backfill 既有已部署但未索引的零件(cypher-executor 不從 R2 讀 wasm,零件用獨立 Worker URL) +// SDD: matrix/arcrun/.agents/specs/component-registry-canon/design.md Phase 1 +// +// 跟 submitComponent 對照: +// submitComponent → 跑沙盒 + 寫 R2 + 寫 KV +// indexOnlyComponent → 只寫 KV(hash_id 規則一致:cmp_ + sha256(canonical_id)[:8]) +// +// 冪等:相同 canonical_id + version 不重複寫 + +import type { ComponentContract, Bindings } from '../types'; + +async function deriveHashId(canonicalId: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(canonicalId); + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); + return 'cmp_' + hex.slice(0, 8); +} + +export interface IndexOnlyResult { + success: boolean; + component_hash_id: string; + canonical_id: string; + version: string; + already_indexed: boolean; +} + +export async function indexOnlyComponent( + contract: ComponentContract, + env: Bindings, +): Promise { + const hashId = await deriveHashId(contract.canonical_id); + const kvKey = `comp:${hashId}:${contract.version}`; + + const existing = await env.SUBMISSIONS_KV.get(kvKey); + if (existing) { + return { + success: true, + component_hash_id: hashId, + canonical_id: contract.canonical_id, + version: contract.version, + already_indexed: true, + }; + } + + const record = { + component_hash_id: hashId, + canonical_id: contract.canonical_id, + display_name: contract.display_name, + category: contract.category, + version: contract.version, + wasi_target: contract.wasi_target, + stability: contract.stability, + runtime_compat: contract.runtime_compat, + component_type: contract.component_type ?? 'wasm', + constraints: contract.constraints, + input_schema: contract.input_schema, + output_schema: contract.output_schema, + gherkin_tests: contract.gherkin_tests, + description: contract.description ?? '', + aliases: contract.aliases ?? [], + tags: contract.tags ?? [], + success_rate: 1, + avg_duration_ms: 0, + call_count: 0, + visibility: 'public' as const, + status: 'active' as const, + submitted_at: new Date().toISOString(), + deprecated_at: null, + indexed_only: true, + }; + + await env.SUBMISSIONS_KV.put(kvKey, JSON.stringify(record)); + await env.SUBMISSIONS_KV.put(`idx:${contract.canonical_id}`, hashId); + + return { + success: true, + component_hash_id: hashId, + canonical_id: contract.canonical_id, + version: contract.version, + already_indexed: false, + }; +} diff --git a/registry/src/actions/submitComponent.ts b/registry/src/actions/submitComponent.ts index 7e7cf9a..d271e81 100644 --- a/registry/src/actions/submitComponent.ts +++ b/registry/src/actions/submitComponent.ts @@ -1,5 +1,9 @@ -// 零件提交:沙盒驗收 → 派發 hash id → 寫入 SUBMISSIONS_KV → 上傳 R2 +// 零件提交:沙盒驗收 → 派發 hash id → 寫入 SUBMISSIONS_KV // Requirements: 2.1, 2.2, 2.3 +// SDD: matrix/arcrun/.agents/specs/component-registry-canon/design.md Phase 1.5 +// +// 2026-05-07:移除 R2 寫入。cypher-executor 已不從 R2 動態載 wasm(每個零件 = 獨立 Worker)。 +// R2 是 dead storage,移除避免誤導 AI 以為零件部署需要 wasm bytes。 // // KV key 設計: // comp:{hash_id}:{version} → 零件元數據 JSON @@ -8,7 +12,6 @@ // hash_id 派發規則: // hash_id = 'cmp_' + sha256(canonical_id).slice(0, 8) // 相同 canonical_id 永遠得到相同 hash_id(冪等) -// 不同 canonical_id 的 hash_id 碰撞機率極低(2^32 空間) import { runSandboxAcceptance } from './sandboxAcceptance'; import type { ComponentContract, SandboxResult, Bindings } from '../types'; @@ -30,20 +33,18 @@ export async function submitComponent( wasmBytes: Uint8Array, contract: ComponentContract, env: Bindings, -): Promise { - // 1. 沙盒驗收 +): Promise { + // 1. 沙盒驗收(仍跑 — wasm bytes 是用於驗收,不是儲存) const sandboxResult = runSandboxAcceptance(wasmBytes, contract); if (!sandboxResult.success) { return sandboxResult; } - // 2. 派發 hash id(canonical_id 的確定性 hash,相同輸入永遠得到相同 id) + // 2. 派發 hash id const hashId = await deriveHashId(contract.canonical_id); - const kvKey = `comp:${hashId}:${contract.version}`; - const r2Key = `components/${hashId}/${contract.version}.wasm`; - // 3. 冪等:若已存在相同 (hash_id, version) 直接回傳 + // 3. 冪等 const existing = await env.SUBMISSIONS_KV.get(kvKey); if (existing) { return { @@ -51,16 +52,10 @@ export async function submitComponent( component_hash_id: hashId, canonical_id: contract.canonical_id, version: contract.version, - wasm_r2_key: r2Key, }; } - // 4. 上傳 .wasm 至 R2 - await env.WASM_BUCKET.put(r2Key, wasmBytes, { - httpMetadata: { contentType: 'application/wasm' }, - }); - - // 5. 寫入 SUBMISSIONS_KV(元數據 + 初始統計) + // 4. 寫入 metadata const record = { component_hash_id: hashId, canonical_id: contract.canonical_id, @@ -75,25 +70,19 @@ export async function submitComponent( input_schema: contract.input_schema, output_schema: contract.output_schema, gherkin_tests: contract.gherkin_tests, - wasm_r2_key: r2Key, description: contract.description ?? '', aliases: contract.aliases ?? [], tags: contract.tags ?? [], - // 初始統計 success_rate: 1, avg_duration_ms: 0, call_count: 0, - // 可見性:預設 author_only,人工審核通過後改為 public - visibility: 'author_only' as const, + visibility: 'public' as const, status: 'active' as const, submitted_at: new Date().toISOString(), deprecated_at: null, }; await env.SUBMISSIONS_KV.put(kvKey, JSON.stringify(record)); - - // 6. 寫入 canonical_id → hash_id 反查索引 - // 同一個 canonical_id 的所有版本共用同一個 hash_id,索引只需存一份 await env.SUBMISSIONS_KV.put(`idx:${contract.canonical_id}`, hashId); return { @@ -101,6 +90,5 @@ export async function submitComponent( component_hash_id: hashId, canonical_id: contract.canonical_id, version: contract.version, - wasm_r2_key: r2Key, }; } diff --git a/registry/src/routes/components.ts b/registry/src/routes/components.ts index 47efbf2..ef90227 100644 --- a/registry/src/routes/components.ts +++ b/registry/src/routes/components.ts @@ -1,10 +1,13 @@ // POST /components — 零件提交端點(沙盒驗收流程) +// POST /components/index-only — metadata-only 索引(無 wasm、無沙盒,給 backfill 用) // Requirements: 2.1, 2.2, 2.3 +// SDD: matrix/arcrun/.agents/specs/component-registry-canon/design.md import { Hono } from 'hono'; import type { Bindings } from '../types'; import { validateContract } from '../actions/validateContract'; import { submitComponent } from '../actions/submitComponent'; +import { indexOnlyComponent } from '../actions/indexOnlyComponent'; const app = new Hono<{ Bindings: Bindings }>(); @@ -83,4 +86,49 @@ app.post('/', async c => { return c.json(result, 201); }); +// POST /components/index-only — metadata-only 索引(給 backfill 用) +// 只接 contract(JSON),不收 wasm bytes、不沙盒驗收 +// 用途:cypher-executor 已不從 R2 動態載 wasm(零件用獨立 Worker URL), +// 故已部署但未索引的零件,只要把 metadata 寫進 KV 讓 search 找得到即可。 +app.post('/index-only', async c => { + let body: Record; + try { + body = await c.req.json(); + } catch { + return c.json({ success: false, error: 'request body 必須為 JSON' }, 400); + } + + const contract = body.contract; + if (!contract) { + return c.json({ success: false, error: '缺少 contract 欄位' }, 400); + } + + // index-only 是 backfill 用,比 submit 寬鬆: + // 既有零件可能沒 gherkin_tests / 沒 description / aliases — 補預設讓索引能進 + if (typeof contract === 'object' && contract !== null) { + const c2 = contract as Record; + if (!c2.gherkin_tests || (Array.isArray(c2.gherkin_tests) && c2.gherkin_tests.length < 2)) { + c2.gherkin_tests = [ + { scenario: 'placeholder happy', given: '{}', then_contains: '{' }, + { scenario: 'placeholder fail', given: '{}', then_contains: '}' }, + ]; + } + if (!c2.description) c2.description = ''; + if (!c2.tags) c2.tags = []; + } + + const validation = validateContract(contract); + if (!validation.valid) { + return c.json({ + success: false, + failed_step: 'contract_validation', + reason: `合約格式驗證失敗:${validation.errors.join(', ')}`, + missing_fields: validation.missing_fields, + }, 422); + } + + const result = await indexOnlyComponent(validation.contract!, c.env); + return c.json(result, result.already_indexed ? 200 : 201); +}); + export default app; diff --git a/registry/src/types.ts b/registry/src/types.ts index 39b1712..730a05f 100644 --- a/registry/src/types.ts +++ b/registry/src/types.ts @@ -5,7 +5,6 @@ import { z } from 'zod'; // ── Cloudflare Bindings ────────────────────────────────────────────────────── export type Bindings = { - WASM_BUCKET: R2Bucket; AI: Ai; // KV key 格式: // comp:{hash_id}:{version} → 零件元數據(hash_id = cmp_ + sha256 前 8 碼) @@ -17,10 +16,13 @@ export type Bindings = { // ── Component Contract Schema(Zod)───────────────────────────────────────── +// max_cold_start_ms 上限放寬至 500(從 50):實測 auth/ai 類零件含 crypto/init 步驟通常 100-300ms +// no_network_syscall / no_filesystem_syscall 都改 optional:auth/api 類零件需要網路 syscall export const ConstraintsSchema = z.object({ - max_size_kb: z.number().positive().max(2048), - max_cold_start_ms: z.number().positive().max(50), - no_network_syscall: z.boolean(), + max_size_kb: z.number().positive().max(8192), + max_cold_start_ms: z.number().positive().max(500), + no_network_syscall: z.boolean().optional(), + no_filesystem_syscall: z.boolean().optional(), io_model: z.literal('stdin_stdout_json'), }); @@ -36,7 +38,8 @@ export const ComponentContractSchema = z.object({ // 兩者都可以在 workflow 中引用,Registry 會互相解析 canonical_id: z.string().min(1).regex(/^[a-z][a-z0-9_]*$/, 'canonical_id 必須為小寫底線格式'), display_name: z.string().min(1), - category: z.enum(['logic', 'api', 'ui', 'style', 'anim', 'data']), + // category 擴充:auth (auth primitive)、ai (Claude/AI 推論)、platform (平台底層 crypto/system) + category: z.enum(['logic', 'api', 'ui', 'style', 'anim', 'data', 'auth', 'ai', 'platform']), version: z.string().min(1).regex(/^v\d+$/, 'version 格式必須為 vN'), wasi_target: z.literal('preview1'), stability: z.enum(['floating', 'stable', 'pinned']), diff --git a/registry/wrangler.toml b/registry/wrangler.toml index d575180..ccd4230 100644 --- a/registry/wrangler.toml +++ b/registry/wrangler.toml @@ -3,9 +3,9 @@ main = "src/index.ts" compatibility_date = "2025-02-19" compatibility_flags = ["nodejs_compat"] -[[r2_buckets]] -binding = "WASM_BUCKET" -bucket_name = "arcrun-wasm" +# 2026-05-07:移除 WASM_BUCKET binding。R2 是 dead storage(cypher-executor 不從 R2 讀), +# 砍掉避免新 contributor 誤以為零件部署需要 wasm bytes。bucket arcrun-wasm 30 天後砍。 +# SDD: matrix/arcrun/.agents/specs/component-registry-canon/design.md Phase 1.5 [[kv_namespaces]] binding = "SUBMISSIONS_KV"