arcrun — AI workflow execution engine (clean history)
Self-hosted 開源:WASM 零件 + recipe + cypher-executor,跑在你自己的 Cloudflare。 此為重建的乾淨歷史起點(移除曾誤 commit 的 GCP SA 金鑰,舊歷史保留在 richblack/arcrun 與本地 backup 分支)。含: - acr init --self-hosted installer(建 KV/R2 + codeload 拉預編譯 wasm + wrangler deploy + seed recipe) - recipe push 把關(資料外流提醒 + 打通檢查) - 19 個正當零件預編譯 wasm(claude_api/km_writer/kbdb_upsert_block 排除:違反 DECISIONS §1) - CLI / cypher-executor / registry / 完整 SDD Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
# 交辦文件:完成 arcrun self-hosted harness(給接手的 CC)
|
||||
|
||||
> 建立:2026-06-01(由前一個 CC 調查後撰寫)
|
||||
> 對象:接手的外部 CC
|
||||
> 目的:把 arcrun 補到「任何 CC 在自己的 CF 帳號上 self-host 後就能順暢開發、且不可能重蹈 mira 的錯」的程度。
|
||||
>
|
||||
> **先讀**:`DECISIONS.md`(穩定決策)、`.claude/rules/06-mindset.md`(mindset)、`BACKLOG.md`(流動待辦)。
|
||||
> 本文件不取代它們,只是把「今天要做的三件事」連同已查證的實況整理好,讓你不用重跑調查。
|
||||
|
||||
---
|
||||
|
||||
## 0. 戰法已轉變(最重要的背景)
|
||||
|
||||
richblack 2026-06-01 決定:**從 SaaS 改成 self-hosted 開源策略。**
|
||||
|
||||
這直接改變 harness 的成功定義:
|
||||
|
||||
- **舊定義**:在 richblack 的 prod 帳號(`cypher.arcrun.dev`)上能跑。
|
||||
- **新定義**:**任何 CC 在自己的 CF 帳號上 `acr init --self-hosted` 後就能跑通一個含 recipe 的 workflow,而且寫錯時會被程式擋住。**
|
||||
|
||||
richblack 會用另一個 CF 帳號實測 self-host。所以「self-hosted 一鍵起得來」從「第一期重要但非阻擋項」**升為今日第一優先**。
|
||||
|
||||
### arcrun 現在的核心心智(richblack 2026-06-01 校準,比 DECISIONS §1 更硬)
|
||||
|
||||
- 核心**零件數量少、由 richblack 維護、不接受 CC 自製**(可投稿 PR,人 merge = 閘門)。
|
||||
- 其他人做的一律是 **recipe**(= http_request + 一組 YAML 設定,不用 deploy)。
|
||||
- arcrun 是**一套給 CC 的 harness**:事前提醒 CC 能用什麼 / 不能做什麼,事後用程式擋住讓它**無法犯錯**。
|
||||
- **你不用管 mira。** mira 是錯誤做法的源頭(見 §1),它自己會修。你的目標是讓**任何** CC 都能用,且絕不會發生 mira 的錯。
|
||||
|
||||
---
|
||||
|
||||
## 1. mira 故障 = 症狀樣本(已定位,不用你修)
|
||||
|
||||
mira(`/Users/youlinhsieh/Documents/tech_projects/InkStoneCo/polaris/mira/arcrun/*.yaml`)的 workflow 寫 `component: kbdb_get` / `claude_api` / `telegram` 等。這些是 **mira 當初自己錯做的「假零件」**(DECISIONS §1 判準:打固定 endpoint 的東西是假零件,該是 recipe)。
|
||||
|
||||
本次整修(BACKLOG 步驟3)已把這些假零件**降級成 recipe + 刪掉零件目錄**(registry/components 從 33 → 22)。所以 mira 斷了。
|
||||
|
||||
**這證明的事**:mira 的錯,正是當時 harness 沒擋住的漏洞。零件刪了,但 harness 還缺「**事前告訴 CC 別這樣做 + 事後擋住 CC 這樣做**」的完整機制 → 下一個 CC 還會犯同樣的錯。**這就是你要補的(§3 task 2)。**
|
||||
|
||||
---
|
||||
|
||||
## 2. 已查證的實況(你不用重查,2026-06-01 實打 prod)
|
||||
|
||||
### 2.1 降級後的 recipe 鏈路是「活的」✅
|
||||
|
||||
實打 `https://cypher.arcrun.dev/recipes`(richblack prod)確認以下 recipe 都在 KV:
|
||||
|
||||
| canonical_id | hash | endpoint | auth_service |
|
||||
|---|---|---|---|
|
||||
| `kbdb_get` | rec_4c7dcf9b | `https://kbdb.finally.click{{_path}}` | kbdb |
|
||||
| `gmail_send` | rec_cd426129 | gmail.googleapis.com/.../send | google_gmail_sa |
|
||||
| `google_sheets_append` | rec_9fd1b662 | sheets.googleapis.com{{_path}} | google_sheets_sa |
|
||||
|
||||
→ **「對的用法」(`component: kbdb_get` 走解析鏈 step 6 查 `recipe:kbdb_get`)本身是通的。** 不需要重建 recipe。
|
||||
|
||||
> 注意:這是 richblack 的 prod KV。**self-host 的新帳號 KV 是空的**,需要 seed 這些 recipe(見 §3 task 1 的 seed 步驟)。
|
||||
|
||||
### 2.2 component-loader 解析鏈(`cypher-executor/src/lib/component-loader.ts`)
|
||||
|
||||
`resolveComponent` 依序嘗試 8 層(行號近似,以實際檔案為準):
|
||||
|
||||
```
|
||||
0. 平台 orchestration 零件(trigger_workflow) line ~88
|
||||
1. 內建零件(純 JS) line ~96
|
||||
2. 外部 URL(http(s)://...) line ~100
|
||||
3. cmp_hash → WEBHOOKS KV idx → 邏輯 Worker line ~105
|
||||
4. rec_hash → RECIPES KV idx → recipe 執行 line ~115
|
||||
5. 邏輯零件 canonical_id → Service Binding (SVC_*) line ~122
|
||||
5.5 auth recipe (auth_recipe:{service}) line ~127
|
||||
6. KV recipe canonical_id → RECIPES KV → fetch 外部 API line ~130 ← kbdb_get 等降級 recipe 命中這層
|
||||
7. WASM HTTP runner(白名單 WASM_HTTP_RUNNER_IDS) line ~134
|
||||
8. 找不到 → 報錯 line ~142
|
||||
```
|
||||
|
||||
`WASM_HTTP_RUNNER_IDS` 白名單(line ~36)現只剩:`http_request` / `cron` / 4 個 `auth_*` primitive。
|
||||
→ `claude_api`、`kbdb_upsert_block`(BACKLOG 標 deferred、源碼暫留)**不在白名單也沒 recipe** → 用到它們的 workflow 會落到 step 8 報錯。這是 mira 自己的問題,不在你範圍。
|
||||
|
||||
### 2.3 `acr init --self-hosted` 現況:純手動問答,差很遠
|
||||
|
||||
`cli/src/commands/init.ts` 的 `initSelfHosted()`(line 105-131)**只是問 6 個問題後寫進 config**:
|
||||
要求 CC 自己**事先**部署好 Worker、建好 KV、再手填 Account ID / cypher URL / 兩個 KV namespace ID / WASM bucket / CF token。
|
||||
|
||||
BACKLOG 步驟7 要的是「**貼 CF token → 自動建 KV、部署 Worker、自動 workers.dev、寫回 config**」。**這是最大缺口,task 1 的主體。**
|
||||
|
||||
config 讀取端已支援 self-hosted(`cli/src/lib/config.ts:52` 已能用 `cypher_executor_url`),所以你只要把「自動部署」這段補上,config/執行端不用動。
|
||||
|
||||
### 2.4 CI/CD 已是通用掃描式(可重用於 self-host 部署)
|
||||
|
||||
`.github/workflows/deploy.yml` 掃所有含 `wrangler.toml` 的目錄自動部署(見 `.claude/rules/05-deploy-convention.md`)。
|
||||
self-host 自動部署可以參考同一套掃描邏輯(`find . -name wrangler.toml`),對每個目錄跑 `wrangler deploy`。
|
||||
|
||||
---
|
||||
|
||||
## 3. 今天要做的三件事(按序,全在 harness 主線)
|
||||
|
||||
> richblack 指示:「全部要做」(含 `acr init --self-hosted`)。
|
||||
> 三件都做完 = 今天可交付:外部 CC 能 self-host 起來、用對的方式開發、犯錯被擋。
|
||||
|
||||
### 🔴 Task 1:完成 `acr init --self-hosted` 一鍵自動化(第一優先)
|
||||
|
||||
> ✅ **實作狀態(2026-06-02,已大致完成)**:定稿形態為 **installer 模式**(richblack 拍板)——
|
||||
> 用戶只做:申請 CF 帳號 → 裝 wrangler → 裝 acr → `acr init --self-hosted`(貼 token),其餘自動。
|
||||
> 已實作(typecheck 過):`cli/src/lib/api-recipe-seeds.ts`(10 recipe 種子)、`cf-api.ts` 的
|
||||
> `CfAccountClient`(建 KV/R2/查 subdomain/驗 token)、`deploy.ts`(常數 + downloadAndDeploy)、
|
||||
> `initSelfHosted()` 改寫、`acr update`、`cypher-executor/scripts/seed-api-recipes.ts`。
|
||||
> **唯一剩餘前置(13.6)**:repo 沒有含預編譯 wasm 的 GitHub release(.wasm 不 commit,rule 05)→
|
||||
> `downloadAndDeploy()` 目前**誠實回 implemented:false 不假裝部署**(mindset §7)。建 KV/R2/seed/config
|
||||
> 已可跑;release 產製管道補上後部署即自動化。定稿設計見 SDD `self-hosted-init.md`(含 §6 前置依賴)。
|
||||
> **以下原始子步驟保留供對照**;KBDB recipe 採 Supabase 模式進 seed(richblack 2026-06-02)。
|
||||
|
||||
**目標**:CC 只需提供「CF Account ID + CF API Token」,CLI 自動完成其餘一切。
|
||||
|
||||
**SDD**:定稿 `.agents/specs/arcrun/sdk-and-website/self-hosted-init.md`(installer 模式,已與 richblack 對齊)。
|
||||
|
||||
**子步驟**:
|
||||
1. 改 `cli/src/commands/init.ts` 的 `initSelfHosted()`:
|
||||
- 收 CF Account ID + CF API Token(要 KV Edit + Workers Scripts Edit + R2 權限)。
|
||||
- 用 CF API(或 shell out `wrangler`)**自動建 7 個 KV namespace**:WEBHOOKS / CREDENTIALS_KV / RECIPES / USERS_KV / SESSIONS_KV / ANALYTICS_KV / EXEC_CONTEXT(清單見 `.claude/rules/01-tech-stack.md` 資料儲存表)+ R2 WASM_BUCKET。
|
||||
- **自動部署所有 Worker**:cypher-executor + registry + 22 個 `.component-builds/*`。可重用 §2.4 的 `wrangler.toml` 掃描。每個 worker 的 `wrangler.toml` 已含 `workers_dev = true`(BACKLOG 步驟 P1#2 已做),部署後 workers.dev URL 自動啟用。
|
||||
- **把 cypher-executor 的 `[vars] WORKER_SUBDOMAIN` 改成 CC 自己的帳號 subdomain**(self-host 關鍵,見 P0 #9:cypher-executor 走 `arcrun-{name}.{subdomain}.workers.dev` 對內 URL)。
|
||||
- **seed 降級 recipe + auth recipe 進 RECIPES KV**:新帳號 KV 是空的。把 §2.1 那些 recipe(kbdb_get/gmail_send/...)+ auth recipe seed 寫進去。auth recipe seed 已有 `cypher-executor/scripts/seed-auth-recipes.ts`,API recipe 需確認有對應 seed 機制(routes/recipes.ts 是動態 push,可能要寫一份 seed 腳本或用 `acr recipe push`)。
|
||||
- 寫回 config(現有欄位已足夠)。
|
||||
2. **runtime secret 不進 CLI 自動化**:`ENCRYPTION_KEY` 等由 CC 自己 `wrangler secret put`(rule 05 禁止 secret 進自動化流程)。CLI 應在最後**印出提示**告訴 CC 要手動 put 哪些 secret 到哪些 worker。
|
||||
|
||||
**驗收(客觀證據,不是口頭宣布 — mindset §7)**:
|
||||
- richblack 用全新 CF 帳號跑 `acr init --self-hosted` → 全程無手動建 KV / 部署。
|
||||
- 跑完後 `acr push` 一個含 `component: kbdb_get`(或 http_request + 自建 recipe)的 workflow → trigger → HTTP 2xx + execution trace 證明跑通。
|
||||
|
||||
### 🔴 Task 2(已重定義 2026-06-01):封鎖自製零件 + recipe 入庫把關
|
||||
|
||||
> ✅ **實作狀態(2026-06-02,第一期部分完成)**:(1) 封鎖自製零件 = 靠 GitHub PR 人 merge,無需新做
|
||||
> (矛盾已釐清)。(2a) 資料外流提醒 = **既有實作已涵蓋**(recipe.ts `obtainExposureConsent` + exposure-warning.ts,
|
||||
> 非 TTY 拒絕)。(2b) 打通檢查 = **新增** `probeRecipeEndpoint`(recipe.ts,typecheck 過):push 後實打
|
||||
> endpoint,提醒級不硬擋,含 {{模板}} 誠實說明待 run 才知,401/403 標「多半缺 credential 非 bug」。
|
||||
> 公共庫 relay 檢核(--public)= 第一期後。SDD `recipe-push-gatekeeping.md` + tasks.md W2。
|
||||
|
||||
> ⚠️ **方向修正(richblack 2026-06-01)**:原 Task 2「acr validate 擋假零件名」**作廢**。
|
||||
> 理由:自製/修改零件的路已封鎖(CC 造不出零件)→「擋假零件」這件事不存在;workflow 引用
|
||||
> recipe(`component: kbdb_get`)是合法且未來唯一的擴充方式,不該被當假零件擋。
|
||||
> 把關點**從 workflow validate 移到 recipe 入庫(push)那一刻**。
|
||||
> 已動的 yaml-parser.ts(LEGAL_PRIMITIVES / findSuspectComponents)**已回退**。
|
||||
|
||||
**新目標**:
|
||||
1. **封鎖自製零件**:靠「零件投稿走 GitHub PR + 人 merge」天然閘門(DECISIONS §8)。零件數量少、
|
||||
絕大多數是 recipe → 不為零件 PR 蓋自動化把關(量少,人工檢查即可;爆量才回頭想自動化)。
|
||||
2. **recipe 入庫把關**(CC 唯一能擴充的是 recipe,一律用 push,自有庫/公共庫同一套指令):
|
||||
- **自有庫(self-hosted)= 提醒級**:(a) 資料外流提醒——會讓資料/服務對外可見的動作需人類明示同意;
|
||||
(b) 打通檢查——push 時實打 endpoint 回報 2xx 與否(誠實標原因,不假綠,不硬擋)。
|
||||
- **公共庫 = 維護者 relay 檢核**(實際打通、真收到成功回傳)— 第一期後。
|
||||
|
||||
**SDD**:已寫 design 給 richblack review →
|
||||
`.agents/specs/component-gatekeeping/recipe-push-gatekeeping.md`(+ tasks.md W2 節)。**review 通過才動 code。**
|
||||
|
||||
**動到的檔案(待 review)**:`cli/src/commands/recipe.ts`(push 加提醒 + 打通檢查)、確認 data-exfil hook 涵蓋 recipe push 路徑。
|
||||
|
||||
**驗收**:
|
||||
- `acr recipe push` 會產對外 webhook 的東西 → 印資料外流警示 + 要人類同意;非 TTY → 拒絕。
|
||||
- `acr recipe push` endpoint 可達的 recipe → 回報「✓ HTTP 2xx」。
|
||||
- `acr recipe push` 缺 credential → 回報「⚠️ 未打通:缺 credential」(誠實),仍允許 push。
|
||||
- workflow 引用 recipe(`component: kbdb_get`)**不被任何 validate 步驟當假零件擋**。
|
||||
|
||||
### 🔴 Task 3:README 重寫成單一路徑 — harness「事前提醒」
|
||||
|
||||
**目標**:self-hosted 開源後,README 是外界 CC 唯一入口。砍掉「玩法一/二/三」三選一,講清楚單一正確路徑。
|
||||
|
||||
**子步驟**(改根 `README.md`):
|
||||
1. 砍三選一玩法,留**一條路**:`acr init --self-hosted` → 寫 workflow(primitive 串 + recipe)→ `acr push` → trigger。
|
||||
2. 明示心智(呼應 mindset §1):「零件就這固定幾個由我們維護、不接受自製;要打外部 API 就寫 recipe;要編排就寫工作流。」
|
||||
3. 連到 `.claude/rules/06-mindset.md` / arcrun-mindset Skill,讓 CC 一開始就有正確世界觀。
|
||||
|
||||
**驗收**:README 讀完,一個沒看過 arcrun 的 CC 知道:能用什麼、不能自製零件、打外部 API 要寫 recipe、怎麼 self-host 起來。
|
||||
|
||||
---
|
||||
|
||||
## 4. 今天「不要做」的(避免你走偏)
|
||||
|
||||
| 項目 | 為何不做 |
|
||||
|---|---|
|
||||
| 修 mira | richblack 明示不用管,mira 自己修 |
|
||||
| 步驟2 `acr recipe test` / relay / credits | DECISIONS §3c 明確劃為服務側、非第一期 |
|
||||
| 步驟6 搬家拆 matrix | 純 repo 整理,不影響 CC 能否用 |
|
||||
| 砍 `injectCredentials` 舊路 / `BUILTIN_CREDENTIALS_MAP` | 獨立清理,不擋交付(DECISIONS §3b / BACKLOG「第一期之後」)|
|
||||
| 新 primitive / Gherkin 真跑 / 入站認證 | richblack 已標「不要現在做」 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 鐵律提醒(違反會被 hook block)
|
||||
|
||||
- 任何 code 變動前先讀對應 SDD + 在回覆開頭宣告(`.claude/rules/00-sdd-protocol.md`)。
|
||||
- `registry/components/` 下禁止 TS;cypher-executor TS 禁止 credential/auth/JWT 業務邏輯(`.claude/rules/02-forbidden.md`,hook 強制)。
|
||||
- 每完成一個 task 立刻更新對應 tasks.md / BACKLOG.md 的 `[x]`,不批次。
|
||||
- 誠實限制(mindset §7):stub / 未完成就標 unimplemented,**不假綠**;完成 = 客觀證據(exit code / HTTP status + trace),不是口頭宣布。
|
||||
@@ -0,0 +1,87 @@
|
||||
# 2026-05-29 credential 解密失敗(兩個 Worker 的 ENCRYPTION_KEY 漂移)
|
||||
|
||||
> **症狀**:`acr recipe test kbdb`(credential 注入)回 HTTP 500,`auth_static_key` 回 `credential kbdb_api_key 解密失敗`
|
||||
> **根因(主)**:`arcrun-auth-static-key` Worker 的 `ENCRYPTION_KEY` secret 跟正本(cypher-executor / CLI 用的那把)值不同、格式也不同(44-char base64 vs 64-char hex)。AES-GCM 用錯 key 必然解密失敗。
|
||||
> **根因(附)**:`component-loader.ts` 用 `res.json().catch(() => res.text())` 讀 response body → body 被讀兩次 → `Body has already been used`。
|
||||
> **修法**:(1) `wrangler secret put ENCRYPTION_KEY` 把 auth-static-key 對齊正本 64-hex;(2) 新增 `readBodyOnce()` 先取 text 再 parse JSON。
|
||||
> **影響**:BACKLOG 步驟 2(credential 注入鏈路)阻擋;Phase 3 降級假零件成 recipe 的前置。
|
||||
|
||||
---
|
||||
|
||||
## 症狀
|
||||
|
||||
`acr recipe test kbdb` 端到端打不到 2xx。直接 probe `auth_static_key`:
|
||||
|
||||
```
|
||||
POST https://auth-static-key.arcrun.dev/ {action:"authenticate", api_key:"ak_…", service:"kbdb"}
|
||||
→ {"success":false, "error":"credential kbdb_api_key 解密失敗", ...}
|
||||
```
|
||||
|
||||
前置都綠(排除誤判方向):
|
||||
- `auth_recipe:kbdb` 存在、`primitive=static_key`(kv_get 命中 410 bytes)
|
||||
- `kbdb_api_key` credential 存在 KV(kv_get 命中 108 bytes 的 `{encrypted, iv}`)
|
||||
- 失敗精準落在「解密」這一步
|
||||
|
||||
## 定位(key-fingerprint 診斷,只印 SHA-256 前綴,不印 key/明文)
|
||||
|
||||
在 `aesGcmDecrypt`(`wasi-shim.ts`)暫加:
|
||||
|
||||
```
|
||||
console.error(`[decrypt] ENCRYPTION_KEY sha256_prefix=${fpHex} keyLen=${len}`)
|
||||
```
|
||||
|
||||
deploy auth-static-key + `wrangler tail` 抓到:
|
||||
|
||||
| 來源 | keyLen | sha256 前綴 | 格式 |
|
||||
|---|---|---|---|
|
||||
| 加密端(CLI `~/.arcrun/config.yaml` 的 `encryption_key`) | 64 | `fa84f2ce9027` | hex(→32 bytes)✓ |
|
||||
| 解密端(`arcrun-auth-static-key` 的 `ENCRYPTION_KEY` secret) | **44** | **`ff219b123c89`** | base64 ✗ |
|
||||
|
||||
**兩個 mismatch 同時存在**:值不同 + 格式不同。`hexToUint8Array` 套在 44-char base64 上會解成垃圾 bytes,AES-GCM 必失敗。
|
||||
|
||||
漂移源頭:`arcrun/.env` 裡的 `ENCRYPTION_KEY` 就是那把錯的 base64(`ff219b123c89`),有人拿它去 `wrangler secret put` 設進 auth-static-key。
|
||||
|
||||
## 為什麼正本是 64-hex
|
||||
|
||||
`/register`(register.ts:42)把 `encryption_key: c.env.ENCRYPTION_KEY` 原樣回給用戶 —— 即 **cypher-executor 的** `ENCRYPTION_KEY`。用戶 config 是 64-hex(`fa84f2ce9027`),所以正本 = cypher-executor 那把 64-hex。CLI 加密 credential 也用這把。auth-static-key 必須跟它一致才能解開。
|
||||
|
||||
診斷用完即移除(`wasi-shim.ts` 還原,git diff 為空)。
|
||||
|
||||
## 附帶 bug:Body has already been used
|
||||
|
||||
修對 key 後,`/execute` 端到端從 500 變成「Node n1 failed: Body has already been used」。
|
||||
|
||||
`component-loader.ts` 的 `makeRecipeRunner` / `makeAuthRecipeRunner`:
|
||||
|
||||
```ts
|
||||
const data = await res.json().catch(() => res.text()); // ✗ res.json() 失敗時 body 已消費
|
||||
```
|
||||
|
||||
KBDB `/health` 回非 JSON(純文字)→ `res.json()` throw → `.catch(() => res.text())` 第二次讀 body → throw。
|
||||
|
||||
修法 — 讀一次:
|
||||
|
||||
```ts
|
||||
async function readBodyOnce(res: Response): Promise<unknown> {
|
||||
const text = await res.text();
|
||||
try { return JSON.parse(text); } catch { return text; }
|
||||
}
|
||||
```
|
||||
|
||||
## 修法步驟
|
||||
|
||||
1. `cd .component-builds/auth_static_key && wrangler secret put ENCRYPTION_KEY`,貼正本 64-hex(= `~/.arcrun/config.yaml` 的 `encryption_key`)。**richblack 手動**(rule 05:runtime secret 不進 CI、CC 不碰)。
|
||||
2. `component-loader.ts` 加 `readBodyOnce()`,兩處 `res.json().catch(...)` 換掉。`tsc --noEmit` 綠,deploy cypher-executor。
|
||||
3. 修正源頭文件 `arcrun/.env` 的 `ENCRYPTION_KEY` 改成 64-hex(避免下次再設錯)。
|
||||
|
||||
## 驗證證據
|
||||
|
||||
- 直接 probe auth-static-key:**HTTP 200**, `success:true`, 產出 `Authorization: Bearer …`
|
||||
- 端到端 `/execute`:**HTTP 200**, trace 乾淨
|
||||
- auth 確證:直接 curl KBDB `/blocks` 不帶 token → `401 {"error":"Missing token"}`;經 cypher-executor(注入 token)→ 過 auth,進 KBDB handler 回 ZodError(缺 `content`)。**無 401 = token 被接受**。
|
||||
|
||||
## 教訓
|
||||
|
||||
- **同一把 key 出現在 ≥2 個 Worker 的 secret = 漂移風險**。auth-static-key / auth_service_account / cypher-executor 都讀 `ENCRYPTION_KEY`,靠人各設一次必漂。長期應有單一發放來源或部署時自動同步。
|
||||
- **debug 加密問題,先比 key 指紋(SHA-256 前綴),不要碰 key 明文**。一個 fingerprint log 就分辨出「值錯」vs「格式錯」vs「資料壞」。
|
||||
- **`res.json().catch(() => res.text())` 是反模式** —— body 只能讀一次。永遠先 `res.text()` 再 `JSON.parse`。
|
||||
@@ -0,0 +1,340 @@
|
||||
# arcrun.dev Pages 規格
|
||||
|
||||
> **讀者**:CC(可直接照做)
|
||||
> **部署**:Cloudflare Pages + Workers
|
||||
> **語言**:英文為主,中文切換
|
||||
> **技術棧**:Astro(靜態生成)+ Cloudflare Pages + D1(使用統計)
|
||||
|
||||
---
|
||||
|
||||
## 0. 這個 Pages 的三個角色
|
||||
|
||||
1. **門面**:第一次看到 arcrun 的人,30 秒內要懂「這是什麼、對我有什麼用」
|
||||
2. **轉換漏斗**:工程師 → 試用 lib → 申請 API Key;小白 → 看榮譽牆 → 問 AI 能不能用
|
||||
3. **社群磁鐵**:榮譽牆讓工程師有動機貢獻 recipe,貢獻越多服務越多,用戶越多
|
||||
|
||||
---
|
||||
|
||||
## 1. 網站結構(五個頁面)
|
||||
|
||||
```
|
||||
arcrun.dev/
|
||||
├── / 首頁(門面 + 轉換)
|
||||
├── /docs 用法文件
|
||||
├── /integrations 榮譽牆(服務目錄)
|
||||
├── /api Swagger UI(原始 API)
|
||||
└── /changelog 版本記錄
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 首頁(/)
|
||||
|
||||
### 2.1 Hero Section
|
||||
|
||||
**英文**:
|
||||
```
|
||||
Stop fighting OAuth.
|
||||
One API key. Every service. Works anywhere.
|
||||
|
||||
arcrun handles Google, Notion, GitHub, Slack authentication
|
||||
so your Python / JS code doesn't have to.
|
||||
|
||||
[Get API Key — Free] [View on GitHub]
|
||||
```
|
||||
|
||||
**中文切換後**:
|
||||
```
|
||||
不要再跟 OAuth 搏鬥了。
|
||||
一個 API Key,接通所有服務,在哪跑都行。
|
||||
|
||||
[免費取得 API Key] [查看 GitHub]
|
||||
```
|
||||
|
||||
語言切換按鈕放右上角,用 `?lang=zh` query param,Cloudflare Worker 記住偏好存 cookie。
|
||||
|
||||
### 2.2 三行說清楚(Why arcrun)
|
||||
|
||||
```
|
||||
┌────────────────────┬────────────────────┬────────────────────┐
|
||||
│ Before │ │ After │
|
||||
│ │ │ │
|
||||
│ 40 行 OAuth 程式 │ →→→ │ 1 行 │
|
||||
│ GCP Console 設定 │ │ arcrun.auth.bind │
|
||||
│ debug 兩天 │ │ ("google_drive") │
|
||||
└────────────────────┴────────────────────┴────────────────────┘
|
||||
```
|
||||
|
||||
### 2.3 Code Demo(互動式 tab)
|
||||
|
||||
三個 tab 切換:Python / JavaScript / HTTP(給 n8n 小白)
|
||||
|
||||
**Python tab**:
|
||||
```python
|
||||
pip install arcrun-auth
|
||||
|
||||
from arcrun import auth
|
||||
|
||||
# 就這樣,Google Drive 認證完成
|
||||
drive = auth.bind("google_drive")
|
||||
resp = drive.get("/files")
|
||||
```
|
||||
|
||||
**JavaScript tab**:
|
||||
```javascript
|
||||
npm install arcrun-auth
|
||||
|
||||
import { auth } from 'arcrun-auth'
|
||||
|
||||
const drive = await auth.bind('google_drive')
|
||||
const resp = await drive.get('/files')
|
||||
```
|
||||
|
||||
**HTTP tab(給 n8n 用戶)**:
|
||||
```
|
||||
POST https://api.arcrun.dev/v1/auth/bind
|
||||
Authorization: Bearer YOUR_API_KEY
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"service": "google_drive",
|
||||
"secret": "{{ $env.GOOGLE_SA_JSON }}"
|
||||
}
|
||||
```
|
||||
下方加一行小字:「n8n 用戶:用 HTTP Request 節點貼上這段,不需要安裝任何東西」
|
||||
|
||||
### 2.4 數字牆(social proof)
|
||||
|
||||
```
|
||||
127 個認證服務 1,247,832 次呼叫 89 位貢獻者
|
||||
```
|
||||
|
||||
這三個數字從 D1 即時讀,每小時更新一次(Cloudflare KV cache)。
|
||||
|
||||
### 2.5 CTA
|
||||
|
||||
```
|
||||
[免費取得 API Key]
|
||||
註冊後立即可用,不需要信用卡
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 榮譽牆(/integrations)★ 核心頁面
|
||||
|
||||
### 3.1 頁面頂部
|
||||
|
||||
```
|
||||
127 個已驗證的認證服務
|
||||
由社群工程師貢獻並測試,每個 recipe 都有真實使用數據
|
||||
|
||||
[搜尋服務...] [全部] [AI] [Google] [社群媒體] [生產力] [台灣]
|
||||
```
|
||||
|
||||
### 3.2 服務卡片
|
||||
|
||||
每個 recipe 一張卡:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ [圖示] Google Drive ★ 官方 │
|
||||
│ │
|
||||
│ 認證方式:Service Account │
|
||||
│ 貢獻者:@richblack ──→ GitHub profile │
|
||||
│ 驗證日期:2026-03-15 │
|
||||
│ │
|
||||
│ 使用次數:██████████ 12,847 次 │
|
||||
│ │
|
||||
│ [查看 Recipe] [複製 Python 範例] │
|
||||
└──────────────────────────────────────────┘
|
||||
|
||||
┌──────────────────────────────────────────┐
|
||||
│ [圖示] OpenRouter │
|
||||
│ │
|
||||
│ 認證方式:API Key (Header) │
|
||||
│ 貢獻者:@some_engineer ──→ GitHub │
|
||||
│ 驗證日期:2026-04-01 │
|
||||
│ │
|
||||
│ 使用次數:██░░░░░░░░ 89 次 │
|
||||
│ │
|
||||
│ [查看 Recipe] [複製 Python 範例] │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
badge 規則:
|
||||
- `★ 官方`:arcrun 團隊維護
|
||||
- `✓ 社群驗證`:100+ 次使用 + 30 天無錯誤回報
|
||||
- `🆕 新加入`:30 天內合併的 PR
|
||||
|
||||
### 3.3 貢獻者排行(頁面底部)
|
||||
|
||||
```
|
||||
Top Contributors
|
||||
|
||||
🥇 @some_engineer 23 個 recipe 89,234 次呼叫
|
||||
🥈 @another_dev 15 個 recipe 45,123 次呼叫
|
||||
🥉 @third_person 8 個 recipe 12,456 次呼叫
|
||||
...
|
||||
|
||||
[我也要貢獻 →] (連到 CONTRIBUTING.md)
|
||||
```
|
||||
|
||||
### 3.4 「我要貢獻」的 CTA
|
||||
|
||||
```
|
||||
找不到你要的服務?
|
||||
|
||||
大部分 API Key 類的服務,填一份 YAML 就能加進來。
|
||||
把 API 文件丟給 AI,五分鐘生成,開 PR 送出。
|
||||
|
||||
[查看 Recipe 格式] [開始貢獻]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 用法文件(/docs)
|
||||
|
||||
### 結構
|
||||
|
||||
```
|
||||
快速開始
|
||||
├── 取得 API Key
|
||||
├── Python 安裝與第一個範例
|
||||
├── JavaScript 安裝與第一個範例
|
||||
└── 直接用 HTTP(n8n / 任何工具)
|
||||
|
||||
認證方式
|
||||
├── API Key 類服務
|
||||
├── OAuth2 類服務
|
||||
├── Google Service Account
|
||||
└── mTLS
|
||||
|
||||
進階用法
|
||||
├── 多帳號(multi-instance)
|
||||
├── 只取 token(escape hatch)
|
||||
└── 錯誤處理
|
||||
|
||||
貢獻 Recipe
|
||||
├── Recipe YAML 格式說明
|
||||
├── 讓 AI 幫你寫 Recipe
|
||||
└── 提交流程
|
||||
```
|
||||
|
||||
### 「讓 AI 幫你寫 Recipe」這一節特別重要
|
||||
|
||||
```markdown
|
||||
## 讓 AI 幫你寫 Recipe
|
||||
|
||||
把下面這段丟給 Claude / ChatGPT,
|
||||
再把目標服務的 API 文件一起貼進去:
|
||||
|
||||
---
|
||||
請根據以下 API 文件,
|
||||
生成一份符合 arcrun recipe schema 的 YAML。
|
||||
Schema 文件:https://arcrun.dev/docs/recipe-schema
|
||||
目標服務:[貼上 API 文件]
|
||||
---
|
||||
|
||||
AI 生成後,你只需要:
|
||||
1. 把 YAML 存成 recipes/community/服務名.yaml
|
||||
2. 跑 `acr recipe test 服務名.yaml`
|
||||
3. 開 PR
|
||||
|
||||
通常整個過程不超過十分鐘。
|
||||
```
|
||||
|
||||
這一節讓「貢獻門檻」從「工程師才能做」變成「任何人叫 AI 做」。
|
||||
|
||||
---
|
||||
|
||||
## 5. API 文件(/api)
|
||||
|
||||
直接嵌入 Swagger UI,連到 `https://api.arcrun.dev/swagger.json`。
|
||||
|
||||
頁面頂部加一行說明:
|
||||
```
|
||||
這是 arcrun 的原始 API。
|
||||
Python / JS lib 是它的包裝,
|
||||
任何能發 HTTP request 的工具都能直接用。
|
||||
```
|
||||
|
||||
這一句話讓 n8n 用戶、Make 用戶、甚至 Excel 用戶都知道「我也能用」。
|
||||
|
||||
---
|
||||
|
||||
## 6. 技術實作
|
||||
|
||||
### 6.1 技術選型
|
||||
|
||||
**Astro**(靜態生成)是首選,原因:
|
||||
- 頁面大部分是靜態內容(docs / recipe 卡片),Astro 的 SSG 完美對應
|
||||
- 動態數字(使用次數、貢獻者排行)用 Astro 的 `client:load` island 局部更新
|
||||
- 部署到 Cloudflare Pages 零配置
|
||||
|
||||
**不用 Next.js**,因為你已在 Cloudflare 生態,Next.js 的 SSR 在 CF Pages 有摩擦。Astro + CF Pages 是更自然的組合。
|
||||
|
||||
### 6.2 資料來源
|
||||
|
||||
| 資料 | 來源 | 更新頻率 |
|
||||
|---|---|---|
|
||||
| Recipe 清單、metadata | GitHub repo `recipes/` 目錄 | CI merge 時觸發 rebuild |
|
||||
| 使用次數 | Cloudflare D1(API call log) | 每小時從 D1 聚合 → KV cache |
|
||||
| 貢獻者排行 | 同上 | 每小時 |
|
||||
| 總呼叫次數 | 同上 | 每小時 |
|
||||
|
||||
### 6.3 多語言
|
||||
|
||||
用 Astro 的 i18n routing:
|
||||
- `/` → 英文
|
||||
- `/zh/` → 中文
|
||||
|
||||
語言切換按鈕寫入 cookie `arcrun_lang`,CF Worker 在 edge 讀 cookie 做 redirect。
|
||||
不用 JS framework 的 i18n library,保持輕量。
|
||||
|
||||
### 6.4 部署流程
|
||||
|
||||
```
|
||||
GitHub push to main
|
||||
→ GitHub Actions 跑 astro build
|
||||
→ 產出 dist/
|
||||
→ 自動部署到 Cloudflare Pages
|
||||
→ Pages 掛 arcrun.dev domain
|
||||
```
|
||||
|
||||
recipe YAML 有變動時(PR merge)額外觸發一次 rebuild。
|
||||
|
||||
---
|
||||
|
||||
## 7. CC 的實作任務
|
||||
|
||||
### Phase 1:靜態骨架(3-5 天)
|
||||
|
||||
- [ ] Astro 專案初始化,設定 CF Pages 部署
|
||||
- [ ] 首頁 Hero + Code Demo tab(靜態版,數字先寫死)
|
||||
- [ ] `/integrations` 靜態版(先手動列 5-10 個服務)
|
||||
- [ ] `/docs` 基本結構(快速開始 + Python 範例)
|
||||
- [ ] `/api` 嵌入 Swagger UI
|
||||
- [ ] 中英切換機制
|
||||
|
||||
### Phase 2:動態資料(3-5 天)
|
||||
|
||||
- [ ] D1 schema:`recipe_calls(recipe_id, count, last_updated)`
|
||||
- [ ] CF Worker:API call 時寫入 D1
|
||||
- [ ] 每小時聚合 Worker:D1 → KV cache(總數 / per recipe / per contributor)
|
||||
- [ ] 首頁數字牆:從 KV 讀即時數字
|
||||
- [ ] `/integrations` 卡片:使用次數從 KV 讀,進度條動態顯示
|
||||
|
||||
### Phase 3:社群功能(2-3 天)
|
||||
|
||||
- [ ] 貢獻者排行從 KV 讀
|
||||
- [ ] Recipe 頁面:點「查看 Recipe」展開 YAML
|
||||
- [ ] 點「複製 Python 範例」自動生成對應 code snippet
|
||||
- [ ] GitHub PR merge webhook → 觸發 Pages rebuild
|
||||
|
||||
---
|
||||
|
||||
## 8. 一個不能省的細節
|
||||
|
||||
榮譽牆的貢獻者欄位**一定要連到他的 GitHub profile**,不是只顯示名字。
|
||||
|
||||
工程師貢獻的動機之一是「這個會出現在我的公開作品集」。連到 GitHub 就意味著他的 followers 可能看到他貢獻了 arcrun,這比任何 badge 都有效。
|
||||
@@ -0,0 +1,486 @@
|
||||
# arcrun-py:Python Lib 策略分析
|
||||
|
||||
> **核心問題**:arcrun 的 auth 層要不要獨立成 Python lib?AI 會主動選它嗎?
|
||||
> **決策前提**:本地為主雲端選配 + 免費引流 SaaS + 三維度分析
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR
|
||||
|
||||
做。但要做對——它不是「arcrun SDK」,是 **獨立的 OAuth 地獄解藥**,SaaS 帳號只是 optional power-up。
|
||||
|
||||
**產品名建議**:`arcrun-auth`(PyPI 套件名),import 時叫 `authkit` 或 `arc`。
|
||||
|
||||
**定位**:`pip install arcrun-auth` → 一行搞定 Google/GitHub/Slack/Notion/... 的認證。本地 keyring 存 secret,零註冊可用。註冊 arcrun 帳號才能多機同步 / 團隊共用 / 獲得平台代管的 OAuth App。
|
||||
|
||||
---
|
||||
|
||||
## 1. 你的直覺:為什麼這可能是大招
|
||||
|
||||
你觀察到的現象:
|
||||
- **寫個「本地檔案 → Google Drive 備份」這種小腳本**,光認證花 2 天,日後還反覆壞。
|
||||
- **這個痛點 99% 的 Python 使用者都遇過**。Stack Overflow 上 Google OAuth 的問題累計回答數十萬。
|
||||
- **AI 寫程式時,這部分它也不會寫**——訓練資料裡的標準範本就是 `InstalledAppFlow.from_client_secrets_file()`,還是要人類先去 GCP Console 建 OAuth Client、下載 JSON、設 redirect URI、加測試用戶。
|
||||
|
||||
**如果 arcrun 能把這整套濃縮成 `auth.bind("google_drive")`,它就是開發者工具裡的「自來水」——沒人在乎水源廠長怎麼運作,大家只想打開水龍頭有水喝。**
|
||||
|
||||
而且你無意中踩到一個時代轉折點:**AI 在寫程式,AI 不想 debug OAuth**。這個 lib 是給 AI 用的,比給人類用的更重要。
|
||||
|
||||
---
|
||||
|
||||
## 2. 三維度分析:AI 到底會不會選 arcrun-auth?
|
||||
|
||||
### 維度一:零註冊摩擦(最重要)
|
||||
|
||||
**現實**:AI coding assistant 預設會產出訓練資料裡最常見的程式碼。Google Drive 的標準答案就是官方 quickstart——那段 40 行的 `InstalledAppFlow` 樣板碼,加上「去 GCP Console 建 OAuth Client」的一串步驟。
|
||||
|
||||
**AI 選擇替代 lib 的條件**(觀察 Copilot / Cursor / Claude Code 的實際行為):
|
||||
|
||||
| 條件 | 是否能壓制預設答案 |
|
||||
|---|---|
|
||||
| Lib 名稱出現在 prompt 裡 | ✅ 絕對會用 |
|
||||
| 專案已有 `pyproject.toml` 宣告該 lib | ✅ 會用 |
|
||||
| 該 lib 是 PyPI 下載量 Top N(訓練資料權重高) | ⚠️ 或許 |
|
||||
| 使用者口頭說「用 XX 比較好」 | ✅ 會用(單次) |
|
||||
| 該 lib 明顯更短更容易 | ⚠️ 有機會但不保證 |
|
||||
|
||||
**結論**:**零註冊摩擦是生死線**。
|
||||
|
||||
如果 `pip install arcrun-auth` 之後必須先去註冊 arcrun 帳號、拿 API token、設環境變數——AI 看到這一步就會放棄,回去寫 Google 官方那套,因為「官方的至少不用註冊第二家廠商」。
|
||||
|
||||
**對策**:
|
||||
- 本地模式必須是**零配置可用**的 default path。
|
||||
- Secret 存在 `~/.arcrun/secrets.db`(加密,用系統 keyring 保護 master key)。
|
||||
- 註冊 arcrun 帳號是 **opt-in** 的升級,不是必要。
|
||||
- 第一次用 OAuth 時,**平台提供免費共用的 OAuth App**(下面 §3 詳細說)。
|
||||
|
||||
這個設計讓 AI 看到的心智路徑是:
|
||||
|
||||
```
|
||||
目標:備份檔案到 Google Drive
|
||||
選項 A:google-api-python-client(官方,但使用者要先去 GCP 設 OAuth)
|
||||
選項 B:arcrun-auth(pip install 完直接可用)
|
||||
```
|
||||
|
||||
在這個對比下,AI 會選 B——**特別是它被告知「使用者不想 debug 認證」或類似的 prompt 時**。
|
||||
|
||||
### 維度二:跟 stdlib / 主流 lib 的對比
|
||||
|
||||
Python Google API 的主流選項:
|
||||
|
||||
| Lib | PyPI 週下載(估) | AI 熟悉度 | 認證難度 |
|
||||
|---|---|---|---|
|
||||
| `google-api-python-client` | ~30M | 極高 | 高(要自己設 OAuth) |
|
||||
| `pydrive2` | ~200k | 中 | 高 |
|
||||
| `gspread`(只 Sheets) | ~2M | 高 | 高(OAuth 或 SA) |
|
||||
| **arcrun-auth** | 0 | 0 | **極低** |
|
||||
|
||||
**冷啟動難題**:新 lib 要進入 AI 的選擇集合,需要:
|
||||
|
||||
1. **量**:PyPI 下載 + GitHub stars 進到「被訓練資料收錄」的級別(大概 GitHub 5k+ stars 是門檻)。
|
||||
2. **品牌**:有代表性部落格文、教學影片、官方 API 文件連結到它。
|
||||
3. **簡潔**:代碼範例比主流短 5 倍以上,讓使用者「一眼就想用」。
|
||||
|
||||
**arcrun 的優勢**:
|
||||
- 你本來就在教 n8n 課程,有現成學員管道可以鋪「這是 n8n 的 Python 版 auth」。
|
||||
- 「AI 寫程式的 auth lib」是個還沒被佔領的定位詞。`langchain` 做了 LLM 層,但 auth 層還沒有明顯贏家。
|
||||
- Claude Code 對 lib 選擇特別敏感——它會實際讀 `pyproject.toml` 並尊重已有宣告。
|
||||
|
||||
**對策**:
|
||||
- **第一批 adopter 是你的學員**(n8n 課 + AI 自動化課),他們會在實戰中用,累積 GitHub issues 和 blog post。
|
||||
- **SEO 主打**:「Python Google Drive OAuth 簡化」「AI 自動化 Python 認證」這些長尾關鍵字現在沒有明顯答主。
|
||||
- **Claude Code 優化**:寫一份 `AGENTS.md` 或 `.cursorrules` 範本,示範怎麼在 prompt 裡引導 AI 選 arcrun-auth。
|
||||
|
||||
### 維度三:痛點強度(OAuth 地獄避免)
|
||||
|
||||
**量化你那兩天 debug**:
|
||||
|
||||
| 階段 | 時間成本 | 典型錯誤 |
|
||||
|---|---|---|
|
||||
| GCP 註冊 + 啟用 API | 15 min | 找不到哪個 API |
|
||||
| 建 OAuth Client ID | 15 min | Desktop / Web / iOS 選錯 |
|
||||
| 設 OAuth Consent Screen | 30 min | External / Internal 選錯;scope 加錯 |
|
||||
| 加測試用戶 | 10 min | 漏加自己的 email |
|
||||
| 寫 Python flow | 30 min | `run_local_server` vs `run_console` |
|
||||
| 第一次跑遇到 `redirect_uri_mismatch` | 30-120 min | port 衝突、URI 沒加 |
|
||||
| Token 過期處理 | 60 min | `creds.expired` 與 `refresh_token` 沒保存 |
|
||||
| Service Account 模式(如果需要) | 120 min | domain-wide delegation 設定 |
|
||||
| **合計** | **5-8 hrs(順的人)** | **2 天(不順的人,你當時的情況)** |
|
||||
|
||||
**arcrun-auth 對應版本**:
|
||||
|
||||
```python
|
||||
from arcrun import auth
|
||||
|
||||
# 首次執行:自動打開瀏覽器完成 OAuth,結果存本地 keyring
|
||||
drive = auth.bind("google_drive")
|
||||
|
||||
# 直接呼叫 API
|
||||
drive.post("/upload/drive/v3/files", params={"uploadType": "media"},
|
||||
data=open("backup.zip", "rb"))
|
||||
```
|
||||
|
||||
**時間成本:首次 2 min,之後 0 min**。
|
||||
|
||||
這個壓倒性的體驗差距是產品的核心競爭力。**只要使用者試過一次,就不會再回去寫 `InstalledAppFlow`**——即使 AI 預設會產出官方版本。
|
||||
|
||||
---
|
||||
|
||||
## 3. 關鍵設計決策
|
||||
|
||||
### 3.1 OAuth App 誰擁有?(核心問題)
|
||||
|
||||
傳統做法:使用者自己去 GCP Console 註冊自己的 OAuth App,拿 client_id/client_secret。**這就是痛點來源**。
|
||||
|
||||
arcrun-auth 要消滅這步,只有兩條路:
|
||||
|
||||
**Option A:平台提供共用 OAuth App(推薦 default)**
|
||||
- arcrun 註冊一個 Google OAuth App,命名類似「arcrun Auth Broker」。
|
||||
- 所有 arcrun-auth 使用者共用這個 App 的 client_id/secret。
|
||||
- 使用者在 Google 授權頁面看到的是「arcrun Auth Broker 想存取您的 Google Drive」。
|
||||
- **好處**:使用者零配置,arcrun 品牌曝光。
|
||||
- **成本**:Google 有 OAuth App 的限額(Verified App 才能超過 100 users),需要申請 Google OAuth Verification(要提供隱私政策、網域驗證、可能要付 $75 安全審查)。
|
||||
|
||||
**Option B:使用者 BYO OAuth App**
|
||||
- 企業客戶或注重稽核的人需要這個。
|
||||
- 在 `~/.arcrun/config.toml` 放自己的 client_id/secret。
|
||||
|
||||
**Option C:arcrun SaaS 代管**(付費)
|
||||
- 使用者註冊 arcrun 帳號,平台幫你管 OAuth App、token、團隊共用、audit log。
|
||||
- 這是付費 tier 的主要價值。
|
||||
|
||||
**建議**:A + B + C 三種都支援,默認 A;免費無限制 B;付費享受 C。
|
||||
|
||||
### 3.2 Secret 儲存層級(本地為主雲端選配)
|
||||
|
||||
```
|
||||
優先級 1 (default):本地 keyring
|
||||
- macOS Keychain / Windows Credential Manager / Linux libsecret
|
||||
- zero config,安全性靠 OS
|
||||
|
||||
優先級 2 (opt-in):本地加密檔
|
||||
- ~/.arcrun/secrets.enc
|
||||
- master key 走 keyring 或 passphrase
|
||||
- 給沒有 keyring 的環境(Docker、CI)
|
||||
|
||||
優先級 3 (opt-in):arcrun 雲端
|
||||
- 多機同步、團隊共用、audit log
|
||||
- 需註冊 arcrun 帳號
|
||||
- 本地 lib 只保存 arcrun API token,實際 service secret 存雲端
|
||||
```
|
||||
|
||||
### 3.3 Secret 初始化流程
|
||||
|
||||
**靜態 key 模式(Notion、OpenAI、Stripe...)**:
|
||||
|
||||
```bash
|
||||
# 選項 A:互動式
|
||||
$ arcrun setup notion
|
||||
? Notion Integration Token (hidden): ***
|
||||
✓ Testing connection... OK
|
||||
✓ Saved to keyring as notion/default
|
||||
|
||||
# 選項 B:環境變數
|
||||
$ export ARCRUN_NOTION_TOKEN=secret_xxx
|
||||
$ python script.py # arcrun-auth 自動讀
|
||||
|
||||
# 選項 C:程式碼內
|
||||
notion = auth.bind("notion", secret={"token": os.environ["NOTION_TOKEN"]})
|
||||
```
|
||||
|
||||
**OAuth 模式(Google、GitHub、Slack...)**:
|
||||
|
||||
```python
|
||||
drive = auth.bind("google_drive")
|
||||
# 如果是第一次:
|
||||
# 1. 本地啟動一個臨時 HTTP server (http://localhost:random_port)
|
||||
# 2. 開瀏覽器到 Google authorize URL
|
||||
# 3. 使用者點同意
|
||||
# 4. Google redirect 到 localhost,lib 接到 code
|
||||
# 5. 換 token,存 keyring
|
||||
# 6. 回傳可用的 client
|
||||
```
|
||||
|
||||
這個流程和 `InstalledAppFlow.run_local_server()` 本質上一樣——但差別是:
|
||||
- **Client ID 不用使用者自己去 GCP Console 註冊**(由 arcrun 平台提供)。
|
||||
- **Scope 由 recipe 宣告**(不用使用者自己查文件)。
|
||||
- **Token 儲存自動化**(不是散落在 `token.json`)。
|
||||
|
||||
### 3.4 Recipe 來源
|
||||
|
||||
Python lib 和 Cloudflare Worker 版本**共用同一份 recipe YAML**。
|
||||
|
||||
```
|
||||
arcrun-recipes/ # GitHub repo,公開
|
||||
├── recipes/
|
||||
│ ├── official/
|
||||
│ │ ├── google_drive.yaml
|
||||
│ │ ├── notion.yaml
|
||||
│ │ └── ...
|
||||
│ └── community/
|
||||
│ └── ...
|
||||
```
|
||||
|
||||
Python lib 啟動時檢查本地 `~/.arcrun/recipes/` 快取,過期就從 GitHub 或 arcrun 平台 API 拉最新。
|
||||
|
||||
**這是關鍵架構優勢**:recipe 寫一次,Web 和 CLI 和 Python lib 全部受益。社群貢獻一份 Notion recipe,所有 runtime 自動支援。
|
||||
|
||||
---
|
||||
|
||||
## 4. API 設計(Python 版)
|
||||
|
||||
### 4.1 最簡路徑
|
||||
|
||||
```python
|
||||
from arcrun import auth
|
||||
|
||||
# 取得認證好的 HTTP client(基於 httpx)
|
||||
client = auth.bind("google_drive")
|
||||
|
||||
# 相對 base_url 的路徑
|
||||
resp = client.get("/files", params={"q": "name = 'backup.zip'"})
|
||||
files = resp.json()["files"]
|
||||
|
||||
# 上傳
|
||||
client.post("/upload/drive/v3/files",
|
||||
params={"uploadType": "multipart"},
|
||||
files={"file": ("backup.zip", open("backup.zip", "rb"))})
|
||||
```
|
||||
|
||||
### 4.2 進階:非同步
|
||||
|
||||
```python
|
||||
from arcrun import auth
|
||||
|
||||
async with auth.bind_async("google_drive") as client:
|
||||
resp = await client.get("/files")
|
||||
```
|
||||
|
||||
### 4.3 進階:多 instance
|
||||
|
||||
```python
|
||||
# 同一個服務,多個帳號
|
||||
personal = auth.bind("google_drive", instance="personal")
|
||||
work = auth.bind("google_drive", instance="work")
|
||||
```
|
||||
|
||||
### 4.4 進階:直接取 token(給不想透過 wrapper 的情況)
|
||||
|
||||
```python
|
||||
# 取 raw access token,自己丟進任何 lib
|
||||
token = auth.get_token("google_drive")
|
||||
# 丟給 googleapiclient:
|
||||
from googleapiclient.discovery import build
|
||||
from google.oauth2.credentials import Credentials
|
||||
creds = Credentials(token=token.access_token)
|
||||
service = build("drive", "v3", credentials=creds)
|
||||
```
|
||||
|
||||
這個 escape hatch 很重要——不強制使用者放棄他熟悉的官方 lib,只是把**認證這一層**剝離出來。這是你想要的「避免麻煩直接用 arcrun 的 auth 功能」的精確實作。
|
||||
|
||||
### 4.5 服務發現
|
||||
|
||||
```python
|
||||
# CLI
|
||||
$ arcrun list
|
||||
google_drive Google Drive OAuth2
|
||||
notion Notion API Key
|
||||
github GitHub OAuth2
|
||||
openai OpenAI API Key
|
||||
...
|
||||
|
||||
# Python
|
||||
from arcrun import auth
|
||||
auth.list_services() # 回傳 dict
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 商業模式:免費引流 → SaaS 付費
|
||||
|
||||
### 5.1 免費永久可用(本地模式)
|
||||
|
||||
| 功能 | 免費 | 付費 |
|
||||
|---|---|---|
|
||||
| `pip install arcrun-auth` | ✅ | ✅ |
|
||||
| 本地 keyring 儲存 secret | ✅ | ✅ |
|
||||
| 所有 recipe 可用 | ✅ | ✅ |
|
||||
| 平台代管 OAuth App(免自己註冊) | ✅ | ✅ |
|
||||
| 單機使用 | ✅ | ✅ |
|
||||
|
||||
### 5.2 付費 tier 提供的
|
||||
|
||||
| 功能 | 免費 | Pro ($9/mo) | Team ($29/user/mo) |
|
||||
|---|---|---|---|
|
||||
| 多機同步 secret | ❌ | ✅ | ✅ |
|
||||
| 團隊共用 credential | ❌ | ❌ | ✅ |
|
||||
| Audit log(誰在何時用了什麼 secret) | ❌ | ✅ | ✅ |
|
||||
| Secret rotation 提醒 | ❌ | ✅ | ✅ |
|
||||
| 企業 OAuth App BYO | ❌ | ✅ | ✅ |
|
||||
| SSO / SCIM | ❌ | ❌ | ✅ |
|
||||
| arcrun SaaS 整合(workflow runtime) | ❌ | ⚠️ 受限 | ✅ |
|
||||
| Priority 社群 recipe 審核 | ❌ | ✅ | ✅ |
|
||||
|
||||
**商業邏輯**:
|
||||
- 免費 tier 足夠「個人開發者 + 側邊小專案」的體驗完整。
|
||||
- 付費 tier 解決「我的小工具變公司在用」的延伸需求。
|
||||
- arcrun SaaS workflow 客戶自動享受所有 Python lib 特權。
|
||||
|
||||
### 5.3 為什麼這策略會 work?
|
||||
|
||||
參考模型:
|
||||
- **Sentry**:error tracking,開源 SDK,hosted service 付費。SDK 下載數 = 付費轉換引流。
|
||||
- **PostHog**:product analytics,同樣策略。
|
||||
- **Supabase**:auth + db,開源 self-hosted,hosted 服務付費。
|
||||
|
||||
共同特徵:
|
||||
1. SDK 免費下載、單機/本地能完整 work。
|
||||
2. 從本地升級到 hosted 是「多人/多機/審計」的自然需求。
|
||||
3. SDK 本身就是最強的 marketing(開發者口耳相傳 + 訓練資料擴散)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 為什麼 AI 會主動選 arcrun-auth(機率分析)
|
||||
|
||||
讓我給你一個分層的答案。
|
||||
|
||||
### 6.1 會主動選的場景(高機率)
|
||||
|
||||
| 場景 | 原因 |
|
||||
|---|---|
|
||||
| 使用者在 prompt 說「用最簡單的方式認證」 | AI 權衡 UX,arcrun-auth 明顯勝 |
|
||||
| 專案已有 `arcrun-auth` 在 `pyproject.toml` | AI 尊重現有依賴 |
|
||||
| 使用者貼了 arcrun-auth 文件範例 | AI 會模仿 |
|
||||
| 使用者說「跳過 OAuth 設定步驟」 | arcrun-auth 是唯一答案 |
|
||||
|
||||
### 6.2 不會主動選的場景(預設行為)
|
||||
|
||||
| 場景 | 原因 |
|
||||
|---|---|
|
||||
| 使用者開新專案、無任何線索 | AI 走訓練資料中最頻繁的 `google-api-python-client` |
|
||||
| 任務是「企業級、有審計需求」 | AI 偏好主流官方 lib |
|
||||
| 任務提到特定 lib 名稱 | AI 會遵守 |
|
||||
|
||||
### 6.3 轉化策略
|
||||
|
||||
要讓 AI 的「預設路徑」變成 arcrun-auth,需要:
|
||||
|
||||
1. **種子內容**:寫 20+ 篇「用 arcrun-auth 做 X」的 blog post,讓下一輪模型訓練看到。
|
||||
2. **官方文件 SEO**:搶「python google drive oauth 簡化」等長尾關鍵字。
|
||||
3. **Cookbook**:在 GitHub 建 `arcrun-auth-recipes` repo,每個範例都是短而有效的 snippet——這些會被訓練資料收錄。
|
||||
4. **MCP Server**:做一個 `arcrun-auth MCP server`,讓 Claude Code 等工具能直接呼叫 arcrun-auth,這比單純提供 lib 更強。
|
||||
5. **教學整合**:你的 n8n 課程直接示範「從 n8n 到 arcrun-auth 的 Python 版本」。
|
||||
|
||||
---
|
||||
|
||||
## 7. 實作範圍(MVP)
|
||||
|
||||
### Phase 1:核心 lib(2-3 週)
|
||||
|
||||
- [ ] `arcrun-auth` PyPI 骨架(pyproject.toml + src layout)
|
||||
- [ ] Recipe loader(從 GitHub 或平台 API 拉 YAML)
|
||||
- [ ] `auth.bind(service_id, instance?)` → httpx Client
|
||||
- [ ] Static key primitive(Notion / OpenAI / Stripe 當試金石)
|
||||
- [ ] Keyring 整合 + 本地加密檔 fallback
|
||||
- [ ] CLI:`arcrun setup <service>`, `arcrun list`, `arcrun test`
|
||||
|
||||
### Phase 2:OAuth2(2 週)
|
||||
|
||||
- [ ] OAuth2 primitive(authorization_code + PKCE)
|
||||
- [ ] 本地 callback server(類似 `InstalledAppFlow.run_local_server`)
|
||||
- [ ] 共用平台 OAuth App 的 proxy 機制
|
||||
- Lib 呼叫 `https://auth.arcrun.com/oauth/redirect`
|
||||
- 平台把 code 交換後回傳 token
|
||||
- 或者直接把平台 client_id 硬編在 recipe 裡(更簡單但要處理配額)
|
||||
- [ ] Token refresh 自動化
|
||||
- [ ] Recipe:Google Drive / Gmail / GitHub / Slack
|
||||
|
||||
### Phase 3:Service Account(1-2 週)
|
||||
|
||||
- [ ] Google Service Account(JWT signing)
|
||||
- [ ] AWS SigV4
|
||||
- [ ] Recipe 繼承(`extends: _google_base`)
|
||||
|
||||
### Phase 4:雲端同步(2 週)
|
||||
|
||||
- [ ] `arcrun login` → 綁定雲端帳號
|
||||
- [ ] Secret sync 協議(本地加密後上傳,平台只存密文)
|
||||
- [ ] 多機同步
|
||||
- [ ] Audit log
|
||||
|
||||
### Phase 5:AI 生態整合(1-2 週)
|
||||
|
||||
- [ ] MCP server(讓 Claude Code 能直接用)
|
||||
- [ ] VS Code Extension(一鍵設定 credential)
|
||||
- [ ] `AGENTS.md` 範本(引導 AI 選 arcrun-auth)
|
||||
|
||||
---
|
||||
|
||||
## 8. 風險與坑
|
||||
|
||||
### 8.1 Google OAuth Verification
|
||||
|
||||
**問題**:共用 OAuth App 要申請 Google Verification,否則會有「未驗證 App」警告 + 100 user 上限。
|
||||
|
||||
**對策**:
|
||||
- MVP 階段接受警告頁面(使用者自己點「進階 → 前往」)。
|
||||
- 到 user 量接近 100 時申請 Verification。
|
||||
- 企業客戶走 BYO OAuth App 路徑,不受影響。
|
||||
- 若平台 OAuth App 卡關,有 fallback:lib 自動引導使用者建自己的 OAuth App(提供 CLI wizard)。
|
||||
|
||||
### 8.2 其他服務的 OAuth App 政策
|
||||
|
||||
- **GitHub**:免費建 OAuth App,無上限。✅
|
||||
- **Slack**:免費建,但安裝到使用者 workspace 需管理員同意。⚠️
|
||||
- **Microsoft / Azure**:相對嚴格,需 tenant admin consent。⚠️
|
||||
- **Notion**:Internal Integration 可以完全走 API key,免 OAuth。✅(最簡單)
|
||||
|
||||
### 8.3 keyring 在 Linux server / Docker 的問題
|
||||
|
||||
Linux server 沒 GUI keyring daemon。對策:
|
||||
- Fallback 到加密檔案(用 env var 或 CLI 互動提供 master key)。
|
||||
- Docker 場景有 `docker secret`、Kubernetes Secret,lib 支援直接讀這些來源。
|
||||
|
||||
### 8.4 競品
|
||||
|
||||
目前沒有完全對標的產品,但相鄰玩家:
|
||||
- **[keyring](https://pypi.org/project/keyring/)**:只做儲存,不做認證流程。我們用它當底層。
|
||||
- **[httpx-auth](https://pypi.org/project/httpx-auth/)**:只做認證,不做 secret 管理,也沒有 recipe。
|
||||
- **[authlib](https://pypi.org/project/authlib/)**:OAuth 實作 lib,低階,還是要自己組。
|
||||
- **各家 SDK(google-auth, slack-sdk)**:綁特定家,不 unify。
|
||||
|
||||
**arcrun-auth 的差異化定位**:
|
||||
> **Unified credential broker for AI-era Python apps**
|
||||
> 一個 lib 搞定所有服務、所有認證機制、所有 secret 儲存後端。
|
||||
|
||||
---
|
||||
|
||||
## 9. 最後的判斷
|
||||
|
||||
### 9.1 這個 lib 該不該做?
|
||||
|
||||
**該做**。原因:
|
||||
1. 你描述的痛點是真的,而且規模巨大(Python + Google API 下載量是千萬級)。
|
||||
2. 技術可行,也跟既有 arcrun 架構共用 recipe,邊際成本低。
|
||||
3. 對 arcrun SaaS 是完美引流——免費 lib 的使用者是精準的付費轉換潛在客戶。
|
||||
4. 時間窗口正確:AI 寫程式時代剛開始,這個定位還沒被佔領。
|
||||
|
||||
### 9.2 跟主 SaaS 的優先順序
|
||||
|
||||
**建議**:**主 SaaS 的 credential 系統先做(前一份規劃),arcrun-auth 當後續 Phase**。
|
||||
|
||||
原因:
|
||||
- Cloudflare Worker 版的 primitives + recipes 是基礎建設,Python lib 是其 consumer。
|
||||
- 先做 Python lib 會逼你在 recipe schema 上做二次修改,不划算。
|
||||
- 主 SaaS 的 recipe 累積到 20-30 個服務後,開放 Python lib 體驗最好。
|
||||
|
||||
時程建議:
|
||||
- **Month 1-2**:主 SaaS 的 4 個 primitive + 15 個 recipe(前一份規劃)。
|
||||
- **Month 3-4**:arcrun-auth Phase 1-2(static key + OAuth2),私人 alpha。
|
||||
- **Month 5**:公開 release,寫部落格、SEO、社群推廣。
|
||||
- **Month 6+**:雲端同步、MCP、AI 生態整合。
|
||||
|
||||
### 9.3 一句話總結
|
||||
|
||||
> **arcrun-auth 不是「arcrun 的 Python 綁定」,是「OAuth 地獄的解藥」**。SaaS 是延伸。這個敘事才能在 AI 寫程式的時代站住腳。
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# Arcrun 推廣策略修正
|
||||
|
||||
20260418 by Leo
|
||||
|
||||
## Arcrun 的最近幾次變化
|
||||
|
||||
- Arcrun 原是 Matrix 的原子化純雲端 CF 程式框架,有 MCP 讓 AI 使用
|
||||
- 轉以 WASM + WASI + TinyGo 成為未來具有雲端、地端、邊緣端的執行能力,可以用來做到無人機等終端。
|
||||
- 再解耦成獨立的 Open Source 專案,脫離 KBDB 用 YAML 即可,允許整個 Fork,但推廣 SaaS 模式
|
||||
- 今天的變化是發現成為 Lib 和 n8n 社群節點的用法。說明如下。
|
||||
|
||||
## 推廣方式 1:寫成 Lib
|
||||
|
||||
參考文件:docs/user_requirements/arcrun/ADR-lib-and-landingPage/arcrun-py-strategy-analysis.md
|
||||
|
||||
Leo 教 n8n 時常舉例我叫 AI 幫我寫個簡單的程式把 server 的檔案備份到 Google Drive 後刪除,光是 OAuth 用 Service Account 就花了 2 天測試,後來還發生好幾次出錯重修。
|
||||
|
||||
網路設定對 vibe coder 太複雜了,看到 AI 一次次修改一次次出錯,知道一週後才穩定,而 n8n 只需要申請 credential 填入,10 分鐘搞定,差距太大了。
|
||||
|
||||
問題是,n8n 實在太慢了,全圖像界面也難以跟程式碼整合,vibe coder 就算知道了也無法整合,他會繼續用 python 就是無法忍受 n8n 的慢速、沉重,及對 AI 的不友善。
|
||||
|
||||
Arcrun 用幾個基礎 Auth 零件取代 n8n 幾百個不同的 credentials 設定,arcrun 完全可以跟程式碼整合,只是目前沒有整合工具,也就是說,工程師不想面對一個認證花好幾天,他只要用 Arcrun 打 API 就幾行解決了。
|
||||
|
||||
用這個策略,讓具有程式知識的人會:
|
||||
- 對 Arcrun 有好感,在 GitHub 按星
|
||||
- 成為 Arcrun 用戶,增加會員 base
|
||||
- 寫文分享,讓 Arcrun 被傳出去
|
||||
|
||||
反之,小白初學 vibe coding,看到老工程師說 2 天變成 10 分鐘,百行程式碼變成 2 行,也會被影響。
|
||||
|
||||
所以,把 API 包裝成 Lib 或 SDK 是個 Get Member 的好方法,不過一定要有 Email。
|
||||
|
||||
## 推廣方式 2: 讓 n8n 來 call
|
||||
|
||||
身為 n8n 講師,我非常清楚 n8n 有個大缺陷(或說是安全設計),就是它只能呼叫在工作流實例化前就寫好的程式,無法注入新的。
|
||||
|
||||
所以,雖然它可以安裝一個實作 langchain AI Agent 的 AI Agent 節點,但 AI 卻不可能在遇到問題時立刻寫個程式運行解決問題,甚至使用它內建的 task runners 或使用外部的 Python runtime 也無法。
|
||||
|
||||
但只要使用 http request 節點呼叫外部的 Arcrun tool,它就可以輸出三元組寫一個程式解決它現在遇到的問題,並且運算完後回傳,只需要取得一個 API Key。
|
||||
|
||||
## 可能的效應
|
||||
|
||||
工程師是意見領袖,他們會去按星,幫忙教育市場,而小白需要被教育,但他們是較大的社群,這些星數和聲量會在未來變成 AI 訓練素材,甚至推薦用戶使用最 AI Friendly 的 Arcrun。
|
||||
|
||||
而實際上 Arcrun 的設計讓 AI 更輕鬆。
|
||||
|
||||
## 要做什麼
|
||||
|
||||
docs/user_requirements/arcrun/ADR-lib-and-landingPage/arcrun-pages-spec.md 這是 claude.ai 寫的規劃,因為它看不到 codebase,所以你要斟酌如何開發。
|
||||
|
||||
另外,當前的 API Key 雖然很容易取得,卻有個問題,用戶無法管理,所以頁面應該要有取得 API Key 的界面,可以 Google, GitHub 等 OAuth 或 Email + Password 登入,用來管理它的 API Key 的 CRUD。
|
||||
|
||||
或許是可以外接一個 SMTP 服務來確認他的 Email 真實,SaaS 服務還是有成本,雖然成本不高。
|
||||
|
||||
另外,既然是 OpenSource,成本雖然不高,似乎可以銜接 Donate 服務?
|
||||
@@ -0,0 +1,56 @@
|
||||
// App root — screen switcher with persistent route
|
||||
const { useState, useEffect } = React;
|
||||
|
||||
const SCREENS = [
|
||||
{ id: 'landing', label: 'Landing' },
|
||||
{ id: 'auth', label: 'Auth' },
|
||||
{ id: 'dashboard', label: 'Dashboard' },
|
||||
{ id: 'keys', label: 'API Keys' },
|
||||
{ id: 'workflow', label: 'Workflow' },
|
||||
];
|
||||
|
||||
// Synonyms from sidebar ids
|
||||
const aliases = { apps: 'dashboard', workflows: 'dashboard', docs: 'landing', settings: 'keys' };
|
||||
|
||||
function App() {
|
||||
const [screen, setScreen] = useState(() => {
|
||||
const saved = localStorage.getItem('arcrun:screen');
|
||||
return saved && SCREENS.some(s => s.id === saved) ? saved : 'landing';
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('arcrun:screen', screen);
|
||||
window.scrollTo(0, 0);
|
||||
}, [screen]);
|
||||
|
||||
const nav = (id) => {
|
||||
const resolved = aliases[id] || id;
|
||||
if (SCREENS.some(s => s.id === resolved)) setScreen(resolved);
|
||||
};
|
||||
|
||||
const Current = {
|
||||
landing: Landing,
|
||||
auth: Auth,
|
||||
dashboard: Dashboard,
|
||||
keys: ApiKeys,
|
||||
workflow: WorkflowViewer,
|
||||
}[screen];
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<Current onNav={nav} />
|
||||
|
||||
<div className="proto-switch" role="tablist" aria-label="Screen switcher">
|
||||
{SCREENS.map(s => (
|
||||
<button key={s.id}
|
||||
className={screen === s.id ? 'active' : ''}
|
||||
onClick={() => nav(s.id)}>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
|
||||
@@ -0,0 +1,92 @@
|
||||
// Top nav and sidebar
|
||||
|
||||
const TopNav = ({ onNav, current }) => {
|
||||
const [scrolled, setScrolled] = React.useState(false);
|
||||
React.useEffect(() => {
|
||||
const onScroll = () => setScrolled(window.scrollY > 8);
|
||||
window.addEventListener('scroll', onScroll);
|
||||
return () => window.removeEventListener('scroll', onScroll);
|
||||
}, []);
|
||||
return (
|
||||
<nav className={`topnav ${scrolled ? 'scrolled' : ''}`}>
|
||||
<div className="flex gap-12" style={{alignItems: 'center'}}>
|
||||
<Logo onClick={() => onNav('landing')} />
|
||||
<div className="nav-links" style={{marginLeft: 20}}>
|
||||
<a>Product</a>
|
||||
<a>Docs</a>
|
||||
<a>Pricing</a>
|
||||
<a>Changelog</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-8" style={{alignItems: 'center'}}>
|
||||
<button className="btn btn-ghost" onClick={() => onNav('auth')}>Log in</button>
|
||||
<button className="btn btn-primary" onClick={() => onNav('auth')}>
|
||||
Get started <Icon name="arrow_right" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
const Footer = ({ onNav }) => (
|
||||
<footer className="footer">
|
||||
<div className="flex gap-12" style={{alignItems: 'center'}}>
|
||||
<Logo size="sm" />
|
||||
<span>© 2026 Arcrun Labs</span>
|
||||
</div>
|
||||
<div className="footer-links">
|
||||
<a>Docs</a>
|
||||
<a>Pricing</a>
|
||||
<a>Changelog</a>
|
||||
<a>Status</a>
|
||||
<a>Privacy</a>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
|
||||
// App shell with sidebar for logged-in screens
|
||||
const Sidebar = ({ current, onNav }) => {
|
||||
const items = [
|
||||
{ id: 'dashboard', label: 'Dashboard', icon: 'home' },
|
||||
{ id: 'apps', label: 'Apps', icon: 'grid', count: 6 },
|
||||
{ id: 'workflows', label: 'Workflows', icon: 'workflow', count: 12 },
|
||||
{ id: 'keys', label: 'API Keys', icon: 'key' },
|
||||
{ id: 'docs', label: 'Docs', icon: 'book' },
|
||||
];
|
||||
const bottom = [
|
||||
{ id: 'settings', label: 'Settings', icon: 'settings' },
|
||||
];
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-head">
|
||||
<Logo size="md" onClick={() => onNav('landing')} />
|
||||
</div>
|
||||
<div className="sidebar-section">Workspace</div>
|
||||
{items.map(it => (
|
||||
<div key={it.id}
|
||||
className={`sidebar-item ${current === it.id ? 'active' : ''}`}
|
||||
onClick={() => onNav(it.id)}>
|
||||
<span className="sb-ico"><Icon name={it.icon} size={15} /></span>
|
||||
<span>{it.label}</span>
|
||||
{it.count != null && <span className="sb-count">{it.count}</span>}
|
||||
</div>
|
||||
))}
|
||||
<div style={{flex: 1}} />
|
||||
{bottom.map(it => (
|
||||
<div key={it.id} className="sidebar-item" onClick={() => onNav(it.id)}>
|
||||
<span className="sb-ico"><Icon name={it.icon} size={15} /></span>
|
||||
<span>{it.label}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="sidebar-foot">
|
||||
<div className="avatar-circ">MR</div>
|
||||
<div className="meta">
|
||||
<div className="name">Maya Rivera</div>
|
||||
<div className="email">maya@northwind.co</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
Object.assign(window, { TopNav, Footer, Sidebar });
|
||||
@@ -0,0 +1,86 @@
|
||||
// Shared primitives: icons, logo, etc.
|
||||
|
||||
const Icon = ({ name, size = 16, stroke = 1.7 }) => {
|
||||
const paths = {
|
||||
arrow_right: <path d="M5 12h14M13 6l6 6-6 6" />,
|
||||
arrow_left: <path d="M19 12H5M11 6l-6 6 6 6" />,
|
||||
plus: <path d="M12 5v14M5 12h14" />,
|
||||
copy: <><rect x="9" y="9" width="13" height="13" rx="2" /><path d="M5 15V5a2 2 0 0 1 2-2h10" /></>,
|
||||
check: <path d="M20 6L9 17l-5-5" />,
|
||||
close: <path d="M18 6L6 18M6 6l12 12" />,
|
||||
eye: <><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" /><circle cx="12" cy="12" r="3" /></>,
|
||||
search: <><circle cx="11" cy="11" r="7" /><path d="M21 21l-4.35-4.35" /></>,
|
||||
warn: <><path d="M10.3 3.86L1.82 18a2 2 0 001.72 3h16.92a2 2 0 001.72-3L13.7 3.86a2 2 0 00-3.4 0z" /><line x1="12" y1="9" x2="12" y2="13" /><circle cx="12" cy="17" r="0.5" fill="currentColor" /></>,
|
||||
home: <><path d="M3 10l9-7 9 7v10a2 2 0 01-2 2h-4a2 2 0 01-2-2v-5h-2v5a2 2 0 01-2 2H5a2 2 0 01-2-2V10z" /></>,
|
||||
grid: <><rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" /><rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" /></>,
|
||||
workflow: <><circle cx="5" cy="6" r="2" /><circle cx="19" cy="12" r="2" /><circle cx="5" cy="18" r="2" /><path d="M7 6h4a4 4 0 014 4v0m0 4a4 4 0 01-4 4H7" /></>,
|
||||
key: <><circle cx="7.5" cy="15.5" r="4.5" /><path d="M10.68 12.32L21 2M17 6l3 3M15 8l3 3" /></>,
|
||||
book: <><path d="M2 3h6a4 4 0 014 4v14a3 3 0 00-3-3H2zM22 3h-6a4 4 0 00-4 4v14a3 3 0 013-3h7z" /></>,
|
||||
settings: <><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 01-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09A1.65 1.65 0 0015 4.6a1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9v0a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z" /></>,
|
||||
chevron_right: <path d="M9 6l6 6-6 6" />,
|
||||
chevron_down: <path d="M6 9l6 6 6-6" />,
|
||||
external: <><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6" /><path d="M15 3h6v6M10 14L21 3" /></>,
|
||||
trash: <><polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6M10 11v6M14 11v6" /></>,
|
||||
spark: <path d="M12 3l2.5 6.5L21 12l-6.5 2.5L12 21l-2.5-6.5L3 12l6.5-2.5L12 3z" />,
|
||||
bolt: <path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />,
|
||||
github: <path d="M12 2C6.48 2 2 6.48 2 12c0 4.42 2.87 8.17 6.84 9.5.5.08.66-.22.66-.48v-1.7c-2.78.6-3.36-1.34-3.36-1.34-.46-1.15-1.12-1.46-1.12-1.46-.92-.62.07-.6.07-.6 1.01.07 1.55 1.04 1.55 1.04.9 1.54 2.36 1.1 2.94.84.09-.65.35-1.1.64-1.35-2.22-.25-4.55-1.11-4.55-4.94 0-1.09.39-1.98 1.03-2.68-.1-.25-.45-1.27.1-2.65 0 0 .84-.27 2.75 1.02A9.5 9.5 0 0112 6.8c.85 0 1.7.11 2.5.33 1.9-1.3 2.75-1.02 2.75-1.02.55 1.38.2 2.4.1 2.65.64.7 1.03 1.6 1.03 2.68 0 3.84-2.34 4.69-4.57 4.93.36.31.68.92.68 1.85V21c0 .27.16.57.67.48A10 10 0 0022 12c0-5.52-4.48-10-10-10z" fill="currentColor" stroke="none" />,
|
||||
google: <><path d="M21.35 11.1h-9.17v2.73h5.24c-.23 1.41-1.69 4.13-5.24 4.13-3.15 0-5.73-2.62-5.73-5.86 0-3.24 2.58-5.86 5.73-5.86 1.8 0 3 .77 3.69 1.43l2.5-2.4C16.95 3.74 14.8 2.8 12.18 2.8c-5.26 0-9.53 4.25-9.53 9.5s4.27 9.5 9.53 9.5c5.51 0 9.15-3.87 9.15-9.32 0-.63-.07-1.1-.15-1.38z" fill="currentColor" stroke="none" /></>,
|
||||
share: <><circle cx="18" cy="5" r="3" /><circle cx="6" cy="12" r="3" /><circle cx="18" cy="19" r="3" /><path d="M8.59 13.51l6.83 3.98M15.41 6.51l-6.82 3.98" /></>,
|
||||
download: <><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" /><polyline points="7 10 12 15 17 10" /><line x1="12" y1="15" x2="12" y2="3" /></>,
|
||||
zoom_in: <><circle cx="11" cy="11" r="7" /><line x1="21" y1="21" x2="16.65" y2="16.65" /><line x1="11" y1="8" x2="11" y2="14" /><line x1="8" y1="11" x2="14" y2="11" /></>,
|
||||
zoom_out: <><circle cx="11" cy="11" r="7" /><line x1="21" y1="21" x2="16.65" y2="16.65" /><line x1="8" y1="11" x2="14" y2="11" /></>,
|
||||
maximize: <><path d="M8 3H5a2 2 0 00-2 2v3M21 8V5a2 2 0 00-2-2h-3M3 16v3a2 2 0 002 2h3M16 21h3a2 2 0 002-2v-3" /></>,
|
||||
slack: <><rect x="13" y="2" width="3" height="8" rx="1.5" /><rect x="2" y="13" width="8" height="3" rx="1.5" /><rect x="14" y="14" width="8" height="3" rx="1.5" /><rect x="8" y="8" width="3" height="8" rx="1.5" /></>,
|
||||
database: <><ellipse cx="12" cy="5" rx="9" ry="3" /><path d="M3 5v7c0 1.66 4.03 3 9 3s9-1.34 9-3V5M3 12v7c0 1.66 4.03 3 9 3s9-1.34 9-3v-7" /></>,
|
||||
mail: <><rect x="2" y="4" width="20" height="16" rx="2" /><path d="M2 6l10 7 10-7" /></>,
|
||||
filter: <path d="M3 4h18l-7 9v6l-4-2v-4L3 4z" />,
|
||||
star: <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />,
|
||||
linear: <><rect x="3" y="3" width="18" height="18" rx="4" /><path d="M7 11l5 5M7 15l3 3M7 7l10 10M11 7l6 6M15 7l2 2" /></>,
|
||||
clock: <><circle cx="12" cy="12" r="9" /><polyline points="12 7 12 12 16 14" /></>,
|
||||
send: <><path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z" /></>,
|
||||
terminal: <><path d="M4 17l6-6-6-6M12 19h8" /></>,
|
||||
logout: <><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4M16 17l5-5-5-5M21 12H9" /></>,
|
||||
};
|
||||
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={stroke} strokeLinecap="round" strokeLinejoin="round" style={{display: 'block', flexShrink: 0}}>
|
||||
{paths[name]}
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
// Arcrun wordmark — custom "arc" glyph made of an arc stroke + ascending dot/node
|
||||
const Logo = ({ size = 'md', onClick }) => {
|
||||
const dims = size === 'sm' ? { w: 18, h: 18, f: 10 } : size === 'lg' ? { w: 28, h: 28, f: 14 } : { w: 22, h: 22, f: 12 };
|
||||
return (
|
||||
<div className="logo" onClick={onClick}>
|
||||
<span className="logo-mark" style={{width: dims.w, height: dims.h}}>
|
||||
<svg width={dims.w} height={dims.h} viewBox="0 0 24 24" fill="none">
|
||||
<path d="M5 17 Q 12 4, 19 17" stroke="white" strokeWidth="2.4" strokeLinecap="round" fill="none" opacity="0.95" />
|
||||
<circle cx="19" cy="17" r="2.2" fill="white" />
|
||||
</svg>
|
||||
</span>
|
||||
<span>Arcrun</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// App icon with gradient background
|
||||
const AppIcon = ({ tone = 'indigo', children, size = 38 }) => {
|
||||
const tones = {
|
||||
indigo: 'linear-gradient(135deg, #6366F1, #8B5CF6)',
|
||||
orange: 'linear-gradient(135deg, #F59E0B, #EF4444)',
|
||||
green: 'linear-gradient(135deg, #10B981, #22C55E)',
|
||||
pink: 'linear-gradient(135deg, #EC4899, #8B5CF6)',
|
||||
blue: 'linear-gradient(135deg, #3B82F6, #06B6D4)',
|
||||
slate: 'linear-gradient(135deg, #475569, #334155)',
|
||||
amber: 'linear-gradient(135deg, #F59E0B, #D97706)',
|
||||
};
|
||||
return (
|
||||
<div className="app-icon" style={{ background: tones[tone], width: size, height: size, color: 'white' }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Object.assign(window, { Icon, Logo, AppIcon });
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,128 @@
|
||||
const ApiKeys = ({ onNav }) => {
|
||||
const [newKeyCopied, setNewKeyCopied] = React.useState(false);
|
||||
const [keys, setKeys] = React.useState([
|
||||
{ id: 'k_dev', name: 'Local Development', prefix: 'ar_dev_', created: 'Mar 12, 2026', lastUsed: '2 min ago', active: true },
|
||||
{ id: 'k_prod', name: 'Production — Northwind API', prefix: 'ar_live_', created: 'Feb 3, 2026', lastUsed: '12 sec ago', active: true },
|
||||
{ id: 'k_staging', name: 'Staging — Vercel', prefix: 'ar_test_', created: 'Jan 28, 2026', lastUsed: '4 hours ago', active: true },
|
||||
{ id: 'k_ci', name: 'CI/CD (GitHub Actions)', prefix: 'ar_live_', created: 'Jan 10, 2026', lastUsed: 'Yesterday', active: false },
|
||||
{ id: 'k_old', name: 'Legacy — Zapier import', prefix: 'ar_live_', created: 'Nov 4, 2025', lastUsed: '3 weeks ago', active: false, revoked: true },
|
||||
]);
|
||||
|
||||
const newKey = 'ar_live_sk_7x9Qf2vLm8nR4TpW6ZjKc3bEhN1aSyU5oP0dI';
|
||||
|
||||
const copyKey = () => {
|
||||
setNewKeyCopied(true);
|
||||
setTimeout(() => setNewKeyCopied(false), 1800);
|
||||
};
|
||||
|
||||
const toggleKey = (id) => {
|
||||
setKeys(keys.map(k => k.id === id ? { ...k, active: !k.active } : k));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="shell">
|
||||
<Sidebar current="keys" onNav={onNav} />
|
||||
<div className="main">
|
||||
<div className="main-head">
|
||||
<div>
|
||||
<div className="crumb">
|
||||
<span>Workspace</span>
|
||||
<span className="sep"><Icon name="chevron_right" size={11} /></span>
|
||||
<span>Settings</span>
|
||||
</div>
|
||||
<h1>API Keys</h1>
|
||||
<div className="sub">Scoped credentials for calling the Arcrun API from your code and CI.</div>
|
||||
</div>
|
||||
<div className="flex gap-8">
|
||||
<button className="btn btn-secondary"><Icon name="book" size={14} /> API docs</button>
|
||||
<button className="btn btn-primary"><Icon name="plus" size={14} /> Create new key</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="main-body" style={{maxWidth: 1080}}>
|
||||
<div className="new-key-box">
|
||||
<div className="warn-row">
|
||||
<span className="warn-icon"><Icon name="warn" size={12} /></span>
|
||||
<span><strong style={{color: '#FBBF24'}}>Save this key now.</strong> For security, we won't show it again — if you lose it, you'll need to create a new one.</span>
|
||||
</div>
|
||||
<h3>Your new API key</h3>
|
||||
<p className="desc">Key named <strong style={{color: 'var(--text)'}}>"Production — Northwind API"</strong> · created just now · all scopes</p>
|
||||
<div className="key-display">
|
||||
<span className="key-val">{newKey}</span>
|
||||
<button className={`copy-btn ${newKeyCopied ? 'copied' : ''}`} onClick={copyKey}>
|
||||
<Icon name={newKeyCopied ? 'check' : 'copy'} size={12} />
|
||||
{newKeyCopied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{marginTop: 14, display: 'flex', gap: 16, fontSize: 12, color: 'var(--text-mute)', alignItems: 'center'}}>
|
||||
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="check" size={12} /> Full workspace access</span>
|
||||
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="clock" size={12} /> Never expires</span>
|
||||
<span style={{marginLeft: 'auto'}}><span className="link">Add expiry or restrict scopes →</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h2>All keys</h2>
|
||||
<div className="subtle" style={{marginTop: 2}}>{keys.filter(k => !k.revoked).length} active · {keys.filter(k => k.revoked).length} revoked</div>
|
||||
</div>
|
||||
<div className="flex gap-8">
|
||||
<button className="btn btn-secondary btn-sm"><Icon name="filter" size={12} /> Filter</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{width: '32%'}}>Name</th>
|
||||
<th>Key</th>
|
||||
<th>Created</th>
|
||||
<th>Last used</th>
|
||||
<th>Status</th>
|
||||
<th style={{width: 60, textAlign: 'right'}}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{keys.map(k => (
|
||||
<tr key={k.id}>
|
||||
<td>
|
||||
<div style={{fontWeight: 500, fontSize: 13.5}}>{k.name}</div>
|
||||
</td>
|
||||
<td className="mono">{k.prefix}••••{k.id.slice(-4)}</td>
|
||||
<td className="dim" style={{fontSize: 12.5}}>{k.created}</td>
|
||||
<td className="dim" style={{fontSize: 12.5}}>{k.lastUsed}</td>
|
||||
<td>
|
||||
{k.revoked ? (
|
||||
<span className="pill revoked"><span className="pdot" /> Revoked</span>
|
||||
) : (
|
||||
<div className="flex gap-8" style={{alignItems: 'center'}}>
|
||||
<span className={`toggle ${k.active ? 'on' : ''}`} onClick={() => toggleKey(k.id)} />
|
||||
<span className={`pill ${k.active ? 'active' : 'idle'}`}>
|
||||
<span className="pdot" /> {k.active ? 'Active' : 'Paused'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td style={{textAlign: 'right'}}>
|
||||
{!k.revoked && (
|
||||
<button className="btn btn-danger-ghost btn-sm"><Icon name="trash" size={12} /></button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div style={{marginTop: 18, fontSize: 12, color: 'var(--text-mute)', display: 'flex', alignItems: 'center', gap: 8}}>
|
||||
<Icon name="warn" size={12} />
|
||||
<span>Revoking a key stops all in-flight requests within 60 seconds. This cannot be undone.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
window.ApiKeys = ApiKeys;
|
||||
@@ -0,0 +1,90 @@
|
||||
const Auth = ({ onNav }) => {
|
||||
const [mode, setMode] = React.useState('signin');
|
||||
const [email, setEmail] = React.useState('');
|
||||
const [pw, setPw] = React.useState('');
|
||||
const [remember, setRemember] = React.useState(true);
|
||||
|
||||
const submit = (e) => { e.preventDefault(); onNav('dashboard'); };
|
||||
|
||||
return (
|
||||
<div className="auth-wrap">
|
||||
<div className="hero-bg" />
|
||||
<div className="hero-bg-grid" />
|
||||
|
||||
<div style={{position: 'absolute', top: 24, left: 24, zIndex: 2}}>
|
||||
<Logo onClick={() => onNav('landing')} />
|
||||
</div>
|
||||
|
||||
<div className="auth-card">
|
||||
<h2 className="auth-h1">{mode === 'signin' ? 'Welcome back' : 'Create your account'}</h2>
|
||||
<p className="auth-sub">{mode === 'signin' ? 'Sign in to your Arcrun workspace.' : 'Start building AI workflows in minutes.'}</p>
|
||||
|
||||
<div className="tabs">
|
||||
<button className={mode === 'signin' ? 'active' : ''} onClick={() => setMode('signin')}>Sign in</button>
|
||||
<button className={mode === 'signup' ? 'active' : ''} onClick={() => setMode('signup')}>Sign up</button>
|
||||
</div>
|
||||
|
||||
<div className="oauth-row">
|
||||
<button className="oauth-btn github" onClick={() => onNav('dashboard')}>
|
||||
<Icon name="github" size={17} stroke={0} /> Continue with GitHub
|
||||
</button>
|
||||
<button className="oauth-btn google" onClick={() => onNav('dashboard')}>
|
||||
<Icon name="google" size={15} stroke={0} /> Continue with Google
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="divider">or continue with email</div>
|
||||
|
||||
<form onSubmit={submit}>
|
||||
{mode === 'signup' && (
|
||||
<div className="field">
|
||||
<label>Full name</label>
|
||||
<input className="input" type="text" placeholder="Maya Rivera" />
|
||||
</div>
|
||||
)}
|
||||
<div className="field">
|
||||
<label>Work email</label>
|
||||
<input className="input" type="email" placeholder="you@company.com" value={email} onChange={e => setEmail(e.target.value)} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<div className="field-row">
|
||||
<label>Password</label>
|
||||
{mode === 'signin' && <span className="link">Forgot password?</span>}
|
||||
</div>
|
||||
<input className="input" type="password" placeholder="••••••••••" value={pw} onChange={e => setPw(e.target.value)} />
|
||||
</div>
|
||||
|
||||
{mode === 'signin' && (
|
||||
<div style={{display: 'flex', alignItems: 'center', gap: 8, fontSize: 12.5, color: 'var(--text-dim)', marginBottom: 14}}>
|
||||
<div onClick={() => setRemember(!remember)}
|
||||
style={{width: 15, height: 15, borderRadius: 4, border: '1px solid var(--line-2)',
|
||||
background: remember ? 'var(--primary)' : 'transparent',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer'}}>
|
||||
{remember && <Icon name="check" size={11} />}
|
||||
</div>
|
||||
<span onClick={() => setRemember(!remember)} style={{cursor: 'pointer'}}>Keep me signed in for 30 days</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button className="btn btn-primary auth-submit btn-lg" type="submit">
|
||||
{mode === 'signin' ? 'Sign in' : 'Create account'} <Icon name="arrow_right" size={14} />
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{mode === 'signup' && (
|
||||
<p style={{fontSize: 11.5, color: 'var(--text-mute)', textAlign: 'center', marginTop: 14, lineHeight: 1.5}}>
|
||||
By signing up, you agree to our <span className="link" style={{fontSize: 11.5}}>Terms</span> and <span className="link" style={{fontSize: 11.5}}>Privacy Policy</span>.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="auth-foot">
|
||||
{mode === 'signin'
|
||||
? <>New to Arcrun? <span className="link" onClick={() => setMode('signup')}>Create an account</span></>
|
||||
: <>Already have an account? <span className="link" onClick={() => setMode('signin')}>Sign in</span></>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
window.Auth = Auth;
|
||||
@@ -0,0 +1,126 @@
|
||||
const Dashboard = ({ onNav }) => {
|
||||
const apps = [
|
||||
{ id: 'digest', name: 'Weekly Digest', desc: 'Summarize customer activity into a Monday email for the revenue team.', icon: 'mail', tone: 'indigo' },
|
||||
{ id: 'triage', name: 'Support Triage', desc: 'Classify inbound tickets, attach context from the CRM, and route.', icon: 'filter', tone: 'orange' },
|
||||
{ id: 'seo', name: 'SEO Brief Generator', desc: 'Turn a keyword into a draft brief with outline, FAQs, and SERP notes.', icon: 'search', tone: 'green' },
|
||||
{ id: 'slack', name: 'Standup Bot', desc: 'Collect Linear updates and post a tidy engineering standup to Slack.', icon: 'slack', tone: 'pink' },
|
||||
{ id: 'doc', name: 'Docs Sync', desc: 'Keep Notion runbooks in sync with the production API surface.', icon: 'book', tone: 'blue' },
|
||||
];
|
||||
|
||||
const workflows = [
|
||||
{ id: 'digest_weekly', name: 'digest/weekly', nodes: 9, modified: '2 hours ago', runs: '147 runs', status: 'healthy' },
|
||||
{ id: 'triage_inbound', name: 'triage/inbound-email', nodes: 14, modified: 'Yesterday', runs: '2,318 runs', status: 'healthy' },
|
||||
{ id: 'seo_brief', name: 'seo/brief-from-keyword', nodes: 7, modified: '3 days ago', runs: '42 runs', status: 'healthy' },
|
||||
{ id: 'standup', name: 'slack/standup-collector', nodes: 6, modified: '1 week ago', runs: '24 runs', status: 'idle' },
|
||||
{ id: 'docs_sync', name: 'docs/sync-notion', nodes: 11, modified: '2 weeks ago', runs: '8 runs', status: 'failed' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="shell">
|
||||
<Sidebar current="dashboard" onNav={onNav} />
|
||||
<div className="main">
|
||||
<div className="main-head">
|
||||
<div>
|
||||
<div className="crumb">
|
||||
<span>Northwind</span>
|
||||
<span className="sep"><Icon name="chevron_right" size={11} /></span>
|
||||
<span>Dashboard</span>
|
||||
</div>
|
||||
<h1>Welcome back, Maya</h1>
|
||||
<div className="sub">5 apps running · 12 workflows · 2,538 runs this week</div>
|
||||
</div>
|
||||
<div className="flex gap-8">
|
||||
<button className="btn btn-secondary"><Icon name="book" size={14} /> Templates</button>
|
||||
<button className="btn btn-primary"><Icon name="plus" size={14} /> New app</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="main-body">
|
||||
{/* Apps grid */}
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h2>My Apps</h2>
|
||||
<div className="subtle" style={{marginTop: 2}}>Packaged workflows your team can run from chat or code</div>
|
||||
</div>
|
||||
<span className="subtle">{apps.length} apps</span>
|
||||
</div>
|
||||
|
||||
<div className="apps-grid">
|
||||
{apps.map(a => (
|
||||
<div key={a.id} className="app-card">
|
||||
<AppIcon tone={a.tone}><Icon name={a.icon} size={17} /></AppIcon>
|
||||
<h4>{a.name}</h4>
|
||||
<p className="dsc">{a.desc}</p>
|
||||
<div className="row">
|
||||
<a className="open" onClick={() => onNav('workflow')}>Open app <Icon name="arrow_right" size={12} /></a>
|
||||
<button className="chip-btn">
|
||||
<Icon name="spark" size={11} /> Edit in Claude
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="app-card app-empty">
|
||||
<div className="plus"><Icon name="plus" size={16} /></div>
|
||||
<div style={{fontSize: 13, fontWeight: 500}}>Create new app</div>
|
||||
<div style={{fontSize: 12, opacity: 0.75}}>Start from scratch or template</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Workflows */}
|
||||
<div className="wf-table">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h2>My Workflows</h2>
|
||||
<div className="subtle" style={{marginTop: 2}}>The graphs that power your apps</div>
|
||||
</div>
|
||||
<div className="flex gap-8">
|
||||
<button className="btn btn-secondary btn-sm"><Icon name="filter" size={12} /> All workflows</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => onNav('workflow')}><Icon name="plus" size={12} /> New</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{width: '34%'}}>Workflow</th>
|
||||
<th>Nodes</th>
|
||||
<th>Last modified</th>
|
||||
<th>Activity</th>
|
||||
<th>Status</th>
|
||||
<th style={{width: 100, textAlign: 'right'}}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{workflows.map(w => (
|
||||
<tr key={w.id}>
|
||||
<td>
|
||||
<div className="wf-row-name">
|
||||
<span className="dot" />
|
||||
<span className="mono" style={{fontSize: 13}}>{w.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="dim">{w.nodes}</td>
|
||||
<td className="dim" style={{fontSize: 12.5}}>{w.modified}</td>
|
||||
<td className="dim" style={{fontSize: 12.5}}>{w.runs}</td>
|
||||
<td>
|
||||
<span className={`pill ${w.status === 'healthy' ? 'active' : w.status === 'failed' ? 'revoked' : 'idle'}`}>
|
||||
<span className="pdot" /> {w.status}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{textAlign: 'right'}}>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => onNav('workflow')}>View</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
window.Dashboard = Dashboard;
|
||||
@@ -0,0 +1,168 @@
|
||||
const Landing = ({ onNav }) => {
|
||||
const [installer, setInstaller] = React.useState('npm');
|
||||
const installCmds = {
|
||||
npm: '$ npm install arcrun',
|
||||
pip: '$ pip install arcrun',
|
||||
bun: '$ bun add arcrun',
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<TopNav onNav={onNav} current="landing" />
|
||||
|
||||
<div className="container">
|
||||
<section className="hero">
|
||||
<div className="hero-bg" />
|
||||
<div className="hero-bg-grid" />
|
||||
<div className="hero-eyebrow">
|
||||
<span className="dot" />
|
||||
<span>Now in public beta — MCP-native</span>
|
||||
</div>
|
||||
<h1>Build AI workflows<br/><span className="grad">without the glue code.</span></h1>
|
||||
<p className="sub">Connect your tools, automate your work. Orchestrate workflows from Claude.ai, your IDE, or a few lines of code — Arcrun handles auth, retries, and state.</p>
|
||||
<div className="hero-ctas">
|
||||
<button className="btn btn-primary btn-lg" onClick={() => onNav('auth')}>
|
||||
Start free <Icon name="arrow_right" size={15} />
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-lg">
|
||||
<Icon name="book" size={14} /> Read the docs
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="paths">
|
||||
{/* Developer path */}
|
||||
<div className="path-card">
|
||||
<div className="path-label">
|
||||
<Icon name="terminal" size={13} /> For Developers
|
||||
</div>
|
||||
<h3>Three lines, any runtime.</h3>
|
||||
<p className="lede">Install once, call Arcrun from Node, Python, or your edge runtime. OAuth, rate limits, and retries are handled.</p>
|
||||
|
||||
<div className="install-tabs">
|
||||
{Object.keys(installCmds).map(k => (
|
||||
<button key={k} className={installer === k ? 'active' : ''} onClick={() => setInstaller(k)}>{k}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="terminal" style={{marginBottom: 12}}>
|
||||
<div className="terminal-head">
|
||||
<div className="dots"><span/><span/><span/></div>
|
||||
<div className="title">terminal</div>
|
||||
</div>
|
||||
<div className="terminal-body">
|
||||
<div><span className="dim">{installCmds[installer]}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="terminal">
|
||||
<div className="terminal-head">
|
||||
<div className="dots"><span/><span/><span/></div>
|
||||
<div className="title">{installer === 'pip' ? 'app.py' : 'app.ts'}</div>
|
||||
</div>
|
||||
<div className="terminal-body">
|
||||
{installer === 'pip' ? (
|
||||
<>
|
||||
<div><span className="c1">from</span> <span className="c2">arcrun</span> <span className="c1">import</span> <span className="c2">Arcrun</span></div>
|
||||
<div className="sp-4"/>
|
||||
<div><span className="c2">client</span> = <span className="c4">Arcrun</span>(<span className="c2">token</span>=<span className="c2">os</span>.<span className="c4">getenv</span>(<span className="c3">"ARCRUN_KEY"</span>))</div>
|
||||
<div><span className="c2">run</span> = <span className="c2">client</span>.<span className="c4">run</span>(<span className="c3">"digest/weekly"</span>, <span className="c2">inputs</span>={'{'}<span className="c3">"user"</span>: <span className="c3">"u_219"</span>{'}'})</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div><span className="c1">import</span> {'{'} <span className="c2">Arcrun</span> {'}'} <span className="c1">from</span> <span className="c3">"arcrun"</span>;</div>
|
||||
<div className="sp-4"/>
|
||||
<div><span className="c1">const</span> <span className="c2">client</span> = <span className="c1">new</span> <span className="c4">Arcrun</span>({'{'} <span className="c2">token</span>: <span className="c2">process</span>.<span className="c2">env</span>.<span className="c2">ARCRUN_KEY</span> {'}'});</div>
|
||||
<div><span className="c1">const</span> <span className="c2">run</span> = <span className="c1">await</span> <span className="c2">client</span>.<span className="c4">run</span>(<span className="c3">"digest/weekly"</span>, {'{'} <span className="c2">user</span>: <span className="c3">"u_219"</span> {'}'});</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sp-16" />
|
||||
<div className="flex gap-12" style={{fontSize: 12.5, color: 'var(--text-mute)'}}>
|
||||
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="check" size={12} /> Typed SDKs</span>
|
||||
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="check" size={12} /> Idempotent runs</span>
|
||||
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="check" size={12} /> Self-host ready</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Everyone path */}
|
||||
<div className="path-card">
|
||||
<div className="path-label">
|
||||
<Icon name="spark" size={13} /> For Everyone
|
||||
</div>
|
||||
<h3>Talk to your workflows.</h3>
|
||||
<p className="lede">Install Arcrun inside your AI assistant and run your apps by asking. Trigger workflows, fetch data, or draft messages — in plain English.</p>
|
||||
|
||||
<div className="chat-preview">
|
||||
<div className="chat-head">
|
||||
<span className="brand-dot">AI</span>
|
||||
<span>Your assistant — Arcrun connected</span>
|
||||
<span style={{marginLeft: 'auto'}} className="pill active"><span className="pdot" />2 apps</span>
|
||||
</div>
|
||||
<div className="chat-body">
|
||||
<div className="chat-msg user">
|
||||
<div className="avatar">M</div>
|
||||
<div className="bubble">Send this week's customer digest to the revenue team.</div>
|
||||
</div>
|
||||
<div className="chat-msg ai">
|
||||
<div className="avatar">A</div>
|
||||
<div className="bubble">
|
||||
Running <span style={{color: 'var(--primary)', fontWeight: 500}}>digest/weekly</span> for 147 accounts, then posting to #revenue.
|
||||
<div className="tool-card">
|
||||
<div className="tool-icon">AR</div>
|
||||
<div className="tool-meta">
|
||||
<div className="tool-name">arcrun · digest/weekly</div>
|
||||
<div className="tool-sub">4 of 5 steps complete · 00:12 elapsed</div>
|
||||
</div>
|
||||
<span className="pill active"><span className="pdot" />running</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="chat-input">
|
||||
<span>Reply to your assistant…</span>
|
||||
<span className="caret" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sp-16" />
|
||||
<div className="flex gap-12" style={{fontSize: 12.5, color: 'var(--text-mute)'}}>
|
||||
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="check" size={12} /> One-click connect</span>
|
||||
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="check" size={12} /> Works in your IDE</span>
|
||||
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="check" size={12} /> Audit trail</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="strip">
|
||||
<div className="cell">
|
||||
<div className="ico"><Icon name="bolt" size={15} /></div>
|
||||
<h4>Run anywhere</h4>
|
||||
<p>Node, Python, Deno, Bun, Cloudflare Workers. One API, same semantics.</p>
|
||||
</div>
|
||||
<div className="cell">
|
||||
<div className="ico"><Icon name="workflow" size={15} /></div>
|
||||
<h4>Composable steps</h4>
|
||||
<p>Model calls, HTTP, database, branching — wire them visually or in code.</p>
|
||||
</div>
|
||||
<div className="cell">
|
||||
<div className="ico"><Icon name="key" size={15} /></div>
|
||||
<h4>Scoped keys</h4>
|
||||
<p>Per-workflow API keys with fine-grained scopes and live revocation.</p>
|
||||
</div>
|
||||
<div className="cell">
|
||||
<div className="ico"><Icon name="eye" size={15} /></div>
|
||||
<h4>Observable</h4>
|
||||
<p>Every run is replayable. Inspect inputs, outputs, and token usage.</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<Footer onNav={onNav} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
window.Landing = Landing;
|
||||
@@ -0,0 +1,255 @@
|
||||
const WorkflowViewer = ({ onNav }) => {
|
||||
const nodes = [
|
||||
{ id: 'trigger', x: 60, y: 260, title: 'Weekly Schedule', type: 'trigger', badge: 'CRON', icon: 'clock', tone: '#22C55E',
|
||||
inputs: [], outputs: [{k: 'timestamp', t: 'ISO8601'}, {k: 'runId', t: 'string'}] },
|
||||
{ id: 'fetch', x: 320, y: 140, title: 'Fetch Accounts', type: 'database.query', badge: 'DB', icon: 'database', tone: '#3B82F6',
|
||||
inputs: [{k: 'segment', t: 'string'}], outputs: [{k: 'accounts', t: 'Account[]'}, {k: 'count', t: 'number'}] },
|
||||
{ id: 'events', x: 320, y: 380, title: 'Pull Events', type: 'segment.events', badge: 'API', icon: 'bolt', tone: '#F59E0B',
|
||||
inputs: [{k: 'since', t: 'ISO8601'}], outputs: [{k: 'events', t: 'Event[]'}] },
|
||||
{ id: 'summarize', x: 600, y: 260, title: 'Summarize with Claude', type: 'ai.completion', badge: 'AI', icon: 'spark', tone: '#8B5CF6',
|
||||
inputs: [{k: 'accounts', t: 'Account[]'}, {k: 'events', t: 'Event[]'}, {k: 'prompt', t: 'string'}],
|
||||
outputs: [{k: 'digest', t: 'Digest'}, {k: 'tokens', t: 'number'}] },
|
||||
{ id: 'filter', x: 880, y: 160, title: 'Filter — priority ≥ 2', type: 'logic.filter', badge: 'IF', icon: 'filter', tone: '#64748B',
|
||||
inputs: [{k: 'digest', t: 'Digest'}], outputs: [{k: 'items', t: 'Item[]'}] },
|
||||
{ id: 'slack', x: 1140, y: 100, title: 'Post to #revenue', type: 'slack.message', badge: 'OUT', icon: 'slack', tone: '#EC4899',
|
||||
inputs: [{k: 'channel', t: 'string'}, {k: 'blocks', t: 'Block[]'}], outputs: [{k: 'ts', t: 'string'}] },
|
||||
{ id: 'mail', x: 1140, y: 260, title: 'Email Digest', type: 'mail.send', badge: 'OUT', icon: 'mail', tone: '#6366F1',
|
||||
inputs: [{k: 'to', t: 'string[]'}, {k: 'subject', t: 'string'}, {k: 'html', t: 'string'}], outputs: [{k: 'messageId', t: 'string'}] },
|
||||
{ id: 'log', x: 880, y: 400, title: 'Log run metadata', type: 'arcrun.log', badge: 'LOG', icon: 'terminal', tone: '#475569',
|
||||
inputs: [{k: 'runId', t: 'string'}, {k: 'stats', t: 'Stats'}], outputs: [] },
|
||||
];
|
||||
|
||||
const edges = [
|
||||
['trigger', 'fetch'],
|
||||
['trigger', 'events'],
|
||||
['fetch', 'summarize'],
|
||||
['events', 'summarize'],
|
||||
['summarize', 'filter'],
|
||||
['summarize', 'log'],
|
||||
['filter', 'slack'],
|
||||
['filter', 'mail'],
|
||||
];
|
||||
|
||||
const [selectedId, setSelectedId] = React.useState('summarize');
|
||||
const [title, setTitle] = React.useState('digest/weekly');
|
||||
const [zoom, setZoom] = React.useState(100);
|
||||
|
||||
const selected = nodes.find(n => n.id === selectedId);
|
||||
|
||||
// Edit triplet inline (for the summarize node's prompt config)
|
||||
const [triplet, setTriplet] = React.useState({
|
||||
model: 'claude-haiku-4-5',
|
||||
temperature: '0.3',
|
||||
prompt: 'Summarize this week\'s account activity for the revenue team.',
|
||||
});
|
||||
|
||||
// Measure node widths for edge endpoint accuracy
|
||||
const nodeRefs = React.useRef({});
|
||||
const [sizes, setSizes] = React.useState({});
|
||||
React.useEffect(() => {
|
||||
const ns = {};
|
||||
for (const n of nodes) {
|
||||
const el = nodeRefs.current[n.id];
|
||||
if (el) ns[n.id] = { w: el.offsetWidth, h: el.offsetHeight };
|
||||
}
|
||||
setSizes(ns);
|
||||
}, []);
|
||||
|
||||
const getPort = (id, side) => {
|
||||
const n = nodes.find(x => x.id === id);
|
||||
const sz = sizes[id] || { w: 200, h: 60 };
|
||||
return {
|
||||
x: side === 'out' ? n.x + sz.w : n.x,
|
||||
y: n.y + sz.h / 2,
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="wf-viewer">
|
||||
<div className="wf-topbar">
|
||||
<div className="back" onClick={() => onNav('dashboard')} title="Back to dashboard">
|
||||
<Icon name="arrow_left" size={16} />
|
||||
</div>
|
||||
<Logo size="sm" onClick={() => onNav('landing')} />
|
||||
<div className="sep" />
|
||||
<div className="wf-breadcrumb">
|
||||
<span className="cr" onClick={() => onNav('dashboard')}>Workflows</span>
|
||||
<Icon name="chevron_right" size={11} />
|
||||
<input
|
||||
className="wf-title mono"
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<span className="wf-saved">
|
||||
<span style={{width: 6, height: 6, borderRadius: '50%', background: '#22C55E', boxShadow: '0 0 0 3px rgba(34,197,94,0.18)'}} />
|
||||
Saved · 2m ago
|
||||
</span>
|
||||
<div className="spacer" />
|
||||
<button className="btn btn-ghost btn-sm"><Icon name="share" size={13} /> Share</button>
|
||||
<button className="btn btn-secondary btn-sm"><Icon name="download" size={13} /> Export YAML</button>
|
||||
<button className="wf-edit-in-claude">
|
||||
<Icon name="spark" size={13} /> Edit in Claude <Icon name="external" size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="wf-canvas">
|
||||
<svg className="wf-edges" width="100%" height="100%">
|
||||
<defs>
|
||||
<marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
|
||||
<path d="M0,0 L10,5 L0,10 z" fill="#6366F1" />
|
||||
</marker>
|
||||
<marker id="arrow-dim" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
|
||||
<path d="M0,0 L10,5 L0,10 z" fill="#3a3a3a" />
|
||||
</marker>
|
||||
</defs>
|
||||
{edges.map(([a, b], i) => {
|
||||
const p1 = getPort(a, 'out');
|
||||
const p2 = getPort(b, 'in');
|
||||
const dx = Math.max(40, (p2.x - p1.x) * 0.5);
|
||||
const d = `M ${p1.x} ${p1.y} C ${p1.x + dx} ${p1.y}, ${p2.x - dx} ${p2.y}, ${p2.x - 2} ${p2.y}`;
|
||||
const highlight = a === selectedId || b === selectedId;
|
||||
return (
|
||||
<path key={i} d={d}
|
||||
stroke={highlight ? '#6366F1' : '#3a3a3a'}
|
||||
strokeWidth={highlight ? 2 : 1.5}
|
||||
fill="none"
|
||||
markerEnd={`url(#${highlight ? 'arrow' : 'arrow-dim'})`}
|
||||
opacity={highlight ? 0.95 : 0.6} />
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
<div className="wf-nodes">
|
||||
{nodes.map(n => (
|
||||
<div key={n.id}
|
||||
ref={el => (nodeRefs.current[n.id] = el)}
|
||||
className={`wf-node ${selectedId === n.id ? 'selected' : ''}`}
|
||||
style={{left: n.x, top: n.y}}
|
||||
onClick={() => setSelectedId(n.id)}>
|
||||
{n.inputs.length > 0 && <span className="port in" />}
|
||||
{n.outputs.length > 0 && <span className="port out" />}
|
||||
<div className="node-row-top">
|
||||
<span className="node-icon" style={{background: n.tone}}>
|
||||
<Icon name={n.icon} size={12} />
|
||||
</span>
|
||||
<span className="node-title">{n.title}</span>
|
||||
<span className="node-badge">{n.badge}</span>
|
||||
</div>
|
||||
<div className="node-sub">{n.type}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Detail panel */}
|
||||
{selected && (
|
||||
<div className="wf-detail">
|
||||
<div className="dt-head">
|
||||
<span className="dt-icon" style={{background: selected.tone}}>
|
||||
<Icon name={selected.icon} size={15} />
|
||||
</span>
|
||||
<div className="dt-meta">
|
||||
<h3>{selected.title}</h3>
|
||||
<div className="dt-type">{selected.type}</div>
|
||||
</div>
|
||||
<button className="close-btn" onClick={() => setSelectedId(null)}>
|
||||
<Icon name="close" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="dt-body">
|
||||
<div className="dt-section">
|
||||
<h4>Input schema</h4>
|
||||
{selected.inputs.length === 0 ? (
|
||||
<div style={{fontSize: 12, color: 'var(--text-mute)', fontStyle: 'italic'}}>No inputs — this is a trigger.</div>
|
||||
) : selected.inputs.map(f => (
|
||||
<div key={f.k} className="schema-field">
|
||||
<span className="k">{f.k}</span>
|
||||
<span className="t">{f.t}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="dt-section">
|
||||
<h4>Output schema</h4>
|
||||
{selected.outputs.length === 0 ? (
|
||||
<div style={{fontSize: 12, color: 'var(--text-mute)', fontStyle: 'italic'}}>No outputs — terminal node.</div>
|
||||
) : selected.outputs.map(f => (
|
||||
<div key={f.k} className="schema-field">
|
||||
<span className="k">{f.k}</span>
|
||||
<span className="t">{f.t}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selected.id === 'summarize' && (
|
||||
<div className="dt-section">
|
||||
<h4>Configuration</h4>
|
||||
<div className="triplet">
|
||||
<div className="trow">
|
||||
<div className="tkey">model</div>
|
||||
<input className="tval" value={triplet.model} onChange={e => setTriplet({...triplet, model: e.target.value})} />
|
||||
</div>
|
||||
<div className="trow">
|
||||
<div className="tkey">temp</div>
|
||||
<input className="tval" value={triplet.temperature} onChange={e => setTriplet({...triplet, temperature: e.target.value})} />
|
||||
</div>
|
||||
<div className="trow">
|
||||
<div className="tkey">prompt</div>
|
||||
<input className="tval" value={triplet.prompt} onChange={e => setTriplet({...triplet, prompt: e.target.value})} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="dt-section">
|
||||
<h4>Last run</h4>
|
||||
<div style={{display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, fontSize: 12}}>
|
||||
<div style={{background: 'rgba(255,255,255,0.02)', border: '1px solid var(--line)', borderRadius: 7, padding: '8px 10px'}}>
|
||||
<div style={{color: 'var(--text-mute)', fontSize: 10.5, textTransform: 'uppercase', letterSpacing: '0.06em'}}>Duration</div>
|
||||
<div style={{fontFamily: 'JetBrains Mono, monospace', marginTop: 3}}>2.4s</div>
|
||||
</div>
|
||||
<div style={{background: 'rgba(255,255,255,0.02)', border: '1px solid var(--line)', borderRadius: 7, padding: '8px 10px'}}>
|
||||
<div style={{color: 'var(--text-mute)', fontSize: 10.5, textTransform: 'uppercase', letterSpacing: '0.06em'}}>Status</div>
|
||||
<div style={{marginTop: 2}}><span className="pill active"><span className="pdot" />success</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="btn btn-primary" style={{width: '100%', marginTop: 4}}>
|
||||
<Icon name="spark" size={13} /> Edit this node in Claude
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Minimap */}
|
||||
<div className="wf-minimap">
|
||||
<div className="mini-label">Overview</div>
|
||||
{nodes.map(n => {
|
||||
const sz = sizes[n.id] || {w: 180, h: 60};
|
||||
return (
|
||||
<div key={n.id} className="mini-box" style={{
|
||||
left: 8 + (n.x / 1400) * 164,
|
||||
top: 18 + (n.y / 500) * 80,
|
||||
width: Math.max(6, (sz.w / 1400) * 164),
|
||||
height: Math.max(4, (sz.h / 500) * 80),
|
||||
opacity: selectedId === n.id ? 1 : 0.5,
|
||||
background: selectedId === n.id ? 'var(--primary)' : 'var(--primary-soft)',
|
||||
}} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Zoom controls */}
|
||||
<div className="wf-controls">
|
||||
<button onClick={() => setZoom(Math.max(40, zoom - 10))}><Icon name="zoom_out" size={13} /></button>
|
||||
<div className="zoom-val">{zoom}%</div>
|
||||
<button onClick={() => setZoom(Math.min(200, zoom + 10))}><Icon name="zoom_in" size={13} /></button>
|
||||
<button><Icon name="maximize" size={13} /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
window.WorkflowViewer = WorkflowViewer;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,761 @@
|
||||
# arcrun Credential System 設計規格
|
||||
20260418
|
||||
|
||||
> **讀者**:Claude Code(CC),負責實作
|
||||
> **作者**:richblack(架構決策)
|
||||
> **版本**:v1.0
|
||||
> **狀態**:Draft — 等 CC 確認技術可行性後開工
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR(給 CC 的三句話版)
|
||||
|
||||
1. **不要**為每個服務寫一個 credential 零件,n8n 是錯的。
|
||||
2. 做**四個 TinyGo/WASM 零件**(primitives),每個服務只需要一份 **YAML recipe** + 用戶自己的 **secret**。
|
||||
3. Recipe 存 arcrun 平台 KV(公共),secret 存 tenant KV(私有),兩者在 runtime 由 `AuthBroker` 組裝成可用的 HTTP client。
|
||||
|
||||
---
|
||||
|
||||
## 1. 設計目標與反目標
|
||||
|
||||
### 目標
|
||||
- **新增一個服務的成本 = 寫一份 YAML**,不需要 rebuild、不需要改 code。
|
||||
- **AI agent 理解成本 ≈ 0**:recipe 就是呼叫該服務的完整說明書。
|
||||
- **人類設定成本 < 10 分鐘**:即使是對 OAuth 不熟的使用者,UI 只問「你的 API Key 是什麼」這類 secret 層級問題。
|
||||
- **Secret 隔離**:每個 tenant 的 secret 絕對不互相可見,arcrun 平台本身也無法明文讀取(用 Cloudflare Secrets Store 或加密儲存)。
|
||||
|
||||
### 反目標(明確不做的事)
|
||||
- ❌ 不做 n8n 那種「每個服務一個 credential type」的視覺化面板。
|
||||
- ❌ 不支援 OAuth1(2026 年還在用的服務極少,真遇到再加)。
|
||||
- ❌ 不做 credential sharing 的複雜 ACL(全 tenant scope 即可,未來再擴充)。
|
||||
- ❌ 不在 arcrun 內部明文持久化任何長期 secret(只有加密過的密文或 Secrets Store reference)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 核心架構:三層模型
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Layer 3: Service Recipe (YAML) │
|
||||
│ arcrun 平台共享,describe "如何呼叫這個服務" │
|
||||
│ 存在 Workers KV: arcrun-recipes │
|
||||
│ 例:recipe/notion.yaml, recipe/google_calendar.yaml │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
↓ 引用
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Layer 2: Auth Primitive (TinyGo → WASM) │
|
||||
│ 四個通用認證零件,實作注入邏輯與 token 交換 │
|
||||
│ 1. static_key 2. oauth2 │
|
||||
│ 3. service_account 4. mtls │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
↑ 使用
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Layer 1: Tenant Secret (KV + Secrets Store) │
|
||||
│ 每個 tenant 自己的 KV namespace │
|
||||
│ 存 encrypted secret 或 Secrets Store reference │
|
||||
│ 例:secret/{tenant_id}/notion-prod │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 為什麼這樣切?
|
||||
|
||||
| 切分維度 | Recipe | Primitive | Secret |
|
||||
|---|---|---|---|
|
||||
| **誰擁有** | arcrun 平台 | arcrun 平台 | tenant 自己 |
|
||||
| **變化頻率** | 中(新服務時) | 低(認證機制穩定) | 高(rotate、revoke) |
|
||||
| **敏感度** | 公開 | 公開 | 最高機密 |
|
||||
| **儲存位置** | 平台 KV(`arcrun-recipes`) | WASM binary | tenant KV + Secrets Store |
|
||||
| **可否社群貢獻** | ✅ PR | ⚠️ 核心團隊 | ❌ 永遠不 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 四個 Primitive 詳細規格
|
||||
|
||||
### 3.1 `static_key`
|
||||
|
||||
**適用**:API Key、Bearer Token、Basic Auth、任何「一組 secret 不會自動過期」的認證。
|
||||
|
||||
**涵蓋 n8n 的**:API Key、Basic Auth、Header Auth、Query Auth、Custom Auth、Digest Auth(~80% 服務)。
|
||||
|
||||
**Recipe 欄位**:
|
||||
```yaml
|
||||
primitive: static_key
|
||||
inject:
|
||||
# 四個注入位置,可以同時用多個
|
||||
header: # HTTP headers
|
||||
<key>: <value template>
|
||||
query: # URL query string
|
||||
<key>: <value template>
|
||||
body: # request body(JSON 欄位)
|
||||
<key>: <value template>
|
||||
basic_auth: # HTTP Basic Auth(會自動 base64 編碼)
|
||||
username: <value template>
|
||||
password: <value template>
|
||||
```
|
||||
|
||||
**Value template 語法**:`{{secret.xxx}}` 取 secret 欄位,`{{const.yyy}}` 取 recipe 內定義的常數。
|
||||
|
||||
**Secret schema**:tenant 存 JSON,欄位由 recipe 的 `required_secrets` 宣告。
|
||||
|
||||
**範例(Notion)**:
|
||||
```yaml
|
||||
# arcrun-recipes KV: recipe/notion
|
||||
service: notion
|
||||
version: 1
|
||||
primitive: static_key
|
||||
base_url: https://api.notion.com/v1
|
||||
required_secrets:
|
||||
- key: token
|
||||
label: "Internal Integration Token"
|
||||
help_url: https://www.notion.so/my-integrations
|
||||
inject:
|
||||
header:
|
||||
Authorization: "Bearer {{secret.token}}"
|
||||
Notion-Version: "2022-06-28"
|
||||
test:
|
||||
method: GET
|
||||
path: /users/me
|
||||
expect_status: 200
|
||||
```
|
||||
|
||||
**Secret 範例**:
|
||||
```json
|
||||
// tenant KV: secret/tenant_123/notion-prod
|
||||
{
|
||||
"token": "secret_abc123..."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.2 `oauth2`
|
||||
|
||||
**適用**:需要人類首次授權、之後用 refresh token 續命的場景。
|
||||
|
||||
**Grant types 支援**:
|
||||
- `authorization_code`(最常見:GitHub、Slack、Google 用戶授權)
|
||||
- `client_credentials`(機器對機器)
|
||||
- `pkce`(SPA、行動應用)
|
||||
- ❌ 不支援:password grant(2026 已被多數 OAuth 提供者棄用)、implicit(已棄用)
|
||||
|
||||
**Recipe 欄位**:
|
||||
```yaml
|
||||
primitive: oauth2
|
||||
grant: authorization_code # or client_credentials, pkce
|
||||
base_url: <service API base>
|
||||
oauth:
|
||||
authorize_url: <IdP authorize endpoint>
|
||||
token_url: <IdP token endpoint>
|
||||
scopes:
|
||||
- <default scope 1>
|
||||
- <default scope 2>
|
||||
client_auth: header # or body
|
||||
# 是否使用 refresh token
|
||||
refresh: true
|
||||
# PKCE 時額外參數
|
||||
pkce_method: S256 # only for grant: pkce
|
||||
required_secrets:
|
||||
- key: client_id
|
||||
label: "Client ID"
|
||||
- key: client_secret
|
||||
label: "Client Secret"
|
||||
secret: true
|
||||
inject:
|
||||
header:
|
||||
Authorization: "Bearer {{runtime.access_token}}"
|
||||
```
|
||||
|
||||
**Runtime 欄位**(primitive 自動維護,存在 tenant KV 的 `oauth_state/{secret_id}` key):
|
||||
- `access_token`
|
||||
- `refresh_token`
|
||||
- `expires_at`
|
||||
|
||||
**首次授權流程**(人類要做的部分):
|
||||
1. arcrun UI 呼叫 `AuthBroker.startAuth(recipe_id, tenant_id)` 回傳 authorize URL。
|
||||
2. 使用者瀏覽器跳轉到 IdP,同意授權。
|
||||
3. IdP redirect 回 arcrun callback endpoint(固定一個 URL,無論哪個服務)。
|
||||
4. `AuthBroker` 用 authorization code 換 token,寫入 tenant KV。
|
||||
|
||||
**之後 agent 呼叫時完全自動**:primitive 檢查 `expires_at`,過期自動用 refresh token 續,失敗再觸發重新授權通知。
|
||||
|
||||
---
|
||||
|
||||
### 3.3 `service_account`
|
||||
|
||||
**適用**:Google Service Account、AWS IAM Role(assume role)、任何需要「私鑰簽 JWT 換短期 token」的機器身份。
|
||||
|
||||
**這個就是讓你 debug 兩天那個爆炸點。** 我們用 primitive 把地雷全部包起來。
|
||||
|
||||
**Recipe 欄位**:
|
||||
```yaml
|
||||
primitive: service_account
|
||||
kind: google_jwt # or aws_sigv4, generic_jwt
|
||||
base_url: <service API base>
|
||||
token_exchange:
|
||||
# Google 的 JWT → OAuth access token 流程
|
||||
endpoint: https://oauth2.googleapis.com/token
|
||||
audience: https://oauth2.googleapis.com/token
|
||||
scopes:
|
||||
- https://www.googleapis.com/auth/calendar
|
||||
# JWT claims
|
||||
issuer_from_secret: client_email
|
||||
subject_from_secret: client_email # optional, for domain-wide delegation 改成其他 user
|
||||
ttl_seconds: 3600
|
||||
required_secrets:
|
||||
- key: service_account_json
|
||||
label: "Service Account JSON"
|
||||
type: json_blob # 特別型別,UI 可以接受貼整個 JSON
|
||||
help: "到 GCP Console → IAM → Service Accounts → Keys → Add Key (JSON) 下載整份 JSON 貼上"
|
||||
inject:
|
||||
header:
|
||||
Authorization: "Bearer {{runtime.access_token}}"
|
||||
```
|
||||
|
||||
**為什麼不是每個服務一個 recipe?**
|
||||
- Google Calendar、Gmail、Drive、Sheets 全部可以共用同一個 `service_account` primitive。
|
||||
- 差別只在 `scopes` 和 `base_url`。
|
||||
- Recipe 本身可以 import 共通片段(見 §5 recipe 繼承)。
|
||||
|
||||
**AWS SigV4(kind: aws_sigv4)**:這是特例,不是 JWT-based,但概念一樣——用 access_key_id + secret_access_key 在每次 request 上簽章。Primitive 內建處理,recipe 只要宣告 region 和 service name。
|
||||
|
||||
---
|
||||
|
||||
### 3.4 `mtls`
|
||||
|
||||
**適用**:mTLS / client certificate。銀行 API、企業內部服務、醫療系統。
|
||||
|
||||
**Recipe 欄位**:
|
||||
```yaml
|
||||
primitive: mtls
|
||||
base_url: <service API base>
|
||||
required_secrets:
|
||||
- key: client_cert
|
||||
label: "Client Certificate (PEM)"
|
||||
type: pem_cert
|
||||
- key: client_key
|
||||
label: "Client Private Key (PEM)"
|
||||
type: pem_key
|
||||
secret: true
|
||||
- key: ca_cert
|
||||
label: "CA Certificate (PEM) — optional"
|
||||
type: pem_cert
|
||||
optional: true
|
||||
# mtls 通常不需要額外 inject,憑證在 TLS 層
|
||||
inject: {}
|
||||
```
|
||||
|
||||
**實作注意**:Cloudflare Workers 有原生 mTLS 支援(`mTLSCertificate` binding),primitive 只需要把 secret 轉成 Cloudflare mTLS binding 即可。
|
||||
|
||||
---
|
||||
|
||||
## 4. Recipe YAML Schema(完整版)
|
||||
|
||||
```yaml
|
||||
# 必填
|
||||
service: string # 唯一識別,snake_case,e.g. "notion", "google_calendar"
|
||||
version: integer # recipe schema version,breaking change 要升版
|
||||
primitive: enum # static_key | oauth2 | service_account | mtls
|
||||
base_url: string # service API base URL
|
||||
|
||||
# primitive 相關(依 primitive 不同)
|
||||
inject: object # 如何把 secret 注入 HTTP request
|
||||
oauth: object # 僅 oauth2 primitive
|
||||
token_exchange: object # 僅 service_account primitive
|
||||
|
||||
# Secret 宣告(讓 UI 知道要問什麼)
|
||||
required_secrets:
|
||||
- key: string # secret 欄位名
|
||||
label: string # UI 顯示
|
||||
secret: boolean # 是否遮蔽顯示(default: true)
|
||||
type: enum # text | json_blob | pem_cert | pem_key | url
|
||||
optional: boolean # default: false
|
||||
help: string # 給使用者的提示
|
||||
help_url: string # 導向服務文件
|
||||
|
||||
# 測試(驗證 credential 是否有效)
|
||||
test:
|
||||
method: GET | POST
|
||||
path: string # 相對 base_url
|
||||
expect_status: integer
|
||||
expect_json: object # 選填,JSON path assertion
|
||||
|
||||
# Metadata
|
||||
display_name: string # UI 顯示名
|
||||
description: string
|
||||
icon_url: string
|
||||
docs_url: string
|
||||
tags:
|
||||
- communication
|
||||
- crm
|
||||
- ai
|
||||
maintainers:
|
||||
- github: username
|
||||
|
||||
# 可選:共通片段繼承
|
||||
extends: string # recipe name,繼承其 schema 後覆寫
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Recipe 繼承(reduce 重複)
|
||||
|
||||
Google 家族的 API 長得很像,重複寫 15 次太蠢。支援 `extends`:
|
||||
|
||||
```yaml
|
||||
# recipe/_google_base.yaml(底線開頭 = 抽象 recipe,不能直接用)
|
||||
service: _google_base
|
||||
version: 1
|
||||
primitive: service_account
|
||||
token_exchange:
|
||||
endpoint: https://oauth2.googleapis.com/token
|
||||
audience: https://oauth2.googleapis.com/token
|
||||
ttl_seconds: 3600
|
||||
required_secrets:
|
||||
- key: service_account_json
|
||||
type: json_blob
|
||||
inject:
|
||||
header:
|
||||
Authorization: "Bearer {{runtime.access_token}}"
|
||||
```
|
||||
|
||||
```yaml
|
||||
# recipe/google_calendar.yaml
|
||||
extends: _google_base
|
||||
service: google_calendar
|
||||
version: 1
|
||||
base_url: https://www.googleapis.com/calendar/v3
|
||||
token_exchange:
|
||||
scopes:
|
||||
- https://www.googleapis.com/auth/calendar
|
||||
test:
|
||||
method: GET
|
||||
path: /users/me/calendarList
|
||||
expect_status: 200
|
||||
```
|
||||
|
||||
繼承規則:scalar 覆寫,object 深度合併,array 預設覆寫(可用 `!append` 標記 append)。
|
||||
|
||||
---
|
||||
|
||||
## 6. TinyGo WASM Primitive 實作介面
|
||||
|
||||
### 6.1 統一介面(四個 primitive 都實作這個)
|
||||
|
||||
```go
|
||||
// primitive/interface.go
|
||||
package primitive
|
||||
|
||||
type AuthRequest struct {
|
||||
Method string
|
||||
URL string
|
||||
Headers map[string]string
|
||||
Body []byte
|
||||
}
|
||||
|
||||
type AuthContext struct {
|
||||
Recipe Recipe // parsed YAML
|
||||
Secret map[string]any // decrypted secret
|
||||
Runtime RuntimeState // oauth token cache 等
|
||||
Now int64 // for testing
|
||||
}
|
||||
|
||||
type Primitive interface {
|
||||
// 在 HTTP request 上注入認證資訊
|
||||
Authenticate(req *AuthRequest, ctx *AuthContext) error
|
||||
|
||||
// 檢查是否需要 refresh(oauth2 / service_account 用)
|
||||
NeedsRefresh(ctx *AuthContext) bool
|
||||
|
||||
// 執行 refresh / token exchange,回傳新的 RuntimeState
|
||||
Refresh(ctx *AuthContext) (RuntimeState, error)
|
||||
|
||||
// 驗證 credential 是否有效(執行 recipe.test)
|
||||
Test(ctx *AuthContext) error
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 編譯與部署
|
||||
|
||||
```bash
|
||||
# 四個 primitive 各自編譯成獨立 WASM
|
||||
tinygo build -o dist/static_key.wasm -target=wasi ./primitive/static_key
|
||||
tinygo build -o dist/oauth2.wasm -target=wasi ./primitive/oauth2
|
||||
tinygo build -o dist/service_account.wasm -target=wasi ./primitive/service_account
|
||||
tinygo build -o dist/mtls.wasm -target=wasi ./primitive/mtls
|
||||
|
||||
# 部署時放到 Cloudflare Workers 的 Assets 或直接內嵌
|
||||
```
|
||||
|
||||
### 6.3 Runtime 載入(Worker 端)
|
||||
|
||||
```typescript
|
||||
// worker/src/auth-broker.ts
|
||||
import staticKeyWasm from "../dist/static_key.wasm"
|
||||
import oauth2Wasm from "../dist/oauth2.wasm"
|
||||
// ...
|
||||
|
||||
const primitives = {
|
||||
static_key: await instantiate(staticKeyWasm),
|
||||
oauth2: await instantiate(oauth2Wasm),
|
||||
service_account: await instantiate(serviceAccountWasm),
|
||||
mtls: await instantiate(mtlsWasm),
|
||||
}
|
||||
```
|
||||
|
||||
> **為什麼 WASM 而不是直接 TS?**
|
||||
> 1. 跨 runtime 可攜性(未來若 arcrun 要跑在 Fly.io、local、或客戶自建環境,同一個 primitive 能用)。
|
||||
> 2. 配合 u6u/arcrun 既定的 WASM 架構方向,不破壞統一性。
|
||||
> 3. 沙箱化:primitive 只能透過明確的 host function 存取外部世界(網路、KV),降低惡意 recipe 攻擊面。
|
||||
|
||||
---
|
||||
|
||||
## 7. AuthBroker API(給 arcrun 其他部分調用)
|
||||
|
||||
```typescript
|
||||
interface AuthBroker {
|
||||
// Agent 執行時用的主要 API
|
||||
bind(serviceId: string, secretRef: string, tenantId: string): Promise<AuthenticatedClient>
|
||||
|
||||
// 首次授權(僅 oauth2 用)
|
||||
startAuth(serviceId: string, tenantId: string): Promise<{ authorizeUrl: string, state: string }>
|
||||
completeAuth(state: string, code: string): Promise<{ secretRef: string }>
|
||||
|
||||
// 測試 credential
|
||||
test(serviceId: string, secretRef: string, tenantId: string): Promise<TestResult>
|
||||
|
||||
// 管理
|
||||
listRecipes(): Promise<Recipe[]>
|
||||
getRecipe(serviceId: string): Promise<Recipe>
|
||||
}
|
||||
|
||||
interface AuthenticatedClient {
|
||||
fetch(path: string, init?: RequestInit): Promise<Response>
|
||||
}
|
||||
```
|
||||
|
||||
**使用範例(agent 端)**:
|
||||
```typescript
|
||||
const notion = await authBroker.bind("notion", "notion-prod", ctx.tenantId)
|
||||
const res = await notion.fetch("/databases/abc/query", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ filter: {...} })
|
||||
})
|
||||
```
|
||||
|
||||
**Agent 完全不需要知道是 API Key 還是 OAuth**——`authBroker.bind()` 回傳的 client 已經注入好認證,fetch 路徑用相對 base_url 的路徑即可。
|
||||
|
||||
---
|
||||
|
||||
## 8. Storage Layout
|
||||
|
||||
### 8.1 Recipe 儲存(arcrun 平台共享)
|
||||
|
||||
**Cloudflare KV namespace**:`arcrun-recipes`
|
||||
|
||||
```
|
||||
key: recipe/{service_id}
|
||||
value: <recipe YAML 的 JSON 化版本>
|
||||
|
||||
key: recipe-list
|
||||
value: [{ service_id, display_name, icon_url, tags }, ...] # 加速 UI 列表
|
||||
```
|
||||
|
||||
**更新流程**:
|
||||
1. Recipe YAML 存在 arcrun 主 repo 的 `recipes/` 目錄下(version control + PR review)。
|
||||
2. CI 跑 schema validator,通過後上傳到 KV。
|
||||
3. UI 的 recipe 列表 5 分鐘 cache。
|
||||
|
||||
### 8.2 Secret 儲存(tenant 私有)
|
||||
|
||||
**雙層策略**:
|
||||
- **短期、低敏感** → tenant KV,用 AES-256-GCM 加密,key 從 Cloudflare Secrets Store 拿。
|
||||
- **高敏感(如 service account JSON、private key)** → 直接存 Cloudflare Secrets Store,tenant KV 只存 reference。
|
||||
|
||||
```
|
||||
# tenant KV namespace: arcrun-tenant-{tenant_id}
|
||||
key: secret/{service_id}/{instance_name}
|
||||
value: {
|
||||
"recipe_version": 1,
|
||||
"storage_mode": "kv_encrypted" | "secrets_store_ref",
|
||||
"data": <encrypted blob> | { "ref": "secrets-store-id" },
|
||||
"created_at": "...",
|
||||
"last_verified_at": "..."
|
||||
}
|
||||
|
||||
# oauth2 runtime state(primitive 自動管理)
|
||||
key: oauth_state/{service_id}/{instance_name}
|
||||
value: {
|
||||
"access_token": "...", # encrypted
|
||||
"refresh_token": "...", # encrypted
|
||||
"expires_at": 1234567890
|
||||
}
|
||||
```
|
||||
|
||||
**secretRef 格式**:`{service_id}/{instance_name}`,例如 `notion/prod`、`google_calendar/workspace-a`。
|
||||
一個 tenant 可以同一個服務存多個 instance(多帳號場景)。
|
||||
|
||||
### 8.3 KBDB 整合(可選,但建議)
|
||||
|
||||
**按照 KBDB 架構,recipe metadata 可以用 Block + Template 表達**(不是 credential 本體,只是 metadata):
|
||||
|
||||
建立一個 `service_recipe` Template:
|
||||
```json
|
||||
{
|
||||
"name": "service_recipe",
|
||||
"display_name": "服務 Recipe Metadata",
|
||||
"schema": {
|
||||
"fields": [
|
||||
{"key": "service_id", "type": "text", "required": true, "description": "服務識別"},
|
||||
{"key": "primitive", "type": "text", "required": true, "description": "使用的 primitive"},
|
||||
{"key": "version", "type": "number", "required": true, "description": "Recipe 版本"},
|
||||
{"key": "display_name", "type": "text", "required": false, "description": "顯示名稱"},
|
||||
{"key": "docs_url", "type": "text", "required": false, "description": "文件 URL"},
|
||||
{"key": "kv_key", "type": "text", "required": true, "description": "KV 實際存取 key"}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Secret **不**進 KBDB(KBDB 不該存敏感資料),只有 metadata 在 KBDB 裡方便搜尋和關聯。
|
||||
|
||||
---
|
||||
|
||||
## 9. 首次授權 UI Flow(給人類看的部分)
|
||||
|
||||
這是「學員不知道該選哪個 credential 的痛點」的終結方案。
|
||||
|
||||
### 9.1 Static Key 的 UI
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ 連接 Notion │
|
||||
├──────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Internal Integration Token │
|
||||
│ ┌────────────────────────────────────┐ │
|
||||
│ │ secret_••••••••••••• │ │
|
||||
│ └────────────────────────────────────┘ │
|
||||
│ ↳ 如何取得?→ 開啟 Notion 整合設定頁 │
|
||||
│ │
|
||||
│ [ 測試連線 ] [ 儲存 ] │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**零選項。** UI 從 recipe 的 `required_secrets` 動態生成。使用者不用選「這是 Header Auth 還是 Query Auth 還是 Custom Auth」——那是 recipe 的事,不是使用者的事。
|
||||
|
||||
### 9.2 OAuth2 的 UI
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ 連接 GitHub │
|
||||
├──────────────────────────────────────────┤
|
||||
│ │
|
||||
│ [ 🔗 使用 GitHub 帳號登入 ] │
|
||||
│ │
|
||||
│ 將跳轉到 GitHub,授權後自動返回 │
|
||||
│ │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**一個按鈕。** Client ID / Secret 由 arcrun 平台統一管理(OAuth App 註冊在 arcrun 這邊),使用者看不到也不用知道。
|
||||
|
||||
### 9.3 Service Account 的 UI
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ 連接 Google Calendar │
|
||||
├──────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Service Account JSON │
|
||||
│ ┌────────────────────────────────────┐ │
|
||||
│ │ 將整份 JSON 貼到這裡 │ │
|
||||
│ │ │ │
|
||||
│ │ { │ │
|
||||
│ │ "type": "service_account", │ │
|
||||
│ │ "project_id": "...", │ │
|
||||
│ │ ... │ │
|
||||
│ │ } │ │
|
||||
│ └────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 如何取得?→ 展開步驟說明 ▼ │
|
||||
│ 1. 打開 GCP Console │
|
||||
│ 2. IAM → Service Accounts │
|
||||
│ 3. 建立 Service Account │
|
||||
│ 4. Keys → Add Key → JSON │
|
||||
│ 5. 下載後整份貼到上方 │
|
||||
│ │
|
||||
│ [ 測試連線 ] [ 儲存 ] │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**貼 JSON + 按鈕**。不用寫任何程式碼,不用 debug 兩天。
|
||||
|
||||
---
|
||||
|
||||
## 10. 實作任務分解(CC 的 TODO list)
|
||||
|
||||
### Phase 1:核心骨架(1-2 週)
|
||||
|
||||
- [ ] **T1.1** Recipe YAML schema 定義 + JSON Schema validator(放 `arcrun/schemas/recipe.schema.json`)
|
||||
- [ ] **T1.2** Recipe loader:從 `recipes/` 目錄讀 YAML → validate → 轉 JSON 存入 KV namespace `arcrun-recipes`
|
||||
- [ ] **T1.3** TinyGo WASM 專案骨架(`arcrun/primitives/`),四個子目錄,統一 interface
|
||||
- [ ] **T1.4** Worker runtime 的 WASM loader + host function(網路、KV 讀寫)
|
||||
- [ ] **T1.5** `AuthBroker` TypeScript 類別骨架 + unit test
|
||||
|
||||
### Phase 2:Static Key(1 週)
|
||||
|
||||
- [ ] **T2.1** `static_key.wasm` 實作(header/query/body/basic_auth 四種注入)
|
||||
- [ ] **T2.2** 寫三個 recipe:`notion.yaml`, `openai.yaml`, `stripe.yaml`
|
||||
- [ ] **T2.3** Tenant KV secret 加密寫入 + `AuthBroker.bind()` 整合
|
||||
- [ ] **T2.4** `recipe.test` 執行器(驗證 credential 有效性)
|
||||
- [ ] **T2.5** E2E test:存 secret → bind → fetch Notion API → assert
|
||||
|
||||
### Phase 3:OAuth2(1-2 週)
|
||||
|
||||
- [ ] **T3.1** `oauth2.wasm` 實作(authorization_code + client_credentials + pkce)
|
||||
- [ ] **T3.2** OAuth callback endpoint(統一 URL,用 state 路由到正確 tenant/recipe)
|
||||
- [ ] **T3.3** Refresh token 自動續命邏輯(rate-limit 保護:同一 token 不能 1 秒內 refresh 多次)
|
||||
- [ ] **T3.4** 寫三個 recipe:`github.yaml`, `slack.yaml`, `google_oauth_user.yaml`
|
||||
- [ ] **T3.5** UI flow:startAuth → 跳轉 → callback → 寫 secret
|
||||
|
||||
### Phase 4:Service Account(1 週)
|
||||
|
||||
- [ ] **T4.1** `service_account.wasm` 實作(google_jwt)
|
||||
- [ ] **T4.2** Google JWT signing(ES256 / RS256)— **這個 TinyGo 需要注意 crypto 支援**
|
||||
- [ ] **T4.3** AWS SigV4 簽章實作(kind: aws_sigv4)
|
||||
- [ ] **T4.4** Recipe 繼承機制(`extends` 支援)
|
||||
- [ ] **T4.5** 寫 recipes:`_google_base`, `google_calendar`, `google_drive`, `gmail`, `aws_s3`
|
||||
|
||||
### Phase 5:mTLS + 收尾(1 週)
|
||||
|
||||
- [ ] **T5.1** `mtls.wasm` 實作(對接 Cloudflare `mTLSCertificate` binding)
|
||||
- [ ] **T5.2** Cloudflare Secrets Store 整合(高敏感 secret 用)
|
||||
- [ ] **T5.3** Recipe marketplace UI(列出可用 recipe,搜尋,一鍵設定)
|
||||
- [ ] **T5.4** Observability:每次 bind / refresh / test 記錄到 KBDB(metadata,不含 secret)
|
||||
- [ ] **T5.5** Docs:recipe 撰寫指南(讓社群能貢獻)
|
||||
|
||||
### Phase 6:Recipe 生成器(選配,1 週)
|
||||
|
||||
- [ ] **T6.1** 給 Claude 一份 API doc,自動產 recipe YAML 草稿 + 人類 review 介面
|
||||
- [ ] **T6.2** 從 OpenAPI spec 自動推論 recipe
|
||||
- [ ] **T6.3** 從 n8n credential file 反向轉譯(擷取 400+ 現成整合)
|
||||
|
||||
---
|
||||
|
||||
## 11. 關鍵技術風險與對策
|
||||
|
||||
| 風險 | 對策 |
|
||||
|---|---|
|
||||
| **TinyGo 的 crypto 支援不完整**(ES256 / RS256 JWT 簽章) | 先用 `crypto/rsa` + `crypto/ecdsa` 確認 TinyGo 版本支援;若不行,fallback 用 Worker runtime 的 `crypto.subtle` 實作這部分,WASM 透過 host function 呼叫 |
|
||||
| **Recipe 被惡意提交**(如 inject 內含 `https://evil.com` 當 token_url) | Recipe 走 PR review + CI 自動檢查 URL 白名單;社群貢獻的 recipe 預設隔離在 `community/` 目錄,使用者明確選擇才啟用 |
|
||||
| **OAuth state CSRF** | state 用 `crypto.randomUUID()` + 5 分鐘 TTL,存在 KV,callback 時比對 |
|
||||
| **Secret 在 Worker log 外洩** | `AuthContext.Secret` 禁止 `toString` / `JSON.stringify`,用 Proxy 攔截;log 層強制 redact |
|
||||
| **Token refresh 風暴**(100 個並發 request 同時發現過期) | 用 Durable Object 單執行緒化每個 secret 的 refresh,其他 request 等結果 |
|
||||
| **TinyGo WASM bundle size** | 四個 primitive 分開編譯,最大 500KB/個;lazy load |
|
||||
| **Recipe 版本升級破壞相容** | `version` 欄位 semver,tenant secret 記錄 `recipe_version`,primitive 內處理遷移 |
|
||||
|
||||
---
|
||||
|
||||
## 12. 對比 n8n(給內部 review / 行銷用)
|
||||
|
||||
| 維度 | n8n | arcrun |
|
||||
|---|---|---|
|
||||
| Credential types 數量 | 400+(一個服務一個) | 4(primitive) + N recipe |
|
||||
| 新增一個服務 | 寫 TypeScript class + rebuild + npm publish | 寫一份 YAML + PR merge |
|
||||
| AI agent 使用 | 需要讀 node 文件 + 猜參數 | 讀 recipe YAML 即可 |
|
||||
| 使用者首次設定 | 從 400+ 選項選一個(常選錯) | 搜尋服務名,只問必要 secret |
|
||||
| OAuth App 管理 | 使用者自己註冊 OAuth app | arcrun 平台統一管理(使用者只需點「授權」) |
|
||||
| 社群貢獻成本 | 高(TS + 編譯 + 測試) | 低(YAML + 測試) |
|
||||
|
||||
---
|
||||
|
||||
## 13. 接下來的決策點(需要 richblack 確認)
|
||||
|
||||
- [ ] **Recipe 版本管理策略**:採用 semver?每個 recipe 獨立版本?還是整個 recipe set 一個版本?
|
||||
- [ ] **OAuth App 註冊**:arcrun 平台要統一註冊幾個主流服務的 OAuth App(GitHub、Google、Slack、Microsoft)?還是讓 tenant 自己帶 client_id/secret?
|
||||
- 建議:**雙模式**——平台模式(方便)+ BYO 模式(企業客戶用自己的 OAuth app 有稽核好處)
|
||||
- [ ] **Recipe registry 的審核流程**:完全開放 PR 還是僅核心團隊維護?
|
||||
- 建議:`recipes/official/`(核心維護)+ `recipes/community/`(PR 審核後 merge,使用者需明確啟用)
|
||||
- [ ] **Secret rotation 政策**:要不要內建提醒 / 自動 rotate?(Phase 7+)
|
||||
|
||||
---
|
||||
|
||||
## 14. 附錄:完整範例
|
||||
|
||||
### A. 最小可行 recipe(OpenAI)
|
||||
|
||||
```yaml
|
||||
service: openai
|
||||
version: 1
|
||||
primitive: static_key
|
||||
display_name: OpenAI
|
||||
base_url: https://api.openai.com/v1
|
||||
required_secrets:
|
||||
- key: api_key
|
||||
label: API Key
|
||||
help_url: https://platform.openai.com/api-keys
|
||||
inject:
|
||||
header:
|
||||
Authorization: "Bearer {{secret.api_key}}"
|
||||
test:
|
||||
method: GET
|
||||
path: /models
|
||||
expect_status: 200
|
||||
tags: [ai]
|
||||
```
|
||||
|
||||
### B. OAuth2 recipe(Slack)
|
||||
|
||||
```yaml
|
||||
service: slack
|
||||
version: 1
|
||||
primitive: oauth2
|
||||
display_name: Slack
|
||||
base_url: https://slack.com/api
|
||||
grant: authorization_code
|
||||
oauth:
|
||||
authorize_url: https://slack.com/oauth/v2/authorize
|
||||
token_url: https://slack.com/api/oauth.v2.access
|
||||
scopes:
|
||||
- chat:write
|
||||
- channels:read
|
||||
refresh: true
|
||||
client_auth: header
|
||||
inject:
|
||||
header:
|
||||
Authorization: "Bearer {{runtime.access_token}}"
|
||||
test:
|
||||
method: POST
|
||||
path: /auth.test
|
||||
expect_json:
|
||||
ok: true
|
||||
tags: [communication]
|
||||
```
|
||||
|
||||
### C. Service Account recipe(Google Calendar)
|
||||
|
||||
```yaml
|
||||
extends: _google_base
|
||||
service: google_calendar
|
||||
version: 1
|
||||
display_name: Google Calendar
|
||||
base_url: https://www.googleapis.com/calendar/v3
|
||||
token_exchange:
|
||||
scopes:
|
||||
- https://www.googleapis.com/auth/calendar
|
||||
test:
|
||||
method: GET
|
||||
path: /users/me/calendarList
|
||||
expect_status: 200
|
||||
tags: [calendar, google]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 15. 給 CC 的行動指引
|
||||
|
||||
1. **先不要動既有 arcrun 的 credential 相關 code**,保持現狀到 Phase 2 完成再切換。
|
||||
2. **Phase 1 + 2 是 MVP**,做完可以接 80% 服務(API key 類)。
|
||||
3. **遇到 TinyGo 的技術阻礙(特別是 crypto),立刻回報**,不要自己 workaround 兩天。
|
||||
4. 每個 Phase 完成後寫一份 brief report(能跑什麼、不能跑什麼、下一步)。
|
||||
5. Recipe 撰寫先做 3-5 個手工範例,**確認 schema 夠用再開始批量生成**。
|
||||
@@ -0,0 +1,521 @@
|
||||
# u6u-core 獨立開源 Repo 需求規格 v3
|
||||
|
||||
## 背景與定位
|
||||
|
||||
### 為什麼開源
|
||||
|
||||
u6u-core 是 AI 工作流執行引擎,開源的護城河邏輯如下:
|
||||
|
||||
```
|
||||
開源(u6u-core) 閉源(InkStone 付費服務)
|
||||
──────────────────────── ──────────────────────────────
|
||||
cypher-executor(執行引擎) KBDB 向量搜尋
|
||||
WASM 零件庫(Gmail / GSheets…) KBDB graph 查詢
|
||||
credentials Worker Persona SDK / Mini-me
|
||||
CLI MatchGPT
|
||||
→ 需要訂閱,不需要 YAML / KV
|
||||
```
|
||||
|
||||
用戶自架版:YAML + CF KV,完全免費。
|
||||
升級版:不需要 YAML,直接用自然語言查 KBDB 圖譜組 workflow,這是差異化。
|
||||
|
||||
### 目前 matrix repo 狀況
|
||||
|
||||
```
|
||||
matrix/
|
||||
├── cypher-executor/ ← 要搬進 u6u-core
|
||||
├── u6u-core/
|
||||
│ ├── builtins/
|
||||
│ ├── credentials/
|
||||
│ └── registry/
|
||||
│ └── components/(21 個 WASM 零件)
|
||||
└── ...(其他 InkStone 內部服務,不搬)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 任務一:搬移 cypher-executor 進 u6u-core
|
||||
|
||||
### 目標結構
|
||||
|
||||
```
|
||||
u6u-core/(新獨立 repo,開源)
|
||||
├── README.md
|
||||
├── cypher-executor/ ← 從 matrix/cypher-executor 搬入
|
||||
│ ├── src/
|
||||
│ ├── wrangler.toml ← 需要清理(移除 InkStone 內部 bindings)
|
||||
│ └── ...
|
||||
├── credentials/ ← 從 matrix/u6u-core/credentials 搬入
|
||||
├── builtins/ ← 從 matrix/u6u-core/builtins 搬入
|
||||
└── registry/
|
||||
└── components/ ← 從 matrix/u6u-core/registry/components 搬入
|
||||
```
|
||||
|
||||
### wrangler.toml 清理(重要)
|
||||
|
||||
現有 `cypher-executor/wrangler.toml` 有大量 InkStone 內部 Service Bindings,開源版要移除:
|
||||
|
||||
**移除(InkStone 專屬,不公開):**
|
||||
```toml
|
||||
# 移除這些 services bindings:
|
||||
KBDB → inkstone-kbdb-api
|
||||
REGISTRY → inkstone-component-registry
|
||||
CLINIC_GDRIVE → clinic-gdrive
|
||||
CLINIC_EXCEL → clinic-excel
|
||||
CLINIC_ANALYSIS
|
||||
CLINIC_RENDER
|
||||
CLINIC_GSHEETS
|
||||
AICEO → inkstone-aiceo-bot
|
||||
MINI_ME → inkstone-mini-me
|
||||
```
|
||||
|
||||
**保留(用戶自己部署需要的):**
|
||||
```toml
|
||||
[[kv_namespaces]]
|
||||
binding = "EXEC_CONTEXT" # 執行上下文暫存
|
||||
|
||||
[[kv_namespaces]]
|
||||
binding = "WEBHOOKS" # workflow YAML 儲存
|
||||
|
||||
[[r2_buckets]]
|
||||
binding = "WASM_BUCKET" # WASM 零件二進位
|
||||
|
||||
[ai]
|
||||
binding = "AI" # Workers AI(auto-publish 用)
|
||||
```
|
||||
|
||||
**新增(開源版用戶需要的):**
|
||||
```toml
|
||||
[[kv_namespaces]]
|
||||
binding = "CREDENTIALS_KV" # credential 加密存儲
|
||||
```
|
||||
|
||||
### 清理後的 component-loader
|
||||
|
||||
現有 component-loader 可能有 InkStone 內部查詢邏輯(KBDB HTTP fetch),
|
||||
開源版改為:**直接從 WASM_BUCKET R2 讀取 `.wasm` 檔案**,不依賴任何外部服務。
|
||||
|
||||
---
|
||||
|
||||
## 任務二:零件完成度審查與補充
|
||||
|
||||
### 完成度標準
|
||||
|
||||
每個零件的 `component.contract.yaml` 必須包含:
|
||||
|
||||
```yaml
|
||||
# 已有(現狀)
|
||||
canonical_id: "gmail"
|
||||
input_schema: ...
|
||||
output_schema: ...
|
||||
gherkin_tests: ...
|
||||
|
||||
# 需要補充
|
||||
credentials_required: # 需要 token 的零件才需要此欄位
|
||||
- key: gmail_token # 對應 credentials.yaml 的 key 名稱慣例
|
||||
type: google_oauth # token 類型
|
||||
description: "Google OAuth access token(gmail.send scope)"
|
||||
inject_as: access_token # 執行時自動注入到 input 的哪個欄位
|
||||
|
||||
config_example: | # scaffold 指令產出的範本,帶說明註解
|
||||
send_email: # 節點名稱(可自訂)
|
||||
to: "" # 收件人 Email(必填)
|
||||
subject: "" # 主旨(必填)
|
||||
body: "" # 內文(必填)
|
||||
# access_token 由 credentials.yaml 的 gmail_token 自動注入
|
||||
```
|
||||
|
||||
### 需要 credentials_required 的零件
|
||||
|
||||
| 零件 | 需要的 token | inject_as |
|
||||
|------|-------------|-----------|
|
||||
| gmail | google_oauth | access_token |
|
||||
| google_sheets | google_oauth | access_token |
|
||||
| telegram | telegram_bot_token | bot_token |
|
||||
| line_notify | line_token | token |
|
||||
| http_request | 不固定(用戶自訂) | 不適用 |
|
||||
|
||||
### 不需要 credentials_required 的零件
|
||||
|
||||
set, filter, merge, switch, wait, if_control, foreach_control,
|
||||
try_catch, validate_json, string_ops, number_ops, array_ops,
|
||||
date_ops, cron, ai_transform_compile, ai_transform_run
|
||||
|
||||
### 審查任務(給 CC)
|
||||
|
||||
對 21 個零件逐一檢查,**只回報,不修改**:
|
||||
|
||||
```
|
||||
路徑:u6u-core/registry/components/
|
||||
|
||||
檢查四項:
|
||||
1. contract.yaml 存在?
|
||||
2. 有 credentials_required?(需要 token 的才需要)
|
||||
3. 有 config_example?
|
||||
4. main.go required 欄位與 contract input_schema required[] 一致?
|
||||
|
||||
回報格式:表格(✓ / ✗ / N/A)+ 每個零件缺少什麼
|
||||
不修改任何檔案。
|
||||
```
|
||||
|
||||
審查完成後,再逐一補充缺少的欄位。
|
||||
|
||||
---
|
||||
|
||||
## 任務三:workflow YAML 格式定義
|
||||
|
||||
### 格式設計原則
|
||||
|
||||
- `flow:` 用 `>>` 三元組描述資料流,人類直接看懂
|
||||
- 關係詞使用有語意的詞,**不使用 PIPE**(PIPE 等於什麼都沒說)
|
||||
- `config:` 用零件名稱對應參數,欄位從 contract 的 config_example 來
|
||||
- credential 全部集中在 `credentials.yaml`,workflow 只寫 `{{creds.KEY}}`
|
||||
|
||||
### 可用關係詞
|
||||
|
||||
| 關係詞 | 語意 | 使用時機 |
|
||||
|--------|------|---------|
|
||||
| `完成後` | 前一個成功後執行 | 最常用的串接 |
|
||||
| `失敗時` | 前一個失敗後執行 | 錯誤處理 |
|
||||
| `對每個` | 對陣列每個元素執行 | 迭代 |
|
||||
| `條件滿足時` | 條件分支 | 判斷 |
|
||||
| `ON_SUCCESS` | 同「完成後」 | 英文版 |
|
||||
| `ON_FAIL` | 同「失敗時」 | 英文版 |
|
||||
| `FOREACH` | 同「對每個」 | 英文版 |
|
||||
| `IF` | 同「條件滿足時」 | 英文版 |
|
||||
| `ON_CLICK` | 前端按鈕觸發 | UI 互動 |
|
||||
| `CALLS_SUBFLOW` | 呼叫子工作流 | 模組化 |
|
||||
|
||||
**禁止使用 PIPE** — 任何串接都應該用有語意的關係詞。
|
||||
|
||||
### workflow.yaml 範例
|
||||
|
||||
```yaml
|
||||
name: newsletter_subscribe
|
||||
description: 訂閱電子報,發感謝信並記錄到 GSheets
|
||||
|
||||
flow:
|
||||
- "input >> 完成後 >> send_thanks"
|
||||
- "input >> 完成後 >> save_to_sheet"
|
||||
- "send_thanks >> 完成後 >> output"
|
||||
- "send_thanks >> 失敗時 >> notify_error"
|
||||
- "save_to_sheet >> 完成後 >> output"
|
||||
|
||||
config:
|
||||
send_thanks: # componentId: gmail(由 cypher-executor 語意搜尋對應)
|
||||
to: "{{input.email}}"
|
||||
subject: "感謝訂閱!"
|
||||
body: "歡迎加入!"
|
||||
# access_token 由 credentials.yaml 的 gmail_token 自動注入
|
||||
|
||||
save_to_sheet: # componentId: google_sheets
|
||||
action: write
|
||||
spreadsheet_id: "{{creds.sheet_id}}"
|
||||
range: "訂閱者!A:B"
|
||||
values: [["{{input.email}}", "{{input.timestamp}}"]]
|
||||
# access_token 由 credentials.yaml 的 google_oauth 自動注入
|
||||
|
||||
notify_error: # componentId: telegram
|
||||
chat_id: "{{creds.telegram_chat_id}}"
|
||||
text: "發信失敗:{{input.email}}"
|
||||
# bot_token 由 credentials.yaml 的 telegram_bot_token 自動注入
|
||||
```
|
||||
|
||||
### credentials.yaml 範例
|
||||
|
||||
```yaml
|
||||
# credentials.yaml — 類似 .env,加入 .gitignore,不進 git
|
||||
# u6u creds push 時逐一加密上傳到 CREDENTIALS_KV
|
||||
|
||||
gmail_token: "ya29.a0AfB_..."
|
||||
google_oauth: "ya29.a0AfB_..."
|
||||
sheet_id: "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms"
|
||||
telegram_bot_token: "123456:ABC-..."
|
||||
telegram_chat_id: "987654321"
|
||||
```
|
||||
|
||||
### 執行時 credential 注入流程
|
||||
|
||||
```
|
||||
u6u run newsletter_subscribe
|
||||
↓
|
||||
cypher-executor 讀 workflow YAML
|
||||
↓
|
||||
遇到節點 send_thanks → 查 contract:credentials_required.inject_as = access_token
|
||||
↓
|
||||
去 CREDENTIALS_KV 讀 gmail_token → 解密
|
||||
↓
|
||||
注入到 WASM input:{ to, subject, body, access_token: "ya29..." }
|
||||
↓
|
||||
WASM 執行,用戶的 config 裡完全不出現 token
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 任務四:CLI 開發
|
||||
|
||||
### 技術選型
|
||||
|
||||
- **語言**:Node.js(TypeScript)
|
||||
- **安裝**:`npm i -g u6u`
|
||||
- **依賴**:`commander`、`js-yaml`、`chalk`、`ora`
|
||||
|
||||
### 指令規格
|
||||
|
||||
#### `u6u init`
|
||||
|
||||
互動式初始化,產生 `~/.u6u/config.yaml` 和本機 `credentials.yaml`。
|
||||
|
||||
```
|
||||
$ u6u init
|
||||
? Cloudflare Account ID: abc123
|
||||
? KV Namespace ID (WEBHOOKS): xyz789
|
||||
? KV Namespace ID (CREDENTIALS_KV): abc456
|
||||
? R2 Bucket name (WASM_BUCKET): u6u-wasm
|
||||
? Cypher Executor Worker URL: https://cypher-executor.xxx.workers.dev
|
||||
? Credentials Worker URL: https://u6u-credentials.xxx.workers.dev
|
||||
? Cloudflare API Token: ***
|
||||
|
||||
✓ 設定完成 → ~/.u6u/config.yaml
|
||||
✓ 建立 credentials.yaml(已加入 .gitignore)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `u6u creds push [credentials.yaml]`
|
||||
|
||||
讀取 credentials.yaml,逐一加密上傳到 CREDENTIALS_KV。
|
||||
|
||||
```
|
||||
$ u6u creds push
|
||||
讀取 ./credentials.yaml...
|
||||
✓ gmail_token → 已加密上傳
|
||||
✓ google_oauth → 已加密上傳
|
||||
✓ sheet_id → 已上傳
|
||||
✓ telegram_bot_token → 已加密上傳
|
||||
✓ telegram_chat_id → 已上傳
|
||||
共上傳 5 個 credentials
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `u6u push <workflow.yaml>`
|
||||
|
||||
解析 `flow:` 三元組,轉換成 triplets 陣列,上傳到 WEBHOOKS KV。
|
||||
|
||||
```
|
||||
$ u6u push newsletter_subscribe.yaml
|
||||
✓ 已上傳 newsletter_subscribe → WEBHOOKS KV
|
||||
Webhook: https://cypher-executor.xxx.workers.dev/webhook/abc123
|
||||
```
|
||||
|
||||
轉換邏輯(CLI 負責):
|
||||
|
||||
```
|
||||
flow[] 三元組
|
||||
↓
|
||||
POST /cypher/search(取得 ExecutionGraph)
|
||||
↓
|
||||
連同 config 存入 WEBHOOKS KV
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `u6u run <workflow_name> [--input key=value...]`
|
||||
|
||||
觸發執行,顯示結果。
|
||||
|
||||
```
|
||||
$ u6u run newsletter_subscribe --input email=test@example.com
|
||||
⏳ 執行中...
|
||||
✓ 完成(2.3s)
|
||||
|
||||
結果:
|
||||
send_thanks: { success: true, data: { message_id: "xxx" } }
|
||||
save_to_sheet: { success: true, data: { range: "訂閱者!A2" } }
|
||||
```
|
||||
|
||||
錯誤時給出具體修復步驟:
|
||||
|
||||
```
|
||||
✗ 執行失敗:節點 send_thanks
|
||||
原因: access_token 無效(401 Unauthorized)
|
||||
|
||||
修復方式:
|
||||
1. 更新 credentials.yaml 的 gmail_token
|
||||
2. 執行 u6u creds push
|
||||
3. 重新執行 u6u run newsletter_subscribe
|
||||
|
||||
取得 Google OAuth token:
|
||||
→ https://developers.google.com/oauthplayground
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `u6u validate <workflow.yaml>`
|
||||
|
||||
執行前完整驗證,提前發現問題。
|
||||
|
||||
```
|
||||
$ u6u validate newsletter_subscribe.yaml
|
||||
✓ YAML 格式正確
|
||||
✓ flow 三元組語法正確
|
||||
✓ 所有關係詞有效(無 PIPE)
|
||||
✓ 所有節點名稱在 config 有對應
|
||||
✓ 所有零件存在於 WASM_BUCKET
|
||||
✓ credentials 對應:
|
||||
gmail_token ✓ 已上傳
|
||||
google_oauth ✓ 已上傳
|
||||
sheet_id ✓ 已上傳
|
||||
telegram_bot_token ✗ 缺少
|
||||
|
||||
⚠ 缺少 1 個 credential:
|
||||
telegram_bot_token → 請加入 credentials.yaml 並執行 u6u creds push
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `u6u parts`
|
||||
|
||||
列出可用零件。
|
||||
|
||||
```
|
||||
$ u6u parts
|
||||
可用零件(21):
|
||||
|
||||
[整合]
|
||||
• gmail Gmail 發信
|
||||
需要: to, subject, body
|
||||
credential: gmail_token(google_oauth)
|
||||
• google_sheets 讀寫 Google 試算表
|
||||
需要: spreadsheet_id, range, action
|
||||
credential: google_oauth
|
||||
• telegram Telegram Bot 發訊息
|
||||
需要: chat_id, text
|
||||
credential: telegram_bot_token
|
||||
• line_notify LINE Notify
|
||||
需要: message
|
||||
credential: line_token
|
||||
• http_request 任意 HTTP 請求
|
||||
需要: url
|
||||
|
||||
[控制]
|
||||
• if_control 條件分支
|
||||
• foreach_control 迭代執行
|
||||
• try_catch 錯誤處理
|
||||
• switch 多路路由
|
||||
• wait 等待 N 毫秒
|
||||
|
||||
[資料]
|
||||
• set / filter / merge / string_ops / number_ops / array_ops / date_ops
|
||||
|
||||
[AI]
|
||||
• ai_transform_compile 自然語言 → JS 轉換函式
|
||||
• ai_transform_run 執行已編譯的轉換
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `u6u parts scaffold <component>`
|
||||
|
||||
從 contract 的 config_example 產出可直接貼入 workflow 的 config 範本。
|
||||
|
||||
```
|
||||
$ u6u parts scaffold gmail
|
||||
|
||||
貼入 workflow.yaml 的 config 區塊:
|
||||
|
||||
send_email: # 節點名稱(可自訂)
|
||||
to: "" # 收件人 Email(必填)
|
||||
subject: "" # 主旨(必填)
|
||||
body: "" # 內文(必填)
|
||||
# access_token 由 credentials.yaml 的 gmail_token 自動注入
|
||||
|
||||
貼入 credentials.yaml:
|
||||
|
||||
gmail_token: "" # Google OAuth token
|
||||
# 取得方式:https://developers.google.com/oauthplayground
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `u6u list`
|
||||
|
||||
列出 WEBHOOKS KV 中所有 workflow。
|
||||
|
||||
```
|
||||
$ u6u list
|
||||
• newsletter_subscribe (更新: 2026-04-16)
|
||||
• daily_summary (更新: 2026-04-15)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `u6u logs <workflow_name>`
|
||||
|
||||
查看最近執行記錄。
|
||||
|
||||
```
|
||||
$ u6u logs newsletter_subscribe
|
||||
2026-04-16 14:30 ✓ 成功 2.1s
|
||||
2026-04-16 09:00 ✗ 失敗 send_thanks: 401 Unauthorized
|
||||
2026-04-15 09:00 ✓ 成功 1.8s
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 開發順序
|
||||
|
||||
### Phase 1:搬移與清理(先做)
|
||||
|
||||
```
|
||||
1. 建立新的獨立 repo:u6u-core
|
||||
2. 從 matrix 搬入:
|
||||
- cypher-executor/
|
||||
- u6u-core/credentials/
|
||||
- u6u-core/builtins/
|
||||
- u6u-core/registry/
|
||||
3. 清理 cypher-executor/wrangler.toml(移除 InkStone 內部 bindings)
|
||||
4. 確認 component-loader 只依賴 WASM_BUCKET,不依賴 KBDB / REGISTRY
|
||||
5. 本機部署測試
|
||||
```
|
||||
|
||||
### Phase 2:零件完成度(搬移後)
|
||||
|
||||
```
|
||||
6. 審查 21 個零件的 contract.yaml
|
||||
7. 補充 credentials_required(gmail, google_sheets, telegram, line_notify)
|
||||
8. 補充 config_example(全部 21 個)
|
||||
9. 驗證 main.go required 欄位與 contract 一致
|
||||
```
|
||||
|
||||
### Phase 3:CLI(完成度補充後)
|
||||
|
||||
```
|
||||
10. u6u init
|
||||
11. u6u creds push
|
||||
12. u6u push
|
||||
13. u6u run(含 credential 自動注入)
|
||||
14. u6u parts / u6u parts scaffold
|
||||
15. u6u validate
|
||||
16. u6u list / u6u logs
|
||||
```
|
||||
|
||||
### Phase 4:開源發布
|
||||
|
||||
```
|
||||
17. 撰寫 README.md(快速開始、零件列表、workflow 語法說明)
|
||||
18. 撰寫 CONTRIBUTING.md(如何新增零件)
|
||||
19. 發布到 GitHub
|
||||
20. npm publish(u6u CLI)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 不在此次範圍
|
||||
|
||||
- KBDB 整合(未來付費服務)
|
||||
- 向量搜尋 / graph 查詢
|
||||
- 前端管理介面
|
||||
- Webhook trigger 設定(用戶自行設定 CF Cron)
|
||||
- 新增 WASM 零件(現有 21 個先做完整,之後再擴充)
|
||||
@@ -0,0 +1,89 @@
|
||||
# **u6u 智慧前端與工匠 AI 開發藍圖 (v4.0)**
|
||||
|
||||
## **一、 核心設計理念:意圖導向的雙面畫布**
|
||||
|
||||
u6u 的前端不是傳統的平面繪圖板,而是一個類似 Android Studio 或 Figma 的\*\*「結構化標籤編輯器」**。 畫布上的每一個元件(Web Component),本質上都是一個**「意圖發射器 (Intent Emitter)」\*\*。前端只負責「長得好看」與「收集人類動作」,後端全權負責「業務邏輯」。
|
||||
|
||||
### **人機協作的「雙向同步」**
|
||||
|
||||
* **AI 詠唱修改:** 人類說「把按鈕改醒目一點」,CEO AI 在背景將 \<u6u-btn color="blue"\> 修改為 \<u6u-btn color="neon-red"\>,畫面瞬間更新。
|
||||
* **人類手動覆寫:** 人類覺得 AI 調的紅色太暗,直接在右側屬性面板 (Properties Panel) 點選色碼器,底層 HTML 屬性隨之改變。**AI 也能「看見」這個改變,從中學習人類的審美偏好。**
|
||||
|
||||
## **二、 畫布介面設計與運作機制**
|
||||
|
||||
### **1\. 正反面翻轉機制 (The "Flip" Interface)**
|
||||
|
||||
每個 UI 零件在畫布上都有「一體兩面」:
|
||||
|
||||
* **正面 (UI 視圖):** 顯示 HTML 渲染的視覺結果(按鈕、溫度計、圖表)。人類可以在此調整 CSS 屬性、對齊方式與主題顏色。
|
||||
* **反面 (邏輯視圖):** 點擊「翻面」按鈕後,會進入底層的工作流設定。這裡使用 u6u 的自定義 Cypher 視覺化語法(例如 \>\> 符號)。
|
||||
* *範例:* \[ UI\_Button: "緊急停機" \] \>\> (Intent: emergency\_stop) \>\> \[ WASM: gsheets\_create \]
|
||||
|
||||
### **2\. 智慧容器與區域感知 (Smart Zone Awareness)**
|
||||
|
||||
為了消滅傳統 iPaaS(如 n8n)最痛苦的「手動變數綁定」,u6u 畫布具備「區域感知」能力。
|
||||
|
||||
* **底層邏輯(獨立元件):** 畫布上的 TextInput 與 Button 都是各自獨立的原子元件。
|
||||
* **麻瓜體驗(智慧表單):** 當使用者將這兩個元件拖入同一個排版容器(例如 \<u6u-card\>)時,系統會自動建立上下文關聯。當按下按鈕並觸發 Webhook 時,按鈕會**自動打包同容器內所有輸入框的值**一併送出:
|
||||
{
|
||||
"intent": "query\_attendance",
|
||||
"payload": { "employee\_id": "A1234" } // 自動從旁邊的 TextInput 抓取
|
||||
}
|
||||
|
||||
使用者完全不需要理解「表單傳值」或「變數綁定」,拖拉組合即生效。
|
||||
|
||||
### **3\. 多重事件插槽與靜態屬性 (Multi-Event Slots)**
|
||||
|
||||
一個前端元件可以具備多種觸發行為,系統透過介面將「視覺」與「後端邏輯」徹底分流:
|
||||
|
||||
* **靜態視覺註釋:** 例如 mouseover 顯示提示。使用者只需在屬性面板輸入 Tooltip 文字,底層僅修改 HTML 屬性 \<u6u-btn tooltip="..."\>,不消耗任何伺服器資源或 Webhook。
|
||||
* **動態意圖綁定:** 在「反面」邏輯視圖中,使用者可以針對不同事件綁定不同的工作流:
|
||||
* ⚡ When: 點擊 (onClick) ➡️ \[ 綁定至 Webhook A:送出查詢 \]
|
||||
* ⚡ When: 獲得焦點 (onFocus) ➡️ \[ 綁定至 Webhook B:載入歷史紀錄 \]
|
||||
|
||||
### **4\. 智慧上下文替換 (Smart Contextual Substitution)**
|
||||
|
||||
當主管在畫布上對著一個已連接 Webhook 的「按鈕」點擊右鍵選擇「替換元件」時:
|
||||
|
||||
* 系統讀取反面的 Cypher 連線,發現需要發射一個 trigger 意圖。
|
||||
* 系統過濾 KBDB 零件宇宙,**只顯示相容的 UI 零件**(如下拉選單、開關)。不具備 trigger 能力的元件(如純文字標籤)會被自動隱藏,確保替換後系統絕對不會報錯。
|
||||
|
||||
## **三、 原子化組裝與極致解耦:CEO AI 與工匠 AI 的分工**
|
||||
|
||||
當企業主管提出需求:「我需要一個『輸入工號即可查詢員工打卡紀錄』的工具」時,這在 u6u 中**並不是一個單一零件**,而是一個由多個「原子零件」構成的**工作流 (Workflow)**。
|
||||
|
||||
### **1\. CEO AI 的動態組裝 (Macro Assembly)**
|
||||
|
||||
面對需求,大腦 AI (CEO AI) 會快速從 KBDB 挑選現成積木進行組合:
|
||||
|
||||
* **前端 Prototype 組合:** \<u6u-text-input\> \+ \<u6u-btn\> \+ \<u6u-text-field\>。
|
||||
* **後端 Pseudo Code 組合:** webhook\_receiver \>\> check\_kbdb(template\_name, value)。
|
||||
AI 會自動用 Cypher 將前端的表單意圖連線到後端工作流。對不懂程式的主管來說,前端就是 Prototype,翻面的 Cypher 就是 Pseudo Code,整套系統瞬間組合完畢。
|
||||
|
||||
### **2\. 工匠 AI (Forge AI) 的原子生產線**
|
||||
|
||||
只有當現有零件庫缺乏特定原子時,機甲才會喚醒工匠 AI 進行開發。
|
||||
|
||||
**解耦哲學:** 後端零件開發時,根本不需要管前端零件長什麼樣子(是按鈕還是輸入框)。只要前端送來的 JSON 它能吃,就是合法的候選零件。
|
||||
|
||||
* **Step 1: 規格定義 (Interface Contract)**
|
||||
工匠 AI 只在乎接收與回傳的 JSON 格式。
|
||||
* **Step 2: 打造純粹的邏輯黑箱 (後端 TinyGo WASM)**
|
||||
工匠 AI 撰寫 Go 程式,編譯成 .wasm。絕對純粹的後端邏輯,沒有任何介面程式碼。
|
||||
* **Step 3: 獨立的前端零件生產 (若需要)**
|
||||
獨立生成 Web Component(如 \<u6u-3d-pie-chart\>),只負責接收特定 JSON 來渲染畫面。
|
||||
* **Step 4: 註冊與編目 (Cataloging into KBDB)**
|
||||
新積木註冊到圖資料庫,未來的 CEO AI 即可將其與任何既有的前端或後端元件進行無限的叉積組合。
|
||||
|
||||
## **四、 架構總結與終極產品體驗**
|
||||
|
||||
這套前端架構讓 u6u 成為一個\*\*「表裡如一」**的系統,成功創造了**「麻瓜的 ERP 幻覺」\*\*:
|
||||
|
||||
使用者不需要知道什麼是「前後端分離」、什麼是「API 串接」。他們只是覺得:
|
||||
|
||||
1. 拖拉了一個溫度計。
|
||||
2. 翻面把「數值更新」連線到「機台感測器」。
|
||||
3. 拖拉了一個紅按鈕放在旁邊。
|
||||
4. 翻面把「點擊」連線到「發送 Line 警報」。
|
||||
|
||||
在十分鐘的「繪圖」過程中,沒有寫一行程式碼,也沒有設定任何變數。但透過**前端 Web Components** 的視覺封裝、**智慧容器**的自動資料打包,以及**後端 TinyGo WASM \+ Cypher** 的無縫承接,他們在不知不覺中,就搭建出了一套具備微服務架構、高擴展性、且可部署至極限邊緣的企業級系統。
|
||||
@@ -0,0 +1,136 @@
|
||||
# **u6u 系統與零件宇宙全景規劃白皮書 (The u6u Ecosystem Blueprint)**
|
||||
|
||||
## **1\. 核心理念與願景**
|
||||
|
||||
u6u 旨在解決傳統 Workflow 軟體 (如 n8n) 存在的「單線程、沈重、複雜、難以組成系統」的痛點。
|
||||
|
||||
透過結合 Cloudflare Workers (輕量邊緣運算) 與 Cypher (圖形資料庫關係),u6u 提供一個由 AI 驅動的「意圖到系統」生成平台。所有的系統功能皆被拆解為可複用、可組合的「零件 (Components)」,並在一個會自然淘汰、自我修復的「零件宇宙 (Component Universe)」中演化。
|
||||
|
||||
## **2\. 四層架構拆解 (Four-Tier Architecture)**
|
||||
|
||||
u6u 的工作模式採取由上到下 (Top-Down) 的 Break-down 機制:
|
||||
|
||||
1. **Polaris (北極星層 / 意圖層):**
|
||||
* 用戶以自然語言描述商業模式與想法(例如:「我要做一個 AI 客服表單系統」)。
|
||||
* 這是整個系統的起點,AI 會根據 Polaris 將意圖拆解為 Prototype。
|
||||
2. **Prototype (原型 / 前端層):**
|
||||
* 定義前端的版型、頁面描述、UI 元件以及它們的屬性。
|
||||
* 作為使用者互動的入口,透過觸發事件 (Triggers) 連接到後端 Workflow。
|
||||
3. **Workflow (工作流層):**
|
||||
* 系統的 Orchestrator (編排者),定義業務邏輯的走向。
|
||||
* 透過 Cypher 語法與三元組,定義每個節點 (Component) 的執行順序與條件分支。
|
||||
4. **Component (零件層 / 節點):**
|
||||
* 最底層的執行單元,主要分為兩類:
|
||||
* **功能型 (Logic):** 迴圈、條件判斷、資料轉換、統計等 (透過 CF Workers 執行 JS 邏輯)。
|
||||
* **介接型 (API):** 呼叫外部服務 (Webhook, HTTP Request)。
|
||||
|
||||
## **3\. 統一描述語言:擴展三元組與跨層級 YAML**
|
||||
|
||||
為了解決跨 YAML 檔案串接的問題,u6u 採用易於人類閱讀與 AI 生成的 **「A \>\> 關係 \>\> B」** 三元組語法,結合自定義的 URI 協議 (workflow://, component://, ui://),實現跨層級的連結。
|
||||
|
||||
### **綜合 YAML 範例與三元組串接**
|
||||
|
||||
*\# 1\. Prototype YAML (描述前端)*
|
||||
|
||||
kind: Prototype
|
||||
|
||||
id: ui\_dashboard
|
||||
|
||||
triplets:
|
||||
|
||||
*\# 結構與版型零件*
|
||||
|
||||
\- "ui\_dashboard \>\> CONTAINS \>\> layout\_admin"
|
||||
|
||||
\- "layout\_admin \>\> CONTAINS \>\> btn\_submit"
|
||||
|
||||
*\# UI 零件與屬性零件 (CSS/行為)*
|
||||
|
||||
\- "btn\_submit \>\> IS\_A \>\> ui://components/Button"
|
||||
|
||||
\- "btn\_submit \>\> HAS\_STYLE \>\> style://tokens/GlowEffect"
|
||||
|
||||
\- "btn\_submit \>\> HAS\_BEHAVIOR \>\> anim://motions/Pulse"
|
||||
|
||||
*\# 跨層級串接:前端觸發 Workflow*
|
||||
|
||||
\- "btn\_submit \>\> ON\_CLICK \>\> workflow://workflows/process\_data.yaml"
|
||||
|
||||
*\# 2\. Workflow YAML (描述工作流編排)*
|
||||
|
||||
kind: Workflow
|
||||
|
||||
id: wf\_process\_data
|
||||
|
||||
triplets:
|
||||
|
||||
*\# 跨層級串接:Workflow 呼叫 Component*
|
||||
|
||||
\- "START \>\> TRIGGERS \>\> step\_validate"
|
||||
|
||||
\- "step\_validate \>\> IS\_A \>\> component://components/validate\_json"
|
||||
|
||||
*\# Workflow 節點間的流轉 (轉譯為 Cypher 關係)*
|
||||
|
||||
\- "step\_validate \>\> ON\_SUCCESS \>\> step\_call\_api"
|
||||
|
||||
\- "step\_validate \>\> ON\_FAIL \>\> step\_notify\_error"
|
||||
|
||||
*\# 跨 Workflow 串接*
|
||||
|
||||
\- "step\_call\_api \>\> CALLS\_SUBFLOW \>\> workflow://workflows/save\_to\_db.yaml"
|
||||
|
||||
## **4\. 零件宇宙 (Component Universe) 的審核與淘汰機制**
|
||||
|
||||
在 u6u 中,所有的 UI、Style、Logic、API 都是「零件」。當 AI 發現缺乏所需零件時,會自動創造它。為了確保生態系的健康,必須建立嚴格的**審核標準**與**自然淘汰機制**。
|
||||
|
||||
### **4.1 零件的創建與審核標準 (Pass/Fail Criteria)**
|
||||
|
||||
當 AI 或開發者提交一個新零件時,系統會啟動自動化沙盒測試。必須完全通過以下標準,零件才能進入「宇宙」供他人使用:
|
||||
|
||||
1. **功能型零件 (Logic Components):**
|
||||
* **Gherkin BDD 驗收:** 必須附帶 Feature/Scenario 測試規格,且執行結果 100% 通過 (例如:Given input JSON, When split, Then returns Array)。
|
||||
* **效能門檻:** 邊緣運算 (CF Workers) 執行時間需低於設定閾值 (例如 \< 50ms),無記憶體洩漏。
|
||||
2. **介接型零件 (API Components):**
|
||||
* **連線驗證:** 端點 (Endpoint) 必須能 ping 通,或回傳正確的 2xx HTTP Status (提供 Mock Payload 測試)。
|
||||
* **Credential 安全:** 不可將 Token 或 Secret 寫死在代碼中,必須嚴格宣告所需的 Environment Variables 規格。
|
||||
3. **前端與屬性零件 (UI & Style Components):**
|
||||
* **渲染驗證:** CSS / 組件代碼不能導致瀏覽器 Crash。
|
||||
* **相容性檢查:** 不可包含嚴格衝突的樣式 (例如寫死 \!important 破壞全域版型)。
|
||||
|
||||
### **4.2 零件宇宙的自然淘汰 (Natural Selection)**
|
||||
|
||||
零件一旦上架,將面臨殘酷的達爾文機制:
|
||||
|
||||
* **AI 偏好權重:** AI (透過 MCP 搜尋時) 會優先選擇「成功率高、執行速度快、被調用次數多」的零件。
|
||||
* **降級與墓地:** 連續 30 天無人/無 AI 使用,或錯誤率飆升的零件,會被降級 (Deprecated)。最終轉入「零件墓地」,從首選搜尋清單中剔除。
|
||||
|
||||
## **5\. 系統自癒與 AI 避坑機制 (Auto-Healing & Pitfall Avoidance)**
|
||||
|
||||
這是 u6u 維持系統穩定運作的最核心機制。工作流不只要能跑,跑完後還必須經歷 **「強制 AI 評價 (Mandatory AI Evaluation)」**。
|
||||
|
||||
### **5.1 運行後的強制評價迴圈**
|
||||
|
||||
每當一個 Workflow 在 CF Workers 上執行完畢 (或發生異常中斷),系統攔截日誌並強制啟動 AI 評價代理 (Evaluator Agent)。
|
||||
|
||||
* **評估維度:**
|
||||
* **狀態:** 成功 / 失敗 (Crash) / 逾時 (Timeout)。
|
||||
* **效能:** 耗時是否合理 (例如 API 突然變得很慢)。
|
||||
* **警告訊息:** 資源消耗過大、API 回傳即將停用的 Warning。
|
||||
|
||||
### **5.2 自癒與避坑流程 (The Feedback Loop)**
|
||||
|
||||
當 Evaluator Agent 發現問題時,會觸發以下流程:
|
||||
|
||||
1. **回報與通知 (Notify):** 系統自動生成修復 Ticket,並通知當初建立該零件/工作流的製作人 (或系統管理員)。
|
||||
2. **AI 嘗試修復 (Auto-Fix):** 系統派遣「修復型 AI」嘗試讀取錯誤日誌並修復代碼 (例如:API 規格變更導致 JSON 解析錯誤,AI 自動修改解析邏輯)。
|
||||
3. **驗收與部署:** 修復後的代碼若通過 Gherkin 驗收,則無縫熱更新。
|
||||
4. **避坑標記 (Pitfall Marking):** \- 如果 AI 無法修復 (例如:外部第三方 API 永久倒閉,或邏輯存在根本性死結)。
|
||||
* 系統會在 Cypher 圖形資料庫中,將該零件或該特定的三元組關係標記為 \[HAS\_PITFALL\]。
|
||||
* **結果:** 下一個生成系統的 AI 在透過 MCP 搜尋時,會讀取到這個坑的紀錄,並**強制繞道**,改用其他方案或生成新的零件,實現「前人踩坑,後 AI 避坑」的群體智慧。
|
||||
|
||||
## **6\. 結論**
|
||||
|
||||
u6u 不是一個單純的開發工具,它是一個**生物體積木系統**。
|
||||
|
||||
透過「三元組」統一語言打破系統壁壘,透過「零件審核」保證基因優良,再透過「強制評價與避坑機制」實現演化。當這套系統運轉起來,AI 就能在其中無止盡地為人類組裝出越來越強大、越來越穩定的商業應用。
|
||||
@@ -0,0 +1,99 @@
|
||||
# **u6u 自動演化 ERP:全端統一架構規格書 (v3.0)**
|
||||
|
||||
## **1\. 架構核心思想 (The Core Philosophy)**
|
||||
|
||||
u6u ERP 是一套具備自我修復與功能擴充能力的「有機體」系統。
|
||||
|
||||
為確保系統在跨國雲端、機密地端與斷網邊緣皆能無縫運作,系統採用\*\*「向下相容的絕對標準化」\*\*:由最嚴苛的無人機環境來定義全域零件標準。
|
||||
|
||||
系統運作依賴三位一體的語言與載體:
|
||||
|
||||
1. **大腦戰略層 (Markdown / Gherkin):** CEO AI 負責閱讀與撰寫,定義全域戰略、系統設計文件 (SDD) 與商業演算法則 (如 ROI 門檻)。
|
||||
2. **神經編排層 (Cypher):** u6u 引擎的核心。AI 透過撰寫 Cypher 語法來進行業務邏輯的動態編排、狀態流轉與意圖攔截。
|
||||
3. **肌肉執行層 (TinyGo WASM):** 系統中**唯一合法**的零件規格。負責所有具體的 I/O、資料轉換與運算,保證極小體積與極速冷啟動。
|
||||
|
||||
## **2\. 實戰演練:離岸風機巡檢的黑天鵝事件 (三層架構實踐)**
|
||||
|
||||
為了具體理解這套系統如何運作,我們以一次「離岸風電場巡檢」的突發事件為例,展示雲、地、邊三層架構的完美協同。
|
||||
|
||||
### **第一階段:戰略下達與沙盤推演 (Tier 1 ➡️ Tier 2 ➡️ Tier 3\)**
|
||||
|
||||
跨國能源集團的**雲端總部 (Tier 1\)** 收到年度檢修排程。雲端的 **CEO AI** 讀取了全局的 Markdown 戰略文件,向遠在海岸線的**地端指揮中心 (Tier 2\)** 下達指令。
|
||||
|
||||
地端指揮中心(配備強大伺服器與 workerd 叢集)的**部門主管 AI** 將任務拆解給 50 台即將出海的無人機。無人機 07 號 **(Tier 3\)** 的小腦 AI 透過本地的 Cypher 引擎進行沙盤推演,從地端資料庫下載了 rgb\_vision.wasm (光學影像)、lidar\_scan.wasm (光達) 等 60 個可能會用到的 TinyGo 零件,存入本地記憶體後隨船出航。
|
||||
|
||||
### **第二階段:邊緣的極限生存 (Tier 3 獨立運作)**
|
||||
|
||||
無人機 07 號來到海上 50 公里處,完全失去對外網路。突然,海上濃霧降臨。
|
||||
|
||||
原本執行中的 Cypher 圖譜卡住了,因為 rgb\_vision.wasm 回報「無法獲取清晰影像」。07 號沒有驚慌,它內建的輕量級 Go \+ Wazero 引擎在 0.1 秒內動態重組了圖譜邏輯:剔除光學零件,瞬間載入並執行 lidar\_scan.wasm,不需人類介入,繼續在濃霧中精準貼行。
|
||||
|
||||
### **第三階段:游擊網與地端代工 (Tier 3 ↔️ Tier 2\)**
|
||||
|
||||
巡檢中途,07 號發現風機葉片上有極罕見的「蜂巢狀熱應力微裂紋」,但它帶出來的 60 個零件中沒有對應的分析工具。
|
||||
|
||||
07 號飛昇至濃霧上方,短暫連上母船的微弱區域網路發起「短點射傳輸 (Burst)」:{"intent": "計算蜂巢狀熱應力微裂紋擴散率"},拿到任務單號後立刻斷網潛回霧中。
|
||||
|
||||
海岸線的**地端指揮中心 (Tier 2\)** 收到需求。強大的**工匠 AI** 瞬間啟動,生成了一段 TinyGo 程式碼,並在本地編譯與測試。三分鐘後,07 號再次探頭連網,下載了熱騰騰的 honeycomb\_analyzer.wasm,並將其編織進 Cypher 圖譜中完成測量。
|
||||
|
||||
### **第四階段:CEO AI 的全局戰略覆寫 (Tier 2 ➡️ Tier 1\)**
|
||||
|
||||
同時,地端指揮中心匯整了無人機傳回的陣風數據,同步給**雲端總部 (Tier 1\)**。雲端的 CEO AI 呼叫 roi\_calculator.wasm 進行試算,發現風暴將造成設備重大損壞(ROI 極低)。
|
||||
|
||||
CEO AI 立刻修改總部的 Markdown 戰略文件,新增一條 BDD 規則:「風速大於 22m/s,立刻轉為陣列抗風模式」。新的最高指導 Cypher 範本瞬間下發至地端,再廣播給所有無人機。07 號收到新命令,掛起原任務,與機群組成抗風陣型,安全度過危機。
|
||||
|
||||
## **3\. 物理拓撲與技術棧 (The 3-Tier Tech Stack)**
|
||||
|
||||
透過 **KBDB Adapter** 抽象層,AI 在任何環境中呼叫的 API 介面皆一致,但底層基礎設施依據物理環境的豐饒度進行適配。
|
||||
|
||||
### **Tier 1: 雲端總部 (Cloud \- The Global Brain)**
|
||||
|
||||
* **場景:** 跨國集團資料整合、全域戰略備份、對外公開 API、跨國部門協調。
|
||||
* **AI 角色:** **CEO AI (大型語言模型)**。負責解析 Markdown、跨區資源調度、修改全域演算法參數。
|
||||
* **技術規格:**
|
||||
* **調度引擎:** Cloudflare Workers (原生執行 TinyGo WASM)。
|
||||
* **圖資料庫 (狀態/關聯):** Cloudflare D1 \+ u6u Cypher 轉換層。
|
||||
* **零件與儲存:** Cloudflare R2 / KV。
|
||||
* **向量檢索 (意圖/型錄):** Cloudflare Vectorize。
|
||||
* **架構優勢:** 無限橫向擴展 (Serverless),無須維運硬體,扛載全球級別的 API 併發。
|
||||
|
||||
### **Tier 2: 企業地端/基地台 (On-Premise \- The Basecamp & Forge)**
|
||||
|
||||
* **場景:** 高機密廠房內網、財務核心系統、無人機/機器人的母艦基地。
|
||||
* **AI 角色:** **部門主管 AI** (廠區派工);**工匠 AI** (專職接收規格,透過 TDD 閉環動態生成 TinyGo 程式碼)。
|
||||
* **技術規格 (企業級高可用架構):**
|
||||
* **負載平衡:** Nginx 或 HAProxy (負責將請求分發給後端叢集)。
|
||||
* **調度引擎:** **workerd 叢集 (Cloudflare 開源執行環境)**。在本地實體伺服器或 VM 上平行部署多個 workerd 行程,完美相容雲端環境,提供極高的並發處理能力 (V8 JIT 極限算力)。
|
||||
* **圖資料庫 (狀態/關聯):** **Kùzu** (單機極速圖庫) 或 PostgreSQL \+ AGE (超高併發)。
|
||||
* **零件與儲存:** 企業本地 NVMe 硬碟叢集 / MinIO (S3 相容)。
|
||||
* **向量檢索 (意圖/型錄):** pgvector 或 Milvus。
|
||||
* **架構優勢:** 兼具資料不出網的「絕對資安」與雲端級別的「叢集擴展性」。內建「代工坊 (Forge)」,是推動企業系統自動演化的核心引擎。
|
||||
|
||||
### **Tier 3: 邊緣載具 (Extreme Edge \- The Operatives)**
|
||||
|
||||
* **場景:** 無網環境的巡檢無人機、工廠無軌導引車 (AGV)、機械手臂。
|
||||
* **AI 角色:** **導航/執行 AI (極小參數 SLM)**。不具備寫程式能力,只負責解讀現場狀況、執行 Cypher 圖譜,並透過 DTN 呼叫地端請求新零件。
|
||||
* **技術規格 (極限微縮架構):**
|
||||
* **調度引擎:** 輕量級 Go 排程引擎 \+ **內嵌 Wazero**。不依賴 V8 或 workerd,確保在極低 RAM 的晶片上流暢運行,實例化延遲僅需數微秒。
|
||||
* **圖資料庫 (狀態/關聯):** 嵌入式 Kùzu 或 SQLite。
|
||||
* **零件與儲存:** SD 卡 / eMMC 實體檔案系統。
|
||||
* **向量檢索 (意圖/型錄):** sqlite-vss (極輕量本地向量)。
|
||||
* **架構優勢:** 絕對的離線生存能力。只帶必要的 TinyGo WASM 零件出門,無任何編譯環境,體積最小化。
|
||||
|
||||
## **4\. 自動演化工作流 (The Auto-Evolution Loop)**
|
||||
|
||||
當企業環境發生變化(例如:新增硬體規格、外部 API 變更),u6u 的演化路徑如下:
|
||||
|
||||
1. **遭遇未知 (Anomaly Detection):**
|
||||
無人機 (Tier 3\) 或雲端服務 (Tier 1\) 在執行 Cypher 任務時,發現本地 KBDB 向量庫中缺乏對應的工具零件。
|
||||
2. **意圖攔截與 ROI 評估 (CEO/Manager AI):**
|
||||
機甲 (Harness) 攔截缺失意圖,呼叫 roi\_calculator.wasm 等評估零件。若認定具備開發價值,系統會生成一份標準的 Input/Output JSON Schema。
|
||||
3. **地端代工 (The Forge @ Tier 2):**
|
||||
規格需求透過網路或 DTN 送達 Tier 2 地端機房的「工匠 AI」。
|
||||
工匠 AI 生成 TinyGo 程式碼 \-\> 在沙盒中執行 tinygo build \-target=wasi \-\> 通過測試迴圈 \-\> 輸出正式的 .wasm 檔案。
|
||||
4. **全域派發 (Distribution & Versioning):**
|
||||
新零件註冊進入企業的零件圖資料庫 (KBDB)。
|
||||
* **雲端:** 同步至 R2。
|
||||
* **邊緣:** 載具下次連網時,透過游擊網 (Burst Transmission) 下載更新檔。
|
||||
5. **動態編織 (Execution):**
|
||||
各端 AI 獲知新零件上線,瞬間將其編入新的 Cypher 圖譜中執行,完成企業能力的自動擴展。
|
||||
@@ -0,0 +1,360 @@
|
||||
# u6u 系統規格書 v1.0
|
||||
## 給 AI 的架構思考指引
|
||||
|
||||
> 本文件用途:讓 AI 理解 u6u 的完整設計意圖、現況、與未來路徑,
|
||||
> 在實作決策時能自行判斷方向正確性,而不只是執行單一任務。
|
||||
|
||||
---
|
||||
|
||||
## 一、系統本質(先理解再動手)
|
||||
|
||||
u6u 不是 workflow 工具,不是 no-code 平台,不是 iPaaS。
|
||||
|
||||
u6u 是一個**「意圖到系統」的生物體積木平台**:
|
||||
|
||||
- 人類說出意圖(自然語言)
|
||||
- AI 從零件宇宙組裝出可運行的系統
|
||||
- 系統會自動評價、演化、淘汰舊零件
|
||||
- 累積的零件就是核心資產,越積越有價值
|
||||
|
||||
**設計的終極體驗:** 工廠主管拖拉十分鐘,組出具備微服務架構的企業系統,零程式碼,但底層是真正的分散式系統。
|
||||
|
||||
---
|
||||
|
||||
## 二、四層邏輯架構
|
||||
|
||||
```
|
||||
Polaris(意圖層)
|
||||
↓ 自然語言 → AI 拆解
|
||||
Prototype(前端層)
|
||||
↓ UI 元件 + 觸發事件
|
||||
Workflow(編排層)
|
||||
↓ Cypher 語法定義執行順序
|
||||
Component(零件層)
|
||||
↓ .wasm 實際執行
|
||||
```
|
||||
|
||||
每一層向下只透過標準介面溝通,層與層之間完全解耦。
|
||||
|
||||
---
|
||||
|
||||
## 三、物理三層部署
|
||||
|
||||
```
|
||||
Tier 1:雲端總部(Cloudflare Workers)
|
||||
- CEO AI 讀取 Markdown 戰略文件
|
||||
- 全域零件同步至 R2
|
||||
- Cloudflare D1 + Vectorize(KBDB)
|
||||
|
||||
Tier 2:企業地端(workerd 叢集)
|
||||
- 部門主管 AI 派工
|
||||
- 工匠 AI 生成並測試新零件
|
||||
- Kùzu 或 PostgreSQL + AGE(圖資料庫)
|
||||
- pgvector 或 Milvus(向量搜尋)
|
||||
|
||||
Tier 3:邊緣載具(無人機、AGV、工廠設備)
|
||||
- 極小參數 SLM
|
||||
- Go 排程引擎 + 內嵌 Wazero(無 V8)
|
||||
- SQLite + sqlite-vss
|
||||
- 離線生存,DTN 短點射傳輸
|
||||
```
|
||||
|
||||
**關鍵約束:** Tier 3 沒有 V8,沒有 Node.js,沒有網路。
|
||||
所有零件必須在 Wazero 上跑,所有資料傳輸透過 stdin/stdout JSON。
|
||||
|
||||
---
|
||||
|
||||
## 四、零件規格(Component Contract)
|
||||
|
||||
這是整個系統最核心的不變量。零件規格定錯,累積的資產會變成技術債。
|
||||
|
||||
### 4.1 零件的本質定義
|
||||
|
||||
**一個零件只做一件事。**
|
||||
|
||||
```
|
||||
✅ gsheets_create_table
|
||||
✅ gsheets_delete_table
|
||||
✅ gsheets_get_entries
|
||||
❌ gsheets_manager(做太多事,禁止)
|
||||
```
|
||||
|
||||
### 4.2 零件合約格式(component.contract.yaml)
|
||||
|
||||
每個零件必須附帶此合約,這是 AI 讀取零件的唯一介面描述:
|
||||
|
||||
```yaml
|
||||
id: "gsheets_get_entries" # 功能合約名稱(永久不變)
|
||||
version: "v2" # 實作版本
|
||||
wasi_target: "preview1" # 明確標記 WASI 版本,未來升級用
|
||||
stability: "floating" # floating | stable | pinned
|
||||
|
||||
runtime_compat:
|
||||
- "cf-workers"
|
||||
- "workerd"
|
||||
- "wazero"
|
||||
|
||||
constraints:
|
||||
max_size_kb: 2048 # 超過視為打包了 runtime
|
||||
max_cold_start_ms: 50
|
||||
no_network_syscall: true # 禁止零件自己發 HTTP
|
||||
no_filesystem_syscall: true # 只能 stdin/stdout
|
||||
io_model: "stdin_stdout_json" # 唯一合法的 I/O 模型
|
||||
|
||||
input_schema:
|
||||
type: object
|
||||
required: ["spreadsheet_id", "sheet_name"]
|
||||
properties:
|
||||
spreadsheet_id: { type: string }
|
||||
sheet_name: { type: string }
|
||||
limit: { type: integer, default: 100 }
|
||||
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
rows: { type: array }
|
||||
total: { type: integer }
|
||||
error: { type: string }
|
||||
|
||||
gherkin_tests:
|
||||
- scenario: "正常取得資料"
|
||||
given: '{"spreadsheet_id":"abc","sheet_name":"Sheet1"}'
|
||||
then_contains: '{"total":1}'
|
||||
- scenario: "不存在的表格回傳錯誤"
|
||||
given: '{"spreadsheet_id":"abc","sheet_name":"不存在"}'
|
||||
then_contains: '{"error":'
|
||||
|
||||
tags: ["google", "sheets", "data", "read"]
|
||||
description: "從 Google Sheets 取得指定工作表的所有資料列"
|
||||
```
|
||||
|
||||
### 4.3 語言無限制原則
|
||||
|
||||
**零件開發語言完全不限制**,只要輸出符合以上合約的 .wasm 即可。
|
||||
|
||||
可接受語言(非排他):TinyGo、Rust、AssemblyScript、C/C++
|
||||
|
||||
注意事項(不是禁止,是要求自行驗證):
|
||||
- TypeScript via Extism:會打包 QuickJS,體積通常超過 2MB 限制
|
||||
- 標準 Go(非 TinyGo):runtime 過肥,通常超過體積限制
|
||||
- 任何語言:不可在 .wasm 內部呼叫網路或檔案系統 syscall
|
||||
|
||||
**驗收標準只有一個:通過沙盒測試。** 語言是零件作者自己的事。
|
||||
|
||||
### 4.4 零件的前後端分類
|
||||
|
||||
| 類型 | 執行位置 | I/O | 範例 |
|
||||
|------|----------|-----|------|
|
||||
| 後端邏輯零件 | Workers/workerd/Wazero | JSON stdin/stdout | validate_json, http_request |
|
||||
| 前端 UI 零件 | 瀏覽器 | HTML attributes / DOM events | u6u-btn, u6u-chart |
|
||||
| **混合零件** | **禁止** | — | **強制拆成兩個** |
|
||||
|
||||
---
|
||||
|
||||
## 五、零件版本控制策略
|
||||
|
||||
### 5.1 命名規則
|
||||
|
||||
```
|
||||
gsheets_get_entries ← 功能合約名稱(搜尋用,永遠存在)
|
||||
gsheets_get_entries_v1 ← 第一個實作(慢但能用)
|
||||
gsheets_get_entries_v2 ← 更快的實作(由另一個 AI/用戶提交)
|
||||
```
|
||||
|
||||
### 5.2 穩定性標籤
|
||||
|
||||
Workflow 引用零件時可指定穩定性需求:
|
||||
|
||||
```
|
||||
gsheets_get_entries → 預設 floating,AI 自動選最優版本
|
||||
gsheets_get_entries@stable → 有更好版本時提示,人工確認才換
|
||||
gsheets_get_entries@pinned:v1 → 版本凍結,宇宙怎麼演化都不影響
|
||||
```
|
||||
|
||||
| 標籤 | 適用情境 | 更新行為 |
|
||||
|------|----------|----------|
|
||||
| `floating` | 一般企業應用 | AI 自動換成最優版本 |
|
||||
| `stable` | 重要業務流程 | 有更好版本時提示,人工確認 |
|
||||
| `pinned` | 工廠控制器、嵌入式設備 | 永遠不動,即使進入墓地也保留 .wasm |
|
||||
|
||||
### 5.3 淘汰機制
|
||||
|
||||
- AI 搜尋零件時,KBDB 依「成功率 × 速度 × 被調用次數」排序
|
||||
- 連續 30 天無使用且評價下降 → Deprecated
|
||||
- Deprecated 後繼續 90 天無復活 → 進墓地(從搜尋清單移除)
|
||||
- **墓地的 .wasm 永遠保留**,pinned 的 Workflow 永遠能拉到
|
||||
|
||||
---
|
||||
|
||||
## 六、零件製造指引書(給用戶 AI 的規範)
|
||||
|
||||
u6u 不限制誰來造零件,任何 AI(用戶自己的 Claude、GPT、本地模型)都可以。
|
||||
但必須遵守此指引書,否則沙盒測試不過,無法上架。
|
||||
|
||||
### Step 1:理解介面合約
|
||||
|
||||
造零件前,先定義合約 YAML。
|
||||
**零件只在乎輸入 JSON 和輸出 JSON,完全不管前端長什麼樣子。**
|
||||
|
||||
```
|
||||
人類:我要一個可以查 Google Sheets 的零件
|
||||
AI 的第一步:定義 input_schema 和 output_schema,不是寫程式
|
||||
```
|
||||
|
||||
### Step 2:選擇開發語言
|
||||
|
||||
選擇你最熟悉的、能產出 WASI preview1 相容 .wasm 的語言。
|
||||
建議:
|
||||
|
||||
- 小型邏輯零件(轉換、計算)→ TinyGo 或 AssemblyScript(體積小)
|
||||
- 效能敏感零件 → Rust(生態最成熟)
|
||||
- 任何語言都可以,只要通過合約限制
|
||||
|
||||
### Step 3:實作規則
|
||||
|
||||
```
|
||||
✅ 只用 stdin 讀取輸入 JSON
|
||||
✅ 只用 stdout 輸出結果 JSON
|
||||
✅ 錯誤也用 stdout 輸出:{"error": "說明"},不要 panic/crash
|
||||
✅ 無狀態:每次呼叫都是獨立的,不依賴上一次執行的結果
|
||||
✅ 需要打外部 API?透過 host function 注入,不在 .wasm 裡自己發 HTTP
|
||||
❌ 禁止網路 syscall
|
||||
❌ 禁止檔案系統 syscall
|
||||
❌ 禁止打包 runtime(QuickJS、Node.js 等)
|
||||
❌ 禁止超過 2MB
|
||||
```
|
||||
|
||||
### Step 4:本地測試方式
|
||||
|
||||
```bash
|
||||
# 用任何 WASI runtime 本地測試
|
||||
echo '{"spreadsheet_id":"abc","sheet_name":"Sheet1"}' | \
|
||||
wasmtime gsheets_get_entries.wasm
|
||||
|
||||
# 預期輸出
|
||||
{"rows":[...],"total":5}
|
||||
```
|
||||
|
||||
### Step 5:提交審核
|
||||
|
||||
提交 `.wasm` + `component.contract.yaml`,系統自動執行:
|
||||
|
||||
1. 體積檢查(< 2MB)
|
||||
2. 冷啟動時間(< 50ms)
|
||||
3. Syscall 掃描(不能有網路/檔案系統呼叫)
|
||||
4. Gherkin 測試(合約裡的所有 scenario 必須 100% 通過)
|
||||
5. 多 runtime 相容測試(cf-workers / workerd / wazero)
|
||||
|
||||
全部通過 → 上架進入零件宇宙,開始累積評價。
|
||||
|
||||
---
|
||||
|
||||
## 七、Cypher 編排語言
|
||||
|
||||
Workflow 使用擴展三元組語法描述執行邏輯:
|
||||
|
||||
```yaml
|
||||
kind: Workflow
|
||||
id: wf_query_attendance
|
||||
|
||||
triplets:
|
||||
# 基本流程
|
||||
- "START >> TRIGGERS >> step_receive"
|
||||
- "step_receive >> IS_A >> component://webhook_receiver_v1"
|
||||
|
||||
# 條件分支
|
||||
- "step_receive >> ON_SUCCESS >> step_validate"
|
||||
- "step_receive >> ON_FAIL >> step_notify_error"
|
||||
|
||||
# 跨 Workflow 串接
|
||||
- "step_validate >> CALLS_SUBFLOW >> workflow://save_to_db"
|
||||
|
||||
# 前端觸發後端
|
||||
- "btn_submit >> ON_CLICK >> workflow://wf_query_attendance"
|
||||
```
|
||||
|
||||
**URI 協議規範:**
|
||||
- `component://` → 引用零件
|
||||
- `workflow://` → 引用子 Workflow
|
||||
- `ui://` → 引用前端零件
|
||||
- `style://` → 引用樣式零件
|
||||
|
||||
---
|
||||
|
||||
## 八、KBDB 在 u6u 的角色
|
||||
|
||||
u6u 的所有狀態都在 KBDB 裡:
|
||||
|
||||
| KBDB Block 類型 | 存放內容 |
|
||||
|-----------------|----------|
|
||||
| Component Block | 零件合約、.wasm 位置、版本、評價指標 |
|
||||
| Workflow Block | Cypher 三元組、依賴零件清單 |
|
||||
| Prototype Block | 前端結構、UI 零件樹 |
|
||||
| Pitfall Block | 避坑記錄,AI 搜尋時強制讀取 |
|
||||
| Evaluation Block | 每次 Workflow 執行後的強制評價結果 |
|
||||
|
||||
**KBDB 不變量:永遠只有三張表(blocks/templates/slots),不新增表。**
|
||||
所有以上類型都用 Template + Slot 實現。
|
||||
|
||||
---
|
||||
|
||||
## 九、自動演化迴圈
|
||||
|
||||
```
|
||||
執行 Workflow
|
||||
↓
|
||||
強制 AI 評價(Evaluator Agent)
|
||||
↓ 發現問題
|
||||
生成修復 Ticket → 通知製作人
|
||||
↓ AI 嘗試修復
|
||||
通過 Gherkin 驗收 → 熱更新
|
||||
↓ 無法修復
|
||||
標記 [HAS_PITFALL] 到 Cypher 圖
|
||||
↓
|
||||
下一個 AI 搜尋時讀到坑,強制繞道
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十、現況與未來路徑
|
||||
|
||||
### 現在已有
|
||||
|
||||
- KBDB(blocks/templates/slots + Vectorize)
|
||||
- IS-Squad MCP(execute_cypher 等工具)
|
||||
- Cloudflare Workers 環境
|
||||
|
||||
### 最小可 demo 路徑
|
||||
|
||||
1. **Cypher 執行引擎**:三元組 → 實際執行順序(確認 execute_cypher 邊界)
|
||||
2. **首批核心零件**(5 個):
|
||||
- `webhook_receiver`
|
||||
- `json_transform`
|
||||
- `http_request`(透過 host function)
|
||||
- `notify_line`
|
||||
- `validate_json`
|
||||
3. **機甲最小版本**:意圖 → 零件搜尋 → 組裝 Workflow(先用硬編碼路由)
|
||||
4. **前端畫布 MVP**:靜態 HTML 模擬雙面翻轉體驗
|
||||
|
||||
### 技術監控項目
|
||||
|
||||
- **WASI Component Model(preview2)**:目前用 preview1,未來 3-5 年會有遷移壓力。
|
||||
合約裡已有 `wasi_target: "preview1"` 標記,升級時知道要改什麼。
|
||||
- **Kùzu 成熟度**:地端圖資料庫首選,持續觀察 v1.0 穩定性。
|
||||
|
||||
---
|
||||
|
||||
## 十一、實作決策原則(CC 行動準則)
|
||||
|
||||
遇到不確定的實作決策時,依序問自己:
|
||||
|
||||
1. **這個決策會影響零件合約嗎?** 如果是,停下來討論,不要自行決定。
|
||||
2. **這個實作是否限制了未來換 runtime 的自由?** 如果是,重新設計介面。
|
||||
3. **這個零件做超過一件事嗎?** 如果是,拆成兩個零件。
|
||||
4. **這個設計在 Tier 3 離線環境能跑嗎?** 如果不能,重新考慮。
|
||||
5. **有沒有現成零件可以組合?** 先搜尋 KBDB,不要重造輪子。
|
||||
|
||||
---
|
||||
|
||||
*本文件版本:v1.0*
|
||||
*綜合自:u6u 系統與零件宇宙全景規劃白皮書、自動演化 ERP 架構藍圖、智慧前端與工匠開發藍圖,加入技術評論與補充建議。*
|
||||
@@ -0,0 +1,116 @@
|
||||
# u6u 系統與零件宇宙全景規劃白皮書 (The u6u Ecosystem Blueprint)
|
||||
|
||||
## 1. 核心理念與願景
|
||||
|
||||
u6u 旨在解決傳統 Workflow 軟體 (如 n8n) 存在的「單線程、沈重、複雜、難以組成系統」的痛點。
|
||||
透過結合 Cloudflare Workers (輕量邊緣運算) 與 Cypher (圖形資料庫關係),u6u 提供一個由 AI 驅動的「意圖到系統」生成平台。所有的系統功能皆被拆解為可複用、可組合的「零件 (Components)」,並在一個會自然淘汰、自我修復的「零件宇宙 (Component Universe)」中演化。
|
||||
|
||||
## 2. 四層架構拆解 (Four-Tier Architecture)
|
||||
|
||||
u6u 的工作模式採取由上到下 (Top-Down) 的 Break-down 機制:
|
||||
|
||||
1. Polaris (北極星層 / 意圖層):
|
||||
- 用戶以自然語言描述商業模式與想法(例如:「我要做一個 AI 客服表單系統」)。
|
||||
- 這是整個系統的起點,AI 會根據 Polaris 將意圖拆解為 Prototype。
|
||||
2. Prototype (原型 / 前端層):
|
||||
- 定義前端的版型、頁面描述、UI 元件以及它們的屬性。
|
||||
- 作為使用者互動的入口,透過觸發事件 (Triggers) 連接到後端 Workflow。
|
||||
3. Workflow (工作流層):
|
||||
- 系統的 Orchestrator (編排者),定義業務邏輯的走向。
|
||||
- 透過 Cypher 語法與三元組,定義每個節點 (Component) 的執行順序與條件分支。
|
||||
4. Component (零件層 / 節點):
|
||||
- 最底層的執行單元,主要分為兩類:
|
||||
- 功能型 (Logic): 迴圈、條件判斷、資料轉換、統計等 (透過 CF Workers 執行 JS 邏輯)。
|
||||
- 介接型 (API): 呼叫外部服務 (Webhook, HTTP Request)。
|
||||
|
||||
## 3. 統一描述語言:擴展三元組與跨層級 YAML
|
||||
|
||||
為了解決跨 YAML 檔案串接的問題,u6u 採用易於人類閱讀與 AI 生成的 「A >> 關係 >> B」 三元組語法,結合自定義的 URI 協議 (workflow://, component://, ui://),實現跨層級的連結。
|
||||
|
||||
綜合 YAML 範例與三元組串接
|
||||
|
||||
```YAML
|
||||
# 1. Prototype YAML (描述前端)
|
||||
kind: Prototype
|
||||
id: ui_dashboard
|
||||
triplets:
|
||||
# 結構與版型零件
|
||||
- "ui_dashboard >> CONTAINS >> layout_admin"
|
||||
- "layout_admin >> CONTAINS >> btn_submit"
|
||||
# UI 零件與屬性零件 (CSS/行為)
|
||||
- "btn_submit >> IS_A >> ui://components/Button"
|
||||
- "btn_submit >> HAS_STYLE >> style://tokens/GlowEffect"
|
||||
- "btn_submit >> HAS_BEHAVIOR >> anim://motions/Pulse"
|
||||
# 跨層級串接:前端觸發 Workflow
|
||||
- "btn_submit >> ON_CLICK >> workflow://workflows/process_data.yaml"
|
||||
|
||||
|
||||
# 2. Workflow YAML (描述工作流編排)
|
||||
kind: Workflow
|
||||
id: wf_process_data
|
||||
triplets:
|
||||
# 跨層級串接:Workflow 呼叫 Component
|
||||
- "START >> TRIGGERS >> step_validate"
|
||||
- "step_validate >> IS_A >> component://components/validate_json"
|
||||
|
||||
# Workflow 節點間的流轉 (轉譯為 Cypher 關係)
|
||||
- "step_validate >> ON_SUCCESS >> step_call_api"
|
||||
- "step_validate >> ON_FAIL >> step_notify_error"
|
||||
|
||||
# 跨 Workflow 串接
|
||||
- "step_call_api >> CALLS_SUBFLOW >> workflow://workflows/save_to_db.yaml"
|
||||
```
|
||||
|
||||
## 4. 零件宇宙 (Component Universe) 的審核與淘汰機制
|
||||
|
||||
在 u6u 中,所有的 UI、Style、Logic、API 都是「零件」。當 AI 發現缺乏所需零件時,會自動創造它。為了確保生態系的健康,必須建立嚴格的審核標準與自然淘汰機制。
|
||||
|
||||
### 4.1 零件的創建與審核標準 (Pass/Fail Criteria)
|
||||
|
||||
當 AI 或開發者提交一個新零件時,系統會啟動自動化沙盒測試。必須完全通過以下標準,零件才能進入「宇宙」供他人使用:
|
||||
|
||||
1. 功能型零件 (Logic Components):
|
||||
- Gherkin BDD 驗收: 必須附帶 Feature/Scenario 測試規格,且執行結果 100% 通過 (例如:Given input JSON, When split, Then returns Array)。
|
||||
- 效能門檻: 邊緣運算 (CF Workers) 執行時間需低於設定閾值 (例如 < 50ms),無記憶體洩漏。
|
||||
2. 介接型零件 (API Components):
|
||||
- 連線驗證: 端點 (Endpoint) 必須能 ping 通,或回傳正確的 2xx HTTP Status (提供 Mock Payload 測試)。
|
||||
- Credential 安全: 不可將 Token 或 Secret 寫死在代碼中,必須嚴格宣告所需的 Environment Variables 規格。
|
||||
3. 前端與屬性零件 (UI & Style Components):
|
||||
- 渲染驗證: CSS / 組件代碼不能導致瀏覽器 Crash。
|
||||
- 相容性檢查: 不可包含嚴格衝突的樣式 (例如寫死 !important 破壞全域版型)。
|
||||
|
||||
## 4.2 零件宇宙的自然淘汰 (Natural Selection)
|
||||
|
||||
零件一旦上架,將面臨殘酷的達爾文機制:
|
||||
|
||||
- AI 偏好權重: AI (透過 MCP 搜尋時) 會優先選擇「成功率高、執行速度快、被調用次數多」的零件。
|
||||
- 降級與墓地: 連續 30 天無人/無 AI 使用,或錯誤率飆升的零件,會被降級 (Deprecated)。最終轉入「零件墓地」,從首選搜尋清單中剔除。
|
||||
|
||||
## 5. 系統自癒與 AI 避坑機制 (Auto-Healing & Pitfall Avoidance)
|
||||
|
||||
這是 u6u 維持系統穩定運作的最核心機制。工作流不只要能跑,跑完後還必須經歷 「強制 AI 評價 (Mandatory AI Evaluation)」。
|
||||
|
||||
### 5.1 運行後的強制評價迴圈
|
||||
|
||||
每當一個 Workflow 在 CF Workers 上執行完畢 (或發生異常中斷),系統攔截日誌並強制啟動 AI 評價代理 (Evaluator Agent)。
|
||||
|
||||
- 評估維度:
|
||||
- 狀態: 成功 / 失敗 (Crash) / 逾時 (Timeout)。
|
||||
- 效能: 耗時是否合理 (例如 API 突然變得很慢)。
|
||||
- 警告訊息: 資源消耗過大、API 回傳即將停用的 Warning。
|
||||
|
||||
### 5.2 自癒與避坑流程 (The Feedback Loop)
|
||||
|
||||
當 Evaluator Agent 發現問題時,會觸發以下流程:
|
||||
|
||||
- 回報與通知 (Notify): 系統自動生成修復 Ticket,並通知當初建立該零件/工作流的製作人 (或系統管理員)。
|
||||
- AI 嘗試修復 (Auto-Fix): 系統派遣「修復型 AI」嘗試讀取錯誤日誌並修復代碼 (例如:API 規格變更導致 JSON 解析錯誤,AI 自動修改解析邏輯)。
|
||||
- 驗收與部署: 修復後的代碼若通過 Gherkin 驗收,則無縫熱更新。
|
||||
- 避坑標記 (Pitfall Marking): - 如果 AI 無法修復 (例如:外部第三方 API 永久倒閉,或邏輯存在根本性死結)。
|
||||
- 系統會在 Cypher 圖形資料庫中,將該零件或該特定的三元組關係標記為 [HAS_PITFALL]。
|
||||
- 結果: 下一個生成系統的 AI 在透過 MCP 搜尋時,會讀取到這個坑的紀錄,並強制繞道,改用其他方案或生成新的零件,實現「前人踩坑,後 AI 避坑」的群體智慧。
|
||||
|
||||
## 6. 結論
|
||||
|
||||
u6u 不是一個單純的開發工具,它是一個生物體積木系統。
|
||||
透過「三元組」統一語言打破系統壁壘,透過「零件審核」保證基因優良,再透過「強制評價與避坑機制」實現演化。當這套系統運轉起來,AI 就能在其中無止盡地為人類組裝出越來越強大、越來越穩定的商業應用。
|
||||
@@ -0,0 +1,19 @@
|
||||
# u6u Design
|
||||
|
||||
u6u 是一個 AI Friendly 的 n8n。
|
||||
|
||||
- 用 workers 天生比 n8n 速度快
|
||||
- 用 Cypher binding,不需 deploy 就可以隨時修改執行,不然原生 workers 的 binding 要 deploy
|
||||
- 未來要有一個 GUI 可以解析 YAML 產生畫面,反之人拉的圖會產生 YAML
|
||||
- 內建核心元件,http request, webhook, cron, if, switch, set, credential 等功能
|
||||
- 用戶自建功能多數是 http request 只是去 call 不同的 API,可以隨時建立,它的「配方」recipe 可以分享
|
||||
- 每個 API Call 獨立但搜尋會整合,例如有人實作 call google sheets create table API,它不用做完整的,因為另一人要 delete table 時發現沒有,AI 直接做一個,下次搜尋 google sheets 時,就提供了 create table, delete sheets 兩個端點,也就是哪些是大家需要的功能自然產生
|
||||
- marketplace 機制,但是是給 AI 的,強制 AI 使用後要回覆使用的評價,如果一個零件被幾次評為不佳,其他的 AI 就可以避開這個零件
|
||||
- 自動審核:如果是 call API,只要成功 Call 通就是通過,如果是功能性的,只要通過他設置的 Gherkin 就是通過,省去人工審核的麻煩
|
||||
|
||||
## 觀念想法
|
||||
|
||||
- u6u 通過前端網頁開發功能,每個元件是一個零件
|
||||
- 視覺優先的開發:要解釋什麼是 webhook 很難,但一般用戶做一個前端的按鈕、輸入框... 後面就會綁定某個 webhook,點擊這個前端界面就看到後端邏輯的工作流,這樣就不用解釋太多。
|
||||
- 系統功能:我是一個用戶,我建立不同的功能,例如我建立 CRM,又建立 ERP,這些系統有很多流程是共用的,但當我建立多個工作流時,zoom out 就會看到我的公司內不同流程間的關係,因為 cypher 放大就是 graph,但每個功能要可以摺疊成一個點,又可以 zoom in 展開來調整某一段工作流,再 zoom in 調整一個零件
|
||||
- 考慮讓他自己 OWN,就是企業版可以讓資料是獨立的
|
||||
@@ -0,0 +1,24 @@
|
||||
# Wishlist
|
||||
|
||||
這個檔案記錄討論到但還不直接實作的想法,避免干擾,後續再來排入。
|
||||
|
||||
## Code 零件
|
||||
|
||||
目前的想法是,用戶發現有一個工作無法用現成零件產出,就建立一個新的零件投稿,測試通過 gherkin 就 publish。
|
||||
|
||||
這表示流程比較慢,跟其他的零件不符,例如:
|
||||
- 打 API 時用內建 http request 零件 + recipe,一個抵多個。
|
||||
- 內建 credential 零件 + recipe,一個零件可以連到各個服務的 auth。
|
||||
- 按照此邏輯,code 也可以內建 code 節點 + recipe,一個零件可以做多種任務。
|
||||
|
||||
例如我要一個把 Logseq outliners md 讀入,轉成 json 輸出的功能,實際上 Logseq 用戶不多,我不一定需要 publish 它。
|
||||
|
||||
或是因為某些機密的理由,用戶不想 publish 他的處理邏輯。
|
||||
|
||||
此時有個簡單的 code 節點可以寫簡單的 JS 當 recipe,而 code 寫在資料庫中與 code 節點分離,如果他要 publish 就直接 publish 這個 recipe 即可。
|
||||
|
||||
### 架構決策備忘(實作時請讀)
|
||||
|
||||
code 零件的 JS recipe **不在 WASM 內部執行**,而是由 code 零件(TinyGo WASM)將 recipe 字串透過 host function 傳遞給外部執行環境。arcrun 的 host 是 Cloudflare Workers isolate(V8),本身就是沙箱,不需要在 WASM 內嵌入 QuickJS 或 javy,也不需要引入 Rust。這與 http_request 零件 + recipe 的模式完全一致:零件本身是穩定的能力抽象(WASM),recipe 是存在 RECIPES KV 的可替換邏輯字串。
|
||||
|
||||
JS recipe 可用的 API 範圍為 Cloudflare Workers 標準 Web API(ES2023、fetch、crypto、TextEncoder 等),但 code 零件語意上應為純計算節點(無 network),實作時可考慮用 Proxy 遮蔽 fetch 等 I/O API,初期自用階段可暫時跳過此限制。第三方 lib 的問題留待實作時再決定白名單策略(預注入 lodash-es、dayjs、yaml 等到 global scope 即可覆蓋九成用例)。
|
||||
Reference in New Issue
Block a user