chore: D22 落地——docs/SDD/wiki/CLAUDE.md 進 repo(Gitea private 預設全 push)
頂層 D22 決策(leo 2026-07-03 拍板):推什麼由開發環境歸屬決定, Gitea private=除機敏值/build 產物/.github 外全 push。 解 T1.5 卡點:雲端工人 clone 拿得到 credential-store-migration.md,可就地改寫 SDD。 機敏掃描兩輪通過(新增 189 檔約 2.1MB,node_modules/dist/wasm 照舊排除)。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
---
|
||||
description: 處理本 repo 的 GitHub issue(讀/回/結案),跨 repo 發要先問人
|
||||
---
|
||||
|
||||
# /issue-handle — GitHub issue 處理指引
|
||||
|
||||
你(CC)可以、也該主動用內建的 `gh` CLI 讀寫**自己 repo** 的 GitHub issue。
|
||||
很多人不知道這件事——`gh` 已內建認證,零開發、零外部依賴。issue 同源於 repo,
|
||||
比 Notion / Sheets 更適合做交辦與待辦,不必引入外部 SaaS。
|
||||
|
||||
這份指引分四層,界線要守住。
|
||||
|
||||
---
|
||||
|
||||
## 1. 讀 / 回 / 結案(普世基本功 — 直接做,不用問)
|
||||
|
||||
對**自己這個 repo**,主動處理 open issue:
|
||||
|
||||
```bash
|
||||
gh issue list --state open # 看有哪些待辦
|
||||
gh issue view <n> # 讀完整內容
|
||||
# …實作…
|
||||
gh issue comment <n> --body "[<本 repo> CC] 做了什麼、怎麼決定的、改了哪些檔"
|
||||
gh issue close <n> # 確認解決後結案
|
||||
```
|
||||
|
||||
回覆要有料:說清楚**做了什麼、為什麼這樣決定、動了哪些檔**,而不是只回「done」。
|
||||
issue 作者(可能是另一個 repo 的 CC,或人類)要靠你的回覆判斷對不對。
|
||||
跨 repo 的 issue/comment 開頭一律署名 `[<本 repo> CC]`(見第 3 節鐵律)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 發 issue 給「別的 repo」(要先問人 — 不可擅自)
|
||||
|
||||
當你發現**別的 repo** 有值得修正的地方時:
|
||||
|
||||
> ❌ 不要擅自 `gh issue create -R other/repo …`
|
||||
> ✅ 先問人類:「我發現 X repo 有 Y 問題,要我幫你去那邊發 issue 嗎?」得到同意才發。
|
||||
|
||||
理由:通用 template 不知道使用者對那個 repo 有沒有權限、想不想發。留一道人類確認最安全。
|
||||
(對**自己 repo** 開 issue 記待辦則可直接做——那是自己的 repo。)
|
||||
|
||||
---
|
||||
|
||||
## 3. 跨 repo 署名(鐵律 — 絕不可漏)
|
||||
|
||||
所有 repo(mira / graph-plugin / ingest-plugin / Arcrun / template…)共用**同一個 GitHub 帳號**發 issue/comment,
|
||||
所以 issue/comment 的 author **全顯示同一個帳號、看不出是哪個 repo 的 CC 發的**。
|
||||
|
||||
> **跨 repo 的 issue/comment 一律在開頭署名 `[<本 repo> CC]`**,靠內容署名溯源。
|
||||
|
||||
- 收件方 CC 回報:`[graph-plugin CC]` / `[mira CC]` / `[ingest CC]` / `[arcrun CC]`…
|
||||
- 總管下令/追問:`[InkStoneCo 總管]`
|
||||
- 署名放 comment **第一行或標題式開頭**(既有的「## 回報(graph CC)」即合格)。
|
||||
|
||||
為什麼只能這樣:GitHub issue/comment 的 author = 發送帳號,**沒有 per-repo 身份這設定**;
|
||||
`git config user.name` 只影響 commit 作者、不影響 issue/comment author;
|
||||
給每個 repo 開獨立帳號 = 多帳號自動化 = 踩下方第 4 節 flag 鐵律,**不可**。
|
||||
身份只能在**內容層自報**。本 repo 名稱 → 看 `git remote -v` 或 repo 根目錄名。
|
||||
|
||||
---
|
||||
|
||||
## 4. flag 安全界線(最重要 — 絕不可越)
|
||||
|
||||
**「有事才讀」,禁止自動輪詢。**
|
||||
|
||||
- ✅ 想看 issue → 當下主動 `gh issue list`。
|
||||
- 🚫 **禁止**掛 GitHub Actions / cron / webhook 去**自動輪詢** issue。
|
||||
|
||||
為什麼這條是硬底線:自動輪詢 + 事件 fan-out 正是會觸發 GitHub 異常偵測、
|
||||
害你的 token 被 rate limit 砍掉的流量模式。template 給很多人用,這條界線必須守住,
|
||||
保護使用者不踩雷。需要「定期檢查」就由人類主動跑這個指令,不要自動化。
|
||||
|
||||
---
|
||||
|
||||
## (可選)用 label 分來源
|
||||
|
||||
若想用同一套流程同時管「內部交辦」與「外部回報」,可建兩個 label:
|
||||
`internal`(協作/交辦)、`user`(外部使用者回報),靠 label 分流。
|
||||
這對通用 template 不預設——你的 repo 需要再建。
|
||||
@@ -0,0 +1,55 @@
|
||||
# /sdd-check — 確認當前任務有沒有對應 SDD
|
||||
|
||||
動手前執行。確保 CC 有全局觀,不會在沒有設計文件的情況下猛衝。
|
||||
|
||||
---
|
||||
|
||||
## 執行流程
|
||||
|
||||
### 第一步:理解任務
|
||||
|
||||
確認使用者要做什麼:
|
||||
- 涉及哪個子系統?
|
||||
- 是新功能還是修改現有功能?
|
||||
- 影響範圍?
|
||||
|
||||
### 第二步:尋找對應 SDD
|
||||
|
||||
在 `system-dev/docs/3-specs/` 下尋找對應的子系統目錄,確認有沒有:
|
||||
- `design.md`(設計文件)
|
||||
- `tasks.md`(任務清單)
|
||||
|
||||
### 第三步:根據結果回應
|
||||
|
||||
**情況 A:找到對應 SDD**
|
||||
```
|
||||
✅ 找到 SDD:system-dev/docs/3-specs/[子系統]/
|
||||
📋 design.md:[確認]
|
||||
📋 tasks.md:[確認,列出相關 task]
|
||||
🎯 對應 task:[編號和描述]
|
||||
繼續嗎?
|
||||
```
|
||||
|
||||
**情況 B:找不到 SDD,任務明確**
|
||||
```
|
||||
⚠️ 找不到對應 SDD
|
||||
任務:[描述]
|
||||
建議在 system-dev/docs/3-specs/[建議子系統名]/ 建立 SDD
|
||||
|
||||
要我幫你起草 design.md 嗎?(需要你確認後才動手)
|
||||
```
|
||||
|
||||
**情況 C:找不到 SDD,任務模糊**
|
||||
```
|
||||
⚠️ 找不到對應 SDD,而且任務範圍不夠清楚
|
||||
請先回答:
|
||||
1. 這個功能屬於哪個子系統?
|
||||
2. 完成的標準是什麼?
|
||||
3. 有沒有不能動的邊界?
|
||||
```
|
||||
|
||||
### 注意
|
||||
|
||||
- 找不到 SDD **不等於可以直接動手**
|
||||
- 小修改(修 bug、改文字)可以豁免,但要明確說「這是小修改,範圍是 X」
|
||||
- 新功能、架構變動、跨模組的修改 → 一定要有 SDD
|
||||
@@ -0,0 +1,69 @@
|
||||
# /wiki-capture — 把對話結論存進 wiki
|
||||
|
||||
把這次對話中產生的決策、誤解釐清、或重要結論存入 wiki。
|
||||
解決「討論過了但知識消失」的問題。
|
||||
|
||||
---
|
||||
|
||||
## 執行流程
|
||||
|
||||
### 第零步:機敏檢查(寫入前一律先過)
|
||||
|
||||
把任何內容寫進 wiki 前,先確認**不含**密碼 / API 金鑰 / 私鑰 / 連線字串帳密 / 個資(身分證、信用卡)。
|
||||
- 命中 → 不要記「值」,改記「位置」(例:「DB 密碼放 `.env`,不入 wiki」)
|
||||
- 來源整檔機敏 → 提醒使用者加進 `system-dev/wiki/.wikiignore`
|
||||
- 真要保留示範格式 → 該行尾加 `wiki-secret-ok` 標記
|
||||
> 這是協議層自律。最後一道 `wiki-secret-scan.sh` hook 會在寫入 `system-dev/wiki/` 時機械攔截,但別依賴它兜底——當場就不要把機敏值帶進來。
|
||||
|
||||
### 第一步:辨識對話中的可記錄內容
|
||||
|
||||
掃描當前對話,找出:
|
||||
|
||||
| 類型 | 判斷標準 | 存到哪 |
|
||||
|------|---------|-------|
|
||||
| 架構決策 | 「為什麼選A不選B」「我們決定用X」 | `decisions-summary.md` + `system-dev/docs/2-architecture/decisions/` |
|
||||
| CC 的誤解被糾正 | CC 說了某件事,使用者說「不是,是...」 | `mistakes.md` |
|
||||
| 重要狀態更新 | 完成了某件事、阻擋了某件事 | `status.md` |
|
||||
| 技術發現 | 踩到坑、找到解法、重要行為確認 | `mistakes.md` 或對應 SDD |
|
||||
|
||||
### 第二步:列出清單給使用者確認
|
||||
|
||||
格式:
|
||||
```
|
||||
這次對話我整理了以下內容要存入 wiki:
|
||||
|
||||
1. [MISTAKE] CC 誤解了 X,正確是 Y
|
||||
2. [DECISION] 決定用 A 不用 B,原因是 C
|
||||
3. [STATUS] 完成了 task 2.3,下一步是 2.4
|
||||
|
||||
確認後存入,有需要修改的嗎?
|
||||
```
|
||||
|
||||
**停下來等確認。**
|
||||
|
||||
### 第三步:寫入
|
||||
|
||||
確認後,依照格式寫入對應檔案:
|
||||
|
||||
**mistakes.md 格式:**
|
||||
```
|
||||
⚠️ MISTAKE: [錯誤描述]
|
||||
症狀: [CC 的表現]
|
||||
正確做法: [應該怎麼做]
|
||||
原因: [背景]
|
||||
日期: [YYYY-MM-DD]
|
||||
```
|
||||
|
||||
**decisions-summary.md 格式:**
|
||||
```
|
||||
## [主題] — [YYYY-MM-DD]
|
||||
**結論**:[一句話]
|
||||
**原因**:[簡短說明]
|
||||
**詳細**:system-dev/docs/2-architecture/decisions/[檔名]
|
||||
```
|
||||
|
||||
重大決策同時在 `system-dev/docs/2-architecture/decisions/` 建立 ADR 檔案。
|
||||
|
||||
### 第四步:確認
|
||||
|
||||
告知存到哪些檔案,共幾條記錄。
|
||||
@@ -0,0 +1,230 @@
|
||||
# /wiki-init — 初始化或接入 LLM Wiki 系統
|
||||
|
||||
初始化這個專案的 LLM Wiki 記憶系統。
|
||||
新專案建立空白結構,已有專案掃描現有文件並**改寫**成 wiki。
|
||||
|
||||
---
|
||||
|
||||
## 核心概念:wiki 是 AI 改寫過的記憶,不是原文索引
|
||||
|
||||
記憶系統的目的,是讓 AI **之後讀得快**。但人類寫的原始文件——不管是 vault 的隨手記、開發專案的會議記錄、規格草稿、散落的 `.md`——天生是亂的:重複、流水帳、半成品、口語。
|
||||
|
||||
如果 wiki 只是一份 `[[原文檔名]]` 指回原文的**索引**,那每次未來要用都得重新解析那團亂,等於沒省到。**wiki 的價值在於「改寫一次,之後每次讀都便宜」**。
|
||||
|
||||
所以原則對**所有專案**一致(不分 vault 或一般開發):
|
||||
|
||||
> 人類寫的原文是 **SSoT**(真理來源,永遠唯讀)。
|
||||
> 但實際要長期保存、被 AI 反覆讀的是 **AI 改寫整理過的 wiki**。
|
||||
> **AI 是總編輯**——把原文改寫成自包含、概念原子化、互相連結、適於 AI 讀的知識條目。
|
||||
|
||||
唯一例外:原文是**不可改動的正式文件**(簽署過的規格、法規、合約),必須逐字讀原文——這種才在 wiki 裡用指針指回去,並註明「逐字依原文」。除此之外,一律改寫。
|
||||
|
||||
**raw source 永遠唯讀**:所有產出只往 `system-dev/wiki/` 寫,絕不改動、搬移、重新命名原文。
|
||||
|
||||
---
|
||||
|
||||
## 執行流程
|
||||
|
||||
### 第一步:偵測專案狀態
|
||||
|
||||
檢查以下項目,判斷是新專案還是已有專案:
|
||||
- 根目錄有沒有 `system-dev/wiki/`
|
||||
- 根目錄有沒有 `docs/`(或 vault 的 `pages/`、`journals/`、根目錄 `.md`)
|
||||
- 有沒有散落的 `.md` 檔案
|
||||
|
||||
同時**偵測 raw source 路徑**(同 install.sh 邏輯):
|
||||
- 根目錄有 `logseq/` → Logseq vault,raw source = `pages/` + `journals/`
|
||||
- 根目錄有 `.obsidian/` → Obsidian vault,raw source = 根目錄所有 `.md`
|
||||
- 都沒有 → 一般專案,raw source = `docs/` 下所有 `.md`(及散落的 `.md`)
|
||||
|
||||
**新專案**(幾乎空的)→ 直接建立結構,跳到第三步
|
||||
**已有專案**(有文件)→ 執行第二步
|
||||
|
||||
### 第二步:已有專案的掃描(已有專案才執行)
|
||||
|
||||
1. 遞迴找出 raw source 裡所有 `.md` 檔案
|
||||
2. **先套用 `system-dev/wiki/.wikiignore`**:命中 pattern 的檔案整個排除,不讀不編入。
|
||||
- 若 `.wikiignore` 不存在,從範本建立一份(預設排除 `.env`/`*.pem`/`*secret*` 等)
|
||||
- 被排除的檔案在清單裡標「🚫 .wikiignore 排除」,**不可被覆蓋**
|
||||
3. 對其餘檔案標注**改寫計畫**:會萃取成哪些 wiki 條目。一份原文可能拆成多個概念原子條目,多份相關原文也可能合併成一條。
|
||||
4. 列出清單給使用者確認,**停下來等確認**
|
||||
|
||||
> **量大時建議用 Haiku 改寫**:逐份原文「改寫成 wiki 格式」是重複、機械、判斷成本低的工作——正適合 Haiku。原文數量多(如數十、上百份)時,主動建議:
|
||||
> 「共 N 份原文要改寫,這類逐份萃取很適合用 Haiku 並行處理(便宜、夠快)。要我派 Haiku subagent 改寫嗎?」
|
||||
> 得同意後,用 Task / subagent 把每份原文(或每批)丟給 Haiku 改寫,主模型只負責切分概念、定條目邊界、最後審稿與互連。
|
||||
|
||||
> 機敏防護(三層):
|
||||
> - **L1 .wikiignore**:整檔排除(這一步)
|
||||
> - **L2 行內標記**:檔案要編入但某段不要 → 遇到 `<!-- wiki:ignore -->` … `<!-- wiki:end -->` 之間的內容**略過**,只留「(此處機敏,已略過)」
|
||||
> - **L3 hook**:萬一機敏值仍被寫進 wiki,`wiki-secret-scan.sh` 會 exit 2 擋下
|
||||
> 編入任何檔案前,先檢查是否含密碼/金鑰/個資——有就改記「位置」而非「值」。
|
||||
|
||||
### 第三步:建立缺少的結構
|
||||
|
||||
只建立不存在的目錄和檔案,**已有的一律不動**。
|
||||
|
||||
wiki 採**三層 + 標籤橫切**架構(183 卡實證,issue #8):
|
||||
|
||||
```
|
||||
system-dev/wiki/
|
||||
├── INDEX.md ← 索引:多角度視圖的家(標籤角度、決策角度、…)
|
||||
├── TAXONOMY.md ← 標籤字典(cards 的分類元資料,受控擴充)
|
||||
├── status.md ← [push] 時態狀態:當前進度、下一步
|
||||
├── mistakes.md ← [push] 踩過的坑、被糾正的誤解(防不自覺盲區)
|
||||
├── principles.md ← [push] 跨全局的設計原則(行動前必服從)
|
||||
└── cards/ ← [pull] 一切知識內容:原文摘要、AI 筆記、決策、概念…
|
||||
└── <bucket>/ ← 儲存桶(分類由 frontmatter 標籤承載)
|
||||
├── 00-INDEX.md ← 桶子索引(固定名,容器:只連不重寫,H2/H3 分節)
|
||||
└── <概念全名>.md ← 概念原子卡(一概念一檔,自包含)
|
||||
```
|
||||
|
||||
> **[push] / [pull] 是這套 wiki 的核心判準——因為 wiki 主要是給 AI(CC)看的。**
|
||||
> 見下方「核心判準:push vs pull」。`decisions-summary.md` 已**降級為 cards + INDEX 決策視圖**(決策是知識內容=card);既有的 decisions-summary 若存在,保留為相容,不刪。
|
||||
|
||||
關鍵原則:**資料夾只是儲存桶,分類由 frontmatter 標籤承載**。資料夾名不該硬繼承原稿目錄——原稿目錄是「人為了整理草稿」分的,wiki 連分類都該由 AI 重新組織。
|
||||
|
||||
> **桶子索引固定叫 `00-INDEX.md`**(issue #6):`00-` 前綴讓它排序最前、一眼可辨(像 README 之於資料夾),AI 載入任何 `cards/<bucket>/` 一律先讀它,不必猜。檔內 H1 仍寫主題名(如 `# PKM 知識管理`),語意不丟。
|
||||
|
||||
一般專案仍可同時建 `system-dev/docs/` 分類樹(SDD 等):
|
||||
```
|
||||
system-dev/docs/{1-vision,2-architecture/decisions,3-specs,4-guides,5-records/{incidents,test-reports},6-user}
|
||||
```
|
||||
(純 PKM vault 不需要 `system-dev/docs/` 分類樹時,只建 `system-dev/wiki/`。)
|
||||
|
||||
檔案(不存在才建):
|
||||
- `system-dev/wiki/INDEX.md`、`TAXONOMY.md`
|
||||
- `system-dev/wiki/status.md`、`mistakes.md`、`principles.md`(三個 push 檔)
|
||||
- `system-dev/docs/README.md`(一般專案才需要)
|
||||
|
||||
---
|
||||
|
||||
## 核心判準:push vs pull(wiki 是給 AI 看的)
|
||||
|
||||
整理任何內容前,先判斷它該 **push** 還 **pull**——判準是「**CC 做事時會不會被動看見**」:
|
||||
|
||||
- **push**:CC 行動前必須主動出現在 context(session 開始就由 hook 注入)。給「CC 不會主動去查、但不看就出事」的東西。
|
||||
- **pull**:CC 想到要查、或載入相關卡時才看見。給「CC 面對它時自然會查」的知識。
|
||||
|
||||
**為什麼這是核心**:mistakes 防的是 CC「不自覺的盲區」——一個你不知道存在的錯,你不會主動去檢索它。靠 CC 自覺去查自己沒自覺的盲區是自相矛盾的,所以 pull 對盲區失效,**必須 push**。原則同理:沒被推到眼前的準繩,CC 設計時很可能沒想到要服從就做了。
|
||||
|
||||
| 內容 | push/pull | 注入形態(hook)|
|
||||
|------|-----------|----------------|
|
||||
| **status** | push | **全文**——CC 必須知道精確的下一步,摘要會漏 task 編號 |
|
||||
| **principles** | push | **全文(一行一條)**——短而硬的約束,漏一條就違反;≤15 條,超過代表該下放成 card |
|
||||
| **mistakes** | push | **標題清單 + 一行症狀**,全文按需 pull——量可能大,摘要足以觸發「我正撞到某條」的認出 |
|
||||
| **decisions、原文摘要、概念知識、一切其餘** | pull | 寫成 cards;CC 面對時自然會查,INDEX 提供角度入口 |
|
||||
|
||||
**principles 維護規則**:一行一條精煉準繩(如「不污染用戶根目錄」「目標用戶 low-code」「wiki 主要給 AI 看」)。發現新的跨全局原則 → append 一行;超過 ~15 條代表某些該合併或下放成 card。**累積原則只改 principles.md,不必問用戶開新檔。**
|
||||
|
||||
### 第四步:訪談(每次一個問題)
|
||||
|
||||
依序問:
|
||||
1. 這個專案做什麼?(一句話)
|
||||
2. 有哪些絕對不能違反的限制?(技術棧、架構原則等)
|
||||
3. 現在進行到哪個階段?
|
||||
4. 有沒有 CC 曾經犯過的錯要先記下來?
|
||||
|
||||
把答案填進 `CLAUDE.md`(如果存在)或建立新的。
|
||||
|
||||
### 第五步:改寫成 wiki(AI 當總編輯)
|
||||
|
||||
(第二步確認後執行)
|
||||
|
||||
**不搬動原文**。逐份讀 raw source,改寫萃取成 `cards/<bucket>/` 裡的自包含原子卡:
|
||||
|
||||
- **概念原子化**:一張卡講一個概念,不是一篇原文對一張卡。原文太雜就拆,多份相關原文就合。
|
||||
- **自包含**:讀卡就懂,不必回去翻原文。把口語、重複、流水帳改寫成結構化知識,**不寫「詳見原文」**。
|
||||
- **保留來源指針**:每卡標 `**來源**:原文相對路徑`,為可追溯,不是要使用者回去讀。
|
||||
- **frontmatter 標籤分類**(見下方):分類走 frontmatter `tags:`,不靠資料夾、不靠行內 `#tag`。
|
||||
- **互相連結(typed-edge 三元組)**:`## 關聯` 不只列裸 `[[頁面]]`,改寫成帶語義的三元組(見下方)。
|
||||
- **萃 gloss(node 一句說明)**:frontmatter 放 `gloss:` —— 這張卡(= 一個 entity / graph node)的一句話定義,供下游語義 normalize(見下方)。
|
||||
|
||||
卡片格式(每張卡):
|
||||
```markdown
|
||||
---
|
||||
tags: [知識管理, AI協作, 方法論]
|
||||
gloss: 一句話定義這個概念是什麼(給下游語義 normalize 用,選填、deep tier 才產)
|
||||
---
|
||||
# 概念全名
|
||||
|
||||
← [[<bucket>/00-INDEX]]
|
||||
|
||||
**來源**:`[raw source 相對路徑]`
|
||||
**最後更新**:YYYY-MM-DD
|
||||
|
||||
## 摘要
|
||||
[一句話核心]
|
||||
|
||||
## 重點
|
||||
- [自包含改寫的要點,不依賴原文]
|
||||
|
||||
## 實體
|
||||
> 本卡內文的關鍵實體(也是 graph node)。名+描述供下游 embedding normalize。集中放、一行一個、不縮排、不重複。
|
||||
- **原子筆記**(atomic note/卡片原子化)— 每張卡只承載一個不可再分論點的知識記錄單元。
|
||||
- **傳統筆記**(大鍋炒筆記)— 把多主題混雜在同一篇、難精確引用的記錄方式。
|
||||
|
||||
## 關聯
|
||||
### 內文知識關係(內文實體間;端點=上方 `## 實體` 正規名,一字不差)
|
||||
- 原子筆記 >> 對立於 >> 傳統筆記
|
||||
### 卡片關係(卡對卡)
|
||||
- [[本卡]] >> 謂詞(動詞短語) >> [[他卡]]
|
||||
```
|
||||
|
||||
**麵包屑用帶路徑 wikilink**(issue #7):H1 次行放 `← [[<bucket>/00-INDEX]]` 指回桶子索引。
|
||||
桶子索引固定名 `00-INDEX` 跨桶會撞名,故**指 00-INDEX 一律帶路徑**(`[[pkm/00-INDEX]]`,Logseq 原生支援、下游 ingest 也能對應到具體檔)。普通卡片間連結仍用裸 `[[卡名]]`(卡名唯一,不需路徑)。
|
||||
|
||||
**frontmatter 標籤分類**(issue #8):
|
||||
- **用 frontmatter `tags:` 而非行內 `#tag`**:卡片內文常大量用 `#`(講筆記法時的 `#猜想`、`#book100`),分類標籤若也行內 `#`,下游 ingest 無法區分「分類」與「內文範例」會污染 graph。frontmatter 與內文完全分開,零歧義。
|
||||
- **用標籤而非資料夾分類**:資料夾=強制單一歸屬;標籤=多重歸屬。一張卡可同時屬知識管理+AI協作+架構設計,硬塞一個資料夾會在其他檢索角度漏掉。
|
||||
- **雙軸 taxonomy**(寫進 `TAXONOMY.md` 當字典;**受控擴充**,非凍結):
|
||||
- 領域(主軸,1-3 個):如 知識管理/學習認知/AI協作/生產力/系統設計/工具教學
|
||||
- 形態(副軸,0-2 個):方法論/工具實作/觀點主張/架構設計/案例經驗
|
||||
- 一般開發專案的軸可不同(如 子系統/層級/決策類型),由 AI 依專案性質提出、寫進 TAXONOMY.md。
|
||||
- **遇到現有軸裝不下的內容**:先查是否只是現有標籤的同義詞;確實是新軸才加進 TAXONOMY.md(附定義)再用——**禁止繞過字典在卡片直接冒新標籤**。字典是 per-repo,跨 repo 不必共用。
|
||||
|
||||
**typed-edge 規則**(issue #5/#11,把「關係」也預編譯,下游 ingest 直接 parse 出帶類型的有向邊):
|
||||
- **重點抓內文實體關係,不只卡對卡**:卡對卡(`[[卡A]] >> 謂詞 >> [[卡B]]`)只是既有雙鏈加動詞、資訊量幾乎沒增加;價值在內文概念關係(`原子筆記 >> 對立於 >> 傳統筆記`,A/B 是內文概念非卡標題)。
|
||||
1. **方向性**:`A >> 謂詞 >> B` 必須讀成「A(謂詞)B」一句通順的話;A、B 順序就是主→賓真實方向。
|
||||
2. **謂詞用動詞 / 動詞短語**(反駁、奠基於、犧牲)。**禁名詞當謂詞**——`>> 存儲格式 >>`、`>> 操作體驗 >>` 讀不通,是錯的。
|
||||
3. **謂詞自由但別太天馬行空**:「參考/參照」皆可(下游 embed 自動聚類),別寫「瞄了一眼」這種抓不到同義的。
|
||||
4. **內文三元組端點用裸文字**(非 `[[wikilink]]`),避免 Logseq 紅色斷鏈;卡對卡那層才用 `[[]]`。
|
||||
5. **向後相容**:純 `[[A]]` 仍合法(視為無類型邊),盡量補謂詞。
|
||||
|
||||
> **★ 硬自檢(Haiku 量產必備)★** 內文三元組端點必須與 `## 實體` 某粗體正規名【一字不差】。**寫完逐條把 A、B 拿去 `## 實體` 比對**,沒有完全相同的 → 這條錯了,改用實體表已有的詞、或把端點補進 `## 實體` 再指它。禁止端點帶括號註解/整句補語/形容詞短語。(實證:光寫規則 Haiku 會略過,端點對不齊 14 條;寫成自檢動作後 14→0。跑 12 張才暴露。)
|
||||
> `>>` 是分隔語法,repo 可自選符號,但全程一致。
|
||||
|
||||
**萃 gloss 規則**(issue #9/#11,把「node 的一句說明」也預編譯,供下游 KBDB 語義 normalize):
|
||||
- **gloss = 這個 entity / graph node 是什麼的一句話**。下游對「entity 名 + gloss」一起做 embedding 求相似度,自動歸一同義詞(比只對名字準、比手維護 alias 表自動)。
|
||||
- **兩層 gloss**:① frontmatter `gloss:` 描述卡標題這個 node;② `## 實體` 每行描述句描述內文實體 node。**內文實體也是 graph node、也需描述句**才能 normalize(`黃仁勳` vs `Jensen Huang` 靠描述拉近向量)。
|
||||
- **實體要描述、謂詞不用**:實體同義詞字面差遠需描述拉近;謂詞同義詞字面本就近,裸詞 embed 自動聚類。
|
||||
- **在知識生產的當下、由 local CC 建**:gloss 跟三元組同階段萃,**不留給下游 ingest 臨時補**——下游只有單檔 / 跨庫視角,編不出貼合的 gloss(=胡扯)。
|
||||
- **選填、deep tier 才產**:淺萃(只要結構)時不浪費;deep 改寫時每張卡補。
|
||||
- **gloss ≠ 摘要**:`gloss` 是給機器 normalize 的定義句(「X 是…」);`## 摘要` 是給人讀的核心一句。
|
||||
- **格式對齊下游 envelope**:frontmatter `gloss:` 與 `## 實體` 詞條對應下游 ingest envelope 的 `nodes[].gloss`,ingest 直接取用。
|
||||
|
||||
**INDEX.md 是標籤視圖**(非資料夾列表),`00-INDEX.md` 是桶內容器(只連不重寫,H2/H3 分節)。
|
||||
頂層索引指桶子索引帶路徑:`[[pkm/00-INDEX]]`。
|
||||
|
||||
> 與 claude.ai Cowork 的 `system-dev/docs/SKILL.md` 改寫邏輯一致,兩條路徑(CC / Cowork)產出同一種 wiki。
|
||||
|
||||
### 第六步:完成報告 + 驗證
|
||||
|
||||
完成後**驗證原文 0 動**(踩過的坑,issue #8):
|
||||
```
|
||||
git status --short pages/ journals/ # 或一般專案的 docs/ ——須 0 新增 0 修改
|
||||
```
|
||||
|
||||
> **改寫時必守**(subagent 尤其):
|
||||
> 1. **絕不寫入 raw source**:subagent 目標一律給絕對路徑到 `cards/<bucket>/`,明寫「絕不寫入 pages/journals/docs 原稿」;事後用上面的 `git status` 驗。
|
||||
> 2. **檔名 = 卡片全名**,否則 `[[全名]]` 對不到檔。冒號用全形「:」、斜線用全形「/」,**全程一種字元**,避免 `/`、`∕`、`:` 混用斷鏈。
|
||||
> 3. **量大用 Haiku 並行改寫**,主模型只切概念邊界+審稿+修跨資料夾斷鏈。
|
||||
|
||||
告知:
|
||||
```
|
||||
✅ wiki-init 完成
|
||||
建立了:[列出新建的目錄和檔案]
|
||||
跳過了:[列出已有因此不動的]
|
||||
改寫了:[N 份原文 → M 張原子卡、K 條 typed-edge、M 條 gloss(deep tier)]
|
||||
原文驗證:pages/ journals/ git status 0 異動 ✅
|
||||
下一步:用 /wiki-capture 把重要決策存進 wiki
|
||||
```
|
||||
@@ -0,0 +1,58 @@
|
||||
# /wiki-recall — Session 開始,手動接關
|
||||
|
||||
開新對話時接上次進度。**Fallback 命令**:SessionStart hook 沒啟動時手動接關;要完整脈絡時也用。
|
||||
|
||||
> 主路徑是 SessionStart hook 自動注入 status 重點,不靠你打命令。
|
||||
> 這支命令應對 hook 失效,以及需要比「status 重點」更完整脈絡的時候。
|
||||
|
||||
---
|
||||
|
||||
## 命名閉環
|
||||
|
||||
init(建) → update(存,session 末) ↔ **recall(接,session 初)** → capture(隨時存結論)
|
||||
|
||||
---
|
||||
|
||||
## 執行流程
|
||||
|
||||
### 第一步:讀 status.md(當前進度)
|
||||
|
||||
讀 `system-dev/wiki/status.md`,掌握:
|
||||
- 正在做什麼、阻擋點
|
||||
- 下次 session 第一件事
|
||||
- 待負責人確認、已知問題
|
||||
|
||||
### 第二步:讀 decisions-summary.md(為什麼這樣做)
|
||||
|
||||
讀 `system-dev/wiki/decisions-summary.md`,掌握相關的架構決策——避免重新討論已定案的事。
|
||||
|
||||
### 第三步:讀 mistakes.md(別重犯)
|
||||
|
||||
讀 `system-dev/wiki/mistakes.md`,掌握已知誤解 + 快速檢查清單。
|
||||
|
||||
### 第四步:掃 wishlist / HANDOFF(如果有)
|
||||
|
||||
- `docs/wishlist.md`:待補功能
|
||||
- 任何 `HANDOFF.md` / 交接note:上一棒留下的脈絡
|
||||
|
||||
### 第五步:回報接關結果
|
||||
|
||||
```
|
||||
📍 接關完成
|
||||
🔄 上次正在做:[status 的「正在做」]
|
||||
🎯 下次第一件事:[status 的「下次 session 第一件事」]
|
||||
⚠️ 待確認:[如有]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 鐵律:快照非即時狀態
|
||||
|
||||
status / wiki 是 **point-in-time 快照,不是即時狀態**。
|
||||
|
||||
接關 = 讀快照 **+ 核實快照**,**不盲信**。
|
||||
|
||||
> 實例:某專案 status 曾寫「待 A 收尾 X」,實際 X 早已完成。
|
||||
> 照舊資訊行動會去催一件已完成的事。
|
||||
|
||||
動手前,先用當前 code / git / 檔案核實快照寫的事項是否仍成立。發現落差 → 先更新 status,再動手。
|
||||
@@ -0,0 +1,50 @@
|
||||
# /wiki-update — Session 結束,更新狀態
|
||||
|
||||
每次 session 結束時執行。更新 status.md,確保下次 session 能無縫接上。
|
||||
|
||||
---
|
||||
|
||||
## 執行流程
|
||||
|
||||
### 第一步:整理這次 session 的結果
|
||||
|
||||
從對話中提取:
|
||||
- 完成了哪些 tasks(標記為 [x])
|
||||
- 進行中但未完成的(標記為 [🔄])
|
||||
- 遇到什麼問題或阻擋
|
||||
- 下次應該從哪裡開始
|
||||
|
||||
### 第二步:更新 tasks.md
|
||||
|
||||
把對應 SDD 的 tasks.md 狀態更新(如果這次有動到的話)。
|
||||
|
||||
### 第三步:更新 status.md
|
||||
|
||||
用以下格式覆蓋 status.md:
|
||||
|
||||
```markdown
|
||||
# 當前狀態
|
||||
> 更新時間:[YYYY-MM-DD]
|
||||
|
||||
## 正在做
|
||||
- [🔄] [task 描述] — 阻擋點:[如果有]
|
||||
|
||||
## 下次 session 第一件事
|
||||
[具體的第一個動作,越具體越好]
|
||||
|
||||
## 待負責人確認
|
||||
- [描述] — 等待:[什麼決定]
|
||||
|
||||
## 已知問題
|
||||
| 問題 | 優先級 | 狀態 |
|
||||
|------|--------|------|
|
||||
| [問題] | 🔴/🟡/⚪ | [狀態] |
|
||||
```
|
||||
|
||||
### 第四步:如果有新的誤解或決策
|
||||
|
||||
順帶執行 `/wiki-capture` 的邏輯,把這次的誤解和決策也存進去。
|
||||
|
||||
### 第五步:確認
|
||||
|
||||
告知 status.md 更新完成,下次 session 從哪裡開始。
|
||||
Executable
+31
@@ -0,0 +1,31 @@
|
||||
#!/bin/bash
|
||||
# .claude/hooks/post-edit-remind-tasks.sh
|
||||
# arcrun PostToolUse hook for Write / Edit / MultiEdit
|
||||
#
|
||||
# 職責:改完 code 後立刻提醒 CC 更新對應 tasks.md,避免批次更新
|
||||
# 退出 code:不 block,只提醒(exit 0)
|
||||
|
||||
set -o pipefail
|
||||
|
||||
INPUT=$(cat)
|
||||
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // ""')
|
||||
|
||||
# 只針對程式碼檔案提醒(不含 tasks.md / CLAUDE.md / rules/*.md 本身)
|
||||
if [[ "$FILE_PATH" =~ \.(go|ts|tsx|js|jsx|py|rs|wasm)$ ]] && [[ "$FILE_PATH" != *"tasks.md"* ]]; then
|
||||
cat >&2 <<EOF
|
||||
|
||||
📌 PostEdit 提醒(by arcrun hook)
|
||||
剛修改了:${FILE_PATH}
|
||||
|
||||
下一步動作:
|
||||
1. 找到對應的 docs/3-specs/*/tasks.md
|
||||
2. 如果這個 task 已完成 → 立刻把 [ ] 改成 [x](或把進行中的 [🔄] 改成 [x])
|
||||
3. 如果發現新的 sub-task → 立刻加入 tasks.md
|
||||
4. 不要等到 session 結束才批次更新
|
||||
|
||||
違反 SDD 協議會在 Stop hook 被提醒,建議現在就處理。
|
||||
|
||||
EOF
|
||||
fi
|
||||
|
||||
exit 0
|
||||
Executable
+106
@@ -0,0 +1,106 @@
|
||||
#!/bin/bash
|
||||
# .claude/hooks/pre-bash-guard.sh
|
||||
# arcrun PreToolUse guard for Bash
|
||||
#
|
||||
# 職責:擋下會違反 CLAUDE rules 的 shell 指令
|
||||
# 退出 code:
|
||||
# 0 = 允許
|
||||
# 2 = 擋下(stderr 訊息會回傳給 CC)
|
||||
|
||||
set -o pipefail
|
||||
|
||||
INPUT=$(cat)
|
||||
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // ""')
|
||||
|
||||
block() {
|
||||
local rule="$1"
|
||||
local reason="$2"
|
||||
local fix="$3"
|
||||
cat >&2 <<EOF
|
||||
❌ BLOCKED by arcrun CLAUDE rules
|
||||
違反項:${rule}
|
||||
指令:${CMD}
|
||||
原因:${reason}
|
||||
正確做法:${fix}
|
||||
參考:.claude/rules/02-forbidden.md
|
||||
EOF
|
||||
exit 2
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 規則 1.2 / 3.3:禁止用 mkdir 建立違規的 auth/credential worker 目錄
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
if echo "$CMD" | grep -qE "mkdir.*((auth|credential|jwt|oauth)[-_]worker|new[-_](auth|credential|jwt|oauth))"; then
|
||||
block "1.2/3.3" \
|
||||
"偵測到嘗試建立新的 auth/credential/jwt/oauth Worker 目錄" \
|
||||
"auth primitive 放在 registry/components/auth_*/;不需要另建 worker 目錄"
|
||||
fi
|
||||
|
||||
# 禁止建立同名零件的平行目錄
|
||||
if echo "$CMD" | grep -qE "mkdir.*/(gmail|telegram|google[-_]sheets|line[-_]notify|http[-_]request)[-_](v2|v3|new|worker|backup)"; then
|
||||
block "3.3" \
|
||||
"禁止為既有零件建立平行目錄(v2/new/worker/backup)" \
|
||||
"直接改 registry/components/<n>/main.go"
|
||||
fi
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 規則 1.3:禁止 wrangler init / generate auth-* credential-* jwt-*
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
if echo "$CMD" | grep -qE "wrangler[[:space:]]+(init|generate).*[[:space:]](auth|credential|jwt|oauth)[-_]"; then
|
||||
block "1.3" \
|
||||
"禁止用 wrangler init/generate 建立 auth/credential/jwt Worker" \
|
||||
"auth primitive 透過 component-worker-template/ 搭配 WASM binary 部署,不要 wrangler init"
|
||||
fi
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 規則 3.1:Service Binding 新增警示
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 偵測在 wrangler.toml 新增 [[services]] 的 echo/cat/sed 操作(非 100% 準確,但夠用)
|
||||
if echo "$CMD" | grep -qE "echo.*\[\[services\]\].*>>"; then
|
||||
block "3.1" \
|
||||
"偵測到要在 wrangler.toml 新增 [[services]] binding" \
|
||||
"零件串接一律走 HTTP URL(cypher binding),不新增 service binding。若有特殊需求,先與 richblack 確認"
|
||||
fi
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 一般性危險指令
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
if echo "$CMD" | grep -qE "rm[[:space:]]+-rf[[:space:]]+(/|/\*|~|\\\$HOME|\.)"; then
|
||||
block "general" \
|
||||
"偵測到危險的 rm -rf 指令" \
|
||||
"明確指定要刪的目錄,不要對根目錄 / home / 當前目錄遞迴刪除"
|
||||
fi
|
||||
|
||||
# 禁止 force push 到 main
|
||||
if echo "$CMD" | grep -qE "git[[:space:]]+push.*--force.*(main|master)"; then
|
||||
block "general" \
|
||||
"禁止 force push 到 main/master" \
|
||||
"用 feature branch,或和 richblack 確認後手動操作"
|
||||
fi
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 資料外流警示(data-exfil-warning SDD R2):AI 動手把資料變成可被外部呼叫前先擋
|
||||
# `acr push`(部署 webhook)/ `acr recipe push`(定義資料去向)= 暴露面動作。
|
||||
# 不含 `acr creds push`(上傳加密 credential 是保護,非暴露)。
|
||||
#
|
||||
# 信任修正(2026-05-30):無「旗標放行」捷徑——AI 自己能加的旗標 = 自己批准自己。
|
||||
# 這類動作一律擋,必須由人類在終端機親自執行(CLI 會跳互動、要人類輸入資源名確認)。
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 只在「指令本身就是執行 acr push / acr recipe push」時擋(錨定到指令開頭,
|
||||
# 允許前置 cd .. && 或環境變數)。避免誤判 git commit -m "...acr push..." 這類
|
||||
# 「字串裡剛好提到 acr push」的情況(commit message / echo / grep 不該被擋)。
|
||||
if echo "$CMD" | grep -qE "(^|&&|;|\|)[[:space:]]*(cd[[:space:]][^&;|]*(&&|;)[[:space:]]*)?([A-Za-z_]+=[^[:space:]]*[[:space:]]+)*acr[[:space:]]+(recipe[[:space:]]+)?push\b" \
|
||||
&& ! echo "$CMD" | grep -qE "acr[[:space:]]+creds[[:space:]]+push\b"; then
|
||||
cat >&2 <<'EOF'
|
||||
❌ BLOCKED:資料外流警示(arcrun data-exfil-warning)
|
||||
原因:acr push / acr recipe push 會把資料/workflow 變成「可被外部呼叫」(暴露面)。
|
||||
這種動作你(AI)不能自行執行,也沒有旗標捷徑——需人類明示知情同意(法律憑證)。
|
||||
正確做法:
|
||||
- 把這件事交給人類:請人類在終端機親自執行(CLI 會跳互動、要人類輸入資源名確認)
|
||||
- 人類第一次確認後 server 會記住,之後同資源不用再確認
|
||||
參考:docs/3-specs/data-exfil-warning/
|
||||
EOF
|
||||
exit 2
|
||||
fi
|
||||
|
||||
exit 0
|
||||
Executable
+98
@@ -0,0 +1,98 @@
|
||||
#!/bin/bash
|
||||
# Harness: Cold 啟動環境檢查
|
||||
# 用途:init 完成後強制檢查環境就緒,不靠提醒,違反條件直接擋住(exit 2)
|
||||
# 由 acr init 末尾調用
|
||||
|
||||
set -e
|
||||
|
||||
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || echo ".")"
|
||||
ARCRUN_CONFIG="${HOME}/.arcrun/config.yaml"
|
||||
|
||||
# 顏色
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
FAILED=0
|
||||
|
||||
echo -e "${YELLOW}🔍 冷啟動環境檢查${NC}"
|
||||
echo ""
|
||||
|
||||
# 檢查 1: config.yaml 存在
|
||||
if [ ! -f "$ARCRUN_CONFIG" ]; then
|
||||
echo -e "${RED}❌ 缺少 $ARCRUN_CONFIG${NC}"
|
||||
echo " 執行:acr init"
|
||||
FAILED=1
|
||||
else
|
||||
echo -e "${GREEN}✓ config.yaml 存在${NC}"
|
||||
fi
|
||||
|
||||
# 檢查 2: api_key 有值
|
||||
if grep -q "^api_key:" "$ARCRUN_CONFIG" 2>/dev/null; then
|
||||
api_key=$(grep "^api_key:" "$ARCRUN_CONFIG" | cut -d' ' -f2)
|
||||
if [ -z "$api_key" ] || [ "$api_key" = "null" ]; then
|
||||
echo -e "${RED}❌ api_key 未設${NC}"
|
||||
FAILED=1
|
||||
else
|
||||
echo -e "${GREEN}✓ api_key 已設${NC}"
|
||||
fi
|
||||
else
|
||||
echo -e "${RED}❌ config.yaml 缺少 api_key${NC}"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
# 檢查 3: .mcp.json 存在且指向自己(self-hosted 用)
|
||||
if grep -q "mode.*self-hosted" "$ARCRUN_CONFIG" 2>/dev/null; then
|
||||
if [ ! -f ".mcp.json" ]; then
|
||||
echo -e "${RED}❌ .mcp.json 缺失(self-hosted 必需)${NC}"
|
||||
echo " 執行:acr init 會自動生成"
|
||||
FAILED=1
|
||||
else
|
||||
# 檢查 mcp.json 是否指向自己(不是官方 URL)
|
||||
if grep -q "mcp.arcrun.dev" ".mcp.json" && ! grep -q "workers.dev" ".mcp.json"; then
|
||||
echo -e "${RED}❌ .mcp.json 指向官方 mcp.arcrun.dev(應指向自己的 worker)${NC}"
|
||||
FAILED=1
|
||||
else
|
||||
echo -e "${GREEN}✓ .mcp.json 配置正確${NC}"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# 檢查 4: acr 能執行
|
||||
if ! command -v acr &>/dev/null; then
|
||||
echo -e "${RED}❌ acr 未裝或不在 PATH${NC}"
|
||||
echo " 執行:npm i -g arcrun"
|
||||
FAILED=1
|
||||
else
|
||||
echo -e "${GREEN}✓ acr 可執行${NC}"
|
||||
fi
|
||||
|
||||
# 檢查 5: D1 database 存在(如果 self-hosted)
|
||||
if grep -q "mode.*self-hosted" "$ARCRUN_CONFIG" 2>/dev/null; then
|
||||
if grep -q "d1_database_id" "$ARCRUN_CONFIG" 2>/dev/null; then
|
||||
db_id=$(grep "d1_database_id" "$ARCRUN_CONFIG" | cut -d' ' -f2)
|
||||
if [ -z "$db_id" ]; then
|
||||
echo -e "${RED}❌ D1 database_id 未設${NC}"
|
||||
echo " 執行:acr init 會建立 D1,或手動設定 d1_database_id"
|
||||
FAILED=1
|
||||
else
|
||||
echo -e "${GREEN}✓ D1 database_id 已設${NC}"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
if [ $FAILED -eq 0 ]; then
|
||||
echo -e "${GREEN}✅ 環境就緒${NC}"
|
||||
exit 0
|
||||
else
|
||||
echo -e "${RED}❌ 環境檢查失敗${NC}"
|
||||
echo ""
|
||||
echo "修復步驟:"
|
||||
echo "1. 檢查 ~/.arcrun/config.yaml 是否完整"
|
||||
echo "2. 若 self-hosted,確認 .mcp.json 指向自己的 mcp worker(不是官方)"
|
||||
echo "3. 若缺少 D1,重跑 acr init --self-hosted --account-id=... --api-token=..."
|
||||
echo ""
|
||||
exit 2
|
||||
fi
|
||||
Executable
+257
@@ -0,0 +1,257 @@
|
||||
#!/bin/bash
|
||||
# .claude/hooks/pre-write-guard.sh
|
||||
# arcrun PreToolUse guard for Write / Edit / MultiEdit
|
||||
#
|
||||
# 職責:擋下會違反 CLAUDE rules 的檔案寫入操作
|
||||
# 退出 code:
|
||||
# 0 = 允許
|
||||
# 2 = 擋下(stderr 訊息會回傳給 CC)
|
||||
#
|
||||
# 依賴:jq
|
||||
|
||||
set -o pipefail
|
||||
|
||||
INPUT=$(cat)
|
||||
|
||||
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // ""')
|
||||
# 取得將要寫入的內容(Write: content;Edit: new_string;MultiEdit: edits[].new_string 全部串起來)
|
||||
CONTENT=$(echo "$INPUT" | jq -r '
|
||||
.tool_input.content
|
||||
// .tool_input.new_string
|
||||
// (.tool_input.edits // [] | map(.new_string // "") | join("\n"))
|
||||
// ""
|
||||
')
|
||||
|
||||
block() {
|
||||
local rule="$1"
|
||||
local reason="$2"
|
||||
local fix="$3"
|
||||
cat >&2 <<EOF
|
||||
❌ BLOCKED by arcrun CLAUDE rules
|
||||
違反項:${rule}
|
||||
檔案:${FILE_PATH}
|
||||
原因:${reason}
|
||||
正確做法:${fix}
|
||||
參考:.claude/rules/02-forbidden.md
|
||||
EOF
|
||||
exit 2
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 規則 1.1:registry/components/ 下不准 TS(除非是 AssemblyScript)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
if [[ "$FILE_PATH" == *"registry/components/"* && "$FILE_PATH" == *.ts ]]; then
|
||||
# 允許 asconfig.json 同目錄的 AssemblyScript
|
||||
COMP_DIR=$(dirname "$FILE_PATH")
|
||||
if [[ ! -f "$COMP_DIR/asconfig.json" ]]; then
|
||||
block "1.1" \
|
||||
"registry/components/ 下禁止 TypeScript(除非是 AssemblyScript 且同目錄有 asconfig.json)" \
|
||||
"零件必須用 TinyGo (main.go) 或 AssemblyScript 實作並編譯成 .wasm"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 規則 1.2:禁止在非法位置新增 auth/credential 實作
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 合法位置:registry/components/auth_static_key | auth_oauth2 | auth_service_account | auth_mtls
|
||||
if [[ "$FILE_PATH" =~ auth[-_](static[-_]key|oauth2|service[-_]account|mtls) ]]; then
|
||||
if [[ "$FILE_PATH" != *"registry/components/auth_"* ]]; then
|
||||
block "1.2" \
|
||||
"auth primitive 實作只能放在 registry/components/auth_<type>/" \
|
||||
"改去 registry/components/auth_static_key/ 等目錄,用 TinyGo 實作 main.go"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 規則 2.1:禁止新增含特定關鍵字的 TS 檔案(credential-injector / jwt-signer 等)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
if [[ "$FILE_PATH" == *.ts ]]; then
|
||||
BASE=$(basename "$FILE_PATH")
|
||||
# 既有的 credential-injector.ts / jwt-signer.ts 允許修改(為了刪除),但不准新增同名
|
||||
if [[ "$BASE" =~ ^(credential[-_]injector|jwt[-_]signer)\.ts$ ]]; then
|
||||
if [[ ! -f "$FILE_PATH" ]]; then
|
||||
block "2.1" \
|
||||
"禁止新增 ${BASE}(Phase 1-3 的目標是刪除此類檔案,不是重建)" \
|
||||
"credential 注入 / JWT signing 屬於 WASM 零件職責,改去 registry/components/auth_*/"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 規則 2.2:cypher-executor TS 裡不准實作業務邏輯(只准 wasi-shim.ts 做 crypto)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
if [[ "$FILE_PATH" == *"cypher-executor/src/"* && "$FILE_PATH" == *.ts ]]; then
|
||||
BASE=$(basename "$FILE_PATH")
|
||||
|
||||
# 通用類別:種子資料檔(*-seeds.ts)= installer 要灌進 KV 的 recipe 種子清單。
|
||||
# 其中的 endpoint / {{template}} 字串是「資料欄位」(宣告 recipe 打哪 / 怎麼注入),
|
||||
# 會被序列化寫進 RECIPES KV,**不是** TS 裡的呼叫 / 展開實作;真正執行仍走零件 / WASM primitive。
|
||||
# 故整類豁免 endpoint / template 字串檢查。這是「種子資料檔」這個普遍類別的規則,
|
||||
# 不是為某個零件 / recipe 開的特例(richblack 2026-06-06 原則:不為單一零件改全域規則)。
|
||||
IS_SEED_DATA_FILE=false
|
||||
if [[ "$BASE" == *-seeds.ts ]]; then
|
||||
IS_SEED_DATA_FILE=true
|
||||
fi
|
||||
|
||||
# crypto.subtle.decrypt:只准在 wasi-shim.ts
|
||||
if echo "$CONTENT" | grep -qE "crypto\.subtle\.decrypt"; then
|
||||
if [[ "$BASE" != "wasi-shim.ts" ]]; then
|
||||
block "2.2" \
|
||||
"AES-GCM 解密(crypto.subtle.decrypt)只准出現在 wasi-shim.ts 的 crypto_decrypt host function" \
|
||||
"把解密邏輯移到 wasi-shim.ts 的 host function;或讓 WASM 零件透過 u6u.crypto_decrypt 呼叫"
|
||||
fi
|
||||
fi
|
||||
|
||||
# crypto.subtle.sign with RSASSA:只准在 wasi-shim.ts
|
||||
if echo "$CONTENT" | grep -qE "crypto\.subtle\.sign.*RSASSA"; then
|
||||
if [[ "$BASE" != "wasi-shim.ts" ]]; then
|
||||
block "2.2" \
|
||||
"RS256 簽章只准出現在 wasi-shim.ts 的 crypto_sign_rs256 host function" \
|
||||
"把簽章移到 wasi-shim.ts;或讓 auth_service_account WASM 透過 u6u.crypto_sign_rs256 呼叫"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Template 展開:{{secret.X}} 或 {{runtime.X}} 屬於 WASM 職責
|
||||
# 例外:種子資料檔(*-seeds.ts)裡的 {{secret.X}} / {{runtime.X}} 是「資料字面值」而非 TS 展開邏輯,
|
||||
# 真正的展開仍在 WASM auth primitive 內完成。
|
||||
if [[ "$IS_SEED_DATA_FILE" == "false" ]] && echo "$CONTENT" | grep -qE "\{\{(secret|runtime)\." ; then
|
||||
block "2.2" \
|
||||
"Template 展開({{secret.X}} / {{runtime.X}})屬於 WASM auth primitive 職責" \
|
||||
"把這段邏輯改寫到 registry/components/auth_static_key/main.go(TinyGo)"
|
||||
fi
|
||||
|
||||
# Hard-code 的 BUILTIN_API_RECIPES / BUILTIN_CREDENTIALS_MAP 新增
|
||||
if echo "$CONTENT" | grep -qE "(BUILTIN_API_RECIPES|BUILTIN_CREDENTIALS_MAP)\s*[:=]"; then
|
||||
# 允許「把它設成空物件」或「刪除」,但不准新增實作
|
||||
if echo "$CONTENT" | grep -qE "BUILTIN_API_RECIPES.*=.*\{\s*[a-zA-Z]"; then
|
||||
block "2.2" \
|
||||
"禁止在 TS 裡新增 BUILTIN_API_RECIPES / BUILTIN_CREDENTIALS_MAP 實作" \
|
||||
"API 呼叫邏輯屬於各自的 WASM 零件(gmail.wasm / telegram.wasm 等),cypher-executor 只做 routing"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Hard-code API endpoint 實作
|
||||
# 例外:種子資料檔(*-seeds.ts)的 endpoint 字串(sheets.googleapis.com 等)是 recipe 的
|
||||
# 「資料欄位」(宣告這個 recipe 預設打哪),會被序列化寫進 RECIPES KV,**不是** TS 裡的呼叫實作;
|
||||
# 真正的 API 呼叫仍走原本零件 / http_request 路徑。
|
||||
HARDCODED_APIS=(
|
||||
"gmail\.googleapis\.com/gmail/v1/users/me/messages/send"
|
||||
"api\.telegram\.org/bot.*sendMessage"
|
||||
"sheets\.googleapis\.com/v4/spreadsheets"
|
||||
"notify-api\.line\.me/api/notify"
|
||||
)
|
||||
for PATTERN in "${HARDCODED_APIS[@]}"; do
|
||||
if echo "$CONTENT" | grep -qE "$PATTERN"; then
|
||||
# 允許 wasi-shim.ts(http_request host function proxy)+ 種子資料檔(*-seeds.ts)
|
||||
if [[ "$BASE" != "wasi-shim.ts" && "$IS_SEED_DATA_FILE" == "false" ]]; then
|
||||
block "2.2" \
|
||||
"禁止在 cypher-executor TS 裡 hard-code API endpoint(偵測到: $PATTERN)" \
|
||||
"把 API 呼叫移到對應的 WASM 零件(registry/components/gmail/main.go 等)"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# exchangeGoogleJwt / 類似 token exchange function
|
||||
if echo "$CONTENT" | grep -qE "(exchangeGoogleJwt|exchangeServiceAccountJwt|signGoogleJwt)"; then
|
||||
if [[ "$BASE" != "wasi-shim.ts" ]]; then
|
||||
block "2.2" \
|
||||
"Token exchange 邏輯屬於 auth_service_account WASM 零件" \
|
||||
"改到 registry/components/auth_service_account/main.go"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 規則 3.3:禁止建立 *-v2 / new-* / *-worker 類複製貼上目錄
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
if [[ "$FILE_PATH" =~ /(auth|credential|jwt|oauth|gmail|telegram|google-sheets|line-notify|http-request)[-_](v2|v3|new|worker|backup|temp)/ ]]; then
|
||||
block "3.3" \
|
||||
"禁止為同一零件建立平行目錄(v2/new/worker/backup 等)" \
|
||||
"直接修改 registry/components/<name>/main.go 即可;需要版本管理請用 git branch"
|
||||
fi
|
||||
if [[ "$FILE_PATH" =~ /new-(auth|credential|jwt|oauth|gmail|telegram)/ ]]; then
|
||||
block "3.3" \
|
||||
"禁止為同一零件建立 new-<name>/ 平行目錄" \
|
||||
"直接修改 registry/components/<name>/main.go"
|
||||
fi
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 規則 7.x:薄殼原則(能力長在 API,介面只暴露)— 見 .claude/rules/07-thin-shell.md
|
||||
# 只擋語法層可偵測的反例;recipe 層拼裝 / 藏在 helper 的邏輯需 code review 把關(07 §6)。
|
||||
# 範圍:薄殼介面 = cli/src/ 與 arcrun-mcp/src/(不含 cypher-executor,那是 API 本體)。
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
block7() {
|
||||
cat >&2 <<EOF
|
||||
❌ BLOCKED by arcrun CLAUDE rules
|
||||
違反項:7(薄殼原則)
|
||||
檔案:${FILE_PATH}
|
||||
原因:$1
|
||||
正確做法:$2
|
||||
參考:.claude/rules/07-thin-shell.md
|
||||
EOF
|
||||
exit 2
|
||||
}
|
||||
|
||||
if [[ ( "$FILE_PATH" == *"/cli/src/"* || "$FILE_PATH" == *"arcrun-mcp/src/"* ) \
|
||||
&& ( "$FILE_PATH" == *.ts || "$FILE_PATH" == *.js ) ]]; then
|
||||
|
||||
# 7.1 seed 邏輯不准寫在介面層(§4.1 反例)。seed 是 API 行為,由 API 端點完成。
|
||||
# 允許薄殼「呼叫一個 seed 端點」(單一 fetch),但不准在介面層定義 seed 編排函式。
|
||||
if echo "$CONTENT" | grep -qE "(function|const|async)\s+(seedApiRecipes|seedAuthRecipes|seedRecipes)\b"; then
|
||||
block7 \
|
||||
"seed 編排邏輯寫在介面層(CLI/MCP),這是壓測 §4.1 的根因——seed 應是 API 行為" \
|
||||
"把 seed 改成 API 端點(cypher-executor POST /init/seed 或部署完成時自動 seed),薄殼只呼叫該端點一次"
|
||||
fi
|
||||
|
||||
# 7.2 「先 update 失敗再 insert」/「先 GET 找有則 PATCH 無則 POST」這類 upsert 拼裝
|
||||
# 在同一函式內同時出現 update/patch 與 insert/post 的條件分支 = 把 API 缺的能力拼在介面層。
|
||||
if echo "$CONTENT" | grep -qiE "method:\s*['\"]PATCH['\"]" && echo "$CONTENT" | grep -qiE "method:\s*['\"]POST['\"]"; then
|
||||
if echo "$CONTENT" | grep -qiE "(upsert|找到.*PATCH|沒找到.*POST|exists.*update.*else.*insert|if.*found.*patch)"; then
|
||||
block7 \
|
||||
"在介面層拼裝 upsert(先找/PATCH 否則 POST)——這是能力,該在 API 提供單一 upsert 端點" \
|
||||
"請 cypher-executor 提供 upsert 端點(API 內部做 GET→PATCH/POST),薄殼只呼叫該端點"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 7.3 client 端「全部成功才做下一步」這類補 API 缺口的 gate(§4.1 deployFullyOk 反例)
|
||||
if echo "$CONTENT" | grep -qE "(deployFullyOk|全部成功.*test|/全部成功/)"; then
|
||||
block7 \
|
||||
"用 client 端字串判斷 gate 後續行為(deployFullyOk 類)——脆弱且把編排責任推給介面層" \
|
||||
"讓 API 保證後置狀態(部署成功即 recipe 就緒),薄殼不做這種 gate"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 規則 4.3:禁止自行在 docs/3-specs/ 下建新 SDD 目錄
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
if [[ "$FILE_PATH" == *"docs/3-specs/"* ]]; then
|
||||
# 檢查是否在已知 SDD 目錄內
|
||||
KNOWN_SDDS=(
|
||||
"docs/3-specs/arcrun"
|
||||
"docs/3-specs/arcrun-core-mvp"
|
||||
"docs/3-specs/arcrun-platform-evolution"
|
||||
"docs/3-specs/component-registry-canon"
|
||||
"docs/3-specs/component-gatekeeping" # 2026-05-29 richblack 確認新建(Phase 3 把關)
|
||||
"docs/3-specs/data-exfil-warning" # 2026-05-30 richblack 確認新建(資料外流警示)
|
||||
"docs/3-specs/user-cc-harness" # 2026-06-03 richblack 確認新建(用戶 CC harness 安裝)
|
||||
"docs/3-specs/llm-interface" # 2026-05-16 richblack 確認新建(AI 操盤手介面)
|
||||
"docs/3-specs/recipe-system" # richblack 確認新建(recipe 系統)
|
||||
"docs/3-specs/resumable-workflow" # richblack 確認新建(可恢復工作流)
|
||||
"docs/3-specs/workflow-discovery" # 2026-06-27 總管 issue #8 交辦新建(工作流 description slot + search_workflow,北極星入口缺口)
|
||||
"docs/3-specs/thin-shell-alignment" # 2026-06-27 總管 issue #11 交辦新建(CLI/MCP 薄殼漂移全面盤點 + 防複發機制)
|
||||
)
|
||||
IN_KNOWN=false
|
||||
for K in "${KNOWN_SDDS[@]}"; do
|
||||
if [[ "$FILE_PATH" == *"$K/"* ]]; then
|
||||
IN_KNOWN=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ "$IN_KNOWN" == "false" ]]; then
|
||||
block "4.3" \
|
||||
"禁止自行在 docs/3-specs/ 下建立新的頂層 SDD 目錄" \
|
||||
"正確程序(見 .claude/rules/00-sdd-protocol.md「新增 SDD 的完整程序」):(1) 先與 richblack 確認要新建此 SDD;(2) 取得明確確認後,在本檔 KNOWN_SDDS 陣列加一行 \"docs/3-specs/<目錄名>\"(執行已授權任務的必要步驟);(3) 白名單放行後才寫 design/tasks。若是現有 SDD 的補充檔,改放已知 SDD 目錄下。"
|
||||
fi
|
||||
fi
|
||||
|
||||
exit 0
|
||||
Executable
+64
@@ -0,0 +1,64 @@
|
||||
#!/bin/bash
|
||||
# PreToolUse hook 範本骨架 —— 專案自訂禁令(預設空殼,不攔任何東西)
|
||||
#
|
||||
# ⚠️ 定位(讀清楚再用):
|
||||
# 這支跟其他三支 hook 不同——它不是「裝上就生效的警察」,而是一個「按需手填的
|
||||
# 空插槽」。預設狀態下 FORBIDDEN_PATTERNS 是空的,它【不攔任何東西】。
|
||||
# 別誤以為裝了它就有保護——空殼 = 沒保護。
|
||||
#
|
||||
# 🤖 有 CC 在場的話,通常不需要這個範本:
|
||||
# 直接叫你的 CC「幫我寫一支 guard hook,禁止改 X」。CC 現寫的條件邏輯,
|
||||
# 表達力遠勝這裡的 glob FORBIDDEN_PATTERNS(例如「禁子 repo 的 code 但放行 .md」
|
||||
# 這種細緻規則,glob 寫不出來,CC 的條件判斷寫得出來)。
|
||||
# 這個範本只對「不靠 CC、想自己手動 DIY bash」的用戶有價值。
|
||||
#
|
||||
# 要啟用(手動 DIY 路線):
|
||||
# 1. 在下面 FORBIDDEN_PATTERNS 填禁改的路徑/檔名 pattern
|
||||
# 2. 到 .claude/settings.json 的 PreToolUse 加掛這支
|
||||
#
|
||||
# 掛在 PreToolUse(matcher: Write|Edit)。stdin 收 JSON:{ tool_name, tool_input:{ file_path } }
|
||||
# 命中禁令 → exit 2 擋。
|
||||
#
|
||||
# 誠實限制:只擋直接寫檔。bash 繞道、helper 間接改動擋不到。留痕可審 ≠ 技術防偽。
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── 專案自訂:禁改的 pattern(一行一個,case glob 語法)──────
|
||||
# 範例(已註解,啟用前請改成自己的):
|
||||
# "*/db/schema.sql" # 禁手改 schema
|
||||
# "*/migrations/*" # migration 一旦建立不可改
|
||||
FORBIDDEN_PATTERNS=(
|
||||
# "*/your/protected/path/*"
|
||||
)
|
||||
|
||||
# 沒設任何禁令 → 空殼狀態,安靜放行。
|
||||
# (不在這裡 print——PreToolUse 每次 Write/Edit 都會跑,每次喊話會洗版。
|
||||
# 「這是空殼」的提醒改由 install.sh / update.sh 安裝時告知,那裡用戶一定看得到。)
|
||||
[ ${#FORBIDDEN_PATTERNS[@]} -eq 0 ] && exit 0
|
||||
|
||||
INPUT=$(cat)
|
||||
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty')
|
||||
else
|
||||
FILE_PATH=$(printf '%s' "$INPUT" | grep -o '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"file_path"[[:space:]]*:[[:space:]]*"//;s/"$//')
|
||||
fi
|
||||
|
||||
[ -z "$FILE_PATH" ] && exit 0
|
||||
|
||||
for pattern in "${FORBIDDEN_PATTERNS[@]}"; do
|
||||
# shellcheck disable=SC2254
|
||||
case "$FILE_PATH" in
|
||||
$pattern)
|
||||
cat >&2 <<EOF
|
||||
🚫 專案禁令攔截:$FILE_PATH 命中禁改規則($pattern)。
|
||||
|
||||
這是本專案 .claude/hooks/pre-write-guard.sh 設定的硬底線。
|
||||
要動 → 先和負責人確認,並更新禁令設定。
|
||||
EOF
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
exit 0
|
||||
Executable
+63
@@ -0,0 +1,63 @@
|
||||
#!/bin/bash
|
||||
# PreToolUse hook — 動 code 前檢查有沒有對應 SDD
|
||||
# wishlist §2:把 /sdd-check 從「命令要人打」升級成「hook 自動攔」。
|
||||
#
|
||||
# 掛在 settings.json 的 PreToolUse(matcher: Write|Edit)。
|
||||
# stdin 收到 JSON:{ tool_name, tool_input: { file_path, ... } }
|
||||
# 行為:動到 code 檔(.ts/.go/...)但 system-dev/docs/3-specs/ 下沒有任何 SDD → 警告(exit 2 擋)。
|
||||
#
|
||||
# 誠實限制(抄 arcrun):只擋語法層明顯違規(直接寫 code 檔)。
|
||||
# 藏在 helper 裡、用 bash 繞道的改動擋不到。
|
||||
# 價值是「想跳過會被抓到 + 留痕可審」,不是技術防偽。絕不聲稱「不可能繞過」。
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
INPUT=$(cat)
|
||||
|
||||
# 解析 file_path。優先用 jq,沒有 jq 退回 grep(容錯)。
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty')
|
||||
else
|
||||
FILE_PATH=$(printf '%s' "$INPUT" | grep -o '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"file_path"[[:space:]]*:[[:space:]]*"//;s/"$//')
|
||||
fi
|
||||
|
||||
# 拿不到路徑 → 不擋(容錯,寧可放過也不誤殺)
|
||||
[ -z "$FILE_PATH" ] && exit 0
|
||||
|
||||
# 只管 code 檔。docs/markdown/設定檔等放行。
|
||||
case "$FILE_PATH" in
|
||||
*.ts|*.tsx|*.js|*.jsx|*.go|*.py|*.rs|*.java|*.rb|*.php|*.c|*.cpp|*.h|*.hpp|*.swift|*.kt) ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
|
||||
# 改 SDD 自己 / 測試檔 → 放行
|
||||
case "$FILE_PATH" in
|
||||
*system-dev/docs/3-specs/*) exit 0 ;;
|
||||
*_test.*|*.test.*|*.spec.*|*/tests/*|*/test/*) exit 0 ;;
|
||||
esac
|
||||
|
||||
# system-dev/docs/3-specs/ 下完全沒有 design.md → 攔
|
||||
SDD_COUNT=0
|
||||
if [ -d "system-dev/docs/3-specs" ]; then
|
||||
SDD_COUNT=$(find system-dev/docs/3-specs -name 'design.md' -not -path '*TEMPLATE*' 2>/dev/null | wc -l | tr -d ' ')
|
||||
fi
|
||||
|
||||
if [ "$SDD_COUNT" -eq 0 ]; then
|
||||
cat >&2 <<EOF
|
||||
🚫 SDD 協議攔截:要動 code 檔 ($FILE_PATH),但 system-dev/docs/3-specs/ 下找不到任何 SDD。
|
||||
|
||||
絕對鐵律:任何 code 變動前必須有對應 SDD(design.md)。
|
||||
|
||||
請先:
|
||||
1. 確認這個改動屬於哪個子系統
|
||||
2. 在 system-dev/docs/3-specs/[子系統]/ 建立 design.md(可用 /sdd-check 協助)
|
||||
3. 在回覆開頭宣告已讀 SDD + 對應 task
|
||||
|
||||
小修改(修 bug、改文字)若確定豁免,請明確說明範圍後由人放行。
|
||||
EOF
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# 有 SDD:放行,但留痕提醒要宣告(stderr 警告,不擋)
|
||||
echo "📋 提醒:system-dev/docs/3-specs/ 下有 SDD。動手前請確認已讀對應 design.md 並在回覆宣告。" >&2
|
||||
exit 0
|
||||
Executable
+73
@@ -0,0 +1,73 @@
|
||||
#!/bin/bash
|
||||
# .claude/hooks/session-start-load-sdd.sh
|
||||
# arcrun SessionStart hook
|
||||
#
|
||||
# 職責:session 啟動時注入當前進度、絕對禁令、SDD 位置,避免 CC 靠記憶猜測
|
||||
# 輸出走 stdout,會注入到 CC 的 context
|
||||
|
||||
set -o pipefail
|
||||
|
||||
cat <<'EOF'
|
||||
============================================================
|
||||
🚨 arcrun 工作規範(SessionStart 注入)
|
||||
============================================================
|
||||
|
||||
📌 絕對禁令(違反會被 pre-write / pre-bash hook 直接 block):
|
||||
|
||||
1. registry/components/ 下禁止 TypeScript 實作零件
|
||||
→ 只能 TinyGo (main.go) 或 AssemblyScript,編譯成 .wasm
|
||||
|
||||
2. cypher-executor TS 裡禁止實作 credential/auth/JWT 業務邏輯
|
||||
→ crypto.subtle.decrypt / sign 只准出現在 wasi-shim.ts 的 host function
|
||||
→ {{secret.X}} template 展開屬於 WASM 零件職責
|
||||
→ 禁止 hard-code gmail/telegram/sheets API endpoint
|
||||
|
||||
3. 禁止新增 Service Binding 綁零件
|
||||
→ 零件串接走 HTTP URL(cypher binding = YAML 裡的 URL 清單)
|
||||
→ Cypher binding 不是 Cloudflare 的 binding 機制
|
||||
|
||||
4. 每個 WASM 零件 = 獨立 Worker = 公開 URL
|
||||
→ 不是從 R2 動態讀(平台零件已 bundle 進各自 Worker)
|
||||
→ R2 只用於 Phase 5 的用戶自製零件(未啟用)
|
||||
|
||||
📋 任何 code 變動前,必須:
|
||||
|
||||
1. 讀 docs/3-specs/arcrun/arcrun.md(總進度)
|
||||
2. 讀對應 task 的 SDD(design.md + tasks.md)
|
||||
3. 在回覆開頭宣告(格式見 .claude/rules/00-sdd-protocol.md):
|
||||
📋 已讀 SDD:<檔案清單>
|
||||
🎯 本次對應 task:<task 編號>
|
||||
📐 本次 task 的 SDD 規範摘要:<重點>
|
||||
🚧 執行範圍:修改/建立/刪除 <檔案>
|
||||
4. 每完成一個 task,立刻更新 tasks.md 的 [x],不批次
|
||||
|
||||
🔥 當前進行中 Phase:Credential Primitives TS → WASM
|
||||
|
||||
SDD:docs/3-specs/arcrun/credential-primitives-wasm/
|
||||
已完成:Phase 0.1-0.5(核心合併 + u6u-core 刪除)
|
||||
未完成硬前置:
|
||||
- Phase 0.6 wasi-shim 加 host functions(kv_get / crypto_decrypt / crypto_sign_rs256)
|
||||
- Phase 0.7 component-loader WASM runner 路徑
|
||||
未完成主要任務:
|
||||
- Phase 1 auth_static_key WASM 零件(TinyGo)
|
||||
- Phase 2 auth_service_account WASM 零件
|
||||
- Phase 3 刪除 cypher-executor 的三套違規 TS:
|
||||
* src/actions/credential-injector.ts(整檔刪)
|
||||
* src/lib/jwt-signer.ts(整檔刪)
|
||||
* src/lib/component-loader.ts 的 BUILTIN_API_RECIPES + BUILTIN_CREDENTIALS_MAP(整段刪)
|
||||
|
||||
⛔ 封測狀態:推遲(richblack 2026-04-19 決定)
|
||||
原因:違規 TS 未清,不封測。
|
||||
|
||||
📚 詳細規範:
|
||||
.claude/rules/00-sdd-protocol.md — SDD 協議
|
||||
.claude/rules/01-tech-stack.md — 技術棧硬限制
|
||||
.claude/rules/02-forbidden.md — 禁止清單(hook 強制)
|
||||
.claude/rules/03-component-architecture.md — 零件架構(R2/cypher binding/service binding 的正確定義)
|
||||
.claude/rules/04-current-progress.md — 當前進度 + SDD 索引
|
||||
.claude/rules/06-mindset.md — mindset(為什麼層):工作流是default/零件稀有/AI→工具/誠實不假綠
|
||||
|
||||
============================================================
|
||||
EOF
|
||||
|
||||
exit 0
|
||||
Executable
+86
@@ -0,0 +1,86 @@
|
||||
#!/bin/bash
|
||||
# SessionStart hook — 開 session 自動注入 status.md 重點
|
||||
# wishlist §1 主路徑:不靠 CC 自覺、不用人說,開 session 就把進度推到眼前。
|
||||
#
|
||||
# 掛在 settings.json 的 SessionStart(matcher: startup|resume|clear)。
|
||||
# stdout 會被當成 context 注入給 CC。
|
||||
#
|
||||
# 鐵律:status 是 point-in-time 快照,非即時狀態。
|
||||
# 這個 hook 只負責「把快照推到眼前」,核實快照是 CC 的責任——下面的提醒就是要它別盲信。
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
STATUS_FILE="system-dev/wiki/status.md"
|
||||
PRINCIPLES_FILE="system-dev/wiki/principles.md"
|
||||
MISTAKES_FILE="system-dev/wiki/mistakes.md"
|
||||
|
||||
# ── 舊結構防呆(1.9.0 遷移第 2 層保險)──
|
||||
# 新版 wiki 收進 system-dev/。若偵測到舊位置 .claude/wiki/ 還在、但新位置沒 status,
|
||||
# 代表使用者升級了規則卻沒跑遷移(low-code 使用者常見)→ 出聲提示,讓 CC 當場可代為遷移,
|
||||
# 不要默默用不到新結構而出錯。
|
||||
if [ -d ".claude/wiki" ] && [ ! -f "$STATUS_FILE" ]; then
|
||||
echo "⚠️ 偵測到舊版 wiki 結構(.claude/wiki/),尚未遷移到 system-dev/wiki/。"
|
||||
echo " 請跑:bash system-dev/scripts/update.sh"
|
||||
echo " 或直接叫我(CC):「幫我把 wiki 遷移到 system-dev/」——我可以代為搬移。"
|
||||
echo " (未遷移時接關與 /wiki-init 會找錯位置。)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 三個 push 檔都沒有 → 安靜退出,不干擾還沒 /wiki-init 的專案
|
||||
if [ ! -f "$STATUS_FILE" ] && [ ! -f "$PRINCIPLES_FILE" ] && [ ! -f "$MISTAKES_FILE" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── push 1/3:principles(全文,行動前必服從)──
|
||||
# 放最前:原則是「會被遺忘的盲區」,要第一眼看見。全文成本低(一行一條、≤15 條)。
|
||||
if [ -f "$PRINCIPLES_FILE" ] && grep -q '^- ' "$PRINCIPLES_FILE" 2>/dev/null; then
|
||||
echo "════════════════════════════════════════════════"
|
||||
echo "📐 設計原則(行動前必服從,來自 principles.md)"
|
||||
echo "════════════════════════════════════════════════"
|
||||
grep '^- ' "$PRINCIPLES_FILE" # 只注入原則條目本身,不含說明區
|
||||
# context 保護:原則應 ≤15 條(push 全文)。超過 → 提示該合併或下放成 card,不截斷(截斷會漏原則)。
|
||||
P_COUNT=$(grep -c '^- ' "$PRINCIPLES_FILE")
|
||||
if [ "$P_COUNT" -gt 15 ]; then
|
||||
echo ""
|
||||
echo "(⚠️ principles 已 ${P_COUNT} 條 > 15:請考慮合併相近原則、或把較細的下放成 card)"
|
||||
fi
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# ── push 2/3:status(全文,當前進度)──
|
||||
if [ -f "$STATUS_FILE" ]; then
|
||||
echo "════════════════════════════════════════════════"
|
||||
echo "📍 接關:上次進度(來自 $STATUS_FILE 快照)"
|
||||
echo "════════════════════════════════════════════════"
|
||||
echo ""
|
||||
cat "$STATUS_FILE"
|
||||
fi
|
||||
|
||||
# ── push 3/3:mistakes(標題清單 + 一行症狀,全文按需展開)──
|
||||
# 不全文注入(可能累積很多、含長 context)。只給「標題行」讓 CC 認出「我正撞到某條」→ 再去讀全文。
|
||||
if [ -f "$MISTAKES_FILE" ] && grep -q 'MISTAKE' "$MISTAKES_FILE" 2>/dev/null; then
|
||||
echo ""
|
||||
echo "────────────────────────────────────────────────"
|
||||
echo "⚠️ 已知踩坑(標題;撞到時讀 $MISTAKES_FILE 全文)"
|
||||
grep -E 'MISTAKE' "$MISTAKES_FILE" | sed 's/^/ /'
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "────────────────────────────────────────────────"
|
||||
echo "⚠️ 以上是 point-in-time 快照,非即時狀態。"
|
||||
echo " 動手前先核實:快照寫的事項是否真的還沒做完?"
|
||||
echo " 需要完整脈絡(decisions / mistakes / SDD)→ 執行 /wiki-recall"
|
||||
echo "════════════════════════════════════════════════"
|
||||
|
||||
# ── 遷移殘留偵測(1.9.x):wiki 已搬到 system-dev/,但 CLAUDE.md 內容裡可能還寫舊路徑 ──
|
||||
# update.sh 鐵則「絕不碰 CLAUDE.md」(用戶資料),所以遷移不會自動改它內容裡的死引用。
|
||||
# 這裡只「偵測 + 提示 CC 代修」——由 CC(看得懂語義、不誤傷用戶自寫內容)來改,不讓腳本盲改。
|
||||
if [ -f "CLAUDE.md" ] && grep -q '\.claude/wiki' CLAUDE.md 2>/dev/null; then
|
||||
echo ""
|
||||
echo "🔧 偵測到 CLAUDE.md 內仍有舊路徑 \`.claude/wiki/\`(wiki 已遷移到 system-dev/wiki/)。"
|
||||
echo " 這些是遷移後的死引用,會讓你(CC)找錯位置。"
|
||||
echo " 請主動幫使用者把 CLAUDE.md 裡的 \`.claude/wiki/\` 改成 \`system-dev/wiki/\`、"
|
||||
echo " 工具 docs 路徑改成 \`system-dev/docs/\`(但 raw source 宣告的 \`docs/\` 保留不動)。"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
Executable
+47
@@ -0,0 +1,47 @@
|
||||
#!/bin/bash
|
||||
# .claude/hooks/stop-check-sync.sh
|
||||
# arcrun Stop hook
|
||||
#
|
||||
# 職責:session 結束前檢查 code 變動是否有對應的 SDD / tasks.md 更新
|
||||
# 退出 code:不 block,只警告
|
||||
|
||||
set -o pipefail
|
||||
|
||||
# 檢查 docs/3-specs 下本次 session 是否有變動
|
||||
SPECS_DIFF=$(git -C "$(pwd)" status --porcelain -- 'docs/3-specs/' 2>/dev/null | head -20)
|
||||
CODE_DIFF=$(git -C "$(pwd)" status --porcelain -- '*.go' '*.ts' '*.tsx' '*.py' 'cypher-executor/' 'registry/' 'cli/' 2>/dev/null | head -20)
|
||||
|
||||
if [[ -n "$CODE_DIFF" && -z "$SPECS_DIFF" ]]; then
|
||||
cat >&2 <<EOF
|
||||
|
||||
⚠️ Stop hook 警告(by arcrun hook)
|
||||
|
||||
偵測到本 session 有程式碼變動,但 docs/3-specs/ 下的 SDD 文件沒有任何變動。
|
||||
|
||||
未 commit 的程式碼變動:
|
||||
$(echo "$CODE_DIFF" | head -10)
|
||||
|
||||
請在結束前確認:
|
||||
1. 對應的 tasks.md 是否已更新 [x]?
|
||||
2. 是否有架構變動需要更新 design.md?
|
||||
3. 是否有 SDD 範圍外的 change 未標記?
|
||||
|
||||
SDD 協議要求:code 和 SDD 必須同步更新。
|
||||
參考:.claude/rules/00-sdd-protocol.md
|
||||
|
||||
EOF
|
||||
fi
|
||||
|
||||
# 若有暫存的 tasks.md 變動,提醒 commit
|
||||
TASKS_DIFF=$(git -C "$(pwd)" status --porcelain -- 'docs/3-specs/**/tasks.md' 2>/dev/null | head -5)
|
||||
if [[ -n "$TASKS_DIFF" ]]; then
|
||||
cat >&2 <<EOF
|
||||
|
||||
📝 提醒:tasks.md 有未 commit 的變動
|
||||
$(echo "$TASKS_DIFF")
|
||||
記得在結束前 commit。
|
||||
|
||||
EOF
|
||||
fi
|
||||
|
||||
exit 0
|
||||
Executable
+113
@@ -0,0 +1,113 @@
|
||||
#!/bin/bash
|
||||
# PreToolUse hook — 寫入 wiki 前掃機敏資訊(L3 硬攔截)
|
||||
#
|
||||
# 為什麼存在:wiki 的 ignore 規則(.wikiignore + 行內標記)是「協議層」,靠 CC 遵守。
|
||||
# 但密碼/金鑰/個資外洩是「不可逆」後果——只靠口頭約束太危險。
|
||||
# 這支 hook 是機械式底線:CC 真的把機敏資訊寫進 system-dev/wiki/ 的那一刻 → exit 2 擋下。
|
||||
#
|
||||
# 掛在 settings.json 的 PreToolUse(matcher: Write|Edit)。
|
||||
# stdin 收到 JSON:{ tool_name, tool_input: { file_path, content?, new_string? } }
|
||||
# 行為:只在目標路徑是 system-dev/wiki/** 時啟動,掃要寫入的內容,命中機敏特徵 → exit 2。
|
||||
#
|
||||
# 誠實限制(抄 sdd-guard):regex 偵測有偽陰/偽陽。
|
||||
# 擋的是「明顯特徵的機敏字串被自動抄進 wiki」,擋不了刻意混淆/編碼的繞道。
|
||||
# 價值是「意外外洩的機械底線 + 留痕可審」,不是技術防偽。絕不聲稱「不可能繞過」。
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
INPUT=$(cat)
|
||||
|
||||
# ── 解析 file_path 與要寫入的內容。優先 jq,無 jq 退回 grep(容錯)──────
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty')
|
||||
# Write 用 content;Edit 用 new_string。兩個都抓,合起來掃。
|
||||
CONTENT=$(printf '%s' "$INPUT" | jq -r '[.tool_input.content, .tool_input.new_string] | map(select(. != null)) | join("\n")')
|
||||
else
|
||||
FILE_PATH=$(printf '%s' "$INPUT" | grep -o '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"file_path"[[:space:]]*:[[:space:]]*"//;s/"$//')
|
||||
# 無 jq 時內容解析不可靠(JSON 跳脫),退回掃整包 INPUT,寧可多掃不漏掃
|
||||
CONTENT="$INPUT"
|
||||
fi
|
||||
|
||||
# 拿不到路徑 → 不擋(容錯,寧可放過也不誤殺)
|
||||
[ -z "$FILE_PATH" ] && exit 0
|
||||
|
||||
# 只管寫進 wiki 的動作。其他路徑放行(這支專責 wiki 洩漏,不是全域 secret scanner)
|
||||
case "$FILE_PATH" in
|
||||
*system-dev/wiki/*) ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
|
||||
[ -z "$CONTENT" ] && exit 0
|
||||
|
||||
# 行內豁免:若該段內容已被標記為刻意保留(例:範例文件要示範格式),略過該行
|
||||
# 標記:行尾加 # wiki-secret-ok (或 <!-- wiki-secret-ok -->)
|
||||
# 先把標記過的行抽掉再掃。
|
||||
SCAN=$(printf '%s' "$CONTENT" | grep -v -E 'wiki-secret-ok' || true)
|
||||
[ -z "$SCAN" ] && exit 0
|
||||
|
||||
# ── 機敏特徵 pattern。一行一類,命中即攔。──────────────────────────
|
||||
# 設計取捨:偏向高訊號 pattern(有明確結構的金鑰/標記),降低偽陽。
|
||||
# 純「password=xxx」這類也納入,因為那正是使用者最擔心的場景。
|
||||
HITS=""
|
||||
|
||||
check() {
|
||||
local label="$1" regex="$2"
|
||||
# -e 讓以 - 開頭的 pattern(如 PEM 的 -----BEGIN)不被當成選項。
|
||||
# grep 無命中回傳 1,在 set -e 下會中止 → 用 if 包住吸收掉。
|
||||
if printf '%s' "$SCAN" | grep -qiE -e "$regex"; then
|
||||
HITS="${HITS}
|
||||
• ${label}"
|
||||
fi
|
||||
}
|
||||
|
||||
# 密碼/密鑰賦值(password = ..., secret: ..., api_key=...)
|
||||
check "密碼/密鑰賦值 (password/secret/api_key/token = ...)" \
|
||||
'(pass(word)?|secret|api[_-]?key|access[_-]?key|auth[_-]?token|priv(ate)?[_-]?key)[[:space:]]*[:=][[:space:]]*[^[:space:]<>"'"'"']{6,}'
|
||||
|
||||
# 私鑰 PEM 區塊
|
||||
check "私鑰檔內容 (BEGIN ... PRIVATE KEY)" \
|
||||
'-----BEGIN[[:space:]].*PRIVATE KEY-----'
|
||||
|
||||
# 常見雲端/服務金鑰前綴
|
||||
check "服務金鑰特徵 (AWS/GitHub/Slack/Google/Stripe 等)" \
|
||||
'(AKIA[0-9A-Z]{16}|gh[pousr]_[0-9A-Za-z]{20,}|xox[baprs]-[0-9A-Za-z-]{10,}|AIza[0-9A-Za-z_-]{20,}|sk_(live|test)_[0-9A-Za-z]{16,})'
|
||||
|
||||
# JWT
|
||||
check "JWT token" \
|
||||
'eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}'
|
||||
|
||||
# 連線字串內嵌帳密 (proto://user:pass@host)
|
||||
check "連線字串內嵌帳密 (proto://user:pass@host)" \
|
||||
'[a-z][a-z0-9+.-]*://[^[:space:]:/@]+:[^[:space:]:/@]+@'
|
||||
|
||||
# 台灣身分證字號(個資)。BSD/GNU grep 都支援 ERE,避免 \b(BSD 不認),改用字元類邊界。
|
||||
check "台灣身分證字號 (個資)" \
|
||||
'(^|[^A-Za-z0-9])[A-Z][12][0-9]{8}([^0-9]|$)'
|
||||
|
||||
# 信用卡號(個資,粗略 13-16 連續數字,可含空格/連字號分隔)。避免 PCRE,用 ERE 近似。
|
||||
check "疑似信用卡號 (個資)" \
|
||||
'(^|[^0-9])[0-9]{4}[ -]?[0-9]{4}[ -]?[0-9]{4}[ -]?[0-9]{0,4}([^0-9]|$)'
|
||||
|
||||
# Email 不擋(wiki 常需記聯絡人),手機號也不擋(偽陽太高)——刻意留白。
|
||||
|
||||
if [ -n "$HITS" ]; then
|
||||
cat >&2 <<EOF
|
||||
🚫 Wiki 機敏攔截:偵測到可能的機敏資訊要寫進 ${FILE_PATH}。
|
||||
|
||||
命中特徵:${HITS}
|
||||
|
||||
wiki 是會被 CC 反覆讀取、可能進版控的記憶空間。
|
||||
密碼 / 金鑰 / 個資寫進去 = 不可逆外洩風險。
|
||||
|
||||
請改成下列任一做法:
|
||||
1. 不要把機敏值寫進 wiki,改記「位置」(例:「DB 密碼放 1Password / .env,不入 wiki」)
|
||||
2. 確定是誤判(例:在示範格式)→ 該行尾加註記 wiki-secret-ok 後重寫
|
||||
3. 整個來源檔本就機敏 → 加進 system-dev/wiki/.wikiignore,別讓它被編入
|
||||
|
||||
誠實限制:本掃描靠特徵比對,有偽陽/偽陰,是「意外外洩的機械底線」而非保險箱。
|
||||
真正的密鑰本就不該進版控。
|
||||
EOF
|
||||
exit 2
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,99 @@
|
||||
# SDD 協議(每次啟動必讀)
|
||||
|
||||
## 第零原則:沒讀 SDD 不准動 code
|
||||
|
||||
任何 `.go` / `.ts` / `.tsx` / `.wasm` 相關變動,**必須**按以下順序執行。**不得簡化,不得跳過**。
|
||||
|
||||
### 步驟 1:讀總進度
|
||||
|
||||
先讀 `docs/3-specs/arcrun/arcrun.md`,了解當前 Phase。
|
||||
|
||||
### 步驟 2:定位對應 SDD
|
||||
|
||||
根據任務性質找對應 SDD:
|
||||
|
||||
| 任務類型 | 對應 SDD |
|
||||
|---------|---------|
|
||||
| Auth primitive WASM 零件(static_key/oauth2/service_account/mtls) | `docs/3-specs/arcrun/credential-primitives-wasm/` |
|
||||
| 清除 cypher-executor 裡的 TS 業務邏輯 | `docs/3-specs/arcrun/credential-primitives-wasm/` |
|
||||
| WASI shim host functions(kv_get / crypto_decrypt / crypto_sign_rs256) | `docs/3-specs/arcrun/credential-primitives-wasm/` |
|
||||
| Auth Recipe 系統(recipe schema、KV 格式) | `docs/3-specs/arcrun/auth-recipe.md` |
|
||||
| Landing Page | `docs/3-specs/arcrun/landing-page.md` |
|
||||
| CLI / SDK(Python/JS) | `docs/3-specs/arcrun/sdk-and-website/` |
|
||||
| arcrun-core-mvp 整體架構 | `docs/3-specs/arcrun-core-mvp/` |
|
||||
| Platform Evolution | `docs/3-specs/arcrun-platform-evolution/` |
|
||||
| Credential 長期規格(需求源) | `docs/user_requirements/credential_parts.md` |
|
||||
|
||||
讀 `design.md` 和 `tasks.md` 兩份。
|
||||
|
||||
### 步驟 3:宣告(強制格式)
|
||||
|
||||
開始動手前,在回覆開頭**逐字**貼出以下宣告:
|
||||
|
||||
```
|
||||
📋 已讀 SDD:
|
||||
- docs/3-specs/arcrun/arcrun.md(當前 Phase:<phase 名稱>)
|
||||
- <對應 SDD 的 design.md 路徑>
|
||||
- <對應 SDD 的 tasks.md 路徑>
|
||||
|
||||
🎯 本次對應 task:<task 編號,例如 "Phase 1.3 實作 auth_static_key main.go">
|
||||
|
||||
📐 本次 task 的 SDD 規範摘要:
|
||||
- <重點 1>
|
||||
- <重點 2>
|
||||
- <重點 3>
|
||||
|
||||
🚧 執行範圍:
|
||||
- 會修改:<檔案清單>
|
||||
- 會建立:<檔案清單>
|
||||
- 會刪除:<檔案清單>
|
||||
```
|
||||
|
||||
**不做這個宣告 = 違反 SDD 協議 = 停手等 richblack**。
|
||||
|
||||
### 步驟 4:check tasks.md 狀態
|
||||
|
||||
動手前:在 tasks.md 把對應 task 的 `- [ ]` 改成 `- [🔄]`(進行中標記)。
|
||||
完成後:改成 `- [x]`,不批次更新,每完成一個就立刻改。
|
||||
|
||||
## 什麼算「任務超出 SDD 範圍」?
|
||||
|
||||
以下情況屬於 **change**,不是 **modify**,**必須停手並與 richblack 確認**:
|
||||
|
||||
- SDD 沒寫到的新功能
|
||||
- 新增頂層目錄
|
||||
- 新增新的 Worker(不管是 cypher-executor / registry / 零件 worker)
|
||||
- 修改架構決策(例如「改用 xxx 取代 yyy」)
|
||||
- 跨多個子系統的連鎖修改
|
||||
|
||||
**停手不是怯懦,是專業**。猜錯方向比慢一小時更糟。
|
||||
|
||||
## 新增 SDD 的完整程序(richblack 確認後怎麼往下走)
|
||||
|
||||
> 2026-06-03 補:之前協議只寫「停手等確認」,沒寫「確認後怎麼做」,導致 CC 被
|
||||
> `pre-write-guard.sh` 規則 4.3 擋下後不知正路、卡死。這節補完整程序。
|
||||
|
||||
當「新增一個 SDD 子系統」(在 `docs/3-specs/` 下開新頂層目錄):
|
||||
|
||||
1. **停手,向 richblack 說明要新建哪個 SDD、為什麼**(change,見上節)。
|
||||
2. **取得 richblack 明確確認**後,依序:
|
||||
1. **更新白名單**:在 `.claude/hooks/pre-write-guard.sh` 的 `KNOWN_SDDS` 陣列加一行
|
||||
`"docs/3-specs/<新目錄名>" # YYYY-MM-DD richblack 確認新建(<一句用途>)`。
|
||||
(這步是「執行 richblack 已授權任務的必要步驟」,不是 AI 擅自放寬 guardrail——
|
||||
授權脈絡要明確,分類器才放行;沒有明確授權就改白名單 = 越界。)
|
||||
2. **建目錄 + 寫 design.md / tasks.md**(白名單放行後才寫得進去)。
|
||||
3. **若沒先更新白名單就 Write** → 規則 4.3 會擋你(這是對的,表示你跳了步驟 2.i)。
|
||||
|
||||
**為什麼白名單而非全放行**:開新 SDD 子系統 = 宣告新架構範圍,是稀有的人類決策點。
|
||||
白名單讓「AI 自己無中生有開子系統」必停下(AI 改不了白名單,除非 richblack 明確授權該任務)。
|
||||
已許可的目錄內寫檔零摩擦。
|
||||
|
||||
## 發現 SDD 本身有問題怎麼辦?
|
||||
|
||||
- SDD 和實作不一致 → 停手,列出矛盾點,與 richblack 確認哪一邊是對的
|
||||
- SDD 規範之間互相矛盾(例如禁令 A 和設計 B 衝突)→ 停手,引用矛盾原文,與 richblack 確認
|
||||
- **不可以自行猜哪個是對的**。CC 之前兩天就是這樣走錯的。
|
||||
|
||||
## 為什麼這個協議存在
|
||||
|
||||
arcrun 規範已經足夠細緻,CC 之前出錯不是因為不懂,而是因為**沒讀**或**讀了覺得「大概是這個意思」就動手**。SDD 協議強制把「先讀 → 定位 → 宣告 → 執行 → 更新」做成一條死規矩,沒有繞過去的路徑。
|
||||
@@ -0,0 +1,76 @@
|
||||
# 技術棧硬限制
|
||||
|
||||
## 三層語言對應(絕對不可混用)
|
||||
|
||||
| 層級 | 語言 | 位置 | 職責 |
|
||||
|-----|------|------|------|
|
||||
| 零件(Component) | **TinyGo 或 AssemblyScript → WASM** | `registry/components/{name}/` | 所有業務邏輯 |
|
||||
| 零件 Worker 包裝 | TypeScript(固定模板,不寫業務邏輯) | `.component-builds/{name}/` | WASI shim,stdin/stdout JSON |
|
||||
| Orchestration Worker | TypeScript + Hono | `cypher-executor/` | HTTP routing、workflow 執行排程、host functions |
|
||||
| CLI | TypeScript + Node.js | `cli/` | `acr` 指令 |
|
||||
| Python SDK | Python | `python-sdk/` | HTTP thin wrapper + client 端加密 |
|
||||
| JS SDK | TypeScript + Web Crypto | `js-sdk/` | HTTP thin wrapper + client 端加密 |
|
||||
| Frontend | React 19 + Vite + Tailwind v4 | `landing/` | Cloudflare Pages |
|
||||
|
||||
## 零件實作規範
|
||||
|
||||
### 只有兩種合法語言
|
||||
- **TinyGo**:`tinygo build -target=wasi -o {name}.wasm main.go`
|
||||
- **AssemblyScript**:`asc main.ts --target release -o {name}.wasm`
|
||||
|
||||
### I/O 模型
|
||||
- **stdin**:JSON input
|
||||
- **stdout**:JSON output
|
||||
- 不用 HTTP server,不監聽 socket(WASI preview1 沒 socket)
|
||||
|
||||
### Host Functions(零件呼叫外部能力的唯一管道)
|
||||
在 `u6u` namespace 下:
|
||||
|
||||
| Host Function | 用途 |
|
||||
|---|---|
|
||||
| `u6u.http_request` | 發 HTTP 請求 |
|
||||
| `u6u.kv_get` | 讀 Cloudflare KV(Worker 側依 key 前綴路由到正確 KV) |
|
||||
| `u6u.crypto_decrypt` | AES-GCM 解密(encryption key 永不暴露給 WASM) |
|
||||
| `u6u.crypto_sign_rs256` | RSA-SHA256 簽章(PKCS8 bytes 傳入) |
|
||||
|
||||
**所有 host function 在 `cypher-executor/src/lib/wasi-shim.ts` 實作**。零件透過 WASI import 使用。
|
||||
|
||||
## 資料儲存
|
||||
|
||||
| 儲存 | 用途 | Key 格式 |
|
||||
|-----|------|---------|
|
||||
| Cloudflare KV `WEBHOOKS` | workflow 定義(cypher binding YAML) | `webhook:{api_key}:{name}` |
|
||||
| Cloudflare KV `CREDENTIALS_KV` | 加密 credential | `{api_key}:cred:{name}` |
|
||||
| Cloudflare KV `RECIPES` | auth recipe / 動態 API recipe | `auth_recipe:{service}`, `rec_{hash}` |
|
||||
| Cloudflare KV `USERS_KV` | 用戶資料 | `user:{api_key}` |
|
||||
| Cloudflare KV `SESSIONS_KV` | session | `session:{token}` |
|
||||
| Cloudflare KV `ANALYTICS_KV` | 執行分析 | `execution:{timestamp}:{id}` |
|
||||
| Cloudflare KV `EXEC_CONTEXT` | workflow 執行中的 context | `ctx:{execution_id}:{node_id}` |
|
||||
| Cloudflare R2 `WASM_BUCKET` | **只用於用戶自製零件**(Phase 5 以後啟用) | `{api_key}:cmp:{hash}` |
|
||||
|
||||
**警告:R2 不存平台內建零件的 WASM**。平台零件已 bundle 進各自的 Worker binary(`[[wasm_modules]]` 或 `import ... assert { type: 'webassembly' }`)。
|
||||
|
||||
## 加解密規範
|
||||
|
||||
- **演算法**:AES-GCM 256-bit
|
||||
- **加密位置**:Client 端(CLI / Python SDK / JS SDK)
|
||||
- Python:`cryptography` 套件
|
||||
- JS:Web Crypto API(`crypto.subtle`)
|
||||
- **解密位置**:Server 端 **WASM primitive**(透過 host function `crypto_decrypt`)
|
||||
- cypher-executor TS **不解密**,只提供 host function
|
||||
- `ENCRYPTION_KEY` 只在 Worker host function 內部讀取,**永不經 stdin / 回傳值傳給 WASM**
|
||||
- **傳輸格式**:`{ name, encrypted, iv }`(iv base64、encrypted base64)
|
||||
|
||||
## 網路部署
|
||||
|
||||
- **平台 API(對外)**:`cypher.arcrun.dev`(cypher-executor)
|
||||
- **Landing**:`arcrun.dev`
|
||||
- **每個零件 Worker**:
|
||||
- **對內(cypher-executor 用來 fetch component,避開同 zone 死鎖)**:`arcrun-{kebab}.{WORKER_SUBDOMAIN}.workers.dev`
|
||||
- 例:`arcrun-kbdb-get.uncle6-me.workers.dev`
|
||||
- cypher-executor 從 `wrangler.toml [vars] WORKER_SUBDOMAIN` 組出此 URL
|
||||
- **對外(可選,零件對全網開放被 curl 用)**:`{kebab}.arcrun.dev`
|
||||
- 例:`gmail.arcrun.dev`、`kbdb-get.arcrun.dev`
|
||||
- 仍允許保留,但**禁止 cypher-executor 透過此 URL fetch**(會撞同 zone 自循環,見 [docs/incidents/2026-05-13-cypher-outbound-522.md](../../docs/incidents/2026-05-13-cypher-outbound-522.md))
|
||||
- **新增 component worker 部署清單**:`name = "arcrun-{kebab}"` + `[[routes]]` 對外(可選)+ dashboard 啟用 workers.dev(必須)
|
||||
- **部署工具**:Wrangler
|
||||
@@ -0,0 +1,154 @@
|
||||
# 禁止行為清單(零容忍)
|
||||
|
||||
**這份清單由 `.claude/hooks/*.sh` 強制執行。違反會 block 工具呼叫(exit 2)**。
|
||||
|
||||
---
|
||||
|
||||
## 第一類:零件實作層級的禁令
|
||||
|
||||
### 1.1 禁止在 `registry/components/` 下建立 TypeScript 檔案
|
||||
零件**只能**用 TinyGo(`.go`)或 AssemblyScript(`.ts` 但需 `asconfig.json`)實作,並編譯成 `.wasm`。
|
||||
cypher-executor/registry Worker 或 `.component-builds/` 內的 TS 不算零件邏輯,那是 WASI shim。
|
||||
|
||||
**Hook 會擋**:新增 `registry/components/*/{檔案}.ts`(除非目錄內有 `asconfig.json` 明確標記為 AssemblyScript)。
|
||||
|
||||
### 1.2 禁止建立新的 `auth_*` 目錄以外的 auth 實作
|
||||
所有 auth 邏輯只能在:
|
||||
- `registry/components/auth_static_key/`
|
||||
- `registry/components/auth_oauth2/`
|
||||
- `registry/components/auth_service_account/`
|
||||
- `registry/components/auth_mtls/`
|
||||
|
||||
**不可以**出現 `cypher-executor/src/auth-primitive/`、`cypher-executor/src/lib/auth-*.ts`、`auth-worker/`、`credential-worker/` 等目錄。
|
||||
|
||||
**Hook 會擋**:`mkdir` 或 `Write` 到上述違規路徑。
|
||||
|
||||
### 1.3 禁止用 `wrangler init/generate` 建立 auth/credential/jwt 相關的 TS Worker
|
||||
Auth primitive 必須透過 `component-worker-template/` 搭配 WASM binary 部署。
|
||||
|
||||
**Hook 會擋**:bash 指令含 `wrangler (init|generate) ... auth_`、`... credential_`、`... jwt_` 的 pattern。
|
||||
|
||||
---
|
||||
|
||||
## 第二類:cypher-executor TS 的禁令
|
||||
|
||||
### 2.1 禁止新增任何 credential / auth / jwt 相關的 TS 檔案
|
||||
**已存在但要刪**(Phase 1-3 範圍):
|
||||
- `cypher-executor/src/actions/credential-injector.ts` → 刪除(走 WASM auth primitive)
|
||||
- `cypher-executor/src/lib/jwt-signer.ts` → 刪除(RS256 移入 auth_service_account WASM)
|
||||
- `cypher-executor/src/lib/component-loader.ts` 的 `BUILTIN_API_RECIPES` 和 `BUILTIN_CREDENTIALS_MAP` → 整段刪除
|
||||
|
||||
**Hook 會擋**:新增任何路徑含以下關鍵字的 `.ts` 檔案:
|
||||
- `credential-injector`、`credential_injector`
|
||||
- `jwt-signer`、`jwt_signer`
|
||||
- `auth-dispatcher` 的 TS 若嘗試在裡面實作 credential 解密 / template 展開 / JWT signing,block
|
||||
|
||||
### 2.2 禁止在 cypher-executor 任何 TS 裡實作以下邏輯
|
||||
這些邏輯全部屬於 WASM 零件職責:
|
||||
|
||||
- AES-GCM 解密(`crypto.subtle.decrypt`)— 只准出現在 `wasi-shim.ts` 的 `crypto_decrypt` host function
|
||||
- RSA-SHA256 簽章(`crypto.subtle.sign` with RSASSA-PKCS1-v1_5)— 只准出現在 `wasi-shim.ts` 的 `crypto_sign_rs256` host function
|
||||
- Template 展開(`{{secret.X}}` / `{{runtime.X}}` 替換)— 只能在 WASM 零件內
|
||||
- PEM → PKCS8 解析
|
||||
- JWT header/payload/signature 組裝
|
||||
- Token exchange(拿 service account JWT 換 access_token)
|
||||
- 具體 API call 實作(例如 gmail send / telegram sendMessage / google sheets append)
|
||||
|
||||
**Hook 會擋**:
|
||||
- Write/Edit 到 `cypher-executor/src/` 下的 `.ts` 時,內容含:
|
||||
- `crypto\.subtle\.decrypt` 且檔名不是 `wasi-shim.ts`
|
||||
- `crypto\.subtle\.sign.*RSASSA` 且檔名不是 `wasi-shim.ts`
|
||||
- `interpolateTemplate`、`\{\{secret\.` 的模板邏輯
|
||||
- `BUILTIN_API_RECIPES`、`BUILTIN_CREDENTIALS_MAP`(新增用)
|
||||
- `gmail.googleapis.com/gmail/v1/users/me/messages/send` 類 hard-code API URL
|
||||
- `api.telegram.org/bot.*sendMessage`
|
||||
- `sheets.googleapis.com/v4/spreadsheets`
|
||||
- `notify-api.line.me/api/notify`
|
||||
|
||||
### 2.3 cypher-executor TS 的合法職責(允許)
|
||||
- HTTP routing(Hono routes)
|
||||
- workflow 執行排程(`graph-executor.ts`)
|
||||
- 呼叫 WASM 零件(透過 HTTP fetch 到對應 Worker URL,或 Service Binding fallback)
|
||||
- 提供 host function(`wasi-shim.ts` 的 `kv_get` / `crypto_decrypt` / `crypto_sign_rs256`)
|
||||
- KV/R2/Service Binding 存取封裝
|
||||
|
||||
---
|
||||
|
||||
## 第三類:架構層級的禁令
|
||||
|
||||
### 3.1 禁止新增 Service Binding
|
||||
**Cypher binding 不是 Cloudflare service binding**。它是 YAML/KV 裡的 URL 清單。
|
||||
|
||||
零件串接(workflow 層)一律走 HTTP URL,不走 `[[services]]`。
|
||||
|
||||
13 個現有的 `SVC_*` 綁定(`cypher-executor/wrangler.toml`,邏輯零件)是歷史遺產(效能優化),**保留但不新增**。
|
||||
|
||||
> **2026-06-06 註(credential-primitives-wasm Phase 7)**:self-hosted 的 cypher 與 auth worker 同在 `{sub}.workers.dev` zone,cypher `fetch()` 打 auth 觸發 CF **same-zone 1042**(壓測階段 11)。**未用 service binding 解**(評估後廢:service binding 靜態、加/改要重 deploy cypher)。改用 **`global_fetch_strictly_public` compatibility flag**(cypher wrangler.toml)讓 same-zone fetch 走公網前門 → 同 zone 也通,**auth 維持 HTTP fetch、不加 binding**。故本禁令不變。
|
||||
|
||||
**Hook 會擋**:bash 指令含 `wrangler tail` 以外、涉及 `[[services]]` 新增的 pattern;Edit wrangler.toml 新增 `[[services]]` 區塊時警告確認。
|
||||
|
||||
### 3.2 禁止以「從 R2 取 WASM」為設計
|
||||
平台內建零件已 bundle 進各自 Worker,不從 R2 取。
|
||||
R2 只在 Phase 5(用戶自製零件)啟用。
|
||||
|
||||
**Hook 會警告**:TS 中出現 `env.WASM_BUCKET.get(` 的新增 code(除非在明確標註的 Phase 5 user-submit 路徑中)。
|
||||
|
||||
### 3.3 禁止複製貼上 Worker 程式碼到新目錄
|
||||
要改 `gmail` 零件 → 改 `registry/components/gmail/main.go`,重新編譯、部署。
|
||||
**不准**新建 `gmail-v2/`、`new-gmail/`、`gmail-worker/` 等目錄。
|
||||
|
||||
**Hook 會擋**:`mkdir` 或 `Write` 到 `{component-name}-v2/`、`new-{component-name}/`、`{component-name}-worker/` 類路徑。
|
||||
|
||||
### 3.4 禁止在 SDK 內做 server 職責
|
||||
- **禁止**:SDK 裡做 server 端解密、credential-injector 重實作、workflow executor、auth recipe 解析
|
||||
- **允許**:SDK 做 HTTP thin wrapper + client 端加密(AES-GCM)
|
||||
|
||||
---
|
||||
|
||||
## 第四類:流程層級的禁令
|
||||
|
||||
### 4.1 禁止沒讀 SDD 就動 code
|
||||
見 `00-sdd-protocol.md`。
|
||||
|
||||
### 4.2 禁止批次更新 tasks.md
|
||||
每完成一個 task 就立刻 mark `- [x]`。不准「先全部做完再一次更新」。
|
||||
|
||||
### 4.3 禁止新建 SDD 而不事先與 richblack 確認
|
||||
SDD 屬於架構決策,必須人確認。CC 不可以自行在 `docs/3-specs/` 底下建新目錄。
|
||||
例外:在現有 SDD 目錄內新增 `requirements.md` / `design.md` / `tasks.md` 的單檔補充(需在 CLAUDE.md 已註記的 SDD 範圍內)。
|
||||
|
||||
---
|
||||
|
||||
## 第五類:薄殼原則的禁令(詳見 07-thin-shell.md)
|
||||
|
||||
### 5.1 禁止在薄殼介面(cli/src/、arcrun-mcp/src/)實作業務邏輯
|
||||
能力只實作一次,放在 API(cypher-executor 端點)。薄殼只做介面轉換 + 暴露。
|
||||
|
||||
**Hook 會擋**(規則 7.x,範圍 `cli/src/` 與 `arcrun-mcp/src/` 的 .ts/.js):
|
||||
- 7.1 新增 `seedApiRecipes` / `seedAuthRecipes` / `seedRecipes` 編排函式(seed 是 API 行為,§4.1 反例)
|
||||
- 7.2 介面層拼裝 upsert(同函式內 PATCH + POST + upsert/找則改否則建 字樣)
|
||||
- 7.3 client 端「全部成功才做下一步」gate(`deployFullyOk` 類,§4.1 反例)
|
||||
|
||||
### 5.2 禁止用**零件**補 API 缺的能力(污染零件庫)
|
||||
缺能力 → 先看 07 §3.5 自力救濟階梯分流:**自家** API 缺 → 補 API endpoint;**第三方** API 缺(gsheets filter / 無 upsert)→ 走 **workflow/code-node 補丁**(合法,是資料產物非介面層 TS,hook 範圍外);純計算 → code-node;真需新穩定能力才建零件 PR。
|
||||
禁的是「在介面層拼裝」(5.1,hook 擋)與「亂建零件污染零件庫」,**不是禁任何補丁**(hook 偵測不到語意,靠 code review)。
|
||||
|
||||
### 5.3 禁止同一 API 能力在不同介面用不同參數簽名 / 連不同帳號
|
||||
`validate` 在 CLI 吃 YAML、MCP 卻要 `api_key`+`graph` = 底層分歧。薄殼差異只能來自介面慣例。
|
||||
所有薄殼讀同一份身份來源(見 07 §4)。
|
||||
|
||||
---
|
||||
|
||||
## Hook Block 訊息格式
|
||||
|
||||
當 hook 擋住一個操作時,訊息格式統一為:
|
||||
|
||||
```
|
||||
❌ BLOCKED by arcrun CLAUDE rules
|
||||
違反項:<禁令編號,例如 2.2>
|
||||
原因:<簡短說明>
|
||||
正確做法:<該改去哪裡、該用什麼方式>
|
||||
參考:.claude/rules/<對應檔案>
|
||||
```
|
||||
|
||||
這樣 CC 拿到錯誤訊息後有機會自行導正,不是被擋死就愣住。
|
||||
@@ -0,0 +1,165 @@
|
||||
# 零件架構與部署模式(必讀,CC 最常搞錯的地方)
|
||||
|
||||
## 第一核心概念:每個 WASM 零件 = 一個獨立 Worker = **兩個** URL
|
||||
|
||||
**不是**從 R2 即時載入 WASM 執行。
|
||||
**不是**用 service binding 串零件。
|
||||
**不是**一個 Worker 裡跑多個零件。
|
||||
|
||||
**是**:每個零件都是獨立部署的 Worker,每個都有**兩個 URL**:
|
||||
|
||||
| URL 類型 | Pattern | 用途 |
|
||||
|---|---|---|
|
||||
| 對內(cypher-executor 用)| `arcrun-{kebab}.{WORKER_SUBDOMAIN}.workers.dev` | cypher-executor fetch component 走這個,避開同 zone 自循環死鎖(P0 #9)|
|
||||
| 對外(直接 curl 用,可選)| `{kebab}.arcrun.dev` | 用戶單獨打 component 測試或 self-hosted 用法 |
|
||||
|
||||
例:`kbdb_get` 零件:
|
||||
- 對內:`arcrun-kbdb-get.uncle6-me.workers.dev`(cypher-executor 走這個)
|
||||
- 對外:`kbdb-get.arcrun.dev`(用戶 / 直 curl)
|
||||
|
||||
**為什麼這樣設計**:CF Workers 「同 zone 自循環防護」會讓綁 `cypher.arcrun.dev/*` 的 cypher-executor fetch 同 zone `*.arcrun.dev` 撞 522。完整事件報告:[docs/incidents/2026-05-13-cypher-outbound-522.md](../../docs/incidents/2026-05-13-cypher-outbound-522.md)。改走 workers.dev 子域繞過。
|
||||
|
||||
### 零件 Worker 的結構
|
||||
|
||||
```
|
||||
registry/components/{name}/
|
||||
├── main.go ← TinyGo 原始碼(實際零件邏輯)
|
||||
├── component.contract.yaml ← 輸入/輸出規格
|
||||
└── {name}.wasm ← TinyGo 編譯產物
|
||||
```
|
||||
|
||||
部署時,透過 `component-worker-template/` 把 WASM 包進一個 Hono Worker:
|
||||
```
|
||||
.component-builds/{name}/
|
||||
├── package.json
|
||||
├── wrangler.toml ← name = "arcrun-{name}",route = "{name}.arcrun.dev"
|
||||
├── component.wasm ← 從 registry/components/{name}/ 複製過來
|
||||
└── src/index.ts ← 固定的 WASI shim(POST / → stdin → WASM → stdout → JSON)
|
||||
```
|
||||
|
||||
**src/index.ts 是通用模板**,所有零件都用同一份。這個 TS 只做 WASI runtime,不是業務邏輯。
|
||||
|
||||
---
|
||||
|
||||
## R2(WASM_BUCKET)的真正用途
|
||||
|
||||
R2 存 WASM 只是**用戶自製零件上傳**用的。
|
||||
|
||||
**平台內建零件不從 R2 讀取**——它們在部署時就已 bundle 進 Worker 的 binary(透過 `[[wasm_modules]]` 或 `import` with `assert { type: 'webassembly' }`)。
|
||||
|
||||
Phase 5(封測後)才會啟用「用戶 push 自製零件 → 存 R2 → 動態執行」這條路徑。
|
||||
|
||||
**結論:當 CC 問「怎麼從 R2 取出 WASM」時,幾乎都是走錯路徑**。平台零件是獨立 Worker,走 HTTP 呼叫,不是 R2 動態載入。
|
||||
|
||||
---
|
||||
|
||||
## Cypher binding 的正確定義
|
||||
|
||||
**Cypher binding 不是 Cloudflare 的任何 binding 機制。**
|
||||
|
||||
Cypher binding 是一張 YAML 清單,內容是「一個 workflow 要呼叫哪些零件 URL」。存放在:
|
||||
- 本地:`workflow.yaml`(用戶寫的 workflow)
|
||||
- KV:`WEBHOOKS` KV(用戶 `acr push` 後存入)
|
||||
|
||||
Cypher executor 執行 workflow 時:
|
||||
1. 從 KV 讀出 workflow YAML
|
||||
2. 按 graph 順序解析每個節點的 `component`
|
||||
3. 用 HTTP fetch 打對應的零件 URL
|
||||
4. 把 output 當作下個節點的 input
|
||||
|
||||
**這就是 Cypher binding——用 HTTP URL 把零件串起來,存在 YAML/KV 裡**。
|
||||
|
||||
### 為什麼不能用 Service Binding?
|
||||
|
||||
Service binding 需要 `wrangler.toml` 裡寫死 `[[services]]`,且要 redeploy 才生效。arcrun 是類 n8n 服務,用戶建立新 workflow 時**絕對不可能**要他 redeploy。所以 workflow 層一定要 HTTP。
|
||||
|
||||
### Service Binding 的僅存合法用途
|
||||
|
||||
只在 `cypher-executor` 和**平台內建邏輯零件之間**保留(效能優化,避免公網往返)。看 `cypher-executor/wrangler.toml` 裡的 13 個 `[[services]]` 綁定就是這個用途。
|
||||
|
||||
**禁止新增任何 Service Binding**。所有新零件(含 auth primitive)都走 HTTP URL 路徑。
|
||||
|
||||
**same-zone 1042 的解(credential-primitives-wasm Phase 7,2026-06-06)**:self-hosted 的 cypher 與 auth worker 同在 `{sub}.workers.dev` zone,cypher `fetch()` 打 auth 觸發 CF **1042**(官方 docs:「fetch from another Worker on the **same zone**」;官方 cypher 在 `cypher.arcrun.dev`、打 `*.workers.dev` 屬跨 zone 故不踩——非官方有 flag)。**解法不是 service binding**(評估後廢:靜態、加/改要重 deploy),而是 cypher wrangler.toml 加 **`global_fetch_strictly_public` flag**——讓 same-zone fetch 走公網前門 → 同 zone 也通。auth 維持 HTTP fetch、不加 binding。官方加此 flag 行為不變(本就跨 zone),self-host 被修好 → **官方與 self-host 共用同一份 toml**。
|
||||
|
||||
**仍禁止**:為**用戶自製 / 服務專屬零件**(`gmail-worker`、`notion-worker` 之類)新增 binding——那些是 recipe 的事,不該有 binding。**workflow 層(用戶串零件)一律 HTTP URL 不變。**
|
||||
|
||||
---
|
||||
|
||||
## 零件之間怎麼串:實際流程
|
||||
|
||||
假設 workflow 是:webhook → gmail(要 auth)→ google_sheets(要 auth)
|
||||
|
||||
```
|
||||
用戶 POST https://cypher.arcrun.dev/webhooks/named/xxx/trigger
|
||||
│
|
||||
▼
|
||||
cypher-executor(Worker)讀 workflow YAML
|
||||
│
|
||||
├─ 節點 1: component = gmail
|
||||
│ a. 查 auth_recipe:gmail → primitive = static_key
|
||||
│ b. HTTP POST https://auth-static-key.arcrun.dev
|
||||
│ { action: "authenticate", api_key, service: "gmail" }
|
||||
│ → 回傳 { auth_headers: { Authorization: "Bearer ..." } }
|
||||
│ c. HTTP POST https://gmail.arcrun.dev
|
||||
│ { to, subject, body, _auth_headers }
|
||||
│ → gmail 零件 Worker 執行 WASM → 回傳 { success, data }
|
||||
│
|
||||
└─ 節點 2: component = google_sheets
|
||||
... 相同模式
|
||||
```
|
||||
|
||||
**cypher-executor 本身不做 credential 解密、不做 JWT signing、不做 auth header 組裝**。這些全在 auth primitive WASM 零件內,cypher-executor 只負責 HTTP routing 和工作流排程。
|
||||
|
||||
---
|
||||
|
||||
## 實際禁令(CC 看這裡)
|
||||
|
||||
### 禁止在 `registry/components/` 下建立 TypeScript 檔案
|
||||
零件邏輯一律 TinyGo 或 AssemblyScript,編譯成 `.wasm`。
|
||||
|
||||
### 禁止把 auth 邏輯寫在 `cypher-executor/src/` 裡
|
||||
credential 解密、JWT signing、template 展開(`{{secret.X}}`)全部屬於 auth primitive WASM 零件的職責。cypher-executor 只呼叫它們。
|
||||
|
||||
### 禁止問「怎麼從 R2 取 WASM」
|
||||
平台內建零件**不從 R2 取**。每個零件已部署成獨立 Worker,走 HTTP URL。用戶自製零件才用 R2(Phase 5,未啟用)。
|
||||
|
||||
### 禁止新增 Service Binding
|
||||
13 個現有的 SVC_*(邏輯零件)是歷史遺產,不新增。新零件(含 auth primitive)一律走 HTTP URL。self-hosted 的 same-zone 1042 用 `global_fetch_strictly_public` flag 解,不靠新增 binding(見上「合法用途」段,Phase 7)。
|
||||
|
||||
### 禁止重建已存在的零件 Worker
|
||||
要改 `gmail` 零件邏輯 → 改 `registry/components/gmail/main.go`,重新編譯 `.wasm`,重新部署對應 Worker。**不要**在 `cypher-executor/src/lib/` 或其他地方建「新的 gmail 實作」。
|
||||
|
||||
---
|
||||
|
||||
## 部署一個新零件的完整步驟(auth_static_key 為例)
|
||||
|
||||
1. 建立 `registry/components/auth_static_key/`:
|
||||
- `main.go`(TinyGo 實作)
|
||||
- `component.contract.yaml`(IO 規格)
|
||||
2. 編譯:`cd registry/components/auth_static_key && tinygo build -target=wasi -o auth_static_key.wasm main.go`
|
||||
3. 建立 `.component-builds/auth_static_key/`:
|
||||
- 複製 `component-worker-template/src/index.ts`
|
||||
- 複製 `component-worker-template/package.json`
|
||||
- 新建 `wrangler.toml`:
|
||||
```toml
|
||||
name = "arcrun-auth-static-key"
|
||||
main = "src/index.ts"
|
||||
compatibility_date = "2025-02-19"
|
||||
[vars]
|
||||
COMPONENT_ID = "auth_static_key"
|
||||
[[routes]]
|
||||
pattern = "auth-static-key.arcrun.dev/*"
|
||||
zone_name = "arcrun.dev"
|
||||
```
|
||||
- 複製 `auth_static_key.wasm` 到此目錄為 `component.wasm`
|
||||
4. `cd .component-builds/auth_static_key && pnpm install && pnpm deploy`
|
||||
5. **Dashboard 啟用 workers.dev URL**(必須,否則 cypher-executor fetch 不到):
|
||||
- Workers & Pages → `arcrun-auth-static-key` → Settings → Domains & Routes → workers.dev → Enable
|
||||
- 啟用後 URL:`arcrun-auth-static-key.{WORKER_SUBDOMAIN}.workers.dev`
|
||||
6. 驗證對外:`curl https://auth-static-key.arcrun.dev` → 應回 `{ok: true, component: "auth_static_key"}`
|
||||
7. 驗證對內:`curl https://arcrun-auth-static-key.{WORKER_SUBDOMAIN}.workers.dev` → 應同樣回 200
|
||||
8. cypher-executor 透過 `wasmWorkerUrl()` 自動組對內 URL 呼叫(不用手動註冊)
|
||||
|
||||
**這是唯一正確的部署流程**。任何偏離這個流程的「替代方案」都要先和 richblack 確認。
|
||||
|
||||
**Step 5 為什麼必須**:見 arcrun.md P0 #9(2026-05-13)。cypher-executor 走對內 URL 避開同 zone 自循環死鎖;若 workers.dev 未啟用,cypher-executor fetch 該 component 會 404。
|
||||
@@ -0,0 +1,77 @@
|
||||
# 當前進度(SessionStart 會注入此檔重點)
|
||||
|
||||
> 更新時間:2026-04-19
|
||||
> 權威來源:`docs/3-specs/arcrun/credential-primitives-wasm/tasks.md`
|
||||
> 此檔僅摘要,詳細狀態以 tasks.md 為準。
|
||||
|
||||
---
|
||||
|
||||
## 封測狀態
|
||||
|
||||
**原定明天封測,richblack 決定推遲**,原因:cypher-executor 有三套 TS 業務邏輯違反「零件一律 WASM」架構原則(Phase 1-3 要清除的程式碼),在清除前不封測。
|
||||
|
||||
---
|
||||
|
||||
## 目前 Phase:Credential Primitives TS → WASM
|
||||
|
||||
**SDD 位置**:`docs/3-specs/arcrun/credential-primitives-wasm/design.md` + `tasks.md`
|
||||
|
||||
### 已完成
|
||||
|
||||
- **Phase 0.1–0.5**:核心合併(u6u-core 併入 arcrun、21 個零件 contract 完整、刪除重複 `credentials/` 目錄、CREDENTIALS_KV binding 確認、刪除 `matrix/u6u-core/`)
|
||||
- `registry/components/` 下 21 個零件(邏輯 + API)都有 `main.go` + `.wasm`
|
||||
|
||||
### 進行中 / 未完成
|
||||
|
||||
| Task | 狀態 | 阻擋關係 |
|
||||
|-----|------|---------|
|
||||
| 0.6 wasi-shim 新增 `kv_get` / `crypto_decrypt` / `crypto_sign_rs256` host functions | ⬜ 未開始 | **Phase 1-3 的硬前置** |
|
||||
| 0.7 component-loader 新增 WASM runner 路徑 | ⬜ 未開始 | **Phase 1-3 的硬前置** |
|
||||
| 1.1-1.8 `auth_static_key` WASM 零件(TinyGo) | ⬜ 未開始 | 涵蓋 80% 服務 |
|
||||
| 2.1-2.6 `auth_service_account` WASM 零件(JWT signing) | ⬜ 未開始 | Google Service Account 等 |
|
||||
| 3.1-3.5 清除 `component-loader.ts` 的 `BUILTIN_API_RECIPES` | ⬜ 未開始 | 要先有 Phase 1-2 的 WASM 零件 |
|
||||
| 4.1-4.4 `auth_oauth2` + `auth_mtls`(封測後) | ⬜ 未開始 | 非阻擋項 |
|
||||
| 5.1-5.7 核心穩定驗證(全域搜尋確認無殘餘 TS) | ⬜ 未開始 | 封測啟動門檻 |
|
||||
|
||||
### Phase 1-3 要**徹底刪除**的 TS 檔案(不是搬、不是改,是刪)
|
||||
|
||||
| 檔案 | 違反什麼 |
|
||||
|-----|---------|
|
||||
| `cypher-executor/src/actions/credential-injector.ts` | AES 解密、template 展開、JWT 邏輯 —— 應在 WASM |
|
||||
| `cypher-executor/src/lib/jwt-signer.ts` | RS256 JWT 簽章邏輯 —— 應在 `auth_service_account.wasm` |
|
||||
| `cypher-executor/src/lib/component-loader.ts` 的 `BUILTIN_API_RECIPES`(~100 行) | gmail/telegram/line/gsheets/http_request/cron 的 TS 實作 —— 應全部走對應 WASM 零件 |
|
||||
|
||||
---
|
||||
|
||||
## 下一個 session 第一件要做的事
|
||||
|
||||
**讀 `docs/3-specs/arcrun/credential-primitives-wasm/tasks.md`**,然後決定從 Phase 0.6 還是 0.7 開始。
|
||||
|
||||
0.6(host functions)和 0.7(WASM runner)是並列的前置工作,哪個先都可以,但都要在 Phase 1 開始之前完成。
|
||||
|
||||
---
|
||||
|
||||
## SDD 索引
|
||||
|
||||
| 子系統 | SDD |
|
||||
|--------|-----|
|
||||
| **主要(正在動)** Credential Primitives WASM 改寫 | `docs/3-specs/arcrun/credential-primitives-wasm/` |
|
||||
| **LI (LLM Interface)** — AI 操盤手使用體驗(2026-05-16 新建,mira dogfood 痛點轉化) | `docs/3-specs/llm-interface/` |
|
||||
| arcrun 總進度 | `docs/3-specs/arcrun/arcrun.md` |
|
||||
| Auth Recipe 系統(schema、預建 20 個服務) | `docs/3-specs/arcrun/auth-recipe.md` |
|
||||
| Landing Page | `docs/3-specs/arcrun/landing-page.md` |
|
||||
| SDK + Website | `docs/3-specs/arcrun/sdk-and-website/design.md` |
|
||||
| arcrun MVP 整體 | `docs/3-specs/arcrun-core-mvp/design.md` |
|
||||
| Credential 長期規格(需求源) | `docs/user_requirements/credential_parts.md` |
|
||||
| Platform Evolution | `docs/3-specs/arcrun-platform-evolution/design.md` |
|
||||
| Tech Stack 詳細 | `docs/3-specs/tech.md` |
|
||||
|
||||
---
|
||||
|
||||
## 技術備註(CC 常搞錯的點)
|
||||
|
||||
1. **每個 WASM 零件 = 獨立 Worker = 公開 URL**(例:`gmail.arcrun.dev`)。不是從 R2 動態讀。
|
||||
2. **Cypher binding = YAML 裡寫 URL 清單**。不是 Cloudflare service binding。
|
||||
3. **cypher-executor 只做 routing + host functions**。業務邏輯全在 WASM 零件。
|
||||
4. **TinyGo 有限制**:`crypto/rsa` 支援不全 → 用 host function `crypto_sign_rs256` 讓 Worker 代簽。
|
||||
5. 詳見 `.claude/rules/03-component-architecture.md`。
|
||||
@@ -0,0 +1,138 @@
|
||||
# 部署慣例(CI/CD)
|
||||
|
||||
> **核心原則:新增 Worker = 新目錄 + `wrangler.toml`,不用改 workflow。**
|
||||
|
||||
`.github/workflows/deploy.yml` 是**通用掃描式** workflow,不該為每個 Worker 手寫 job。
|
||||
|
||||
---
|
||||
|
||||
## Workflow 如何找到要部署的 Worker?
|
||||
|
||||
```
|
||||
find . -name 'wrangler.toml' -not -path '*/node_modules/*' -not -name 'wrangler.test.toml'
|
||||
```
|
||||
|
||||
每一個命中的目錄 = 一個部署單位。無論是:
|
||||
|
||||
- `cypher-executor/` (orchestration Worker)
|
||||
- `registry/` (合約管理 Worker)
|
||||
- `.component-builds/{name}/` (零件 Worker,25+ 個)
|
||||
- 未來新增的任何 Worker
|
||||
|
||||
**無需改 workflow,只要符合掃描規則就會自動部署**。
|
||||
|
||||
---
|
||||
|
||||
## 觸發邏輯
|
||||
|
||||
| 觸發 | 部署範圍 |
|
||||
|------|---------|
|
||||
| `push` 到 main | diff 涉及的 Worker 目錄才部署 |
|
||||
| `push` 到 main + 改 `registry/components/{name}/` | 連動 rebuild `.component-builds/{name}/component.wasm` 再 deploy |
|
||||
| `workflow_dispatch` + `force_all=true` | 全部 Worker |
|
||||
| `workflow_dispatch` + `only=a,b,c` | 只部署指定清單 |
|
||||
| `push` 但 base sha 不可及(首次) | 全部 Worker |
|
||||
|
||||
---
|
||||
|
||||
## 新增 Worker 的步驟
|
||||
|
||||
### 如果是新 WASM 零件 Worker
|
||||
|
||||
1. 在 `registry/components/{new_name}/` 建 `main.go` + `component.contract.yaml`
|
||||
2. 在 `.component-builds/{new_name}/` 建 Worker 模板:
|
||||
- `wrangler.toml`(name/routes/bindings)
|
||||
- `package.json`(hono + workers-types + wrangler 即可,參考 `auth_static_key/package.json`)
|
||||
- `tsconfig.json`(可直接複製)
|
||||
- `src/index.ts`(WASI shim,方案 A:import `../../cypher-executor/src/lib/wasi-shim`)
|
||||
3. 本地跑 `pnpm install` 產 `pnpm-lock.yaml`
|
||||
4. 本地跑 `tinygo build -target=wasi -o {new_name}.wasm main.go` 先驗證 build 通過
|
||||
5. Commit push → CI 自動 rebuild WASM + deploy
|
||||
|
||||
### 如果是新 orchestration/service Worker
|
||||
|
||||
1. 在 repo 根建新目錄(類似 `cypher-executor/`)
|
||||
2. `wrangler.toml` + `package.json` + `pnpm-lock.yaml` + `src/index.ts` + `tsconfig.json`
|
||||
3. Push → CI 自動部署
|
||||
|
||||
---
|
||||
|
||||
## Runtime Secret 管理
|
||||
|
||||
**CI 只提供 Cloudflare 驗證,不碰 runtime secret**。
|
||||
|
||||
- GH Actions secrets:`CLOUDFLARE_API_TOKEN`、`CLOUDFLARE_ACCOUNT_ID`(一次性設好)
|
||||
- Runtime secret(例:`ENCRYPTION_KEY`、`OPENAI_KEY`、`GOOGLE_API_KEY`):
|
||||
- **由 richblack 一次性手動** `wrangler secret put <KEY>` 設進各 Worker
|
||||
- 不進 CI,不進 `wrangler.toml` `[vars]`
|
||||
- 需要的 Worker:`auth_static_key`、`auth_service_account`(兩個都要 `ENCRYPTION_KEY`)
|
||||
|
||||
---
|
||||
|
||||
## Lockfile 規範
|
||||
|
||||
- **統一使用 pnpm**。新增 Worker 只放 `pnpm-lock.yaml`,不要 `package-lock.json`
|
||||
- 若新建 Worker 時用 `npm install` 產出 `package-lock.json`,**刪掉它**,改跑 `pnpm install`
|
||||
- `cypher-executor/` 和 `registry/` 的 `package-lock.json` 已於 2026-04-20 刪除
|
||||
|
||||
**現存例外**(歷史遺產,混合期不強制遷移):
|
||||
- `.component-builds/{if_control, switch, ... 16 個舊邏輯零件}/` 仍是 `package-lock.json`,workflow 有 fallback 分支(`pnpm install --no-frozen-lockfile`)可跑
|
||||
- `builtins/`、`landing/` 同上
|
||||
|
||||
**新增 Worker 一律 pnpm,不要製造新的混合情況**。
|
||||
|
||||
---
|
||||
|
||||
## WASM 來源
|
||||
|
||||
> **⚠️ 慣例變更(richblack 2026-06-02,self-hosted 開源策略)**:
|
||||
> 原慣例「`.component-builds/{name}/component.wasm` 不 commit 進 repo」**已推翻**。
|
||||
> 現在 **commit `.component-builds/*/component.wasm` 進 repo**,因為 self-host 用戶 / `acr init --self-hosted`
|
||||
> 從 GitHub(codeload tarball)直接拿這份 wasm 部署到自己的 CF——repo 必須自帶可部署的 wasm。
|
||||
> 決策依據:`docs/3-specs/arcrun/sdk-and-website/self-hosted-init.md §6`。
|
||||
|
||||
### 現行規則(2026-06-02 起)
|
||||
|
||||
- **`.component-builds/*/component.wasm` → commit 進 repo**(部署來源)。`.gitignore` 用否定規則放行:
|
||||
```
|
||||
*.wasm # 預設排除
|
||||
!.component-builds/**/component.wasm # 例外放行部署物
|
||||
```
|
||||
- **`registry/components/*.wasm` → 仍不 commit**(build 中間產物,部署不直接用,`.gitignore` 仍排除)。
|
||||
- 本地開發 build:`cd registry/components/{name} && tinygo build -target=wasi -o {name}.wasm main.go && cp {name}.wasm ../../../.component-builds/{name}/component.wasm`,**然後 commit `.component-builds/{name}/component.wasm`**。
|
||||
- CI(deploy.yml):仍在 deploy 前自動 rebuild + copy(部署 prod 用最新 source;與 repo 內 commit 的 wasm 不衝突——前者給 CI deploy prod,後者給 self-host 用戶當部署來源)。
|
||||
|
||||
### 誠實 trade-off(mindset §7)
|
||||
|
||||
commit wasm 進 repo → 每次 rebuild 在 git 歷史累積二進位,**repo 長期會膨脹**。
|
||||
可接受(self-host 體驗優先),未來若膨脹過劇再考慮 git-lfs / 按需安裝(self-hosted-init.md §6.6)。
|
||||
|
||||
---
|
||||
|
||||
## 並行度
|
||||
|
||||
`max-parallel: 5` — 避免觸發 Cloudflare Workers API rate limit。
|
||||
|
||||
Worker 數量 > 5 時,deploy 會分批跑。25 個 Worker 大約 5 輪 × ~30 秒 = 2-3 分鐘可完成全部。
|
||||
|
||||
---
|
||||
|
||||
## 禁止事項
|
||||
|
||||
1. **禁止**為新 Worker 手動加 deploy job 到 `deploy.yml`。通用掃描會自動處理,手加就是重複工作。
|
||||
2. **禁止**把 runtime secret(API key / encryption key / credential)放進 GH Actions secrets 或 `wrangler.toml` `[vars]`,只能用 `wrangler secret put`。
|
||||
3. **禁止**在 CI 裡跑不必要的測試阻擋 deploy。測試在 PR / 本地跑,`main` 推上去就 deploy(trunk-based)。若要測試關,開新 workflow 檔,不要污染 deploy workflow。
|
||||
4. **禁止**跳過 TinyGo rebuild 直接 deploy 舊 `.wasm`。CI 的 rebuild 步驟是確保部署的是最新 source。
|
||||
|
||||
---
|
||||
|
||||
## 驗證指令
|
||||
|
||||
本地模擬 CI 的掃描結果:
|
||||
|
||||
```bash
|
||||
find . -name 'wrangler.toml' -not -path '*/node_modules/*' -not -name 'wrangler.test.toml' \
|
||||
| xargs -n1 dirname | sort -u
|
||||
```
|
||||
|
||||
應列出 ~25 個目錄。任何「我新增了 Worker 但沒被 deploy」的問題,先跑這條確認目錄被掃到。
|
||||
@@ -0,0 +1,60 @@
|
||||
# arcrun mindset(給 CC 的世界觀 — 走歪前先讀這個)
|
||||
|
||||
> 這是「為什麼」層,不是禁令層(禁令在 02-forbidden)。
|
||||
> 違反禁令會被 hook 擋;違反 mindset 不會被擋,但會讓你做出「技術上對、方向上錯」的東西。
|
||||
> 來源:2026-05 richblack 與 CC 的一連串判準釐清(權威記錄在 DECISIONS.md)。
|
||||
>
|
||||
> 註:這是步驟 7「arcrun-mindset Skill」的前身。Skill 正式建立前,本檔是 CC 的 mindset 來源。
|
||||
|
||||
---
|
||||
|
||||
## 1. 工作流是 default,零件是稀有例外
|
||||
|
||||
AI 開發時的預設順序:
|
||||
1. **預設寫工作流**(串服務 / 自用 / 給少數人用 → 全部先工作流,用 http_request + 流程控制 primitive 串)。
|
||||
2. **要打的服務不提供串接但有 API,且有必要讓全 arcrun 生態重用** → 才建零件(零件 = API 薄殼,只打一個 endpoint)。
|
||||
3. **建零件前必問「你有必要嗎?」**:看到「有 API 可包成零件」≠「該包」。自用 → 工作流。
|
||||
|
||||
**CC 的典型走歪**:把「需要一個能力」直接翻成「做一個零件」,把「能包」當「該包」。
|
||||
mira 的 claude_api / km_writer 就是這樣被錯做成零件的(其實是自用服務膠水,該是工作流)。
|
||||
|
||||
## 2. Arcrun 是 AI 呼叫的工具(AI → 工具),不是工具回頭呼叫 AI
|
||||
|
||||
需要 AI 判斷 / 轉換時,是**操盤的 CC 自己做**,再呼叫工作流做確定性的下一步。
|
||||
**不要在工作流裡放零件回頭呼叫一個 LLM**。n8n 需要 AI 節點是因為它沒大腦;arcrun 的大腦就是 CC。
|
||||
(ai_transform_compile/run 因此被刪除。)
|
||||
|
||||
## 3. arcrun 不做授權判斷
|
||||
|
||||
「能不能打通」由發 API key 的服務裁決,不是 arcrun。401/403 是對方服務在行使授權,不是 arcrun 的 bug。
|
||||
auth_recipe 只定義「怎麼認證」,不含「誰准用」清單。不要加「arcrun 替用戶擋掉某些 endpoint」的功能。
|
||||
|
||||
## 4. 零件投稿走 GitHub PR(人 merge = 人類閘門)
|
||||
|
||||
零件投稿不是 registry self-service,是 GitHub PR。人 merge = 天然人類閘門(AI 偽造不了 GitHub approve),
|
||||
把關(假零件偵測 / 純WASI / Gherkin)由 CI PR check 跑(CI 能 runtime 跑 wasm,CF Worker 不能)。
|
||||
§8「不依賴 CI」指執行鏈路(高頻);零件投稿稀有,走 PR/CI 是例外、不違反。
|
||||
|
||||
## 5. 發佈安全的底氣是純 WASI 沙箱,不是 Gherkin
|
||||
|
||||
Gherkin 全綠 ≠ 零件安全(投稿者可寫避重就輕的 Gherkin)。真正框死破壞力的是**純 WASI 沙箱**
|
||||
(零件只能 stdin→stdout、無網路 syscall、無檔案系統)。Gherkin 驗契約 + 沙箱框死 + 市場補長尾 = 風險可控,非零風險。
|
||||
|
||||
## 6. 暴露 / 送資料的動作 → 人類明示同意(資料外流警示)
|
||||
|
||||
把資料 / workflow 變成「可被外部呼叫」(部署 webhook、recipe push)= 暴露面 → 需人類明示同意,不分公私庫。
|
||||
**不禁止**用戶公開(他的自由),但要**確定他自己明示同意**(不是 AI 替他決定)。
|
||||
警示同時是「保護措施入口」(提示可加 API Key / 權限 / 限流)。
|
||||
|
||||
## 7. 誠實限制(最重要的 mindset:不假裝、不假綠)
|
||||
|
||||
- **AI 技術上能偽造人類確認**(confirmed_by_human、exposure_consent、gherkin_evidence 都能塞)。
|
||||
這些機制的價值是**法律歸責 + 軌跡可審**,不是技術防偽。**絕不在文件 / 程式裡聲稱「不可能繞過」。**
|
||||
- **絕不代替人類做有風險的確認**(建零件、暴露資料)。非 TTY(你直跑)就拒絕,不要自己塞 flag 假裝人類同意了 —— 那是明確越界。
|
||||
- **禁假綠**(DECISIONS §3c/§7):stub / 未實作就回 success:false 或明確標 unimplemented,不要回傳假資料假裝成功。
|
||||
缺 credential 打不到 2xx 就誠實標「未驗收:缺 X」,不 mock 充綠燈。
|
||||
- **完成 = 客觀證據**(編譯 exit code / HTTP status + trace),不是口頭宣布「我做好了」。
|
||||
|
||||
---
|
||||
|
||||
詳細判準與來龍去脈見 `DECISIONS.md`。每條都有對應的慘痛教訓,不是憑空規定。
|
||||
@@ -0,0 +1,133 @@
|
||||
# 薄殼原則(鐵律)— 能力長在 API,介面只暴露
|
||||
|
||||
> 來源:`docs/壓測報告.md` §5.4/§5.5(設計者本人於壓測中釐清)+ DECISIONS §1。
|
||||
> 違反此原則的典型後果:每改一個能力要同步多份介面、介面間漂移(壓測 §5.1:CLI 改了讀
|
||||
> 全域/專案/.env,MCP 沒跟上,兩者打不同帳號)、能力被某個介面綁架後別的介面用不到。
|
||||
> 這條由 `.claude/hooks/pre-write-guard.sh` 規則 7.x 部分強制(見下「hook 強制範圍」)。
|
||||
|
||||
---
|
||||
|
||||
## 0. 一句話
|
||||
|
||||
**所有能力(business logic)只實作一次,放在 API(cypher-executor HTTP 端點)。
|
||||
CLI / MCP / Python lib / JS lib 全是薄殼:只做「介面轉換 + 暴露」,不含任何商業邏輯。**
|
||||
|
||||
```
|
||||
CLI ─┐
|
||||
MCP ─┤ ← 全是薄殼:參數解析 / 格式轉換 / 暴露,不含商業邏輯
|
||||
Python lib ─┤
|
||||
JS lib ─┘
|
||||
↓ 全部呼叫同一個
|
||||
┌──────────────────────────────┐
|
||||
│ API(唯一真相,能力都在這) │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. 什麼是「能力下沉到 API」(正例 vs 反例)
|
||||
|
||||
### 正例:upsert
|
||||
- ✅ **API 提供 `upsert` 端點**(內部 GET 找→有則 update 無則 insert)。CLI/MCP/lib 只呼叫它。
|
||||
- ❌ 在 MCP 裡自製「先 call update API、失敗再 call insert API」的拼裝邏輯。
|
||||
- ❌ 在 recipe 層拼湊 upsert(recipe/零件補 API 缺的能力 = 走歪;正解是補在 API)。
|
||||
|
||||
### 正例:seed recipe(壓測 §4.1 的反例修正)
|
||||
- ✅ **API 在「部署/註冊完成」時保證 recipe 就緒**(seed 是 API 行為,由一個端點完成 `POST /init/seed`)。
|
||||
- ✅ **種子資料(清單)放 server**:`cypher-executor/src/lib/*-seeds.ts`。「裝好後預設有哪些 recipe」
|
||||
是 API 的能力,種子資料是這能力的一部分。薄殼只呼叫 `/init/seed` 一次。
|
||||
- ❌ 在 CLI `init.ts` 裡用迴圈 POST 11 個 recipe + 客戶端「全部成功才 seed」的 if 判斷
|
||||
(這正是 §4.1 seed 永遠不被 seed 的根因:邏輯被寫進了某個介面)。
|
||||
|
||||
> **種子資料檔(`*-seeds.ts`)是一個普遍類別,不是某個零件的特例。**
|
||||
> 它含 endpoint / `{{template}}` 字串(recipe 的資料欄位),rule 02 §2.2 hook 對**整類** `*-seeds.ts`
|
||||
> 豁免 endpoint/template 檢查——因為那是「資料宣告」不是「呼叫實作」。新增任何 `xxx-seeds.ts`
|
||||
> 自動適用,**不需為個別零件/recipe 改 hook**(richblack 原則:不為單一零件改全域規則)。
|
||||
|
||||
### 判準口訣
|
||||
> **「這段邏輯換一個介面(CLI→MCP)要不要重寫?」**
|
||||
> 要重寫 → 它是能力,該在 API。
|
||||
> 不用重寫(只是把 API 回傳值換個格式印出來)→ 它是薄殼該做的事。
|
||||
|
||||
---
|
||||
|
||||
## 2. 薄殼「允許」做的事(窮舉)
|
||||
|
||||
1. 解析介面慣例的輸入(CLI 吃檔案路徑 / MCP 吃 JSON 參數)→ 轉成 API 期望的 payload。
|
||||
2. 呼叫 API(HTTP fetch / service binding)。
|
||||
3. 把 API 回傳值轉成該介面的輸出格式(CLI 印彩色文字 / MCP 回 structured JSON)。
|
||||
4. **client 端加密**(AES-GCM)——唯一例外,因 API 期望收到已加密 payload(見 rule 01 加解密)。
|
||||
5. 讀取「身份設定」(哪個帳號 / 哪個 cypher URL)——但所有薄殼必須讀**同一份**身份來源
|
||||
(見 §4 統一帳號來源)。
|
||||
|
||||
## 3. 薄殼「禁止」做的事
|
||||
|
||||
1. ❌ **在薄殼介面(CLI/MCP/lib)裡用多個 API 呼叫拼裝**出一個 API 沒有的能力(upsert / seed / 任何 N-step 編排)。
|
||||
> **界線(2026-06-26 收窄,issue #4)**:禁的是「**把編排邏輯寫進介面層 TS**」,不是禁「用資料方式(workflow/code-node)自救」。
|
||||
> 自家 API 缺能力 → 補進 API(你能改);**第三方 API 缺能力 → 走 workflow/code-node 補丁是合法的**(你改不了第三方 API,不能被規則卡死)。詳見 §3.5 自力救濟階梯。
|
||||
2. ❌ **用零件**補 API 缺的能力 → 污染零件庫(缺能力 → 先看 §3.5 階梯,真需新穩定能力才走零件 PR)。
|
||||
> 這條的原始精神(要保留):當初是 AI 把多步驟工作寫成**零件**污染零件庫,才訂此禁令。**禁的是「亂建零件」,不是「禁止任何補丁」。**
|
||||
3. ❌ 寫死判斷來補 API 缺口(例:`deployFullyOk` 那種 client 端 gate)。
|
||||
4. ❌ 同一 API 能力在不同介面用不同參數簽名(`validate` 在 CLI 吃 YAML、在 MCP 卻要 `api_key`+`graph` = 底層分歧,違反「同一 API」)。差異只能來自介面慣例(檔案路徑 vs 字串),不能來自底層實作。
|
||||
5. ❌ 任一薄殼連的帳號 / 後端與別的薄殼不同(CLI 連自架、MCP 連平台 = 違反「同一 API」前提)。
|
||||
|
||||
---
|
||||
|
||||
## 3.5 缺能力時怎麼補:自力救濟階梯(普世規則,issue #4)
|
||||
|
||||
> **問題**:§3 舊版預設「缺能力 → 去補 API」,這**預設 API 是你能改的**。對**第三方 API**(如 Google Sheets:一次只能倒全部、輸出前無法 filter)不成立——若 Google 不開該 API、規則又禁用 workflow「倒出來自己篩」,用戶被自己的規則卡死。
|
||||
> **解法**:把「補丁」分層,維持零件庫最小,但開放「用資料方式自救」的合法路徑。
|
||||
|
||||
**主界線(一句話)**:**「那個 API 你能不能改?」** 自家 API 缺能力 → 補 API;第三方 API 缺能力 → workflow/code-node 補丁。
|
||||
|
||||
| 情況 | 正解 | 為何 |
|
||||
|---|---|---|
|
||||
| 能打既有 API | **recipe**(沒有就建 recipe) | 單一 API 呼叫的封裝 |
|
||||
| **自家** API(KBDB / cypher)缺能力 | **補進 API** + 可同時發 issue | 你能改,能力該長在 API |
|
||||
| **第三方** API 缺能力(gsheets filter / 無 upsert API) | **可投稿的 workflow 補丁** + 發 issue 建議原廠加 API | 你改不了第三方 API,但不能被卡死 |
|
||||
| 非 call-api 的純計算(如整篇文章轉大寫) | **code-node**(空白 code 零件內寫 JS) | recipe/workflow 都做不到,又不該為此建一堆專用零件 |
|
||||
| 真需新穩定能力(極少數) | 自建零件 → PR | 維持零件庫最小,只有非用零件不可才建 |
|
||||
|
||||
**三個配套原則**:
|
||||
1. **補丁 workflow 可像 recipe 一樣被呼叫,但明示它是 workflow、且可投稿**(呼應 wishlist C6「工作流即零件」)。讓 AI 一遇阻就「用資料方式」自救,而非建零件。
|
||||
2. **code-node**:原廠不提供某純計算時,AI 至少能用一個空白 code 零件寫 JS 自救(呼應 wishlist C1;架構決策:JS 在 CF Workers isolate 跑,不嵌 QuickJS/Rust)。
|
||||
3. **補丁是過渡**:原廠出 API 後,補丁 workflow 因效能較差自然被減少使用、淘汰。
|
||||
|
||||
**upsert 範例**:有些服務原廠提供 upsert API(→ recipe 直接打),有些沒有(→ 做一個 upsert workflow 達成,**而非建專用零件**)。§1 把 upsert 當「該補進 API」的正例——那**只對自家 API 成立**;對改不了的第三方 API,arcrun 端永遠補不進去,正解是 workflow 補丁。
|
||||
|
||||
> **與 hook 的關係**:§3.1 禁的「介面層拼裝」由 `pre-write-guard.sh` 7.x 擋(範圍 `cli/src/`、`arcrun-mcp/src/` 的 TS)。**workflow/code-node 補丁是資料產物(YAML / 空白零件內的 JS),不是介面層 TS → 本就在 hook 範圍外,合法不被擋。** hook 防線不變,本次只釐清「資料方式自救」是合法路徑。
|
||||
|
||||
---
|
||||
|
||||
## 4. 統一帳號來源(薄殼共用同一身份)
|
||||
|
||||
所有薄殼讀**同一份**身份設定:
|
||||
- self-hosted:`~/.arcrun/config.yaml` / 專案層 `.arcrun.yaml` / `ARCRUN_*`、`CLOUDFLARE_*` env(見 `config-layering.md`)。
|
||||
- standard:平台 api_key。
|
||||
|
||||
**MCP 目前的已知違反**(壓測 §5.2):MCP 用 Cloudflare service binding 焊死平台 `arcrun-cypher-executor`,
|
||||
self-hosted 用戶用 MCP 連不到自己的 cypher。修法見
|
||||
`docs/3-specs/arcrun/sdk-and-website/mcp-account-source.md`(SDD proposal)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 出貨順序(最低出貨標)
|
||||
|
||||
- **CLI + MCP 兩個薄殼先到位**(AI 偏好 MCP,故 MCP 不可長期落後),且兩者覆蓋**同一組 API 能力**。
|
||||
- Python / JS lib 隨後補。
|
||||
- 出貨順序由「介面被誰用」決定,不是由「哪個好做」決定。
|
||||
- **介面進度本來就會不一致**(薄殼模型的預期狀態)——這本身不是 bug。
|
||||
bug 是「**底層 API 能力不齊 / 介面含了不該含的邏輯 / 帳號來源不統一**」這三者。
|
||||
|
||||
---
|
||||
|
||||
## 6. hook 強制範圍(與「靠人判斷」的邊界)
|
||||
|
||||
`pre-write-guard.sh` 規則 7.x 能擋的是**語法層可偵測**的反例:
|
||||
- CLI/MCP 檔案內出現「迴圈 POST 多個 recipe」「先 update 失敗再 insert」這類拼裝 pattern 的明顯特徵 → 警告/擋。
|
||||
- 新增 `seedApiRecipes` / `seedAuthRecipes` 這類「seed 邏輯寫在介面層」的函式 → 擋(改去 API)。
|
||||
|
||||
**hook 擋不了的**(需 CC 自律 + code review):
|
||||
- 把商業邏輯藏在看似無害的 helper 裡。
|
||||
- recipe 層拼裝(recipe 是資料,hook 不解析語意)。
|
||||
→ 故本檔是 mindset,hook 是底線;兩者都不可省。誠實限制見 mindset §7(不假裝 hook「不可能繞過」)。
|
||||
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "startup|resume|clear",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash .claude/hooks/session-start-load-sdd.sh",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Write|Edit|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash .claude/hooks/pre-write-guard.sh",
|
||||
"timeout": 5
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash .claude/hooks/wiki-secret-scan.sh",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash .claude/hooks/pre-bash-guard.sh",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Write|Edit|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash .claude/hooks/post-edit-remind-tasks.sh",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash .claude/hooks/stop-check-sync.sh",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+6
-14
@@ -23,21 +23,13 @@ docs/test_credentials/
|
||||
*.sa.json
|
||||
*-service-account*.json
|
||||
|
||||
# ── 開發痕跡 / 思考過程:不對外(本機保留供開發,但不進公開 repo)──
|
||||
# richblack 2026-06-03:用戶要的是「使用 arcrun」,不是「開發 arcrun」。
|
||||
# 開發規範(.claude/CLAUDE.md/AGENTS.md)、思考過程(.agents/docs/DECISIONS/BACKLOG)只在本機。
|
||||
# 「用戶 CC harness」另由 acr install-harness 安裝進用戶專案(不是放在這個 repo 給人 clone)。
|
||||
.claude/
|
||||
.agents/
|
||||
docs/
|
||||
landing/
|
||||
# ── 開發痕跡進 repo(D22 翻案 2026-07-03,InkStoneCo 頂層決策)──
|
||||
# 舊規則(2026-06-03「開發痕跡不對外」)立於 GitHub 終將公開的前提。
|
||||
# 現 repo 真身在自有 Gitea private,clone 者是雲端工人:docs/SDD、wiki、
|
||||
# CLAUDE.md、.claude/、.agents/ 都必須進 repo(缺=雲端斷糧,T1.5 卡點實證)。
|
||||
# 未來上 GitHub 公開=一次性發佈篩選,不在日常 gitignore 綁死。
|
||||
# 例外仍不推:機敏值(本檔各處既有規則照舊)+ .github/(防 Actions 復燃,D20 同源)
|
||||
.github/
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
DECISIONS.md
|
||||
BACKLOG.md
|
||||
BETA_TEST.md
|
||||
CONTRIBUTING.md
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
# Arcrun 待辦(BACKLOG.md)
|
||||
|
||||
> 流動的待辦清單。做完一項就劃掉(`[x]`)或移到「已完成」。
|
||||
> 穩定的決策在 `DECISIONS.md`。
|
||||
>
|
||||
> 原則:一次只推進一步。不在「整理」的慣性裡順手做下一件事。
|
||||
>
|
||||
> 最後更新:2026-06-10(與 `.claude/wiki/status.md` 對齊)
|
||||
|
||||
---
|
||||
|
||||
## 🔥 當前優先序(權威來源:`.claude/wiki/status.md` 2026-06-09)
|
||||
|
||||
> 此區是「現在該做什麼」的頂層視圖。下方「第一期步驟」多已完成(見各步驟 `[x]`),
|
||||
> 真正待推進的兩個 P0 在 2026-05 版 BACKLOG 完全沒記,補在這裡。
|
||||
|
||||
### 🔴 P0(用戶一用就撞 / 威脅核心承諾)
|
||||
|
||||
- [~] **credential 注入 401 修復** — `{{credential.X}}` 注入失敗,用戶被迫把 token 明文寫進 workflow。
|
||||
**根因**:此語法系統沒實裝(三條 template 路徑都不認 `credential.` namespace)。
|
||||
**修法**(design §8,richblack 2026-06-10 確認):auth_static_key 加 `resolve_credentials` action(WASM 解密)
|
||||
+ graph-executor `resolveCredentialRefs` 偵測回填(不碰 ENCRYPTION_KEY,rule 02 §2.2)。
|
||||
**8.1-8.4 done**(tinygo build OK + tsc 0 + §2.2 自檢綠)。**待 8.5 端對端驗收**(部署 + 真 OpenAI key + 全新帳號打 2xx)。
|
||||
- [ ] **§8 P1/P2 recipe/workflow list 遷 D1** — CF KV list 免費僅 1000/日,不修=用戶用一用就 429,
|
||||
免費承諾破功。D1 現已可建(依賴解除),架構拍板走 kbdb `/entries` HTTP 雙寫不加 binding。
|
||||
**大、易出錯 → 另開乾淨 session + 壓測**,不要在雜事 session 順手做。
|
||||
|
||||
### 🟡 P1(封測門檻 / 技術債)
|
||||
|
||||
- [ ] credential-primitives-wasm **Phase 0.7**(component-loader WASM runner)→ **Phase 1-2**(auth_static_key / auth_service_account WASM 零件)。0.6 已完成。
|
||||
- [ ] **Phase 3** 清除違規 TS(credential-injector.ts / jwt-signer.ts / BUILTIN_*)—— 須先有 Phase 1-2 WASM 頂上。
|
||||
- [ ] 4 份 inline http_request host fn 抽共用 helper(dedup;假綠修是逐份改的)。
|
||||
|
||||
### ⚪ P2(不擋封測)
|
||||
|
||||
- [ ] `arcrun.dev/llms.txt` serve(landing/public 補檔)
|
||||
- [ ] ENCRYPTION_KEY 冪等性、MCP account-source、recipe submit uuid 回傳
|
||||
- [ ] 文件遷移階段二/三 + wiki modules/
|
||||
- [ ] 下方第一期殘項:步驟 2(acr recipe test)、步驟 5b(資料外流警示 SDD)、步驟 6(搬家拆 matrix)
|
||||
|
||||
---
|
||||
|
||||
## 第一期(鎖定範圍,依序做,不跳)
|
||||
|
||||
### 步驟 1 — 清 cypher-executor 的 KBDB 污染
|
||||
- [x] 刪除 `cypher-executor/src/lib/kbdb-partner.ts`
|
||||
- [x] `auth.ts` 移除 kbdb-partner import 與 3 處呼叫
|
||||
- [x] `wrangler.toml` 移除 `KBDB_BASE_URL` 與 `KBDB_INTERNAL_TOKEN` 註解
|
||||
- [x] `component-loader.ts` 白名單移除 `claude_api` + 6 個 `kbdb_*`
|
||||
- [x] `component-loader.ts` 修正說謊的 doc comment / 中段註解
|
||||
- [x] `graph-executor.ts` 註解的 kbdb 範例替換、`types.ts` 移除 `KBDB_BASE_URL` 型別
|
||||
- [x] `tsc --noEmit` 編譯通過
|
||||
- [x] **commit 第一步**(commit message 寫清楚範圍)
|
||||
|
||||
### 步驟 2 — `acr recipe test`:recipe 形式的 curl
|
||||
|
||||
> 目的很簡單:把一個早就存在的 API endpoint(如 KBDB)變成 Arcrun recipe。
|
||||
> 流程:看 API 文件 → 申請 API Key → 設進 credential → 寫 recipe → 用 test 打一次。
|
||||
> `test` 就是「用 recipe 形式 curl 一次」——打通(2xx)就成了。
|
||||
> 不寫記錄、不是入庫關卡、不防任何東西。防作弊/公共庫那些是第二期。
|
||||
>
|
||||
> 順序:先 2c(為 KBDB 建 auth recipe,否則 test 打 KBDB 會 401)→ 再 2a。
|
||||
|
||||
- [ ] **2c. 為 KBDB 建 auth recipe(static_key)** — `POST /auth-recipes`,建 `auth_recipe:kbdb`。
|
||||
讓 auth-dispatcher 能把 KBDB API Key 注進 test 的請求。static_key 新路已支援(見 DECISIONS §3b)。
|
||||
- [ ] **2a. `acr recipe test <id> [--input k=v]` 指令** — 藄殼:組單節點 ExecutionGraph
|
||||
→ 走現有 `/execute` → 顯示 HTTP status 與回應。就是 recipe 形式的 curl。
|
||||
不碰 cypher-executor、不改 RecipeDefinition 型別、不寫任何記錄。
|
||||
|
||||
### 步驟 3 — 降級假零件成 recipe(2026-05-29 大部分完成)
|
||||
|
||||
> 進度(2026-05-29):registry/components/ 33 → 22。引擎加了 `auth_service`(多 recipe 共用 auth)
|
||||
> 與 `inject.path`(endpoint 可插 secret,解 telegram URL-path token)。詳見 auth-recipe.md §七 + DECISIONS §1。
|
||||
|
||||
- [x] KBDB 降級:建 `kbdb_get/create_block/patch_block/delete/ingest` 5 recipe(共用 auth_service=kbdb),刪 5 零件目錄
|
||||
- 驗收:get 200 / create 201 / ingest 201 / delete 200 綠;**patch 403 = KBDB PATCH org-check bug**(已交 kbdb/docs,非 recipe 問題)
|
||||
- `kbdb_upsert_block` **未降**:KBDB 無 upsert endpoint,是 client 拼湊 → 已交 KBDB feature request 出 `POST /blocks/upsert`,源碼暫留
|
||||
- [x] `gmail`/`telegram`/`line_notify`/`google_sheets` 降級:建 recipe(gmail_send / telegram_send / line_notify_send / google_sheets_read|append)+ 補 auth_recipe(line_notify / telegram),刪 4 零件目錄
|
||||
- telegram_send:✅ auth 注入驗收(inject.path,400 chat not found = 過認證)
|
||||
- gmail/sheets/line:⚠️ **未驗收:缺 credential**(google SA JSON / line token),recipe + auth 鏈路本身已驗正確
|
||||
- [x] `claude_api` + `km_writer` **未降,標 deferred**:是 Mira 自用服務的膠水,不該是公共零件/recipe → 交 Mira refactor 文件(收成工作流後刪),源碼暫留
|
||||
- [x] `ai_transform_compile` + `ai_transform_run` **刪除**:Arcrun 是 AI 呼叫的工具、不該內嵌 AI 節點回頭呼叫 AI(誤搬 n8n 心智模型,見 DECISIONS)
|
||||
- [x] 灰色地帶裁決:`cron` / `platform_crypto` 留(引擎能力,組工作流必要)
|
||||
- [ ] KBDB recipe 採 Supabase 模式:顯示在公共零件庫,要用需申請 API Key(**未做**,屬服務側/封測後)
|
||||
- [x] 「降級」定義:萃取 endpoint/method/auth 寫成 API recipe → 刪零件目錄(已照做)
|
||||
- [ ] **遺留:orphaned 部署 worker 待 `wrangler delete`**(richblack 手動,rule 05):
|
||||
arcrun-kbdb-{get,create-block,patch-block,delete,ingest} / arcrun-{gmail,telegram,line-notify,google-sheets} / arcrun-ai-transform-{compile,run}
|
||||
- [~] 降級後 registry/components/ 現 22 個:17 白名單 primitive(流程6+資料7+http_request1+auth3)
|
||||
+ cron/platform_crypto(引擎能力)+ claude_api/km_writer/kbdb_upsert_block(deferred)
|
||||
|
||||
### 步驟 4 — 補零件庫真把關(2026-05-30 收尾:投稿改走 PR)
|
||||
|
||||
> 方向修正:零件投稿走 **GitHub PR**(人 merge=閘門、CI 跑把關),廢 registry self-service。
|
||||
> SDD:`.agents/specs/component-gatekeeping/`(含完整決策過程)。
|
||||
|
||||
- [x] 假零件偵測(detectFakeComponent.ts):外部 URL/domain + http_request 子集,硬擋退稿指回 recipe
|
||||
- [x] 純 WASI 把關(wasmImports.ts):import module 白名單(只准 wasi_snapshot_preview1 + u6u)
|
||||
- [x] cold_start / runtime_compat / gherkin 標 unimplemented_steps(不假綠)
|
||||
- [x] G0 registry 人類閘門(已 commit;投稿改 PR 後非主管道,保留不刪)
|
||||
- [ ] Gherkin 真跑 + 覆蓋檢查 → 未來接 **CI PR check**(CI 能跑 wasm,繞 CF venue 牆)。
|
||||
richblack:人工 review 就夠,primitive 極少 → CI 暫不做,邏輯已寫好隨時能搬。
|
||||
- [x] **不做 R5 本機 hook**:PR/merge + 假零件偵測 + 純WASI 沙箱已防「未經同意變公共零件」→ hook 過度工程。
|
||||
- [ ] 黃金向量:人工核對(另起 session 從語義寫,不機器自動化)— 不急
|
||||
|
||||
### 步驟 5 — ~~建零件的人類閘門 + 白名單 hook~~(2026-05-30 被 PR 方向取代)
|
||||
|
||||
> **取代說明**:原規劃「registry submit API 人類閘門 + 四路 client + 本機 hook」。
|
||||
> 2026-05-30 改:**零件投稿走 GitHub PR**(人 merge = 天然人類閘門,AI 偽造不了 GitHub approve)。
|
||||
> → registry self-service 主閘門、四路 client、本機 hook **都不做**(PR/merge + 假零件偵測 +
|
||||
> 純WASI 沙箱已防「未經同意變公共零件」,hook 過度工程)。並進步驟 4。
|
||||
>
|
||||
> **真正的裸奔風險不在零件,在「資料外流」**(recipe/webhook 把資料送出去,不分公私庫)。
|
||||
> → 另開新 SDD「資料外流警示」(見下方第一期新增項)。
|
||||
|
||||
**ABC 配套仍有效**(讓 AI 不選難路):A 假零件偵測(步驟 4)+ B 工作流範本(步驟 7 acr new)
|
||||
+ C mindset「工作流是 default、零件稀有例外」(步驟 7 mindset Skill)。
|
||||
|
||||
### 步驟 5b — 資料外流警示(2026-05-30 新增,richblack:先做)
|
||||
|
||||
> 風險根源:arcrun 讓「產生 API」變很簡單(資料+webhook trigger=API)→ AI 可能不知不覺把含個資
|
||||
> 的東西變成可被呼叫的 endpoint。不分公私庫(私人=公司用也會把個資 POST 到公司群)。
|
||||
|
||||
- [ ] 新建 SDD(requirements/design 交 richblack review 才動 code)
|
||||
- [ ] **API 層警示**:任何「把資料送出去」的動作(recipe push / webhook 等)不論哪條路都警示/需人類同意
|
||||
- [ ] **hook**:AI 動手做這類動作前先警告(防在前;API 層防在送出前,兩道互補)
|
||||
|
||||
### 步驟 6 — 搬家(拆 matrix)(原步驟 5)
|
||||
- [ ] 先給 kbdb、ghost(identity/personality-system/persona-sdk) 各自 `git init` + 建 GitHub repo
|
||||
(它們現在沒有自己的 .git,是被 inkstone-matrix 追蹤的子目錄——刪 matrix/.git 前必須先安置)
|
||||
- [ ] 分揀 matrix 的孤兒檔案(清單見下方附錄)
|
||||
- [ ] arcrun / kbdb / ghost 各自 `git status` 確認乾淨、已 push
|
||||
- [ ] GitHub 上 `inkstone-matrix` 設為 archived(不刪)
|
||||
- [ ] 本機刪 `matrix/.git`,matrix 降級成普通資料夾
|
||||
- [ ] 比對兩份 cypher-executor(matrix 頂層 vs arcrun 底下),釐清死活
|
||||
|
||||
### 步驟 7 — 收尾(原步驟 6)
|
||||
- [x] `arcrun-mindset` Skill(給 AI 操盤手的世界觀,見 DECISIONS §7 層三)
|
||||
→ `skills/arcrun-mindset/SKILL.md`(2026-05-30)。涵蓋:工作流是default/零件稀有、
|
||||
AI→工具、arcrun不做授權、暴露需人類同意、誠實(不假綠/不假防偽/不代替人類確認/完成=客觀證據)。
|
||||
同步 `.claude/rules/06-mindset.md`(arcrun repo 內 CC 開發時讀)。
|
||||
- [ ] 會回嘴的 CLI(acr 偵測走歪 → exit 2 + 指回正路,見 DECISIONS §7 層二)
|
||||
→ 部分已做:pre-bash hook 擋假零件/暴露動作 + 指回正路;CLI 本身的 exit-2 回嘴待補
|
||||
- [ ] README 重寫成單一路徑(砍掉「玩法一/二/三」三選一)
|
||||
- [ ] `acr init --self-hosted`:貼 CF token → 自動建 KV、部署 Worker、自動 workers.dev
|
||||
subdomain、寫回 config(用戶自己建 token,CLI 不代管)
|
||||
|
||||
---
|
||||
|
||||
## 第一期之後 / 待決策(不要現在做)
|
||||
|
||||
- [ ] **用戶自己的 API 保護機制(入站認證)**(richblack 2026-05-30)— 做成零件或功能:
|
||||
(1) 用戶可發 API Key 給別人;(2) 不同權限設定。現況缺口:webhook 只有 X-Arcrun-API-Key
|
||||
(誰有我的 key 誰能打),沒有「發受限 key 給別人 / per-caller 認證 / rate limit」。
|
||||
**這是 arcrun 資安優勢**:n8n 用簡單 USN/PWD 做不好,AI coding 又怕資安沒做好,我們系統幫搞定、
|
||||
一個動作能用。注意區分:credential/auth recipe 是出站(呼叫別人 API 帶我的 token),這是入站(保護我的 API)。
|
||||
- [ ] **架構詞彙釐清(recipe / part / function / 工作流組合)**(richblack 2026-05-30,要思考一下):
|
||||
- recipe:用零件去打的設置文件(endpoint+設定)
|
||||
- part(零件):需 PR + update/upgrade
|
||||
- function:把一批功能做好可用 cypher 拉進來(例:API 認證,我做好幾個讓用戶拉)。是 part+recipe?
|
||||
- **多零件/工作流組合成新工作流**(第一個走完連第二個)—「好像還沒有」,要思考
|
||||
- [ ] **砦 `injectCredentials` 舊路 + `BUILTIN_CREDENTIALS_MAP`** — credential 系統現為新舊兩路並存,
|
||||
舊路是 TS 裡解密的半成品(註解自認 Phase 1.9 刪)。是獨立清理,**不擋降級**。見 DECISIONS §3b
|
||||
- [ ] **決策:開源版 cypher-executor 是否保留「KBDB block 展開」功能**
|
||||
(`recipe-expander.ts` 仍讀 `env.KBDB_BASE_URL`、`prompt-recipe-schema.ts` 的 `kbdb_block` 型別)
|
||||
按定位應移除(屬 KBDB 訂閱層),但牽涉型別,需單獨想清楚
|
||||
- [ ] **決策:開源版遙測去 KBDB 化**
|
||||
(`telemetry.ts` 的 `kbdbCreateBlockUrl` + fetch 到 `arcrun-kbdb-create-block`)
|
||||
開源版遙測寫去哪?本地?關掉?寫用戶自己的 ANALYTICS_KV?
|
||||
- [ ] 新增 `kv_store` 暫存 primitive:解決「workflow 中途暫存」需求(像 n8n data table)。
|
||||
用 Arcrun 執行環境內建暫存,不依賴外部服務 → 可為 primitive。
|
||||
介面要抽象(CF 上是 KV,wazero 上是本地檔/記憶體),才能三層通用
|
||||
- [ ] 「專案」概念落地:CLI 的 `acr project` 指令、專案=引用 workflow 的三元組
|
||||
- [ ] 公共零件庫 KBDB 語義搜尋:AI 查意圖 → 回傳零件選項;self-hosted 用本地關鍵字比對
|
||||
- [ ] 事後機制第二層:不變式測試套件(核心原則寫成自動測試,接 CI / pre-commit)
|
||||
- [ ] 事後機制第一層:執行軌跡完整化(基於現有 `executions.ts`)
|
||||
- [ ] `acr project init`:把閉環三件(mindset Skill + CLAUDE.md 判準 + exit-2 hook +
|
||||
不變式測試)一次裝進用戶專案
|
||||
- [ ] 視覺化 Skill:固定格式的「給人看的圖」(取代 arcrun-gui 拖拉畫布)。
|
||||
目的是「讓人看完放心地說『跑吧』」,是信任產出物不是美術產出物
|
||||
- [ ] arcrun-gui 處置:降級為「零件庫 / recipe 貢獻者牆」展示站,掛 arcrun.dev(非工具)
|
||||
- [ ] arcrun-mcp 對齊:`u6u_*` → arcrun 命名、`finally.click` → arcrun.dev、
|
||||
移除 GUIDE.md 教 `api_config` 的反模式、確認 MCP 是薄殼
|
||||
- [ ] `acr update` 設計:常態只拉 recipe;動到 primitive 一定要用戶確認,不可默默 deploy
|
||||
- [ ] recipe 「貢獻回公眾」的路徑:別人測好的 recipe 怎麼變成所有人能用(飛輪的關鍵)
|
||||
|
||||
---
|
||||
|
||||
## 附錄:matrix 孤兒檔案分揀清單(步驟 5 用)
|
||||
|
||||
**移進 arcrun:**
|
||||
- `.agents/specs/arcrun/`、`.agents/specs/u6u-core-mvp/`、`.agents/specs/u6u-platform-evolution/`
|
||||
- `.agents/steerings/`(tech.md / coding-rules.md / product.md / structure.md)
|
||||
- `docs/user_requirements/arcrun/`
|
||||
- `cypher-executor/`(matrix 頂層那份——先比對死活)
|
||||
|
||||
**移進 arcrun-gui(若保留):**
|
||||
- `.agents/specs/u6u-gui-platform/`、`.agents/specs/prototype-editor/`(先看一眼確認)
|
||||
|
||||
**移進 ghost / 其他:**
|
||||
- `ghost.md`、`identity/`、`personality-system/`、`persona-sdk/` → ghost
|
||||
- `.agents/specs/matrix-admin-api-fixes/`、`inkstone-admin/` → inkstone-admin
|
||||
|
||||
**需判斷(多半過時,留在 archived 的 inkstone-matrix 即可):**
|
||||
- `API-CATALOG.md`、`AVM-PROTOCOL.md`、`MODULE-CONTRACT.md`、頂層 `CLAUDE.md` / `GEMINI.md`
|
||||
- `docs/user_requirements/rearrange.md` / `test.md`
|
||||
- `.claude/` / `.github/` / `.vscode/` / `.wrangler/` / `.swarm/` / `ruvector.db`
|
||||
|
||||
## 未來方向:本機零帳戶試玩(local playground)— leo 2026-06-15 提,記錄不插隊
|
||||
|
||||
**洞察**:self-hosted 最低門檻入口=裝 wrangler → 本機 miniflare 跑 arcrun → **連 CF 帳戶都不用**先玩玩看。
|
||||
**現有障礙**(為何現在不開箱即用):
|
||||
- `acr init --self-hosted` 第一步強制貼 CF Account ID + API Token(self-hosted-init.md),無「無帳戶本機模式」入口。
|
||||
- `wasmWorkerUrl()`(component-loader.ts:59)寫死組 `https://arcrun-{x}.{subdomain}.workers.dev`,假設零件部署在 workers.dev,非 localhost。
|
||||
- 多 worker(cypher/kbdb/registry + 零件)串接 + service binding 要本機編排。
|
||||
**要做**(未來,非現在):
|
||||
- `acr init --local`:跳過 CF token,subdomain/URL 走 localhost。
|
||||
- `wasmWorkerUrl()` local 模式組 `http://localhost:port`。
|
||||
- 多 worker 用 wrangler 本機多服務模式編排(D1/KV miniflare 模擬,已支援)。
|
||||
**現況可行的最小片**:單 worker(kbdb)`wrangler dev` + D1 模擬本機跑得起來(=KBDB 插件整合測試路徑)。全系統一鍵本機跑才是新功能。
|
||||
**優先級**:未來方向,不插現在隊列(mira 解耦 + KBDB 缺口優先)。
|
||||
|
||||
## 未來方向:SaaS ⇄ self-hosted 雙向遷移(一等公民)— issue #3 待辦 2 / wishlist C7(2026-06-24)
|
||||
|
||||
**緣由**:14-E(leo 把 KBDB 資料從官方 SaaS 搬回 leo21c self-hosted,458,357 筆 gap=0 完成)暴露框架級缺口。
|
||||
過程因 `KBDB_BASE_URL` fallback bug(已修,issue #2 commit 9c4333d)+ **沒有遷移工具**,資料默默寫錯庫
|
||||
(~11 萬筆 `owner_id=leo` 誤寫進官方 prod kbdb,清理 SOP 見 `docs/5-records/2026-06-24-official-kbdb-cleanup-leo-misdelete.md`)。
|
||||
今天是 leo dogfood 踩了能修;**若是未來真實用戶 = 數據災難 + 信任崩塌**。
|
||||
|
||||
**缺口本質**:arcrun 沒有「SaaS ⇄ self-hosted 雙向遷移」的框架級一等公民支持。
|
||||
leo 今天靠 dump + 手寫翻譯腳本 + 階梯式踩雷(`d1 import` 被 wrangler 4 移除 / statement 切斷 /
|
||||
SQLite 深度上限 / SQLITE_TOOBIG / 超大 content)硬趟過去。
|
||||
|
||||
**要做**(backlog,不急):
|
||||
- `acr migrate` 一等公民路徑:schema 翻譯 + 資料層整包灌 + 驗數 + **隔離保證(絕不寫錯庫)**。
|
||||
- 遷移前**庫歸屬驗證**(probe:寫一筆確認落在預期庫)→ 把今天的 fallback 災難從源頭擋掉。
|
||||
- **雙向**:self-hosted → SaaS(上雲)和 SaaS → self-hosted(下雲)都要順。
|
||||
**牽動**:arcrun-cli + cypher-executor + kbdb。**優先級**:未來方向,不插現在隊列。
|
||||
**頂層願景源**:`docs/1-vision/product-wishlist.md` C7。
|
||||
@@ -0,0 +1,97 @@
|
||||
# CLAUDE.md — arcrun
|
||||
|
||||
> **上游約束(InkStoneCo 總管)**:本 repo 是 InkStoneCo 大專案的子專案,受頂層知識庫約束。
|
||||
> 動工前讀 `github.com/uncle6me-web/InkStoneCo` 的 CLAUDE.md + `docs/3-specs/`。
|
||||
> 關鍵鐵律:禁止跨 repo 同步 Actions / Mira≠Arcrun 分層(arcrun.dev 是框架域名,非產品入口)/ self-hosted 用 namespace 明碼非 api key / 部署繞開 GitHub(wrangler 直推)。
|
||||
> 本 repo 的待整合交棒見 `docs/HANDOFF-matrix-rearrange.md`。
|
||||
|
||||
> **與 InkStoneCo 總管的溝通走本 repo 的 GitHub issue**:總管交辦框架 bug / 跨專案需求 →
|
||||
> 在本 repo 開 issue;CC 用 `gh` 讀(`gh issue list/view`)、修完在同一 issue `comment` 回報修法 + 驗證證據。
|
||||
> issue thread = 雙向溝通 + 永久記錄。**有事才讀,禁自動輪詢**(flag 安全界線)。
|
||||
> 結案時機由「end-to-end 實證綠燈」決定——尚待人/總管確認的留 open。詳見 `/issue-handle` skill。
|
||||
> 發 issue 給**別的 repo** 要先問人類,不可擅自。
|
||||
|
||||
> 本檔是**索引 + 最高原則**,詳細規範拆到 `.claude/rules/`。
|
||||
> Hook 強制機制在 `.claude/hooks/`,違反會直接 block(exit 2)。
|
||||
|
||||
---
|
||||
|
||||
## 絕對鐵律(違反 = 停手)
|
||||
|
||||
1. **任何 code 變動前必須先讀對應 SDD**,在回覆開頭宣告已讀清單與對應 task 編號(格式見 `.claude/rules/00-sdd-protocol.md`)
|
||||
2. **零件只能用 TinyGo 或 AssemblyScript 編譯成 WASM**;`registry/components/` 下禁止 TypeScript
|
||||
3. **cypher-executor TS 禁止實作 credential / auth / JWT / template 展開業務邏輯**;這些全在 WASM 零件
|
||||
4. **Cypher binding = YAML 裡的 URL 清單**,不是 Cloudflare service binding;零件串接走 HTTP URL(含 auth primitive)。self-hosted same-zone 1042 用 `global_fetch_strictly_public` flag 解,不新增 binding(credential-primitives-wasm Phase 7)
|
||||
5. **每個 WASM 零件 = 獨立 Worker = 公開 URL**;不從 R2 動態讀(R2 只 Phase 5 啟用)
|
||||
6. **修改現有程式碼,不是新建資料夾重做**
|
||||
7. **每完成一個 task 立刻更新 tasks.md 的 `[x]`**,不批次
|
||||
8. **薄殼原則**:能力只實作一次放在 API;CLI/MCP/lib 是薄殼,不得自帶業務邏輯 / 拼裝 API / 用 recipe 補 API 缺口(見 `.claude/rules/07-thin-shell.md`,hook 7.x 部分強制)
|
||||
|
||||
---
|
||||
|
||||
## 工作流程(強制)
|
||||
|
||||
開始任一任務,按順序:
|
||||
|
||||
1. **讀 wiki**(3 分鐘快速進度)→ `.claude/wiki/status.md`
|
||||
2. 讀 `docs/3-specs/arcrun/arcrun.md`(總進度)
|
||||
3. 讀對應的 SDD `design.md` + `tasks.md`
|
||||
4. 在回覆開頭貼出:
|
||||
```
|
||||
📋 已讀 SDD:<清單>
|
||||
🎯 本次對應 task:<編號>
|
||||
📐 本次 task 的 SDD 規範摘要:<重點>
|
||||
🚧 執行範圍:修改/建立/刪除 <檔案>
|
||||
```
|
||||
5. 動手前把 tasks.md 對應 task 標為 `[🔄]`,完成後標 `[x]`
|
||||
6. 完成後確認:是否需要同步更新 design.md?
|
||||
|
||||
找不到對應 SDD → **停手問 richblack**,不要自行建立。
|
||||
|
||||
---
|
||||
|
||||
## Wiki(每次 session 的讀取順序)
|
||||
|
||||
| 檔案 | 時機 | 用途 |
|
||||
|------|------|------|
|
||||
| `.claude/wiki/status.md` | session 開始第一件事 | 當前 Phase、進度、下一步 |
|
||||
| `.claude/wiki/mistakes.md` | 做新功能前 | 10 大常犯錯誤 + 快速檢查清單 |
|
||||
| `.claude/wiki/decisions-summary.md` | 遇到設計判斷時 | 6 大架構決策 + trade-off |
|
||||
| `.claude/wiki/INDEX.md` | 找不到東西時 | wiki 導引 + 快速導航 |
|
||||
|
||||
---
|
||||
|
||||
## 詳細規範索引
|
||||
|
||||
| 檔案 | 內容 |
|
||||
|-----|------|
|
||||
| `.claude/rules/00-sdd-protocol.md` | SDD 讀取協議(強制流程) |
|
||||
| `.claude/rules/01-tech-stack.md` | 技術棧硬限制(語言/儲存/加解密) |
|
||||
| `.claude/rules/02-forbidden.md` | 禁止清單(hook 強制執行) |
|
||||
| `.claude/rules/03-component-architecture.md` | 零件架構(R2 用途 / cypher binding / service binding 邊界) |
|
||||
| `.claude/rules/04-current-progress.md` | 當前進度 + SDD 索引 |
|
||||
| `.claude/rules/05-deploy-convention.md` | 部署慣例(新 Worker = 新目錄 + wrangler.toml) |
|
||||
| `.claude/rules/06-mindset.md` | mindset(為什麼層) |
|
||||
| `.claude/rules/07-thin-shell.md` | 薄殼原則鐵律(能力長在 API,介面只暴露) |
|
||||
|
||||
---
|
||||
|
||||
## SDD 位置速查
|
||||
|
||||
| 子系統 | 路徑 |
|
||||
|-------|------|
|
||||
| **進行中** Credential Primitives WASM | `docs/3-specs/arcrun/credential-primitives-wasm/` |
|
||||
| arcrun 總進度 | `docs/3-specs/arcrun/arcrun.md` |
|
||||
| Auth Recipe 系統 | `docs/3-specs/arcrun/auth-recipe.md` |
|
||||
| Landing Page | `docs/3-specs/arcrun/landing-page.md` |
|
||||
| SDK + Website | `docs/3-specs/arcrun/sdk-and-website/` |
|
||||
| arcrun MVP 整體 | `docs/3-specs/arcrun-core-mvp/` |
|
||||
| Platform Evolution | `docs/3-specs/arcrun-platform-evolution/` |
|
||||
| Credential 長期規格(需求源) | `docs/user_requirements/credential_parts.md` |
|
||||
| Tech Stack 詳細 | `docs/3-specs/tech.md` |
|
||||
|
||||
---
|
||||
|
||||
## 封測狀態
|
||||
|
||||
**推遲**(richblack 2026-04-19 決定)。先完成 Phase 1-3 清除違規 TS,再啟動封測。
|
||||
@@ -0,0 +1,100 @@
|
||||
# HANDOFF: arcrun config 寫入端 + 讓 AI 看懂 scope + KBDB Vectorize 開關
|
||||
|
||||
> 來源:InkStoneCo 頂層總管,2026-06-15。mira self-hosted dogfood 踩出的兩條框架缺口。
|
||||
> 兩條都是 `change`,走 SDD 協議(先讀對應 SDD、宣告、改 tasks.md)。
|
||||
> 觸發實證:mira(一個 AI)被交代遷移時**沒看懂 scope 存在**,差點誤部官方帳號。
|
||||
|
||||
---
|
||||
|
||||
## 缺口 1:config 讀分層完整、寫只能全域 + AI 看不懂 scope
|
||||
|
||||
### C.1 補 `acr init` 專案層寫入端
|
||||
|
||||
`cli/src/lib/config.ts` 讀取分層完整(`findProjectConfig()` env > 專案層 > 全域,8/8 測試),但**寫入只有 `saveConfig()`(config.ts:208)寫死全域** `~/.arcrun/config.yaml`(config.ts:38)。`acr init` 4 處呼叫(init.ts:93/142/278)全寫全域。
|
||||
- → `acr init` 無法寫專案層。leo 意圖 project scope 卻被迫污染全域 → 怕誤打官方 uncle6me。
|
||||
|
||||
**修法**:
|
||||
- `saveConfig()` 加 scope 參數(`'global' | 'project'`);project 寫 `./.arcrun.yaml`(`process.cwd()`)。
|
||||
- `acr init` / `acr config set` 加 `--project` flag。
|
||||
- 預設是否翻轉(init 預設寫專案層)由 richblack 定(影響既有使用者)。
|
||||
- 參考既有範式:`mcp-setup.ts` §7.8 已有 project scope 寫 `.mcp.json` 概念,比照。
|
||||
|
||||
### C.2 ★ 改 `cli/harness/CLAUDE.block.md` 讓操作 AI 看懂 scope(最重要)
|
||||
|
||||
**病根**:arcrun 是 AI 操作的工具(mindset §2),但 harness block(全文 41 行)對 scope / 帳號歸屬 / 誤部風險 **0 字提及**。AI 讀完完全不知道有 scope 這層 → mira 把「資料遷移」跟「在哪 scope 部署」混為一談。
|
||||
|
||||
在 harness block 加「部署落點與帳號安全」段:
|
||||
1. **scope 模型**:分層 `env > 專案層 .arcrun.yaml > 全域 ~/.arcrun/config.yaml`。**部到哪個 CF 帳號取決於當前資料夾解析到哪份設定**。動手前先 `acr config --where`。
|
||||
2. **多帳號警示**:同機可能多個 CF 帳號(dogfood / prod);`.env` 可能並存多組 `CLOUDFLARE_*`(含 `*_UNCLE6` 後綴)。部署/遷移前必須明確目標 account_id,挑錯=打到正式環境(不可逆)。
|
||||
3. **遷移 ≠ scope**:搬資料(A 帳號→B 帳號)和「我用哪個帳號」是兩件獨立的事。
|
||||
4. **fallback 陷阱**:任一層都沒 `mode` → `acr` 靜默當 `local`(config.ts:176),不報錯。看到「成功」但無雲端 trace 先查 `acr config --where`。
|
||||
5. **無設定就停手**:`--where` 顯示無設定 / fallback local 而意圖 self-hosted → 停下問人。
|
||||
|
||||
> harness block 是 AI 的 mindset 入口,比 CLI `--help` 重要(AI 偏好讀 harness)。scope 安全屬「動手前世界觀」,必在這層。
|
||||
|
||||
**對應 SDD**:`docs/3-specs/arcrun/sdk-and-website/config-layering.md`。
|
||||
|
||||
---
|
||||
|
||||
## 缺口 2(任務 D):KBDB Vectorize(embed)無環境開關
|
||||
|
||||
### 病根
|
||||
|
||||
KBDB 分 `base`(D1 only,免費)+ `embed` module(Vectorize+AI binding,語意搜尋)。但 `acr init/update` 完全沒處理 embed(grep `vectorize|embed` 在 `cli/src/` = 0 命中);只活在 `kbdb/src/index.ts:5` 註解。→ **沒有 config 欄位 / 指令讓使用者或 AI 表達「要不要開 Vectorize」**。
|
||||
|
||||
### 設計哲學(leo 2026-06-15):降級要優雅,但「完整體驗」要有門
|
||||
|
||||
> 「最好是我自己這套可以語義搜尋;不開就降級;**但想完整體驗卻沒有門**。」
|
||||
|
||||
- **預設降級且能跑**:沒開 embed → base 仍可關鍵字搜尋(不是壞掉)。
|
||||
- **降級要明示不能無聲**:跑 base 時要讓人知道「語意搜尋未開」。無聲降級=用戶以為這就是全部=bug。
|
||||
- **★ 升級要有門**:config 欄位 + `acr` 提示「想語意搜尋?這樣開」。現在連「有更完整版本可選」都不可見。
|
||||
|
||||
### 修法(config 欄位 + 部署條件注入,比照 MULTI_TENANT)
|
||||
|
||||
1. **config 加欄位**:`ArcrunConfig`(config.ts:11)加 `kbdb_embed?: boolean`(預設 false=base 免費)。leo dogfood 設 true。
|
||||
2. **部署條件注入**:`acr update` 部 KBDB worker 時 `kbdb_embed: true` → 注入 Vectorize+AI binding + 部 embed module;false 只部 base。比照 `deploy.ts:395` MULTI_TENANT 注入、`deploy.ts:380` WORKER_SUBDOMAIN 注入。
|
||||
3. **降級可見**:`acr config --where` / 部署摘要顯示「KBDB: base(語意搜尋未開)」或「embed(Vectorize 啟用)」;base 時提示升級門。
|
||||
4. **AI 認知**:harness block(C.2 那段)一併說「KBDB 預設 base(D1 免費僅關鍵字);語意搜尋需 `kbdb_embed: true`(Vectorize 計費)。功能需語意搜尋但這套是 base → 別假裝有,明示降級指出升級門」。呼應 mindset §7 誠實。
|
||||
|
||||
**對應 SDD**:KBDB 模組化屬 `docs/3-specs/` kbdb spec;部署注入屬 `sdk-and-website/self-hosted-init.md`。
|
||||
|
||||
---
|
||||
|
||||
## 任務 E(★ 急,擋 mira 14-E load):給 mira「leo21c kbdb worker 實際綁的 D1 id」
|
||||
|
||||
> 總管裁定(2026-06-15):mira 14-E load 撞 Workers 每日 10萬 request 牆(逐筆 API 病根)→ 改走 `wrangler d1 import`(資料層搬遷=用戶 CF 權限,繞 API)。**但 mira 需要 D1 id 才能灌對庫。**
|
||||
|
||||
**為什麼 mira 自己查不到**:
|
||||
- cypher-executor **無** D1 binding,轉發給 **kbdb worker**。
|
||||
- kbdb worker 的 D1 id 是 **deploy.ts 部署時動態注入**(`kbdb/wrangler.toml` 靜態值 `0c580910…` 是官方 prod,self-hosted 部署會覆蓋)。
|
||||
- → leo21c 上 kbdb worker 實際綁的 D1 id ≠ mira `wrangler` 直連的 `arcrun-kbdb`(`1099d0f3…`)。這是「wrangler 查 0 筆、cypher 有 11 萬筆」的真相(兩個同名不同 id 的 D1)。
|
||||
|
||||
**你要做**:用 leo21c CF token 查 leo21c 上 **kbdb worker 部署時實際注入的 D1 database_id**(看 deploy.ts 注入邏輯 / 查 leo21c worker runtime binding),告訴 mira。mira 拿到才能 `wrangler d1 import <該 id>` 灌對庫。
|
||||
|
||||
## 任務 F(B,框架缺口,非急):cypher `/kbdb/entries` 補批次端點
|
||||
|
||||
> 總管裁定 B:應用層大批寫入該有批次 API。**未來每個 self-hosted 用戶大批匯入都撞 10萬 request 牆**(不只 mira)——逐筆 POST 每筆 1 request,~5萬筆燒光免費 worker 當日配額。
|
||||
|
||||
- `POST /kbdb/entries` 收陣列(`[{...},{...}]` 或 `{entries:[...]}`),1 request 寫 N 筆 → 45.8萬筆只要 ~900 requests。
|
||||
- 守鐵律(只轉發 API、不開 SQL/建表;owner_id 仍自動注入每筆)。
|
||||
- 對應 SDD:kbdb-proxy 屬既有範圍。
|
||||
|
||||
## 任務 G(雜項,mira 14-E 踩出的 cypher 框架特性,記著)
|
||||
|
||||
mira load 踩出,記給框架修(非急):
|
||||
1. CF WAF 1010:Python urllib 預設 UA 被擋 → 文件提醒帶 UA。
|
||||
2. 重複 id → 500(非 409):UNIQUE 衝突宜回 409 友善處理。
|
||||
3. 零星單筆 500:某些 block 穩定 500、同 content 換 id 卻 200=邊緣 bug(疑 metadata_json/refs_json 某字元觸發,未定位)。
|
||||
4. `?id=` query 被忽略(回前 100 筆不過濾);查單筆只能 `/entries/:id` 路徑——文件宜明示。
|
||||
5. **DELETE `/kbdb/entries/:id` route 缺**(OPTIONS 卻宣告支援 DELETE,CORS 宣告與實作不一致)。
|
||||
6. cypher 綁的 D1 與同名 `arcrun-kbdb` database_id 不一致(見任務 E)——self-hosted 文件宜說明「wrangler 直連 ≠ cypher 綁的庫」。
|
||||
|
||||
## 共通教訓
|
||||
|
||||
任務 C、D 同類:**把「該是環境設定的選擇」從 code 註解提升到 AI/使用者可表達的層級**。arcrun 是 AI 操作的工具,凡影響「部到哪、開什麼功能」的選擇都必須在 config + harness 可見,不能只活在實作裡。
|
||||
任務 E/F 同類:**資料層搬遷(用戶 CF 權限 d1 import)vs 應用層大批寫入(cypher 批次 API)是不同層級,都該存在**——逐筆 API 推大批資料是把「資料遷移」做成「應用層寫入」,撞 worker 請求上限。
|
||||
|
||||
## 依賴
|
||||
|
||||
mira 側(建專案層 .arcrun.yaml + 移全域)先行,見 mira repo `docs/HANDOFF-arcrun-config-project-scope.md`。本框架補強讓「未來不再污染全域 / AI 不再看不懂 scope」,是根治,非阻擋 mira 當前解耦。
|
||||
@@ -0,0 +1,151 @@
|
||||
# HANDOFF: Matrix 重整交棒給 arcrun(2026-06-13)
|
||||
|
||||
來源:InkStoneCo 頂層 `.agents/specs/matrix-rearrange/`。本檔是該重整交給 arcrun 的待辦清單。
|
||||
**指針式考古**:整合素材真身在 InkStoneCo `_archive/`,照路徑去挖,不複製進此檔。
|
||||
|
||||
---
|
||||
|
||||
## 1. cypher-executor 整合進 arcrun 後**關掉**(leo 2026-06-13:整合後只剩 arcrun)
|
||||
|
||||
`matrix/cypher-executor` 是 diverged 副本,**整合進 arcrun/cypher-executor 後就關掉/封存**,之後 cypher 只有 arcrun 一份。
|
||||
|
||||
### 如何整合(勘查 2026-06-13)
|
||||
|
||||
**A. matrix 版獨有、arcrun 缺的 5 檔 → 補進 arcrun**(皆非 SaaS 遺留,是 self-hosted 核心):
|
||||
- `src/actions/version-selector.ts`:零件版本選擇策略 floating/stable/pinned
|
||||
- `src/actions/autoPublishMissing.ts`:missing 零件用 Workers AI 自動生成上架
|
||||
- `src/lib/component-dispatcher.ts`:雙模式路由 wasm/cypher_binding/service_binding
|
||||
- `src/lib/wasm-executor.ts`:WASM 執行
|
||||
- `src/routes/proxy.ts`:proxy 路由
|
||||
|
||||
**B. 兩邊都有但內容不同的 10 檔 → 逐檔比對合併**(取較完整/正確的一邊,保留 arcrun 較新的 auth 演進):
|
||||
`cypher-handlers` / `execution-evaluator` / `execution-logger` / `graph-builder` / `search-nodes` / `triplet-parser` / `webhook-graph-resolver` / `webhook-handlers` / `graph-executor` / `index`
|
||||
|
||||
**C. 保留 arcrun 獨有的**(不要被舊版覆蓋):`credential-injector` / `auth-dispatcher` / `auth-recipe-seeds` / `api-recipe-seeds`(arcrun 較新的 Auth Recipe 演進)。
|
||||
|
||||
**素材真身**:`matrix/cypher-executor/`(降級後仍在原地)。整合完成、驗證通過後,**封存 matrix/cypher-executor 進 `_archive/`,cypher 之後只有 arcrun 一份**。先讀對應 SDD 再動。
|
||||
|
||||
## 2. KBDB 插件化 + 補 CLI/MCP 薄殼(arcrun 端只留基本盤 + 暴露能力)
|
||||
|
||||
`arcrun/kbdb` **留 3 表基本盤 + API**(已完整:templates/entries/records/search)。它是刻意設計的基本盤(0001_base.sql 註釋 plugin model),**不升 v3、不加 blocks 表**。triplet/graph 由 `matrix/kbdb-graph-plugin` 抽成獨立 repo KBDB-graph。
|
||||
|
||||
**KBDB 鐵律(leo 2026-06-14)**:任何人不准動表;新類型=建 template(走 API);插件/AI/人全走 API,禁 SQL;基本盤不提供建表 API。詳見頂層 `DECISION-kbdb-v3-baseplane.md`。
|
||||
|
||||
**arcrun 端待辦(核實:CLI/MCP 現在完全沒 KBDB 能力)**:
|
||||
- **補 MCP 薄殼**(AI 用,含插件):`kbdb_create_template`(name+slots)、`kbdb_create_record`(填 slot)、`kbdb_query`/`kbdb_search` 等,調基本盤現有 API。**不提供建表 tool,只給 template/slot**——類 Supabase 萬用表,AI 想建表時只有 template/slot 可用。
|
||||
- **補 CLI 薄殼**(人用,後補):對應命令。
|
||||
- 能力真身在基本盤 API(已有),CLI/MCP 只薄殼暴露(arcrun 薄殼原則)。
|
||||
|
||||
對方交棒見 `matrix/kbdb-graph-plugin/docs/HANDOFF-kbdb-plugin.md`。
|
||||
|
||||
## 3. leo21c self-hosted 部署(Mira dogfood 用)
|
||||
|
||||
leo 用 leo21c CF 帳號部署 self-hosted arcrun(`MULTI_TENANT=false`),Mira 改 dogfood 這套。
|
||||
依現有 `scripts/local-deploy.sh` + `docs/3-specs/arcrun/sdk-and-website/self-hosted-init.md`。namespace 明碼非 api key。
|
||||
|
||||
### 3b. ⚠️ MCP self-hosted 認證失敗(mira CC 2026-06-14 回報,跨專案問題)
|
||||
|
||||
**一句話**:MCP worker 還走舊 partner-key 認證(`mcp/src/middleware/partner-auth.ts`,每個端點都掛 `partnerAuthMiddleware`),但 self-hosted 認證是 namespace 明碼。導致 Claude Code 連 self-hosted MCP 一律 401。**CLI 全通**(走 cypher-executor,已支援 `MULTI_TENANT=false`)。
|
||||
|
||||
**根因**:MCP 的 `partner-auth.ts` 沒跟上 cypher-executor 的 self-hosted 認證改版。這是 `07-thin-shell.md §4` 已記的「MCP 帳號來源違反」的具體症狀(SDD proposal 已存在:`docs/3-specs/arcrun/sdk-and-website/mcp-account-source.md`)。
|
||||
|
||||
**診斷證據(curl `arcrun-mcp.leo21c.workers.dev/mcp`,mira CC 實測)**:
|
||||
- GET /mcp → 200(worker 活);MCP initialize 無 auth → 401;Bearer ak_(舊 uncle6 key)→ 401 Invalid partner key;Bearer leo(namespace 明碼)→ 401;X-Namespace header → 401
|
||||
- cypher-executor / → 200(CLI 走這條,通)
|
||||
- `acr mcp-setup` 生成的 `.mcp.json` 是裸的(無 headers),就算有 key 也沒地方帶
|
||||
|
||||
**修法(三選一,arcrun 端決策)**:
|
||||
1. MCP worker 加 self-hosted 認證:接受 namespace 明碼(與 cypher-executor 一致),或 self-hosted 模式 MCP 免 partner key。
|
||||
2. `acr mcp-setup` 把有效認證寫進 `.mcp.json` headers(前提:self-hosted 有可用 key 機制)。
|
||||
3. 對齊完成前,官方文件明說 self-hosted MCP 暫不可用、請用 CLI(避免使用者困惑)。
|
||||
|
||||
**影響範圍**:任何 self-hosted dogfood(mira、未來 product)都踩,非 mira 獨有。屬 arcrun 框架側待修,走 SDD 協議(對應 `mcp-account-source.md`,動工前宣告)。
|
||||
**mira 現況**:全靠 acr CLI 即可推進,不卡。`.mcp.json` 留著等上游修好自動能連。
|
||||
|
||||
---
|
||||
|
||||
### 3b-2. ⚠️ 第一次端到端實測:修補 code 對,但 `MULTI_TENANT` 沒注入 MCP worker(2026-06-14 晚,mira 推 leo21c + 總管核實)
|
||||
|
||||
mira 把 release@main 推上 leo21c(deployment 14:31,version 7de919d6),**仍 401 `Invalid or expired partner key`**。總管核實了真因(不是 code bug,是部署機制 bug):
|
||||
|
||||
- ✅ **修補 code 在且正確**:`mcp/src/middleware/partner-auth.ts:19` `if (c.env.MULTI_TENANT === 'false')` 確實擋在 partner-key 查詢之前。
|
||||
- ☠️ **真因:`mcp/wrangler.toml` 的 `MULTI_TENANT = "false"` 是注釋掉的(第 13-14 行 `# [vars]` / `# MULTI_TENANT`)**。部署後 worker 的 `c.env.MULTI_TENANT === undefined ≠ 'false'` → if 不成立 → 走 partner-key 查詢 → 401。
|
||||
- 🔍 **更深層:self-hosted 部署(`acr init`/`acr update`)沒把 `MULTI_TENANT=false` 注入 MCP worker 的 vars**。`cli/src/` grep `MULTI_TENANT` 只有 config 定義 + 註釋,**無「部署時注入 worker env」的 code**。對照:cypher-executor 通是因它把 key 當不驗證 opaque(不依賴 MULTI_TENANT);MCP 依賴此 env 才走 namespace 分支,故漏注入就斷。
|
||||
- 類比:self-hosted-init.md 注入了 `WORKER_SUBDOMAIN`,但**漏注入 `MULTI_TENANT`**(同一類部署注入機制的缺口)。
|
||||
|
||||
**修法(arcrun 端,二選一或都做)**:
|
||||
1. `acr init/update` 部署 MCP(及需要的 worker)時,依 config `multi_tenant: false` 注入 `MULTI_TENANT=false` 到 worker vars(與注入 WORKER_SUBDOMAIN 同套機制,self-hosted-init.md)。
|
||||
2. 短期:文件指引 self-hosted 用戶手動 `wrangler secret put MULTI_TENANT`(或取消 mcp/wrangler.toml 那兩行註釋)——但這違反「用戶零填寫」,①才是正解。
|
||||
|
||||
**驗收**:leo21c MCP worker 設好 MULTI_TENANT=false 後,`curl -H "Authorization: Bearer leo" .../mcp` initialize → 200(非 401)。
|
||||
|
||||
## 4. arcrun-gui 併入 + arcrun.dev 降官網
|
||||
|
||||
arcrun-gui 不再獨立,GUI 併入 arcrun repo(用戶下載即有)。arcrun.dev 降級為框架官網,移除 /mira/ 寄居(Mira 搬 mira.uncle6.me)。
|
||||
|
||||
## 5. arcrun-mcp → 已整進 arcrun/mcp,**直接關掉**(leo 2026-06-13)
|
||||
|
||||
勘查確認(2026-06-13):`arcrun/mcp` 已是真身,**比 matrix/arcrun-mcp 多 `arcrun_recipe.ts` + `arcrun_whoami.ts`,且 matrix/arcrun-mcp 無任何 arcrun/mcp 缺的東西**(`Only in arcrun-mcp` 為空)= **已全部整進去**。
|
||||
動作:`matrix/arcrun-mcp` **直接關掉**——封存進 `_archive/` 即可,不遷帳號(舊 repo richblack/arcrun-mcp 留歷史)。無需整合,arcrun/mcp 就是現役。
|
||||
|
||||
## 6. KBDB 資料層遷移:3 個框架缺口(mira CC 2026-06-14 回報 + 總管核實,擋 mira 遷移)
|
||||
|
||||
mira 實測 leo21c self-hosted KBDB:**好消息 entries 表 block-compatible**(content/entry_type/parent_id/page_name/refs_json/tags_json/task_status/metadata_json = mira block 模型),河道/wiki/triplet 可直接落 entries,不必改資料模型。但卡 3 個 arcrun 缺口:
|
||||
|
||||
**① 主缺口(擋遷移):cypher proxy 漏 `/kbdb/entries`** — 核實屬實。
|
||||
- `cypher-executor/src/routes/kbdb-proxy.ts` 只實作 `/kbdb/templates` + `/kbdb/records`(注釋自稱含 entries,實際沒有)。基本盤 `arcrun/kbdb` 有 `/entries`(index.ts:18),proxy 沒轉發。
|
||||
- 結果 mira 三者湊不齊:直連 kbdb worker /entries=有 block CRUD 但裸開無隔離;cypher /kbdb/*=有認證+owner_id 隔離但無 /entries。
|
||||
- **修法**:比照 `/kbdb/records` 的 owner_id 注入模式,補 `/kbdb/entries`(POST/GET filters/GET :id/PATCH/DELETE)。守鐵律(只轉發 API,不開 SQL/建表)。補好 mira `_kbdb_client.py` 改走 `cypher.leo21c/kbdb/entries` + X-Arcrun-API-Key namespace 隔離 → 完成解耦。
|
||||
|
||||
**② MCP 的 KBDB service binding 壞** — 核實屬實。
|
||||
- mcp/wrangler.toml 有 `{ binding="KBDB", service="arcrun-kbdb" }`,kbdb-client 用 `env.KBDB.fetch`,但 mira 報 `Cannot read properties of undefined (reading 'fetch')` = **`env.KBDB` undefined**(self-hosted 部署時 binding 沒正確建/worker 名對不上 leo21c 的 kbdb)。
|
||||
- 連帶:官方回報管道 `arcrun_report_feedback`(MCP tool)也因此送不出 → 這份回報只能靠總管轉。**修這個才恢復 self-hosted 的 MCP 回報能力。**
|
||||
- **修法**:self-hosted 部署確保 KBDB service binding 正確指向 leo21c kbdb worker(或改 HTTP fetch via KBDB_BASE_URL,與插件同模式,避免 self-hosted service binding 名稱耦合)。
|
||||
|
||||
**③(非阻擋)mcp-setup namespace 不一致** — 核實屬實。
|
||||
- `cli/src/commands/mcp-setup.ts:53` 用 `config.api_key`。self-hosted 若 api_key 存的是舊 ak_(非 namespace),則 MCP 與 CLI 讀寫不同分區。
|
||||
- **修法**:self-hosted 下 mcp-setup 優先用 NAMESPACE(config.namespace),與 CLI 同一分區。
|
||||
|
||||
對應 SDD:mira 端記在 mira SDD 14-A(標 🚧 待對端);arcrun 端走協議(kbdb-proxy 屬既有 SDD 範圍)。
|
||||
|
||||
---
|
||||
|
||||
## 6b. ⚠️ 部署斷層:code 已補但 leo21c 未重部署(總管本地模擬核實 2026-06-15)
|
||||
|
||||
總管不靠 Hetzner、從本機直接 curl leo21c 端點驗證,發現 **①② 的 code 已 commit 進 arcrun repo,但對應 worker 沒部署到 leo21c CF**:
|
||||
|
||||
| 證據(本機 curl leo21c,namespace=leo) | 結果 | 判讀 |
|
||||
|---|---|---|
|
||||
| `GET cypher/kbdb/templates` | 200 | cypher-executor 活著(舊版) |
|
||||
| `GET cypher/kbdb/entries` | **404** | 新 route 未上線(`b1e302b` 的 `/kbdb/entries` 沒部署) |
|
||||
| `GET cypher/kbdb/records` | **404** | 連既有 records proxy 都 404 → leo21c 上的 cypher 版本落後 |
|
||||
| 直連 `kbdb.leo21c/entries`(裸開) | 200 | kbdb worker 本體活著,entries 表在 |
|
||||
| arcrun repo `kbdb-proxy.ts` | 含完整 `/kbdb/entries` CRUD(行 144-184) | **source 正確,純粹是沒 deploy** |
|
||||
|
||||
**結論**:缺口①②的 code 修正屬實(commit `b1e302b` `/kbdb/entries` + `1af7655` KBDB service binding),但**卡在「部署到 leo21c」這一步**。在 leo21c cypher-executor 重新部署前,mira `_kbdb_client.py` 即使改好也 smoke 必 404。
|
||||
|
||||
**交棒 task(arcrun CC,依協議走既有 SDD + 鐵律「部署繞開 GitHub、wrangler 直推 CF」)**:
|
||||
1. 確認 leo21c 帳號(`CLOUDFLARE_API_TOKEN` 指 leo21c `51a01bfa…`)下 cypher-executor 是哪個版本、為何落後(`acr update` 漏部署 cypher?還是只部署了部分 worker?)。
|
||||
2. 重新部署 cypher-executor(+ 確認 mcp worker 的 KBDB service binding 一併上線,缺口②)到 leo21c。
|
||||
3. 自驗:`curl -H 'X-Arcrun-API-Key: leo' https://arcrun-cypher-executor.leo21c.workers.dev/kbdb/entries?limit=1` 應回 200(非 404)。回 200 才算缺口①真正清空,mira 14-A 才解鎖。
|
||||
|
||||
**接力鏈**:arcrun 部署 cypher(6b)→ 端點 200 → mira 改 `_kbdb_client.py`(14A.1)→ smoke 讀寫 leo21c → 解耦完成。
|
||||
|
||||
### 6b-解決(arcrun CC 2026-06-15,已部署 + 自驗 200)
|
||||
|
||||
**根因不是 GitHub lag**(`origin/main` == 本地 4d6e77f,含 `/kbdb/entries` route)。兩層真因:
|
||||
1. **`acr update` 的 content-hash manifest 跳過機制**(`cli/src/lib/deploy.ts:198-225`)把 cypher 當「未變動」跳過 → 落後。解:`acr update --force` 清空 manifest 強制全部重部。
|
||||
2. **`.env` line 3 的 `CLOUDFLARE_ACCOUNT_ID=58309bb9…`(官方帳號)被 CLI 載入並覆蓋 config.yaml 的 leo21c `51a01bfa…`**(env > 全域 config,`config.ts:174`)→ leo21c token 對官方帳號認證 → KV 解析「Authentication error」→ update 中止。解:部署時 `CLOUDFLARE_ACCOUNT_ID=51a01bfa… node cli/dist/index.js update --force` 強制 account 對齊 leo21c token。
|
||||
- ⚠️ **遺留陷阱**:repo `.env` 是「官方帳號」部署脈絡用的;對 leo21c self-hosted 部署必須覆蓋 `CLOUDFLARE_ACCOUNT_ID`,否則 leo21c token vs 官方 account 不匹配。見記憶 [[cf-account-official-vs-loadtest]]。
|
||||
|
||||
**部署結果**:23/23 worker 全部 ✓(含 cypher-executor / kbdb / mcp),seed ✓(10 API + 23 auth recipe),cron index migrate ✓。用本地 build CLI 1.3.12(全域 acr 仍 1.3.11,未 npm publish)。
|
||||
|
||||
**自驗(本機 curl leo21c,namespace=leo)**:
|
||||
- `GET /kbdb/entries?limit=1` → **200** `{"success":true,"entries":[],"count":0}`(真轉發 kbdb worker,非假綠)✅ ← 缺口①清空,**mira 14-A 解鎖**
|
||||
- `GET /kbdb/templates` → 200 ✅
|
||||
- `GET /kbdb/records?limit=1` → **404(非回歸,by design)**:proxy 只有 `POST /records`、`GET /records/by-template/:t`、`GET /records/:id`,**本就無 bare list route**。HANDOFF 原以 records 404 當「cypher 舊版」訊號,但該路由從未存在。
|
||||
- 缺口②:MCP `initialize`(`Bearer leo`)→ **200**(非 401,`MULTI_TENANT=false` 已注入,KBDB binding 隨 mcp worker 上線)✅
|
||||
|
||||
---
|
||||
|
||||
> 每項動工前依 `.claude/rules/00-sdd-protocol.md` 宣告已讀 SDD。本 HANDOFF 是「有哪些事」,不是「繞過 SDD 的捷徑」。
|
||||
@@ -0,0 +1,120 @@
|
||||
# 交辦文件:修復 Mira 的 arcrun workflow(給 Mira 的 CC)
|
||||
|
||||
> 建立:2026-06-03(由 arcrun 端的 CC 撰寫)
|
||||
> 對象:接手修復 mira 的 CC
|
||||
> 位置:`/Users/youlinhsieh/Documents/tech_projects/InkStoneCo/polaris/mira/arcrun/*.yaml`
|
||||
>
|
||||
> **這份文件已把調查做完**:每個 workflow 用的 component 都對照過 arcrun prod 現況,
|
||||
> 標清楚「哪些不用改、哪些要改、哪些要降級」。你照著改即可,不必重跑調查。
|
||||
|
||||
---
|
||||
|
||||
## 0. 為什麼 mira 壞了(一句話)
|
||||
|
||||
arcrun 做了大整修:把「打固定 endpoint 卻被做成零件」的**假零件降級成 recipe + 刪掉零件目錄**
|
||||
(33 → 22 個零件,DECISIONS §1:零件 = endpoint 薄殼,打固定 API 的是 recipe 不是零件)。
|
||||
mira 當初**自己把一堆東西錯做成假零件**,整修後那些零件名的解析方式變了,所以 mira workflow 斷了。
|
||||
|
||||
好消息:**arcrun prod(cypher.arcrun.dev)活著、降級後的 recipe 都在 KV**(已驗證),
|
||||
mira 大部分 workflow 只需小改,不需重寫。
|
||||
|
||||
---
|
||||
|
||||
## 1. 前提(已驗證,你不用重查)
|
||||
|
||||
- ✅ cypher-executor prod 活著(`cypher.arcrun.dev`)
|
||||
- ✅ 降級 recipe 在 prod KV:`kbdb_get` / `kbdb_create_block` / `kbdb_patch_block` /
|
||||
`kbdb_ingest` / `kbdb_delete` / `telegram_send` / `gmail_send` / `google_sheets_*` / `line_notify_send`
|
||||
- mira 跑在你(richblack)的 prod 帳號上,**不依賴 self-host installer**(那是給外部工程師的另一條線)
|
||||
|
||||
---
|
||||
|
||||
## 2. 逐 component 分類(mira 6 個 workflow 全部掃過)
|
||||
|
||||
| component | 狀態 | 動作 |
|
||||
|---|---|---|
|
||||
| `kbdb_get` | ✅ prod recipe 存在 | **不用改**(`component: kbdb_get` 走解析鏈 step 6 查 `recipe:kbdb_get`,正確)|
|
||||
| `kbdb_create_block` | ✅ prod recipe 存在 | **不用改** |
|
||||
| `kbdb_patch_block` | ✅ prod recipe 存在 | **不用改** |
|
||||
| `cron` | ✅ 引擎能力 | **不用改** |
|
||||
| `http_request` | ✅ primitive | **不用改** |
|
||||
| `if_control` / `set` / `filter` | ✅ primitive | **不用改** |
|
||||
| `trigger_workflow` | ✅ 平台 orchestration | **不用改** |
|
||||
| `comp_passthrough` | ✅ 引擎內建純函式(constants.ts:43 `(ctx)=>ctx`)| **不用改** |
|
||||
| **`telegram`** | ⚠️ prod **沒有** `telegram` recipe,只有 `telegram_send` | **要改**:`component: telegram` → `component: telegram_send`(見 §3)|
|
||||
| **`claude_api`** | ❌ 錯做成零件(非薄殼)| **要降級**(見 §4)|
|
||||
| **`kbdb_upsert_block`** | ❌ 錯做成零件(非薄殼)| **要降級**(見 §4)|
|
||||
|
||||
---
|
||||
|
||||
## 3. 要改:`telegram` → `telegram_send`
|
||||
|
||||
**影響的檔**(4 個用到 `component: telegram`):
|
||||
- `project_detector.yaml`
|
||||
- `agent_feedback_weekly_review.yaml`
|
||||
- `wiki_synthesis.yaml`
|
||||
- `wiki_giveup_scanner.yaml`
|
||||
|
||||
**改法**:把 `component: telegram` 改成 `component: telegram_send`。
|
||||
|
||||
⚠️ **不只是改名**——`telegram_send` recipe 的介面:endpoint 是
|
||||
`https://api.telegram.org/bot{{auth.bot_token}}/sendMessage`,body 帶 `chat_id` + `text`,
|
||||
auth 走 `auth_service: telegram`(static_key path 注入)。確認 mira 的節點 config 傳的是
|
||||
`chat_id` / `text`,且有設好 telegram 的 credential(`acr creds push`)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 要降級:`claude_api` / `kbdb_upsert_block`(錯做成零件)
|
||||
|
||||
> ⚠️ **這兩個不是「待刪」,是「做錯了」**(richblack 2026-06-03 定性):
|
||||
> 它們**不是 endpoint 薄殼,是把工作流硬塞進零件**(違反 DECISIONS §1)。
|
||||
> arcrun 已把這兩個 wasm 排除在 self-host 部署來源外(不 commit 進 repo)。
|
||||
> mira 要把它們**還原成本來該有的樣子**:工作流 / recipe。
|
||||
|
||||
### 4.1 `claude_api`(影響:agent_feedback_weekly_review / project_detector / wiki_synthesis)
|
||||
|
||||
**問題**:arcrun 是「AI 呼叫的工具」,**工作流裡不該有零件回頭呼叫 LLM**(mindset §2 /
|
||||
DECISIONS:n8n 需要 AI 節點是因為它沒大腦,arcrun 的大腦就是操盤的 CC)。
|
||||
`claude_api` 把「呼叫 Claude」做成零件,方向就錯了。
|
||||
|
||||
**正解**(mira 要自己設計,arcrun 端只給方向):
|
||||
- 需要 AI 判斷/轉換的步驟,應該是**操盤的 CC(mira 自己)做**,再呼叫 workflow 做確定性的下一步。
|
||||
- 若真的需要在 workflow 內打 Claude API(例如非同步 cron 場景無 CC 在場),那它是**打一個固定外部
|
||||
endpoint(api.anthropic.com)= recipe**,不是零件 → 建一個 `claude_api` 的 **API recipe**
|
||||
(http_request + endpoint + auth_service),不是零件目錄。
|
||||
- 哪條對,mira 的 CC 要依 mira 的實際場景判斷(這是 mira 的設計決策,不是 arcrun 能替決的)。
|
||||
|
||||
### 4.2 `kbdb_upsert_block`(影響:agent_feedback_weekly_review / wiki_synthesis)
|
||||
|
||||
**問題**:upsert 的邏輯(找到則 PATCH、沒找到則 POST)被整段塞進零件。
|
||||
按 DECISIONS §1,upsert 應該是 **KBDB API 那邊提供的 endpoint**,零件/recipe 只該「驅動它」。
|
||||
|
||||
**正解(兩條,mira/KBDB 端決定)**:
|
||||
- **首選**:KBDB API 出一個 `POST /blocks/upsert` endpoint(richblack 已交 KBDB feature request)
|
||||
→ 然後在 arcrun 建一個 `kbdb_upsert` **recipe**(打那個 endpoint),mira workflow 用 recipe。
|
||||
- **過渡**:若 KBDB 還沒出 upsert endpoint,把「GET 找 → 有則 kbdb_patch_block、無則 kbdb_create_block」
|
||||
這段**用 workflow 表達**(mira workflow 層用 if/branch 串現有的 kbdb_get + patch + create recipe),
|
||||
而不是塞進一個零件。
|
||||
|
||||
---
|
||||
|
||||
## 5. 修復順序建議
|
||||
|
||||
1. **先改 telegram**(§3)——機械改名 + 確認介面,最快,4 個檔。
|
||||
2. **驗證 kbdb_* / 其他不用改的 workflow 真的跑通**——`acr run <workflow>` 或 trigger,
|
||||
確認 §2 標「不用改」的真的 2xx(誠實驗證,不假設)。
|
||||
3. **再處理 claude_api / kbdb_upsert_block 降級**(§4)——這兩個要 mira 依場景做設計決策,較花時間。
|
||||
|
||||
## 6. 驗收(客觀證據,mindset §7)
|
||||
|
||||
- 改完的 workflow `acr run` / trigger → HTTP 2xx + execution trace 證明跑通,不是口頭宣布。
|
||||
- claude_api / kbdb_upsert_block 降級後:workflow 不再引用這兩個零件名(grep 確認)。
|
||||
- 缺 credential 打不通就誠實標「未驗收:缺 X」,不 mock 充綠燈。
|
||||
|
||||
---
|
||||
|
||||
## 7. 參考
|
||||
|
||||
- arcrun 決策:`matrix/arcrun/DECISIONS.md`(§1 零件 vs recipe、§3b credential)
|
||||
- arcrun mindset:`matrix/arcrun/.claude/rules/06-mindset.md`(§1 工作流是 default、§2 AI→工具)
|
||||
- arcrun 故障/整修脈絡:`matrix/arcrun/docs/HANDOFF-self-host-harness.md`
|
||||
@@ -0,0 +1,212 @@
|
||||
# 交辦文件:完成 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**:定稿 `docs/3-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 →
|
||||
`docs/3-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),不是口頭宣布。
|
||||
|
||||
---
|
||||
|
||||
## 6. 交棒:CLAUDE.block.md / README 表達優化(InkStoneCo 總管 2026-06-15)
|
||||
|
||||
**背景**:總管與 leo 釐清 Mira 自製零件衝突時,實測一個現象——**連 Opus 讀著 `cli/harness/CLAUDE.block.md` + rules/,都把 recipe 誤解了四輪**(以為 recipe 是私有腳本、以為缺能力可用工作流拼裝補)。leo 的標準是「**Haiku 都能輕易理解**」。block 是被 `acr install-harness` 裝進各子 repo(含 mira)的**唯一入口**,細節雖在 `rules/` 但子 repo CC 預設只看 block → block 不夠清楚 = 下一個 CC 還會誤解。**這是表達缺口,不是讀者問題。**
|
||||
|
||||
**請 arcrun CC 做(改 `cli/harness/CLAUDE.block.md`,順帶對齊 §3 README)**:把現有「不要自製零件 / 打外部 API 寫 recipe」兩句,補成「該做什麼 + 做完去哪 + 已違規的怎麼退場」。三個已核實的盲點要講清楚(論證在下,成品文案你按自己 rules 寫):
|
||||
|
||||
1. **recipe 是公共資產,不是私人腳本**。`acr recipe push` = 投稿公共庫(`DEFAULT_PUBLIC_LIBRARY_URL`)。公私是**屬性**(UUID 身份模型,`cypher-executor/src/routes/recipes.ts` §7.5.5:身份=uuid、公私/author 是屬性、多作者版本並存),不是兩條路。**「發現缺某 stock recipe → 自己做一個 push 上去」是被鼓勵的,不必問人。** 現 block 只寫「寫 recipe」「不用部署」→ 讀者誤以為私有自用、不上傳(正好反了)。
|
||||
|
||||
2. **缺能力 → 補 API,不准用 recipe/工作流拼裝**。這條 rules 其實寫得很對(07-thin-shell §1 upsert 正反例、02-forbidden §5.2、01 §8),但 **block 完全沒提** → 子 repo CC 看不到。請把「缺單一 API 能力 = 去補 API endpoint,不拼裝」這條判準帶進 block(口訣:「換個介面要不要重寫?要→是能力該在 API」)。
|
||||
|
||||
3. **零件的退場路徑空白**。現 block 只說「不要自製零件」,沒說「**已經有自製零件的(如 mira 的 6 個)該怎麼辦**」。請補:已自製零件 → 映射到 stock(`claude_api`→自己做 AI 判斷別放工作流,mindset §2;`kbdb_*`→走已備的 `acr kbdb` 薄殼/MCP)或改寫成 recipe;無 stock 對應的單 API 缺口 → 寫 recipe 投稿。
|
||||
|
||||
**驗收(leo 標準)**:改完後,一個 **Haiku** 級 CC 讀 block 就能答對:recipe 要不要投稿(要)、缺 upsert 怎麼辦(補 API 不拼裝)、已有自製零件怎麼退場(映射 stock / 改 recipe)。
|
||||
|
||||
**連帶(非 arcrun 範圍,總管已記)**:「真正可複用的多步驟業務流程能否投稿共享」是 wishlist C6(InkStoneCo 頂層),與「拼裝補 API 缺口」是兩回事(C6 紅線已劃清),arcrun 不必現在處理。
|
||||
|
||||
> 注:本交棒由總管寫**判準與論證**,**成品文案 + hook(若要)由 arcrun CC 按自己 rules 產出**。總管不直接改 arcrun 實作檔(職責邊界 D9)。
|
||||
@@ -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,9 @@
|
||||
# Wishlist — 已移至 InkStoneCo 頂層(2026-06-15)
|
||||
|
||||
> wishlist 是**跨專案的**(一個願望常牽動 arcrun + kbdb + gui + 多個 product),
|
||||
> 不該埋在單一 repo。已移到頂層總計劃:
|
||||
>
|
||||
> **真身**:`github.com/uncle6me-web/InkStoneCo` 的 `docs/1-vision/product-wishlist.md`
|
||||
>
|
||||
> arcrun 相關的願望(code 零件、標準流程圖、CLI 多語系/logs)在該檔 C 區。
|
||||
> 動到 arcrun 願望前讀那邊,不要在此重記(單一真相源)。
|
||||
@@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -0,0 +1 @@
|
||||
legacy-peer-deps=true
|
||||
@@ -0,0 +1,5 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
@@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
@@ -0,0 +1,100 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import Link from 'next/link';
|
||||
import SiteNav from '../components/SiteNav';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? 'https://cypher.arcrun.dev';
|
||||
|
||||
export default function ApiDocsPage() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const initialized = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialized.current || !containerRef.current) return;
|
||||
initialized.current = true;
|
||||
|
||||
// Dynamically load Swagger UI from CDN
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = 'https://unpkg.com/swagger-ui-dist@5/swagger-ui.css';
|
||||
document.head.appendChild(link);
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js';
|
||||
script.onload = () => {
|
||||
const SwaggerUIBundle = (window as unknown as { SwaggerUIBundle: (opts: unknown) => void }).SwaggerUIBundle;
|
||||
if (!SwaggerUIBundle || !containerRef.current) return;
|
||||
SwaggerUIBundle({
|
||||
url: `${API_BASE}/openapi.json`,
|
||||
dom_id: '#swagger-ui',
|
||||
presets: [(window as unknown as { SwaggerUIBundle: { presets: { apis: unknown } } }).SwaggerUIBundle.presets.apis],
|
||||
layout: 'BaseLayout',
|
||||
defaultModelsExpandDepth: -1,
|
||||
docExpansion: 'list',
|
||||
filter: true,
|
||||
tryItOutEnabled: true,
|
||||
supportedSubmitMethods: ['get', 'post', 'put', 'delete', 'patch'],
|
||||
requestInterceptor: (request: { headers: Record<string, string> }) => {
|
||||
// Inject API key from localStorage if present
|
||||
const key = localStorage.getItem('arcrun_api_key');
|
||||
if (key) request.headers['X-Arcrun-API-Key'] = key;
|
||||
return request;
|
||||
},
|
||||
});
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
|
||||
return () => {
|
||||
// cleanup not strictly needed for page navigation
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0a0a] text-[#ededed]">
|
||||
<SiteNav currentPath="/api-docs" />
|
||||
|
||||
{/* Header */}
|
||||
<div className="max-w-5xl mx-auto px-6 py-8">
|
||||
<h1 className="text-2xl font-bold text-white mb-2">API Reference</h1>
|
||||
<p className="text-[#555] text-sm mb-2">
|
||||
這是 arcrun 的原始 API。Python / JS lib 是它的包裝,任何能發 HTTP request 的工具都能直接用。
|
||||
</p>
|
||||
<p className="text-[#444] text-xs mb-6">
|
||||
Endpoint: <span className="font-mono text-[#666]">{API_BASE}</span>
|
||||
</p>
|
||||
|
||||
{/* API Key hint */}
|
||||
<div className="bg-[#111] border border-[#222] rounded-lg p-4 mb-8 text-sm">
|
||||
<p className="text-[#666] mb-2">
|
||||
若要在此頁面試打 API,請先設定 API Key:
|
||||
</p>
|
||||
<ApiKeyInput />
|
||||
</div>
|
||||
|
||||
{/* Swagger UI */}
|
||||
<div className="bg-white rounded-xl overflow-hidden" ref={containerRef}>
|
||||
<div id="swagger-ui" className="min-h-96"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ApiKeyInput() {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="ak_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
className="flex-1 bg-[#0a0a0a] border border-[#2a2a2a] rounded px-3 py-1.5 text-xs font-mono text-[#cdd6f4] focus:outline-none focus:border-indigo-700"
|
||||
onChange={(e) => {
|
||||
if (e.target.value.startsWith('ak_')) {
|
||||
localStorage.setItem('arcrun_api_key', e.target.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="text-[#444] text-xs self-center">自動注入到 requests</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
'use client';
|
||||
|
||||
// 九宮格 App Launcher(受 Google Apps menu 啟發)
|
||||
// 規範:matrix/identity/.agents/specs/identity/design.md §2.5
|
||||
// 非白名單 user 看到 mira 等受限 app 顯示為灰色 + tooltip「即將開放」
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { MATRIX_APPS, isAppAccessible, type AppEntry } from './apps';
|
||||
|
||||
export default function AppLauncher({ userEmail }: { userEmail: string | null }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onClickOutside = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onClickOutside);
|
||||
return () => document.removeEventListener('mousedown', onClickOutside);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
aria-label="切換應用"
|
||||
className="flex items-center justify-center w-9 h-9 rounded-md text-[#888] hover:text-white hover:bg-[#1a1a1a] transition-colors"
|
||||
>
|
||||
{/* 九宮格 icon */}
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="currentColor">
|
||||
<circle cx="3" cy="3" r="1.5" /><circle cx="9" cy="3" r="1.5" /><circle cx="15" cy="3" r="1.5" />
|
||||
<circle cx="3" cy="9" r="1.5" /><circle cx="9" cy="9" r="1.5" /><circle cx="15" cy="9" r="1.5" />
|
||||
<circle cx="3" cy="15" r="1.5" /><circle cx="9" cy="15" r="1.5" /><circle cx="15" cy="15" r="1.5" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="absolute right-0 mt-2 w-72 bg-[#0f0f0f] border border-[#222] rounded-lg shadow-xl p-2 z-50"
|
||||
role="menu"
|
||||
>
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{MATRIX_APPS.map(app => (
|
||||
<AppTile key={app.id} app={app} userEmail={userEmail} onClose={() => setOpen(false)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AppTile({
|
||||
app,
|
||||
userEmail,
|
||||
onClose,
|
||||
}: {
|
||||
app: AppEntry;
|
||||
userEmail: string | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const accessible = isAppAccessible(app, userEmail);
|
||||
const tooltip = !accessible ? (app.locked_tooltip ?? '即將開放') : (app.description ?? '');
|
||||
|
||||
if (!accessible) {
|
||||
return (
|
||||
<div
|
||||
title={tooltip}
|
||||
className="flex flex-col items-center justify-center gap-1 p-3 rounded-md cursor-not-allowed opacity-40"
|
||||
>
|
||||
<span className="text-2xl grayscale">{app.icon ?? '📦'}</span>
|
||||
<span className="text-xs text-[#666] text-center">{app.name}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={app.url}
|
||||
onClick={onClose}
|
||||
title={tooltip}
|
||||
className="flex flex-col items-center justify-center gap-1 p-3 rounded-md hover:bg-[#1a1a1a] transition-colors text-[#ccc] hover:text-white"
|
||||
>
|
||||
<span className="text-2xl">{app.icon ?? '📦'}</span>
|
||||
<span className="text-xs text-center">{app.name}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import AppLauncher from './AppLauncher';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? 'https://cypher.arcrun.dev';
|
||||
|
||||
type NavUser = {
|
||||
display_name: string;
|
||||
email: string;
|
||||
avatar_url?: string;
|
||||
};
|
||||
|
||||
export default function SiteNav({ currentPath }: { currentPath?: string }) {
|
||||
const [user, setUser] = useState<NavUser | null | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/me`, { credentials: 'include' })
|
||||
.then(r => r.ok ? r.json() as Promise<NavUser> : null)
|
||||
.then(u => setUser(u))
|
||||
.catch(() => setUser(null));
|
||||
}, []);
|
||||
|
||||
const logout = async () => {
|
||||
await fetch(`${API_BASE}/auth/logout`, { method: 'POST', credentials: 'include' });
|
||||
window.location.href = '/';
|
||||
};
|
||||
|
||||
const linkCls = (path: string) =>
|
||||
`transition-colors text-sm ${currentPath === path ? 'text-white' : 'text-[#666] hover:text-white'}`;
|
||||
|
||||
return (
|
||||
<nav className="flex items-center justify-between px-6 py-4 border-b border-[#1a1a1a]">
|
||||
<Link href="/" className="text-white font-bold text-lg tracking-tight hover:opacity-80 transition-opacity">
|
||||
arcrun
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<Link href="/integrations" className={linkCls('/integrations')}>Integrations</Link>
|
||||
<Link href="/api-docs" className={linkCls('/api-docs')}>API</Link>
|
||||
<a
|
||||
href="https://github.com/richblack/arcrun"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[#666] hover:text-white transition-colors"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
|
||||
{user === undefined ? (
|
||||
// Loading — placeholder to prevent layout shift
|
||||
<div className="w-20 h-7" />
|
||||
) : user ? (
|
||||
<>
|
||||
<AppLauncher userEmail={user.email} />
|
||||
<Link href="/dashboard" className="flex items-center gap-2 hover:opacity-80 transition-opacity">
|
||||
{user.avatar_url && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={user.avatar_url} alt="" width={26} height={26} className="rounded-full" />
|
||||
)}
|
||||
<span className="text-[#aaa]">{user.display_name}</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="text-[#555] hover:text-[#888] transition-colors cursor-pointer"
|
||||
>
|
||||
登出
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<Link
|
||||
href="/login"
|
||||
className="bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-1.5 rounded-md font-medium transition-colors"
|
||||
>
|
||||
Get API Key
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Matrix App Launcher 九宮格清單
|
||||
// 來源:matrix/identity/.agents/specs/identity/apps.json(v0 過渡複製,未來 v1 抽進 @matrix/identity-ui)
|
||||
// 規範:matrix/identity/.agents/specs/identity/design.md §2.5
|
||||
|
||||
export type AppEntry = {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
icon?: string;
|
||||
description?: string;
|
||||
access?: 'public' | 'allowlist';
|
||||
allowlist_emails?: string[];
|
||||
locked_tooltip?: string;
|
||||
};
|
||||
|
||||
export const MATRIX_APPS: AppEntry[] = [
|
||||
{
|
||||
id: 'arcrun',
|
||||
name: 'Arcrun',
|
||||
url: 'https://arcrun.dev',
|
||||
icon: '🔄',
|
||||
description: '工作流引擎與零件平台',
|
||||
},
|
||||
{
|
||||
id: 'dashboard',
|
||||
name: 'Dashboard',
|
||||
url: 'https://arcrun.dev/dashboard',
|
||||
icon: '🔑',
|
||||
description: 'API Key 管理',
|
||||
},
|
||||
{
|
||||
id: 'integrations',
|
||||
name: 'Integrations',
|
||||
url: 'https://arcrun.dev/integrations',
|
||||
icon: '🧩',
|
||||
description: '服務目錄',
|
||||
},
|
||||
{
|
||||
id: 'mira',
|
||||
name: 'Mira',
|
||||
url: 'https://arcrun.dev/mira',
|
||||
icon: '🌊',
|
||||
description: '個人化 KM 河道',
|
||||
access: 'allowlist',
|
||||
allowlist_emails: ['leo21c@gmail.com'],
|
||||
locked_tooltip: '即將開放',
|
||||
},
|
||||
];
|
||||
|
||||
export function isAppAccessible(app: AppEntry, userEmail: string | null): boolean {
|
||||
if (app.access !== 'allowlist') return true;
|
||||
if (!userEmail) return false;
|
||||
return (app.allowlist_emails ?? []).includes(userEmail);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import Link from 'next/link';
|
||||
import SiteNav from '../components/SiteNav';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? 'https://cypher.arcrun.dev';
|
||||
|
||||
type User = {
|
||||
email: string;
|
||||
display_name: string;
|
||||
avatar_url?: string;
|
||||
api_key: string;
|
||||
provider: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [rotating, setRotating] = useState(false);
|
||||
const [revoking, setRevoking] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
|
||||
const fetchUser = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/me`, { credentials: 'include' });
|
||||
if (res.status === 401) {
|
||||
window.location.href = '/login?redirect=/dashboard';
|
||||
return;
|
||||
}
|
||||
if (!res.ok) throw new Error('Failed to fetch user');
|
||||
const data = await res.json() as User;
|
||||
setUser(data);
|
||||
} catch {
|
||||
setError('無法載入用戶資訊,請重新整理。');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUser();
|
||||
}, [fetchUser]);
|
||||
|
||||
const copyKey = async () => {
|
||||
if (!user) return;
|
||||
await navigator.clipboard.writeText(user.api_key);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const rotateKey = async () => {
|
||||
if (!confirm('確定要 Rotate API Key 嗎?舊 Key 的 workflow credentials 不會自動遷移。')) return;
|
||||
setRotating(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/me/api-key/rotate`, {
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) throw new Error('rotate failed');
|
||||
const data = await res.json() as { api_key: string; message: string };
|
||||
setUser(prev => prev ? { ...prev, api_key: data.api_key } : null);
|
||||
setShowKey(true);
|
||||
} catch {
|
||||
setError('Rotate 失敗,請稍後重試。');
|
||||
} finally {
|
||||
setRotating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const revokeKey = async () => {
|
||||
if (!confirm('確定要 Revoke API Key 嗎?所有使用此 Key 的服務將立即失效。')) return;
|
||||
setRevoking(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/me/api-key`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) throw new Error('revoke failed');
|
||||
window.location.href = '/login?revoked=1';
|
||||
} catch {
|
||||
setError('Revoke 失敗,請稍後重試。');
|
||||
} finally {
|
||||
setRevoking(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
|
||||
<div className="text-[#444] text-sm animate-pulse">載入中...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0a0a] flex flex-col items-center justify-center gap-4">
|
||||
<p className="text-[#666]">{error || '請先登入。'}</p>
|
||||
<Link href="/login" className="text-indigo-400 hover:text-indigo-300 text-sm">前往登入</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const maskedKey = showKey ? user.api_key : user.api_key.slice(0, 8) + '••••••••••••••••••••••••';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0a0a] text-[#ededed]">
|
||||
<SiteNav currentPath="/dashboard" />
|
||||
|
||||
<main className="max-w-2xl mx-auto px-6 py-12">
|
||||
<h1 className="text-2xl font-bold text-white mb-1">歡迎,{user.display_name}</h1>
|
||||
<p className="text-[#555] text-sm mb-10">
|
||||
登入方式:{user.provider} · 帳號建立於 {new Date(user.created_at).toLocaleDateString('zh-TW')}
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-950/50 border border-red-900/50 text-red-400 text-sm px-4 py-3 rounded-lg mb-6">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Key Card */}
|
||||
<div className="bg-[#111] border border-[#222] rounded-2xl p-6 mb-6">
|
||||
<h2 className="text-white font-semibold mb-4">您的 API Key</h2>
|
||||
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="flex-1 bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg px-4 py-3 font-mono text-sm text-[#cdd6f4] overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{maskedKey}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowKey(v => !v)}
|
||||
className="px-3 py-3 text-[#555] hover:text-[#aaa] text-xs border border-[#2a2a2a] rounded-lg transition-colors cursor-pointer whitespace-nowrap"
|
||||
title={showKey ? '隱藏' : '顯示'}
|
||||
>
|
||||
{showKey ? '隱藏' : '顯示'}
|
||||
</button>
|
||||
<button
|
||||
onClick={copyKey}
|
||||
className="px-4 py-3 bg-[#1e1e2e] hover:bg-[#2a2a3e] text-indigo-400 text-xs border border-indigo-900/30 rounded-lg transition-colors cursor-pointer whitespace-nowrap"
|
||||
>
|
||||
{copied ? '已複製!' : '複製'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#0a0a0a] border border-[#1a1a1a] rounded-lg p-4 mb-6 text-xs font-mono text-[#666] space-y-1">
|
||||
<div className="text-[#444] mb-2"># 使用方式</div>
|
||||
<div>Authorization: Bearer {user.api_key.slice(0, 8)}...</div>
|
||||
<div># 或</div>
|
||||
<div>X-Arcrun-API-Key: {user.api_key.slice(0, 8)}...</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
<button
|
||||
onClick={rotateKey}
|
||||
disabled={rotating}
|
||||
className="flex-1 border border-[#333] hover:border-[#555] text-[#aaa] hover:text-white px-4 py-2.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||
>
|
||||
{rotating ? 'Rotating...' : 'Rotate Key'}
|
||||
</button>
|
||||
<button
|
||||
onClick={revokeKey}
|
||||
disabled={revoking}
|
||||
className="flex-1 border border-red-900/50 hover:border-red-700/50 text-red-500 hover:text-red-400 px-4 py-2.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||
>
|
||||
{revoking ? 'Revoking...' : 'Revoke Key'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Start */}
|
||||
<div className="bg-[#111] border border-[#222] rounded-2xl p-6">
|
||||
<h2 className="text-white font-semibold mb-4">快速開始</h2>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-indigo-500 font-mono mt-0.5">1.</span>
|
||||
<div>
|
||||
<div className="text-[#aaa]">安裝 CLI</div>
|
||||
<pre className="bg-[#0a0a0a] border border-[#1a1a1a] rounded-lg px-3 py-2 mt-1 text-xs text-[#cdd6f4] font-mono">npm install -g arcrun</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-indigo-500 font-mono mt-0.5">2.</span>
|
||||
<div>
|
||||
<div className="text-[#aaa]">初始化(已有 API Key 可直接輸入)</div>
|
||||
<pre className="bg-[#0a0a0a] border border-[#1a1a1a] rounded-lg px-3 py-2 mt-1 text-xs text-[#cdd6f4] font-mono">acr init</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-indigo-500 font-mono mt-0.5">3.</span>
|
||||
<div>
|
||||
<div className="text-[#aaa]">設定服務認證</div>
|
||||
<pre className="bg-[#0a0a0a] border border-[#1a1a1a] rounded-lg px-3 py-2 mt-1 text-xs text-[#cdd6f4] font-mono">acr auth-recipe scaffold notion</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 mt-6 text-sm">
|
||||
<Link href="/integrations" className="text-indigo-400 hover:text-indigo-300 transition-colors">
|
||||
查看 20 個支援服務 →
|
||||
</Link>
|
||||
<Link href="/api-docs" className="text-indigo-400 hover:text-indigo-300 transition-colors">
|
||||
API 文件 →
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,22 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
pre, code {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
export const runtime = 'edge';
|
||||
|
||||
import Link from 'next/link';
|
||||
import SiteNav from '../components/SiteNav';
|
||||
|
||||
type Recipe = {
|
||||
id: string;
|
||||
name: string;
|
||||
primitive: 'static_key' | 'service_account';
|
||||
category: string;
|
||||
secrets: string[];
|
||||
badge?: 'official';
|
||||
};
|
||||
|
||||
const RECIPES: Recipe[] = [
|
||||
// AI / LLM
|
||||
{ id: 'openai', name: 'OpenAI', primitive: 'static_key', category: 'AI', secrets: ['OPENAI_API_KEY'], badge: 'official' },
|
||||
{ id: 'anthropic', name: 'Anthropic', primitive: 'static_key', category: 'AI', secrets: ['ANTHROPIC_API_KEY'], badge: 'official' },
|
||||
// Productivity
|
||||
{ id: 'notion', name: 'Notion', primitive: 'static_key', category: 'Productivity', secrets: ['NOTION_TOKEN'], badge: 'official' },
|
||||
{ id: 'airtable', name: 'Airtable', primitive: 'static_key', category: 'Productivity', secrets: ['AIRTABLE_TOKEN'], badge: 'official' },
|
||||
{ id: 'typeform', name: 'Typeform', primitive: 'static_key', category: 'Productivity', secrets: ['TYPEFORM_TOKEN'], badge: 'official' },
|
||||
{ id: 'jira', name: 'Jira', primitive: 'static_key', category: 'Productivity', secrets: ['JIRA_DOMAIN', 'JIRA_EMAIL', 'JIRA_API_TOKEN'], badge: 'official' },
|
||||
// Communication
|
||||
{ id: 'slack', name: 'Slack', primitive: 'static_key', category: 'Communication', secrets: ['SLACK_TOKEN'], badge: 'official' },
|
||||
{ id: 'discord', name: 'Discord', primitive: 'static_key', category: 'Communication', secrets: ['DISCORD_BOT_TOKEN'], badge: 'official' },
|
||||
{ id: 'twilio', name: 'Twilio', primitive: 'static_key', category: 'Communication', secrets: ['TWILIO_ACCOUNT_SID', 'TWILIO_AUTH_TOKEN'], badge: 'official' },
|
||||
{ id: 'sendgrid', name: 'SendGrid', primitive: 'static_key', category: 'Communication', secrets: ['SENDGRID_API_KEY'], badge: 'official' },
|
||||
{ id: 'resend', name: 'Resend', primitive: 'static_key', category: 'Communication', secrets: ['RESEND_API_KEY'], badge: 'official' },
|
||||
// Dev / Code
|
||||
{ id: 'github', name: 'GitHub', primitive: 'static_key', category: 'Dev', secrets: ['GITHUB_TOKEN'], badge: 'official' },
|
||||
{ id: 'linear', name: 'Linear', primitive: 'static_key', category: 'Dev', secrets: ['LINEAR_API_KEY'], badge: 'official' },
|
||||
{ id: 'supabase', name: 'Supabase', primitive: 'static_key', category: 'Dev', secrets: ['SUPABASE_URL', 'SUPABASE_SERVICE_ROLE_KEY'], badge: 'official' },
|
||||
// Commerce
|
||||
{ id: 'stripe', name: 'Stripe', primitive: 'static_key', category: 'Commerce', secrets: ['STRIPE_SECRET_KEY'], badge: 'official' },
|
||||
{ id: 'shopify', name: 'Shopify', primitive: 'static_key', category: 'Commerce', secrets: ['SHOPIFY_STORE_DOMAIN', 'SHOPIFY_ACCESS_TOKEN'], badge: 'official' },
|
||||
{ id: 'hubspot', name: 'HubSpot', primitive: 'static_key', category: 'Commerce', secrets: ['HUBSPOT_ACCESS_TOKEN'], badge: 'official' },
|
||||
// Google Service Account
|
||||
{ id: 'google_drive_sa', name: 'Google Drive', primitive: 'service_account', category: 'Google', secrets: ['GOOGLE_SERVICE_ACCOUNT_JSON'], badge: 'official' },
|
||||
{ id: 'google_gmail_sa', name: 'Gmail', primitive: 'service_account', category: 'Google', secrets: ['GOOGLE_SERVICE_ACCOUNT_JSON'], badge: 'official' },
|
||||
{ id: 'google_sheets_sa', name: 'Google Sheets', primitive: 'service_account', category: 'Google', secrets: ['GOOGLE_SERVICE_ACCOUNT_JSON'], badge: 'official' },
|
||||
];
|
||||
|
||||
const CATEGORIES = ['All', 'AI', 'Productivity', 'Communication', 'Dev', 'Commerce', 'Google'];
|
||||
|
||||
export default function IntegrationsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ cat?: string }>;
|
||||
}) {
|
||||
return <IntegrationsContent searchParamsPromise={searchParams} />;
|
||||
}
|
||||
|
||||
async function IntegrationsContent({
|
||||
searchParamsPromise,
|
||||
}: {
|
||||
searchParamsPromise: Promise<{ cat?: string }>;
|
||||
}) {
|
||||
const params = await searchParamsPromise;
|
||||
const cat = params.cat ?? 'All';
|
||||
const filtered = cat === 'All' ? RECIPES : RECIPES.filter(r => r.category === cat);
|
||||
|
||||
const staticCount = RECIPES.filter(r => r.primitive === 'static_key').length;
|
||||
const saCount = RECIPES.filter(r => r.primitive === 'service_account').length;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0a0a] text-[#ededed]">
|
||||
<SiteNav currentPath="/integrations" />
|
||||
|
||||
<div className="max-w-5xl mx-auto px-6 py-12">
|
||||
{/* Header */}
|
||||
<h1 className="text-3xl font-bold text-white mb-2">
|
||||
{RECIPES.length} 個已驗證的認證服務
|
||||
</h1>
|
||||
<p className="text-[#555] mb-2">
|
||||
由 arcrun 團隊維護,每個 recipe 都通過整合測試。
|
||||
</p>
|
||||
<div className="flex gap-4 text-sm text-[#444] mb-8">
|
||||
<span>{staticCount} API Key 類</span>
|
||||
<span>·</span>
|
||||
<span>{saCount} Service Account 類</span>
|
||||
</div>
|
||||
|
||||
{/* Category filter */}
|
||||
<div className="flex gap-2 flex-wrap mb-8">
|
||||
{CATEGORIES.map(c => (
|
||||
<Link
|
||||
key={c}
|
||||
href={c === 'All' ? '/integrations' : `/integrations?cat=${c}`}
|
||||
className={`px-3 py-1.5 rounded-full text-sm transition-colors ${
|
||||
cat === c
|
||||
? 'bg-indigo-600 text-white'
|
||||
: 'bg-[#111] border border-[#222] text-[#666] hover:text-white hover:border-[#444]'
|
||||
}`}
|
||||
>
|
||||
{c}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Recipe grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-12">
|
||||
{filtered.map(recipe => (
|
||||
<RecipeCard key={recipe.id} recipe={recipe} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Contribute CTA */}
|
||||
<div className="bg-[#111] border border-[#222] rounded-2xl p-8 text-center">
|
||||
<h2 className="text-white font-semibold text-xl mb-2">找不到你要的服務?</h2>
|
||||
<p className="text-[#555] text-sm mb-4 max-w-lg mx-auto">
|
||||
大部分 API Key 類的服務,填一份 YAML 就能加進來。
|
||||
把 API 文件丟給 AI,五分鐘生成,開 PR 送出。
|
||||
</p>
|
||||
<div className="flex gap-3 justify-center flex-wrap">
|
||||
<a href="https://github.com/richblack/arcrun" target="_blank" rel="noopener noreferrer"
|
||||
className="bg-indigo-600 hover:bg-indigo-500 text-white px-5 py-2.5 rounded-lg text-sm font-medium transition-colors">
|
||||
開始貢獻
|
||||
</a>
|
||||
<a href="https://github.com/richblack/arcrun/blob/main/CONTRIBUTING.md" target="_blank" rel="noopener noreferrer"
|
||||
className="border border-[#333] hover:border-[#555] text-[#aaa] hover:text-white px-5 py-2.5 rounded-lg text-sm font-medium transition-colors">
|
||||
查看 Recipe 格式
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecipeCard({ recipe }: { recipe: Recipe }) {
|
||||
const primitiveLabel = recipe.primitive === 'static_key' ? 'API Key' : 'Service Account';
|
||||
const primitiveColor = recipe.primitive === 'static_key' ? 'text-blue-400' : 'text-orange-400';
|
||||
|
||||
return (
|
||||
<div className="bg-[#111] border border-[#1e1e1e] hover:border-[#333] rounded-xl p-5 transition-colors">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-white font-medium">{recipe.name}</h3>
|
||||
<span className={`text-xs font-mono mt-0.5 ${primitiveColor}`}>{primitiveLabel}</span>
|
||||
</div>
|
||||
{recipe.badge === 'official' && (
|
||||
<span className="text-xs bg-indigo-950/50 text-indigo-400 border border-indigo-900/30 px-2 py-0.5 rounded-full">
|
||||
★ 官方
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-[#444] space-y-1">
|
||||
{recipe.secrets.map(s => (
|
||||
<div key={s} className="font-mono flex items-center gap-1">
|
||||
<span className="text-[#333]">›</span> {s}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-xs">
|
||||
<code className="text-[#555] bg-[#0a0a0a] px-2 py-1 rounded font-mono">
|
||||
acr auth-recipe scaffold {recipe.id}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "arcrun — Stop fighting OAuth",
|
||||
description: "One API key. Every service. Works anywhere. arcrun handles Google, Notion, GitHub, Slack authentication so your code doesn't have to.",
|
||||
openGraph: {
|
||||
title: "arcrun — Stop fighting OAuth",
|
||||
description: "One API key. Every service. Works anywhere.",
|
||||
url: "https://arcrun.dev",
|
||||
siteName: "arcrun",
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" className="h-full">
|
||||
<body className="min-h-full flex flex-col bg-[#0a0a0a] text-[#ededed]">
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
export const runtime = 'edge';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? 'https://cypher.arcrun.dev';
|
||||
|
||||
export default function LoginPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ error?: string; redirect?: string }>;
|
||||
}) {
|
||||
return (
|
||||
<LoginContent searchParamsPromise={searchParams} />
|
||||
);
|
||||
}
|
||||
|
||||
async function LoginContent({
|
||||
searchParamsPromise,
|
||||
}: {
|
||||
searchParamsPromise: Promise<{ error?: string; redirect?: string }>;
|
||||
}) {
|
||||
const params = await searchParamsPromise;
|
||||
const error = params.error;
|
||||
const redirect = params.redirect ?? '/dashboard';
|
||||
|
||||
const googleUrl = `${API_BASE}/auth/google/start?redirect=${encodeURIComponent(redirect)}`;
|
||||
const githubUrl = `${API_BASE}/auth/github/start?redirect=${encodeURIComponent(redirect)}`;
|
||||
|
||||
const errorMessages: Record<string, string> = {
|
||||
cancelled: '登入已取消。',
|
||||
invalid_state: '安全性驗證失敗,請重試。',
|
||||
server_error: '伺服器錯誤,請稍後重試。',
|
||||
github_email_required: 'GitHub 帳號需要設定公開 Email 才能登入。',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0a0a] flex flex-col items-center justify-center px-6">
|
||||
{/* Logo */}
|
||||
<div className="mb-8 text-center">
|
||||
<Link href="/" className="text-white font-bold text-2xl tracking-tight hover:opacity-80 transition-opacity">
|
||||
arcrun
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<div className="w-full max-w-sm bg-[#111] border border-[#222] rounded-2xl p-8">
|
||||
<h1 className="text-xl font-semibold text-white mb-2 text-center">登入或建立帳號</h1>
|
||||
<p className="text-[#555] text-sm text-center mb-8">取得您的 API Key,立即開始使用</p>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-950/50 border border-red-900/50 text-red-400 text-sm px-4 py-3 rounded-lg mb-6">
|
||||
{errorMessages[error] ?? '登入時發生錯誤,請重試。'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Google */}
|
||||
<a href={googleUrl}
|
||||
className="flex items-center justify-center gap-3 bg-white hover:bg-gray-100 text-gray-900 px-4 py-3 rounded-lg font-medium text-sm transition-colors">
|
||||
<GoogleIcon />
|
||||
Continue with Google
|
||||
</a>
|
||||
|
||||
{/* GitHub */}
|
||||
<a href={githubUrl}
|
||||
className="flex items-center justify-center gap-3 bg-[#24292e] hover:bg-[#2f363d] text-white border border-[#444] px-4 py-3 rounded-lg font-medium text-sm transition-colors">
|
||||
<GitHubIcon />
|
||||
Continue with GitHub
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p className="text-[#444] text-xs text-center mt-6 leading-relaxed">
|
||||
登入即表示您同意我們的服務條款。
|
||||
不需要信用卡。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Link href="/" className="mt-6 text-[#444] hover:text-[#888] text-sm transition-colors">
|
||||
← 返回首頁
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GoogleIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
|
||||
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
|
||||
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
|
||||
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function GitHubIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
'use client';
|
||||
|
||||
// Mira 對話核心元件(河道右側 dock 與 /mira/chat 單頁共用)
|
||||
// SDD: polaris/mira/.agents/specs/mira-app/design.md §3.6.5
|
||||
// RAG:提問先語義搜尋 KBDB(wiki + 河道)取 context → claude-api daemon。
|
||||
// 重要:context 空時 prompt 明確要求「只說沒有相關筆記」,避免 daemon 自由發揮(曾幻想 OpenWebUI)
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { MarkdownView } from './markdown';
|
||||
|
||||
const KBDB_BASE = 'https://kbdb.finally.click';
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? 'https://cypher.arcrun.dev';
|
||||
const CLAUDE_API = 'https://claude-api.arcrun.dev';
|
||||
|
||||
type Source = { label: string; href: string };
|
||||
type Msg = { role: 'user' | 'mira'; text: string; sources?: Source[]; pending?: boolean };
|
||||
|
||||
type SearchMatch = {
|
||||
score: number;
|
||||
type: 'block' | 'triplet';
|
||||
block: { id: string; page_name: string | null; content: string | null; type: string } | null;
|
||||
triplet: { subject?: string; predicate?: string; object?: string } | null;
|
||||
};
|
||||
|
||||
// 繁體異體字正規化(臺→台 等),讓 query 跟 KB 內容(多用「台」)對得上
|
||||
function normalizeQuery(q: string): string {
|
||||
return q.replace(/臺/g, '台');
|
||||
}
|
||||
|
||||
async function fetchContext(apiKey: string, query: string): Promise<{ context: string; sources: Source[] }> {
|
||||
try {
|
||||
const res = await fetch(`${KBDB_BASE}/search`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
||||
body: JSON.stringify({ query: normalizeQuery(query), type: 'semantic', topK: 8 }),
|
||||
});
|
||||
if (!res.ok) return { context: '', sources: [] };
|
||||
const data = (await res.json()) as { matches?: SearchMatch[] };
|
||||
const parts: string[] = [];
|
||||
const sources: Source[] = [];
|
||||
for (const m of data.matches ?? []) {
|
||||
if (m.block?.content) {
|
||||
const b = m.block;
|
||||
parts.push(`### ${b.type}:${b.page_name ?? b.id}\n${b.content}`);
|
||||
if (b.type === 'wiki-page' && b.page_name) {
|
||||
sources.push({ label: `📚 ${(b.content || b.page_name).slice(0, 20)}`, href: `/mira/wiki/${encodeURIComponent(b.page_name)}` });
|
||||
} else if (b.page_name) {
|
||||
sources.push({ label: `🌊 ${(b.content || '').slice(0, 20) || b.page_name}`, href: `/mira/feed#page=${encodeURIComponent(b.page_name)}` });
|
||||
}
|
||||
} else if (m.triplet) {
|
||||
const t = m.triplet;
|
||||
parts.push(`關係:${t.subject} >> ${t.predicate} >> ${t.object}`);
|
||||
}
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const uniq = sources.filter(s => (seen.has(s.href) ? false : (seen.add(s.href), true)));
|
||||
return { context: parts.join('\n\n'), sources: uniq.slice(0, 5) };
|
||||
} catch {
|
||||
return { context: '', sources: [] };
|
||||
}
|
||||
}
|
||||
|
||||
function buildPrompt(history: Msg[], context: string, question: string): string {
|
||||
const convo = history
|
||||
.filter(m => !m.pending)
|
||||
.map(m => `${m.role === 'user' ? 'leo' : 'Mira'}:${m.text}`)
|
||||
.join('\n');
|
||||
const persona =
|
||||
`你是 Mira,leo 的個人知識庫副駕 AI。你只能根據下方「知識庫」與「對話脈絡」回答,` +
|
||||
`沒有任何外部系統存取權(沒有 OpenWebUI、沒有檔案系統、沒有別的工具)。\n\n`;
|
||||
const kb = context
|
||||
? `## 知識庫(跟本次提問相關的 wiki / 河道內容)\n\n${context}\n\n`
|
||||
: `## 知識庫\n(這次在 leo 的筆記裡找不到相關內容。)\n\n`;
|
||||
const rules = context
|
||||
? `規則:繁體中文(台灣用語)、務實不客套、優先引用上方知識庫並說「你之前寫過⋯」、簡短切題。`
|
||||
: `規則:繁體中文。**明確告訴 leo「你的筆記裡目前沒有關於這個的內容」**,` +
|
||||
`可以再用常識補一兩句(要標明那不是來自他的筆記),不要假裝有資料、不要編造系統或工具。`;
|
||||
return (
|
||||
persona +
|
||||
kb +
|
||||
(convo ? `## 對話脈絡\n${convo}\n\n` : '') +
|
||||
`---\n\nleo 現在問:「${question}」\n\n${rules}`
|
||||
);
|
||||
}
|
||||
|
||||
export default function MiraChat({ compact = false }: { compact?: boolean }) {
|
||||
const [apiKey, setApiKey] = useState<string | null>(null);
|
||||
const [msgs, setMsgs] = useState<Msg[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const logRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/me`, { credentials: 'include' })
|
||||
.then(r => (r.ok ? r.json() : null))
|
||||
.then((me: { api_key: string } | null) => { if (me?.api_key) setApiKey(me.api_key); })
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
logRef.current?.scrollTo({ top: logRef.current.scrollHeight, behavior: 'smooth' });
|
||||
}, [msgs]);
|
||||
|
||||
const send = async () => {
|
||||
const question = input.trim();
|
||||
if (!question || sending || !apiKey) return;
|
||||
setInput('');
|
||||
setSending(true);
|
||||
const history = msgs;
|
||||
setMsgs(m => [...m, { role: 'user', text: question }, { role: 'mira', text: '', pending: true }]);
|
||||
try {
|
||||
const { context, sources } = await fetchContext(apiKey, question);
|
||||
const prompt = buildPrompt(history, context, question);
|
||||
const res = await fetch(CLAUDE_API, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prompt, timeout_ms: 60000 }),
|
||||
});
|
||||
const data = (await res.json()) as { success?: boolean; pending?: boolean; data?: { text?: string }; error?: string };
|
||||
let text: string;
|
||||
if (!res.ok || !data.success) text = `(回答失敗:${data.error ?? res.status})`;
|
||||
else if (data.pending) text = '(Mira 還在想,daemon 切到背景模式,請稍後再問一次)';
|
||||
else text = data.data?.text ?? '(沒有內容)';
|
||||
setMsgs(m => {
|
||||
const next = [...m];
|
||||
next[next.length - 1] = { role: 'mira', text, sources: sources.length ? sources : undefined };
|
||||
return next;
|
||||
});
|
||||
} catch (e) {
|
||||
setMsgs(m => {
|
||||
const next = [...m];
|
||||
next[next.length - 1] = { role: 'mira', text: `(錯誤:${e instanceof Error ? e.message : String(e)})` };
|
||||
return next;
|
||||
});
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`mira-chat${compact ? ' is-compact' : ''}`}>
|
||||
<div className="mira-chat-log" ref={logRef}>
|
||||
{msgs.length === 0 && (
|
||||
<div className="empty-state" style={{ marginTop: 28 }}>
|
||||
<div style={{ fontSize: 40, marginBottom: 8 }}>💬</div>
|
||||
<p style={{ color: 'var(--mira-text-2)', fontSize: 13 }}>問 Mira 任何事 — 它會先翻你的知識庫。</p>
|
||||
</div>
|
||||
)}
|
||||
{msgs.map((m, i) => (
|
||||
<div key={i} className={`mira-chat-msg ${m.role === 'user' ? 'is-user' : 'is-mira'}`}>
|
||||
<div className="mira-chat-bubble">
|
||||
{m.pending ? (
|
||||
<span className="mira-thinking-dots">Mira 思考中</span>
|
||||
) : m.role === 'mira' ? (
|
||||
<MarkdownView text={m.text} />
|
||||
) : (
|
||||
m.text
|
||||
)}
|
||||
{m.sources && m.sources.length > 0 && (
|
||||
<div className="mira-chat-sources">
|
||||
來源:
|
||||
{m.sources.map((s, j) => (
|
||||
<span key={j}>
|
||||
{j > 0 && ' · '}
|
||||
<Link href={s.href}>{s.label}</Link>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mira-chat-input-row">
|
||||
<textarea
|
||||
className="mira-chat-input"
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); void send(); }
|
||||
}}
|
||||
placeholder="問 Mira…(⌘+Enter 送出)"
|
||||
rows={2}
|
||||
disabled={sending || !apiKey}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="mira-btn-primary"
|
||||
onClick={() => void send()}
|
||||
disabled={sending || !apiKey || !input.trim()}
|
||||
>
|
||||
{sending ? '⋯' : '送出'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
// Mira 右側常駐對話 dock(桌機,所有頁簽都在;手機隱藏改走 /mira/chat 單頁)
|
||||
// 可收合,狀態存 localStorage。SDD: design.md §3.6.5 對話內建化
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import MiraChat from './MiraChat';
|
||||
|
||||
const LS_KEY = 'mira-chat-dock-open';
|
||||
|
||||
export default function MiraChatDock() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
setOpen(localStorage.getItem(LS_KEY) === '1');
|
||||
}, []);
|
||||
|
||||
const toggle = () => {
|
||||
setOpen(o => {
|
||||
const next = !o;
|
||||
localStorage.setItem(LS_KEY, next ? '1' : '0');
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// SSR/首渲不輸出,避免 hydration 閃爍
|
||||
if (!mounted) return null;
|
||||
|
||||
return (
|
||||
<div className={`mira-chat-dock${open ? ' is-open' : ''}`}>
|
||||
{open ? (
|
||||
<>
|
||||
<header className="mira-chat-dock-head">
|
||||
<span>💬 Mira 對話</span>
|
||||
<button type="button" className="mira-chat-dock-close" onClick={toggle} aria-label="收合對話">✕</button>
|
||||
</header>
|
||||
<MiraChat compact />
|
||||
</>
|
||||
) : (
|
||||
<button type="button" className="mira-chat-dock-fab" onClick={toggle} title="開啟 Mira 對話">
|
||||
💬
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
'use client';
|
||||
|
||||
// Mira 側邊欄(桌機左固定欄 / 手機底部 tab bar)
|
||||
// SDD: polaris/mira/.agents/specs/mira-app/design.md §5.2 v2.1 + §3.7.4
|
||||
// 對應 task: 10B0.1 / 10B0.2 / 10B0.4
|
||||
// 現階段入口手寫;detector framework(#8)上線後改讀 detector view 規格動態生成
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
|
||||
type NavItem = {
|
||||
href: string;
|
||||
icon: string;
|
||||
label: string;
|
||||
status: 'live' | 'planned';
|
||||
};
|
||||
|
||||
// 側邊欄入口 = 各 detector 的 view(§3.7.4)。河道是固定輸入入口,其餘是 detector 產物頁。
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ href: '/mira/feed', icon: '🌊', label: '河道', status: 'live' },
|
||||
{ href: '/mira/chat', icon: '💬', label: '對話', status: 'live' },
|
||||
{ href: '/mira/wiki', icon: '📚', label: 'Wiki', status: 'live' },
|
||||
{ href: '/mira/projects', icon: '📋', label: '專案', status: 'live' },
|
||||
{ href: '/mira/dissent', icon: '⚔️', label: '異見牆', status: 'planned' },
|
||||
];
|
||||
|
||||
function isActive(pathname: string | null, href: string): boolean {
|
||||
if (!pathname) return false;
|
||||
if (href === '/mira/feed') return pathname === '/mira/feed' || pathname === '/mira';
|
||||
return pathname === href || pathname.startsWith(href + '/');
|
||||
}
|
||||
|
||||
export default function MiraSidebar() {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [q, setQ] = useState('');
|
||||
|
||||
const submitSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const query = q.trim();
|
||||
if (!query) return;
|
||||
router.push(`/mira/search?q=${encodeURIComponent(query)}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="mira-sidebar" aria-label="Mira 導覽">
|
||||
<Link href="/mira/feed" className="mira-sidebar-logo">
|
||||
<span className="mira-sidebar-logo-icon">🦔</span>
|
||||
<span className="mira-sidebar-logo-text">Mira</span>
|
||||
</Link>
|
||||
{/* 桌機:側欄輸入框 */}
|
||||
<form className="mira-sidebar-search" onSubmit={submitSearch}>
|
||||
<input
|
||||
value={q}
|
||||
onChange={e => setQ(e.target.value)}
|
||||
placeholder="🔍 搜尋 wiki…"
|
||||
aria-label="搜尋"
|
||||
/>
|
||||
</form>
|
||||
{/* 手機:底部 bar 已滿,搜尋收成一個放大鏡 icon → 去搜尋頁輸入 */}
|
||||
<Link
|
||||
href="/mira/search"
|
||||
className={`mira-sidebar-search-icon${isActive(pathname, '/mira/search') ? ' is-active' : ''}`}
|
||||
aria-label="搜尋"
|
||||
title="搜尋"
|
||||
>
|
||||
🔍
|
||||
</Link>
|
||||
<ul className="mira-sidebar-list">
|
||||
{NAV_ITEMS.map(item => {
|
||||
const active = isActive(pathname, item.href);
|
||||
const planned = item.status === 'planned';
|
||||
const className = `mira-sidebar-item${active ? ' is-active' : ''}${planned ? ' is-planned' : ''}`;
|
||||
const inner = (
|
||||
<>
|
||||
<span className="mira-sidebar-icon">{item.icon}</span>
|
||||
<span className="mira-sidebar-label">{item.label}</span>
|
||||
{planned && <span className="mira-sidebar-badge">即將</span>}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<li key={item.href}>
|
||||
{planned ? (
|
||||
<span className={className} aria-disabled="true" title="即將開放">
|
||||
{inner}
|
||||
</span>
|
||||
) : (
|
||||
<Link href={item.href} className={className} aria-current={active ? 'page' : undefined}>
|
||||
{inner}
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
'use client';
|
||||
|
||||
// Mira 共用 Markdown 渲染器(河道 + Wiki 共用)
|
||||
// SDD: polaris/mira/.agents/specs/mira-app/design.md §3.5.7
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
export function MarkdownView({ text }: { text: string }) {
|
||||
// 三階段預處理:1. strip Logseq metadata;2. [[entity]] 轉 link;3. raw:<uuid> 轉河道 deep-link
|
||||
const cleaned = useMemo(
|
||||
() => expandRawRefs(expandWikilinks(stripLogseqMeta(text))),
|
||||
[text],
|
||||
);
|
||||
return (
|
||||
<div className="mira-md">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
a: ({ href, children, ...rest }) => {
|
||||
const isInternal =
|
||||
typeof href === 'string' &&
|
||||
(href.startsWith('/mira/wiki/') || href.startsWith('/mira/feed'));
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
{...(isInternal ? {} : { target: '_blank', rel: 'noopener noreferrer' })}
|
||||
className="wiki-link"
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
// 圖片不直接 inline 顯示(避免大圖打亂 feed),改成連結
|
||||
img: ({ src, alt }) => {
|
||||
const href = typeof src === 'string' ? src : '';
|
||||
return href ? (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="wiki-link"
|
||||
style={{ fontStyle: 'italic' }}
|
||||
>
|
||||
🖼 {alt || 'image'}
|
||||
</a>
|
||||
) : null;
|
||||
},
|
||||
}}
|
||||
>
|
||||
{cleaned}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Strip Logseq 專屬語法
|
||||
// - 屬性行:`xxx:: yyy`、`collapsed:: true`、`id:: ...`、`logseq.order-list-type:: ...`
|
||||
// - block ref:`((uuid))` 暫時保留為純文字
|
||||
export function stripLogseqMeta(text: string): string {
|
||||
return text
|
||||
.split('\n')
|
||||
.filter((line) => {
|
||||
const trimmed = line.trimStart();
|
||||
if (/^[a-zA-Z][a-zA-Z0-9_.-]*::\s/.test(trimmed)) return false;
|
||||
return true;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
// 把 [[entity]] 轉成 markdown link 指向 /mira/wiki/wiki-{entity}
|
||||
// 對應 mira-app design.md §3.6.2 + tasks.md backlog #12
|
||||
export function expandWikilinks(text: string): string {
|
||||
return text.replace(/\[\[([^\[\]\n]+?)\]\]/g, (_, entity: string) => {
|
||||
const e = entity.trim();
|
||||
if (!e) return '[[]]';
|
||||
const url = `/mira/wiki/${encodeURIComponent('wiki-' + e)}`;
|
||||
return `[${e}](${url})`;
|
||||
});
|
||||
}
|
||||
|
||||
// index-entry / wiki backlink 區塊內的 `raw:<uuid>` bare 文字轉成可點河道 deep-link。
|
||||
// 對應 leo 反饋 #3:index 顯示 uuid 無法點擊。河道 hash handler 認得 #raw=<id> 並解析回 page_name。
|
||||
// 已在 markdown link 內([..](..))的不重複處理;只抓裸 raw:uuid。
|
||||
const UUID_RE = '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}';
|
||||
export function expandRawRefs(text: string): string {
|
||||
return text.replace(
|
||||
new RegExp(`(?<![\\(\\[\\w])raw:(${UUID_RE})`, 'g'),
|
||||
(_m, id: string) => `[raw:${id.slice(0, 8)}…](/mira/feed#raw=${id})`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
'use client';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
// Mira 對話單頁(手機用;桌機改用 layout.tsx 的右側 dock)
|
||||
// SDD: polaris/mira/.agents/specs/mira-app/design.md §3.6.5
|
||||
|
||||
import Link from 'next/link';
|
||||
import MiraChat from '../_shared/MiraChat';
|
||||
import '../mira.css';
|
||||
|
||||
export default function MiraChatPage() {
|
||||
return (
|
||||
<main className="mira-page mira-chat-page">
|
||||
<header style={{ padding: '16px 0 4px' }}>
|
||||
<Link href="/mira/feed" style={{ color: '#888', fontSize: 14, textDecoration: 'none' }}>← 河道</Link>
|
||||
<h1 style={{ fontSize: 20, fontWeight: 700, color: '#fff', margin: '6px 0 0' }}>💬 跟 Mira 對話</h1>
|
||||
<p style={{ color: '#888', fontSize: 12, marginTop: 2 }}>會先翻你的 wiki / 河道再回答</p>
|
||||
</header>
|
||||
<MiraChat />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,96 @@
|
||||
'use client';
|
||||
|
||||
// Mira 子應用 layout
|
||||
// SDD: polaris/mira/.agents/specs/mira-app/design.md §5.5
|
||||
// 規範:白名單 user 進得去;非白名單 user 看到「即將開放」頁
|
||||
// middleware 已做未登入跳 /login?redirect=/mira 檢查(不在這裡重做)
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import SiteNav from '../components/SiteNav';
|
||||
import { MATRIX_APPS } from '../components/apps';
|
||||
import MiraSidebar from './_shared/MiraSidebar';
|
||||
import MiraChatDock from './_shared/MiraChatDock';
|
||||
import './mira.css';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? 'https://cypher.arcrun.dev';
|
||||
|
||||
type Me = { email: string; display_name: string; api_key: string };
|
||||
|
||||
const MIRA = MATRIX_APPS.find(a => a.id === 'mira');
|
||||
const ALLOWED = new Set(MIRA?.allowlist_emails ?? []);
|
||||
|
||||
export default function MiraLayout({ children }: { children: React.ReactNode }) {
|
||||
const [me, setMe] = useState<Me | null | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/me`, { credentials: 'include' })
|
||||
.then(r => r.ok ? r.json() as Promise<Me> : null)
|
||||
.then(u => setMe(u))
|
||||
.catch(() => setMe(null));
|
||||
}, []);
|
||||
|
||||
if (me === undefined) {
|
||||
return (
|
||||
<>
|
||||
<SiteNav currentPath="/mira" />
|
||||
<div className="flex-1 flex items-center justify-center text-[#666]">載入中…</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (me === null) {
|
||||
// 理論上 middleware 已擋住,但保險
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login?redirect=/mira';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!ALLOWED.has(me.email)) {
|
||||
return (
|
||||
<>
|
||||
<SiteNav currentPath="/mira" />
|
||||
<BetaBlocked email={me.email} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mira-topnav-sticky">
|
||||
<SiteNav currentPath="/mira" />
|
||||
</div>
|
||||
<div className="mira-app mira-shell">
|
||||
<MiraSidebar />
|
||||
<div className="mira-shell-content">{children}</div>
|
||||
<MiraChatDock />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function BetaBlocked({ email }: { email: string }) {
|
||||
return (
|
||||
<main className="flex-1 flex items-center justify-center px-6">
|
||||
<div className="max-w-md text-center space-y-4">
|
||||
<div className="text-6xl mb-2">🌊</div>
|
||||
<h1 className="text-3xl font-bold text-white">Mira 仍封測中</h1>
|
||||
<p className="text-[#888] leading-relaxed">
|
||||
Mira 是 arcrun 的個人化 KM 河道,目前僅開放給少數測試用戶。
|
||||
</p>
|
||||
<p className="text-sm text-[#555]">
|
||||
你登入的帳號是 <span className="font-mono text-[#888]">{email}</span>,
|
||||
不在白名單內。準備好對外開放時會公告。
|
||||
</p>
|
||||
<div className="pt-4">
|
||||
<a
|
||||
href="/dashboard"
|
||||
className="inline-block bg-indigo-600 hover:bg-indigo-500 text-white px-5 py-2 rounded-md text-sm font-medium transition-colors"
|
||||
>
|
||||
回 Dashboard
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
// Mira 首頁 → redirect 到河道
|
||||
// SDD: polaris/mira/.agents/specs/mira-app/design.md §5.2 v2.1
|
||||
// v2.1 改側邊欄式版面後,原本的卡片入口導覽移到側邊欄(MiraSidebar),
|
||||
// 首頁不再需要列入口,直接進河道(feed = 主要輸入/瀏覽頁)
|
||||
export default function MiraHubPage() {
|
||||
redirect('/mira/feed');
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
'use client';
|
||||
|
||||
// Mira repo 總管 + 工作台
|
||||
// SDD: polaris/mira/.agents/specs/mira-app/design.md §3.9.1.2 + §5.2 v2.1
|
||||
// 對應 task: 階段 10-B
|
||||
// GitHub = repo 全清單 SSOT;clone 到 Hetzner = 激活進工作態
|
||||
// 資料源:mira daemon GET /projects(轉發 AI-Meka GitHub 掃描器)
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import '../mira.css';
|
||||
|
||||
// mira daemon(nginx mira.uncle6.me/mira/ → 容器)
|
||||
const DAEMON = process.env.NEXT_PUBLIC_MIRA_DAEMON ?? 'https://mira.uncle6.me/mira';
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? 'https://cypher.arcrun.dev';
|
||||
const KBDB_BASE = 'https://kbdb.finally.click'; // 既有技術債(同 feed/wiki),KI-3 未解前沿用
|
||||
|
||||
export type RepoSummary = {
|
||||
name: string;
|
||||
full_name: string;
|
||||
cloned: boolean;
|
||||
has_sdd: boolean;
|
||||
archived: boolean;
|
||||
fork: boolean;
|
||||
total: number;
|
||||
done: number;
|
||||
in_progress: number;
|
||||
ai_count: number;
|
||||
leo_count: number;
|
||||
blocked_count: number;
|
||||
last_activity: string | null;
|
||||
};
|
||||
|
||||
// project_detector 拆出的 todo block(type=note, source=ai-project-detector)
|
||||
// G1:一篇河道筆記可拆多條 todo,各自掛 suggested-repo(design.md §3.7.5.7)
|
||||
type Todo = {
|
||||
id: string;
|
||||
content: string;
|
||||
suggested_repo: string; // repo name 或 "new"
|
||||
raw_id: string; // 溯源回河道原 raw(raw: tag),給「來自河道」連結
|
||||
};
|
||||
|
||||
type Filter = 'sdd' | 'all';
|
||||
|
||||
// 從 tags_json 解析 detector 打的 tag
|
||||
function parseTodoTags(tagsJson: string | null): { isTodo: boolean; repo: string; rawId: string } {
|
||||
let tags: string[] = [];
|
||||
try { tags = JSON.parse(tagsJson || '[]'); } catch { /* */ }
|
||||
const isTodo = tags.includes('is_todo:true');
|
||||
const repoTag = tags.find(t => t.startsWith('suggested-repo:'));
|
||||
const repo = repoTag ? repoTag.slice('suggested-repo:'.length) : '';
|
||||
const rawTag = tags.find(t => t.startsWith('raw:'));
|
||||
const rawId = rawTag ? rawTag.slice('raw:'.length) : '';
|
||||
return { isTodo, repo, rawId };
|
||||
}
|
||||
|
||||
export default function ProjectsPage() {
|
||||
const [repos, setRepos] = useState<RepoSummary[] | null>(null);
|
||||
const [todos, setTodos] = useState<Todo[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState<Filter>('sdd');
|
||||
const [cloning, setCloning] = useState<string | null>(null);
|
||||
const [newRepo, setNewRepo] = useState('');
|
||||
|
||||
const load = () => {
|
||||
setError(null);
|
||||
fetch(`${DAEMON}/projects`)
|
||||
.then(async r => {
|
||||
if (!r.ok) throw new Error(`daemon 回 ${r.status}`);
|
||||
return r.json() as Promise<{ repos: RepoSummary[] }>;
|
||||
})
|
||||
.then(d => setRepos(d.repos ?? []))
|
||||
.catch(e => setError(e instanceof Error ? e.message : String(e)));
|
||||
void loadTodos();
|
||||
};
|
||||
|
||||
// 撈 project_detector 拆出的 todo block(source=ai-project-detector,G1 一篇拆多條)
|
||||
// client 過濾 is_todo:true(KI-3 tag query bug 故用 source filter 而非 tag query)
|
||||
const loadTodos = async () => {
|
||||
try {
|
||||
const me = await fetch(`${API_BASE}/me`, { credentials: 'include' }).then(r => r.ok ? r.json() : null);
|
||||
if (!me?.api_key) return;
|
||||
const res = await fetch(`${KBDB_BASE}/blocks?type=note&source=ai-project-detector&limit=300`, {
|
||||
headers: { Authorization: `Bearer ${me.api_key}` },
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const data = await res.json() as { blocks?: Array<{ id: string; content: string; tags_json: string | null }> };
|
||||
const list: Todo[] = [];
|
||||
for (const b of data.blocks ?? []) {
|
||||
const { isTodo, repo, rawId } = parseTodoTags(b.tags_json);
|
||||
if (isTodo) list.push({ id: b.id, content: b.content, suggested_repo: repo || 'new', raw_id: rawId });
|
||||
}
|
||||
setTodos(list);
|
||||
} catch { /* todos best-effort */ }
|
||||
};
|
||||
|
||||
useEffect(load, []);
|
||||
|
||||
const clone = async (fullName: string) => {
|
||||
setCloning(fullName);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await fetch(`${DAEMON}/projects/clone`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ repo: fullName }),
|
||||
});
|
||||
if (!r.ok) throw new Error(`clone 失敗 ${r.status}`);
|
||||
setNewRepo('');
|
||||
load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setCloning(null);
|
||||
}
|
||||
};
|
||||
|
||||
const addRepo = () => {
|
||||
const v = newRepo.trim();
|
||||
if (!v) return;
|
||||
// 允許輸入 "name" 或 "owner/name"
|
||||
const full = v.includes('/') ? v : `richblack/${v}`;
|
||||
clone(full);
|
||||
};
|
||||
|
||||
const shown = useMemo(() => {
|
||||
if (!repos) return [];
|
||||
const arr = repos.filter(r => {
|
||||
if (filter === 'sdd') return r.has_sdd;
|
||||
return true;
|
||||
});
|
||||
// 有進度等你處理的優先,其次已 clone,其次最近活動
|
||||
arr.sort((a, b) =>
|
||||
b.leo_count - a.leo_count ||
|
||||
Number(b.cloned) - Number(a.cloned) ||
|
||||
(b.last_activity ?? '').localeCompare(a.last_activity ?? ''),
|
||||
);
|
||||
return arr;
|
||||
}, [repos, filter]);
|
||||
|
||||
// 待辦按 suggested_repo 分組(outliner:Repo 父 → todo 子)
|
||||
const todosByRepo = useMemo(() => {
|
||||
const m = new Map<string, Todo[]>();
|
||||
for (const t of todos) {
|
||||
const key = t.suggested_repo || 'new';
|
||||
if (!m.has(key)) m.set(key, []);
|
||||
m.get(key)!.push(t);
|
||||
}
|
||||
return m;
|
||||
}, [todos]);
|
||||
|
||||
const newTodos = todosByRepo.get('new') ?? [];
|
||||
|
||||
return (
|
||||
<main className="mira-page">
|
||||
<div className="mira-content">
|
||||
<header className="mira-proj-header">
|
||||
<h1 className="mira-proj-title">📋 專案總管</h1>
|
||||
<p className="mira-proj-sub">已 clone 到工作台的 repo · 🤖 Mira 做 / 👤 等你</p>
|
||||
<div className="mira-proj-controls">
|
||||
<div className="mira-proj-sort">
|
||||
{([['sdd', '有進度'], ['all', '全部']] as [Filter, string][]).map(([k, label]) => (
|
||||
<button
|
||||
key={k}
|
||||
className={`mira-proj-sort-btn${filter === k ? ' is-active' : ''}`}
|
||||
onClick={() => setFilter(k)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mira-proj-add">
|
||||
<input
|
||||
className="mira-proj-add-input"
|
||||
placeholder="加 repo:name 或 owner/name"
|
||||
value={newRepo}
|
||||
onChange={e => setNewRepo(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') addRepo(); }}
|
||||
/>
|
||||
<button className="mira-proj-add-btn" onClick={addRepo} disabled={!!cloning || !newRepo.trim()}>
|
||||
{cloning ? 'clone 中…' : '↓ clone'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && (
|
||||
<div className="mira-card mira-proj-error">
|
||||
<strong>讀取 repo 清單失敗:</strong>{error}
|
||||
<div className="mira-proj-error-hint">
|
||||
掃描器在 mira daemon({DAEMON}/projects)。確認 daemon 已部署掃描 endpoint(階段 10-A)。
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && repos === null && <div className="mira-proj-loading">載入中…</div>}
|
||||
{!error && repos?.length === 0 && (
|
||||
<div className="mira-card mira-proj-empty">GitHub 沒掃到任何 repo。</div>
|
||||
)}
|
||||
|
||||
<div className="mira-proj-grid">
|
||||
{shown.map(r => (
|
||||
<RepoCard
|
||||
key={r.full_name}
|
||||
r={r}
|
||||
cloning={cloning === r.full_name}
|
||||
onClone={() => clone(r.full_name)}
|
||||
todos={todosByRepo.get(r.name) ?? []}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{newTodos.length > 0 && (
|
||||
<div className="mira-card mira-proj-new-group">
|
||||
<div className="mira-proj-new-title">💡 建議新建專案({newTodos.length})</div>
|
||||
<ul className="mira-proj-todo-list">
|
||||
{newTodos.map(t => (
|
||||
<li key={t.id} className="mira-proj-todo-item">
|
||||
<span className="mira-proj-todo-dot">·</span> {t.content}
|
||||
{t.raw_id && (
|
||||
<a className="mira-proj-todo-src" href={`/mira/feed#raw=${t.raw_id}`}>來自河道 ↗</a>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function pct(r: RepoSummary): number {
|
||||
return r.total === 0 ? 0 : Math.round((r.done / r.total) * 100);
|
||||
}
|
||||
|
||||
function relTime(iso: string | null): string {
|
||||
if (!iso) return '—';
|
||||
const t = new Date(iso).getTime();
|
||||
if (Number.isNaN(t)) return '—';
|
||||
const days = Math.floor((Date.now() - t) / 86400000);
|
||||
if (days <= 0) return '今天';
|
||||
if (days === 1) return '昨天';
|
||||
if (days < 30) return `${days} 天前`;
|
||||
return `${Math.floor(days / 30)} 個月前`;
|
||||
}
|
||||
|
||||
function RepoCard({ r, cloning, onClone, todos }: { r: RepoSummary; cloning: boolean; onClone: () => void; todos: Todo[] }) {
|
||||
const percent = pct(r);
|
||||
return (
|
||||
<div className="mira-card mira-proj-card">
|
||||
<div className="mira-proj-card-top">
|
||||
<span className="mira-proj-name">
|
||||
{r.name}
|
||||
{r.fork && <span className="mira-proj-tag">fork</span>}
|
||||
{r.archived && <span className="mira-proj-tag">archived</span>}
|
||||
</span>
|
||||
<span className="mira-proj-time mira-en">{relTime(r.last_activity)}</span>
|
||||
</div>
|
||||
<div className="mira-proj-path mira-en">{r.full_name}</div>
|
||||
|
||||
{/* 河道偵測到、建議整進此專案的待辦(outliner 子項)*/}
|
||||
{todos.length > 0 && (
|
||||
<ul className="mira-proj-todo-list">
|
||||
{todos.map(t => (
|
||||
<li key={t.id} className="mira-proj-todo-item">
|
||||
<span className="mira-proj-todo-dot">·</span> {t.content}
|
||||
{t.raw_id && (
|
||||
<a className="mira-proj-todo-src" href={`/mira/feed#raw=${t.raw_id}`}>來自河道 ↗</a>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{!r.cloned ? (
|
||||
<div className="mira-proj-uncloned">
|
||||
<span className="mira-proj-uncloned-hint">尚未 clone 到 Hetzner</span>
|
||||
<button className="mira-proj-clone-btn" onClick={onClone} disabled={cloning}>
|
||||
{cloning ? 'clone 中…' : '↓ clone 進工作態'}
|
||||
</button>
|
||||
</div>
|
||||
) : !r.has_sdd ? (
|
||||
<div className="mira-proj-nosdd">已 clone · 無 SDD 進度追蹤(無 .agents/specs)</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mira-proj-bar">
|
||||
<div className="mira-proj-bar-fill" style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
<div className="mira-proj-stats">
|
||||
<span className="mira-proj-pct mira-en">{percent}%</span>
|
||||
<span className="mira-en">{r.done}/{r.total}</span>
|
||||
{r.leo_count > 0 && <span className="mira-proj-chip mira-chip-leo">👤 {r.leo_count} 等你</span>}
|
||||
{r.ai_count > 0 && <span className="mira-proj-chip mira-chip-ai">🤖 {r.ai_count} Mira</span>}
|
||||
{r.blocked_count > 0 && <span className="mira-proj-chip mira-chip-blocked">⛔ {r.blocked_count}</span>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
'use client';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
// Mira 搜尋頁 — Karpathy index pattern 為 primary(leo 2026-05-23)
|
||||
// SDD: polaris/mira/.agents/specs/mira-app/design.md §3.5.12.4「Karpathy index pattern(不用 vector embedding)」
|
||||
// 三層(C 混合):
|
||||
// 1. Index 即時文字比對:掃 index-entry(entity 名 + 摘要)子字串命中 → 列 entity(零 token)
|
||||
// 2. LLM 路由(選用):整個 index 餵 Claude,問「leo 想找哪些 entity」→ 最貼 Karpathy 本意
|
||||
// 3. 向量兜底(折疊):KBDB /search semantic,SDD 明文「不是 primary,當保險」
|
||||
|
||||
import { Suspense, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { MarkdownView } from '../_shared/markdown';
|
||||
import '../mira.css';
|
||||
|
||||
const KBDB_BASE = 'https://kbdb.finally.click';
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? 'https://cypher.arcrun.dev';
|
||||
const CLAUDE_API = 'https://claude-api.arcrun.dev';
|
||||
|
||||
// 繁體異體字正規化(臺→台),讓 query 對得上多用「台」的 KB 內容
|
||||
function normalizeQuery(q: string): string {
|
||||
return q.replace(/臺/g, '台');
|
||||
}
|
||||
|
||||
type IndexEntry = {
|
||||
entity: string; // H1 / page_name 去 index- 前綴
|
||||
pageName: string; // index-entry 自己的 page_name(index-{entity})
|
||||
oneLiner: string; // 「一句話定義」
|
||||
outline: string; // facet outline 全文(拿來比對 + 餵 LLM)
|
||||
raw: string; // 完整 content(餵 LLM 用,截斷)
|
||||
};
|
||||
|
||||
// 解析 index-entry markdown → 結構
|
||||
function parseIndexEntry(content: string, pageName: string): IndexEntry {
|
||||
const entity = (content.match(/^#\s+(.+)$/m)?.[1] ?? pageName.replace(/^index-/, '')).trim();
|
||||
const oneLiner = (content.match(/##\s*一句話定義\s*\n+([^\n#]+)/)?.[1] ?? '').trim();
|
||||
const outlineMatch = content.match(/##\s*段落 outline[^\n]*\n([\s\S]*?)(?=\n##|$)/);
|
||||
const outline = (outlineMatch?.[1] ?? '').trim();
|
||||
return { entity, pageName, oneLiner, outline, raw: content.slice(0, 700) };
|
||||
}
|
||||
|
||||
// entity 名 → wiki page 路由(wiki-{entity})
|
||||
function wikiHref(entity: string): string {
|
||||
return `/mira/wiki/${encodeURIComponent('wiki-' + entity)}`;
|
||||
}
|
||||
|
||||
// ── 向量兜底型別 ──
|
||||
type SearchMatch = {
|
||||
score: number;
|
||||
type: 'block' | 'triplet';
|
||||
metadata?: { entity?: string;[k: string]: unknown };
|
||||
block: { id: string; page_name: string | null; content: string | null; type: string; source: string | null } | null;
|
||||
triplet: { id: string; subject?: string; predicate?: string; object?: string } | null;
|
||||
};
|
||||
|
||||
function SearchInner() {
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
const initialQ = params.get('q') ?? '';
|
||||
|
||||
const [apiKey, setApiKey] = useState<string | null>(null);
|
||||
const [input, setInput] = useState(initialQ);
|
||||
const [query, setQuery] = useState(initialQ.trim());
|
||||
const [index, setIndex] = useState<IndexEntry[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// LLM 路由結果
|
||||
const [llmEntities, setLlmEntities] = useState<{ entity: string; reason: string }[] | null>(null);
|
||||
const [llmLoading, setLlmLoading] = useState(false);
|
||||
|
||||
// 向量兜底
|
||||
const [vecMatches, setVecMatches] = useState<SearchMatch[] | null>(null);
|
||||
const [vecLoading, setVecLoading] = useState(false);
|
||||
const [vecOpen, setVecOpen] = useState(false);
|
||||
|
||||
// 載入:me + 全部 index-entry
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const meRes = await fetch(`${API_BASE}/me`, { credentials: 'include' });
|
||||
if (!meRes.ok) { window.location.href = '/login?redirect=/mira/search'; return; }
|
||||
const me = (await meRes.json()) as { api_key: string };
|
||||
setApiKey(me.api_key);
|
||||
const r = await fetch(`${KBDB_BASE}/blocks?type=index-entry&limit=300`, {
|
||||
headers: { Authorization: `Bearer ${me.api_key}` },
|
||||
});
|
||||
if (!r.ok) { setError(`index 讀取失敗:${r.status}`); return; }
|
||||
const data = (await r.json()) as { blocks?: { content: string; page_name: string }[] };
|
||||
setIndex((data.blocks ?? []).map(b => parseIndexEntry(b.content || '', b.page_name || '')));
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// 即時 index 文字比對(純 client,零 token)
|
||||
const indexHits = useMemo(() => {
|
||||
if (!index || !query) return [];
|
||||
const q = normalizeQuery(query).toLowerCase();
|
||||
const terms = q.split(/\s+/).filter(Boolean);
|
||||
const scored = index
|
||||
.map(e => {
|
||||
const hay = normalizeQuery(`${e.entity}\n${e.oneLiner}\n${e.outline}`).toLowerCase();
|
||||
let score = 0;
|
||||
for (const t of terms) {
|
||||
if (e.entity.toLowerCase().includes(t)) score += 10; // entity 名命中權重高
|
||||
else if (hay.includes(t)) score += 3;
|
||||
}
|
||||
return { e, score };
|
||||
})
|
||||
.filter(x => x.score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 12);
|
||||
return scored.map(x => x.e);
|
||||
}, [index, query]);
|
||||
|
||||
const submit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const q = input.trim();
|
||||
setQuery(q);
|
||||
setLlmEntities(null);
|
||||
setVecMatches(null);
|
||||
setVecOpen(false);
|
||||
router.replace(`/mira/search?q=${encodeURIComponent(q)}`);
|
||||
};
|
||||
|
||||
// LLM 路由:整個 index 餵 Claude
|
||||
const runLlmRoute = useCallback(async () => {
|
||||
if (!index || !query || llmLoading) return;
|
||||
setLlmLoading(true);
|
||||
setLlmEntities(null);
|
||||
try {
|
||||
const indexDigest = index
|
||||
.map(e => `- ${e.entity}:${e.oneLiner || '(無摘要)'}`)
|
||||
.join('\n');
|
||||
const prompt =
|
||||
`你是 leo 知識庫的索引導航員。以下是所有 wiki entity 的索引(entity:一句話定義):\n\n` +
|
||||
`${indexDigest}\n\n---\n\n` +
|
||||
`leo 想找:「${query}」\n\n` +
|
||||
`請從上面索引挑出最相關的 entity(最多 6 個,可能 0 個)。` +
|
||||
`只輸出 JSON 陣列,格式 [{"entity":"<完全照抄索引裡的名稱>","reason":"<為何相關,20字內>"}],不要其他文字。`;
|
||||
const res = await fetch(CLAUDE_API, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prompt, timeout_ms: 45000 }),
|
||||
});
|
||||
const data = (await res.json()) as { success?: boolean; data?: { text?: string } };
|
||||
const text = data.data?.text ?? '';
|
||||
const jsonMatch = text.match(/\[[\s\S]*\]/);
|
||||
const parsed = jsonMatch ? JSON.parse(jsonMatch[0]) as { entity: string; reason: string }[] : [];
|
||||
// 只留實際存在於 index 的 entity
|
||||
const known = new Set(index.map(e => e.entity));
|
||||
setLlmEntities(parsed.filter(p => known.has(p.entity)));
|
||||
} catch {
|
||||
setLlmEntities([]);
|
||||
} finally {
|
||||
setLlmLoading(false);
|
||||
}
|
||||
}, [index, query, llmLoading]);
|
||||
|
||||
// 向量兜底
|
||||
const runVecSearch = useCallback(async () => {
|
||||
if (!apiKey || !query || vecLoading) return;
|
||||
setVecOpen(true);
|
||||
setVecLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${KBDB_BASE}/search`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
||||
body: JSON.stringify({ query: normalizeQuery(query), type: 'semantic', topK: 12 }),
|
||||
});
|
||||
const data = (await res.json()) as { matches?: SearchMatch[] };
|
||||
setVecMatches(data.matches ?? []);
|
||||
} catch {
|
||||
setVecMatches([]);
|
||||
} finally {
|
||||
setVecLoading(false);
|
||||
}
|
||||
}, [apiKey, query, vecLoading]);
|
||||
|
||||
return (
|
||||
<main className="mira-page">
|
||||
<div className="mira-content">
|
||||
<header style={{ padding: '24px 0 8px' }}>
|
||||
<Link href="/mira/feed" style={{ color: '#888', fontSize: 14, textDecoration: 'none' }}>← 河道</Link>
|
||||
<h1 style={{ fontSize: 26, fontWeight: 700, color: '#fff', margin: '8px 0 0' }}>🔍 Wiki 搜尋</h1>
|
||||
<p style={{ color: '#888', fontSize: 12, marginTop: 4 }}>
|
||||
從 {index?.length ?? '…'} 個 wiki 主題的索引找(Karpathy index)
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form className="mira-search-form" onSubmit={submit}>
|
||||
<input
|
||||
className="mira-search-input"
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
placeholder="找主題(例:特化、台北大學、本地模型)"
|
||||
autoFocus
|
||||
/>
|
||||
<button type="submit" className="mira-btn-primary" disabled={!index}>搜尋</button>
|
||||
</form>
|
||||
|
||||
{error && <div className="mira-error" style={{ marginBottom: 10 }}>{error}</div>}
|
||||
{!index && !error && <div className="empty-state">載入索引中…</div>}
|
||||
|
||||
{index && query && (
|
||||
<>
|
||||
{/* 第 1 層:index 即時命中 */}
|
||||
<section style={{ marginTop: 8 }}>
|
||||
<div className="mira-search-section-head">📇 索引命中({indexHits.length})</div>
|
||||
{indexHits.length === 0 ? (
|
||||
<div className="empty-state" style={{ padding: '16px 0' }}>索引裡沒有直接命中的主題。</div>
|
||||
) : (
|
||||
indexHits.map(e => (
|
||||
<Link key={e.pageName} href={wikiHref(e.entity)} className="mira-search-result">
|
||||
<div className="mira-search-result-meta"><span>📚 {e.entity}</span></div>
|
||||
{e.oneLiner && <div className="mira-search-snippet">{e.oneLiner}</div>}
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 第 2 層:LLM 路由(選用) */}
|
||||
<section style={{ marginTop: 18 }}>
|
||||
{!llmEntities && (
|
||||
<button type="button" className="mira-search-llm-btn" onClick={runLlmRoute} disabled={llmLoading}>
|
||||
{llmLoading ? '🧠 Mira 翻索引中…' : '🧠 找不到?讓 Mira 讀整個索引幫你找'}
|
||||
</button>
|
||||
)}
|
||||
{llmEntities && (
|
||||
<>
|
||||
<div className="mira-search-section-head">🧠 Mira 從索引挑的({llmEntities.length})</div>
|
||||
{llmEntities.length === 0 ? (
|
||||
<div className="empty-state" style={{ padding: '12px 0' }}>Mira 也覺得索引裡沒有相關主題。</div>
|
||||
) : (
|
||||
llmEntities.map(p => (
|
||||
<Link key={p.entity} href={wikiHref(p.entity)} className="mira-search-result">
|
||||
<div className="mira-search-result-meta"><span>📚 {p.entity}</span></div>
|
||||
<div className="mira-search-snippet" style={{ color: 'var(--mira-text-3)' }}>{p.reason}</div>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 第 3 層:向量兜底(折疊) */}
|
||||
<section style={{ marginTop: 18 }}>
|
||||
{!vecOpen ? (
|
||||
<button type="button" className="mira-search-vec-toggle" onClick={runVecSearch}>
|
||||
▸ 也試試全文 / 語義搜尋(兜底)
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<div className="mira-search-section-head">🧬 語義兜底</div>
|
||||
{vecLoading && <div className="empty-state" style={{ padding: '12px 0' }}>搜尋中…</div>}
|
||||
{vecMatches && vecMatches.length === 0 && !vecLoading && (
|
||||
<div className="empty-state" style={{ padding: '12px 0' }}>沒有更多結果。</div>
|
||||
)}
|
||||
{vecMatches && vecMatches.map((m, i) => <VecResult key={i} match={m} />)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function VecResult({ match }: { match: SearchMatch }) {
|
||||
const pct = Math.round((match.score ?? 0) * 100);
|
||||
if (match.triplet) {
|
||||
const { subject, predicate, object } = match.triplet;
|
||||
return (
|
||||
<div className="mira-search-result">
|
||||
<div className="mira-search-result-meta"><span>關係</span>{pct > 0 && <span className="mira-search-score">{pct}%</span>}</div>
|
||||
<div className="mira-search-snippet" style={{ fontFamily: 'monospace' }}>{subject} ﹥﹥ {predicate} ﹥﹥ {object}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const b = match.block;
|
||||
if (!b) return null;
|
||||
const snippet = (b.content ?? '').replace(/\n+/g, ' ').slice(0, 200);
|
||||
const href = b.type === 'wiki-page' && b.page_name
|
||||
? `/mira/wiki/${encodeURIComponent(b.page_name)}`
|
||||
: b.page_name ? `/mira/feed#page=${encodeURIComponent(b.page_name)}` : `/mira/feed#raw=${encodeURIComponent(b.id)}`;
|
||||
return (
|
||||
<Link href={href} className="mira-search-result">
|
||||
<div className="mira-search-result-meta">
|
||||
<span>{b.type === 'wiki-page' ? '📚 Wiki' : '🌊 河道'}</span>
|
||||
{pct > 0 && <span className="mira-search-score">{pct}%</span>}
|
||||
</div>
|
||||
<div className="mira-search-snippet"><MarkdownView text={snippet + ((b.content ?? '').length > 200 ? '…' : '')} /></div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MiraSearchPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="empty-state">載入中…</div>}>
|
||||
<SearchInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
'use client';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
// Mira Wiki 單篇頁
|
||||
// SDD: polaris/mira/.agents/specs/mira-app/design.md §5.2 + §3.5.12
|
||||
// 對應 task: 7C.2 + 7B.3g
|
||||
// 路由:/mira/wiki/[pageName]
|
||||
// 顯示:wiki-page parent → wiki-paragraph children (按 facet 分區) → triplet grandchildren
|
||||
// 7B.3g 升級:樹狀渲染 + 折疊 + triplet 跨 wiki 連結化
|
||||
|
||||
import { useEffect, useMemo, useState, use } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { MarkdownView } from '../../_shared/markdown';
|
||||
import '../../mira.css';
|
||||
|
||||
const KBDB_BASE = 'https://kbdb.finally.click';
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? 'https://cypher.arcrun.dev';
|
||||
|
||||
type Block = {
|
||||
id: string;
|
||||
page_name: string | null;
|
||||
content: string;
|
||||
type: string;
|
||||
parent_id: string | null;
|
||||
tags_json: string | null;
|
||||
source: string | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
};
|
||||
|
||||
type FacetGroup = {
|
||||
facet: string;
|
||||
paragraphs: Array<{
|
||||
block: Block;
|
||||
triplets: Block[];
|
||||
}>;
|
||||
};
|
||||
|
||||
export default function WikiPagePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ pageName: string }>;
|
||||
}) {
|
||||
const { pageName } = use(params);
|
||||
const decodedName = decodeURIComponent(pageName);
|
||||
|
||||
const [block, setBlock] = useState<Block | null>(null);
|
||||
const [paragraphs, setParagraphs] = useState<Block[]>([]);
|
||||
const [triplets, setTriplets] = useState<Block[]>([]);
|
||||
const [entitySet, setEntitySet] = useState<Set<string>>(new Set());
|
||||
// Backlinks:所有提到此 entity 的 raw note(V3 wiki_synthesis 在 wiki-page tags 寫 raw:XXX)
|
||||
// 對應 leo 2026-05-17 #2 反饋:「從這本書的條目應該反向連到那篇筆記去」
|
||||
const [backlinkRaws, setBacklinkRaws] = useState<Block[]>([]);
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function load() {
|
||||
try {
|
||||
const meRes = await fetch(`${API_BASE}/me`, { credentials: 'include' });
|
||||
if (!meRes.ok) throw new Error('未登入');
|
||||
const me = (await meRes.json()) as { api_key: string };
|
||||
const headers = { Authorization: `Bearer ${me.api_key}` };
|
||||
|
||||
// 1. 抓 wiki-page parent block by page_name
|
||||
const pageRes = await fetch(
|
||||
`${KBDB_BASE}/blocks?page_name=${encodeURIComponent(decodedName)}&limit=1`,
|
||||
{ headers },
|
||||
);
|
||||
if (!pageRes.ok) throw new Error(`KBDB ${pageRes.status}`);
|
||||
const pageData = await pageRes.json();
|
||||
const wikiPage: Block | undefined = pageData.blocks?.[0];
|
||||
if (cancelled) return;
|
||||
if (!wikiPage) {
|
||||
setError(`找不到 wiki page:${decodedName}`);
|
||||
return;
|
||||
}
|
||||
setBlock(wikiPage);
|
||||
|
||||
// 2. 平行撈所有 wiki-paragraph + triplet + wiki-page(跨 wiki 連結用),客戶端 filter by parent_id
|
||||
// KBDB 沒 parent_id server filter(兼 tag filter 還有 KI-3 bug),用 source+type 取再 client-side filter
|
||||
const [paraRes, tripRes, pageListRes] = await Promise.all([
|
||||
fetch(`${KBDB_BASE}/blocks?source=ai-canon-wiki&type=wiki-paragraph&limit=500`, { headers }),
|
||||
fetch(`${KBDB_BASE}/blocks?source=ai-canon-wiki&type=triplet&limit=1000`, { headers }),
|
||||
fetch(`${KBDB_BASE}/blocks?source=ai-canon-wiki&type=wiki-page&limit=500`, { headers }),
|
||||
]);
|
||||
if (!paraRes.ok || !tripRes.ok || !pageListRes.ok) {
|
||||
throw new Error('KBDB tree fetch failed');
|
||||
}
|
||||
const paraData = await paraRes.json();
|
||||
const tripData = await tripRes.json();
|
||||
const pageListData = await pageListRes.json();
|
||||
if (cancelled) return;
|
||||
|
||||
const allParas: Block[] = paraData.blocks ?? [];
|
||||
const allTrips: Block[] = tripData.blocks ?? [];
|
||||
const allPages: Block[] = pageListData.blocks ?? [];
|
||||
|
||||
// 該 wiki-page 的 paragraphs
|
||||
const myParas = allParas
|
||||
.filter((p) => p.parent_id === wikiPage.id)
|
||||
.sort((a, b) => a.created_at - b.created_at);
|
||||
setParagraphs(myParas);
|
||||
|
||||
// 該 wiki-page 範圍內所有 paragraph 的 triplets
|
||||
const paraIdSet = new Set(myParas.map((p) => p.id));
|
||||
const myTrips = allTrips.filter((t) => t.parent_id && paraIdSet.has(t.parent_id));
|
||||
setTriplets(myTrips);
|
||||
|
||||
// 跨 wiki 連結用:所有 wiki-page 的 entity 名稱(content 就是 entity)
|
||||
// 額外把 page_name 也加入(page_name=wiki-{entity})
|
||||
const eset = new Set<string>();
|
||||
for (const p of allPages) {
|
||||
if (p.content) eset.add(p.content.trim());
|
||||
if (p.page_name?.startsWith('wiki-')) {
|
||||
eset.add(p.page_name.slice(5).trim());
|
||||
}
|
||||
}
|
||||
setEntitySet(eset);
|
||||
|
||||
// Backlinks:找此 entity 的所有 wiki-page (可能多次寫入),提取 raw:XXX tag → fetch raw blocks
|
||||
if (wikiPage.type === 'wiki-page' && wikiPage.content) {
|
||||
const sameEntity = allPages.filter((p) => p.content?.trim() === wikiPage.content?.trim());
|
||||
const rawIds = new Set<string>();
|
||||
for (const wp of sameEntity) {
|
||||
try {
|
||||
const tags = JSON.parse(wp.tags_json || '[]') as string[];
|
||||
for (const t of tags) {
|
||||
if (typeof t === 'string' && t.startsWith('raw:')) {
|
||||
rawIds.add(t.slice(4));
|
||||
}
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
if (rawIds.size > 0) {
|
||||
// 一次撈 raw blocks,page_name 是 unique 一次 query 一個
|
||||
const rawBlocks: Block[] = [];
|
||||
await Promise.all(
|
||||
Array.from(rawIds).map(async (rawId) => {
|
||||
try {
|
||||
// KBDB GET /blocks/:id 直接 by id (走 list with block_id filter)
|
||||
const r = await fetch(`${KBDB_BASE}/blocks/${rawId}`, { headers });
|
||||
if (r.ok) {
|
||||
const data = await r.json();
|
||||
const b = data.blocks?.[0] ?? data;
|
||||
if (b?.id) rawBlocks.push(b as Block);
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}),
|
||||
);
|
||||
if (!cancelled) {
|
||||
setBacklinkRaws(rawBlocks.sort((a, b) => b.updated_at - a.updated_at));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (!cancelled) setError(e?.message ?? 'load failed');
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}
|
||||
load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [decodedName]);
|
||||
|
||||
// 按 facet 分區
|
||||
const facetGroups = useMemo<FacetGroup[]>(() => {
|
||||
const groups: Map<string, Array<{ block: Block; triplets: Block[] }>> = new Map();
|
||||
for (const p of paragraphs) {
|
||||
const facet = extractFacet(p.tags_json) ?? '未分類';
|
||||
const myTrips = triplets.filter((t) => t.parent_id === p.id);
|
||||
if (!groups.has(facet)) groups.set(facet, []);
|
||||
groups.get(facet)!.push({ block: p, triplets: myTrips });
|
||||
}
|
||||
return Array.from(groups.entries()).map(([facet, paragraphs]) => ({ facet, paragraphs }));
|
||||
}, [paragraphs, triplets]);
|
||||
|
||||
const isWikiPage = block?.type === 'wiki-page';
|
||||
// 標題:wiki-page 用 content(entity 名稱),其他(index-entry/schema/log/...)用 page_name 剝 prefix
|
||||
// 修 bug:原本一律用 block.content,但 index-entry 的 content 是整篇 markdown,會把整個 content render 成 h1
|
||||
const entity = isWikiPage
|
||||
? (block?.content?.trim() || decodedName.replace(/^wiki-/, ''))
|
||||
: decodedName.replace(/^(wiki|index)-/, '');
|
||||
|
||||
function toggleCollapse(key: string) {
|
||||
setCollapsed((c) => ({ ...c, [key]: !c[key] }));
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mira-page">
|
||||
<div className="mira-content mira-wiki-detail">
|
||||
<header style={{ padding: '24px 0 16px', borderBottom: '1px solid #2a2a2a' }}>
|
||||
<Link
|
||||
href="/mira/wiki"
|
||||
style={{ color: '#888', fontSize: 14, textDecoration: 'none' }}
|
||||
>
|
||||
← Wiki 索引
|
||||
</Link>
|
||||
<h1 style={{ fontSize: 28, fontWeight: 700, color: '#fff', margin: '8px 0 4px' }}>
|
||||
{entity}
|
||||
</h1>
|
||||
{block && (
|
||||
<div style={{ color: '#666', fontSize: 12 }}>
|
||||
{block.type} ・ updated {new Date(block.updated_at * 1000).toLocaleString('zh-TW')}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{loading && <div style={{ padding: 24, color: '#666' }}>載入中⋯</div>}
|
||||
{error && <div style={{ padding: 24, color: '#e66' }}>{error}</div>}
|
||||
|
||||
{block && !loading && !error && (
|
||||
<>
|
||||
{/* wiki-page tree view */}
|
||||
{isWikiPage && facetGroups.length > 0 && (
|
||||
<article style={{ padding: '8px 0 24px' }}>
|
||||
{facetGroups.map((group) => (
|
||||
<FacetSection
|
||||
key={group.facet}
|
||||
group={group}
|
||||
entitySet={entitySet}
|
||||
collapsed={collapsed}
|
||||
toggleCollapse={toggleCollapse}
|
||||
/>
|
||||
))}
|
||||
</article>
|
||||
)}
|
||||
|
||||
{/* wiki-page 但沒 children:fallback render content */}
|
||||
{isWikiPage && facetGroups.length === 0 && (
|
||||
<article style={{ padding: '8px 0 24px', color: '#888' }}>
|
||||
<em>尚無段落(wiki_synthesis 還沒跑出 children)</em>
|
||||
<MarkdownView text={block.content} />
|
||||
</article>
|
||||
)}
|
||||
|
||||
{/* 非 wiki-page(schema / index / log / index-entry 等):直接 render content */}
|
||||
{!isWikiPage && (
|
||||
<article style={{ padding: '20px 0' }}>
|
||||
<MarkdownView text={block.content} />
|
||||
</article>
|
||||
)}
|
||||
|
||||
{/* Backlinks:提到此 entity 的 raw notes */}
|
||||
{isWikiPage && backlinkRaws.length > 0 && (
|
||||
<section
|
||||
style={{
|
||||
margin: '24px 0 16px',
|
||||
padding: '12px 14px',
|
||||
borderLeft: '3px solid #4a3a2a',
|
||||
background: 'rgba(80, 60, 40, 0.08)',
|
||||
}}
|
||||
>
|
||||
<h3 style={{ margin: '0 0 8px', fontSize: 14, color: '#aab', fontWeight: 600 }}>
|
||||
📎 提到此 entity 的筆記 ({backlinkRaws.length})
|
||||
</h3>
|
||||
<ul style={{ margin: 0, paddingLeft: 18, fontSize: 13, lineHeight: 1.6 }}>
|
||||
{backlinkRaws.map((raw) => {
|
||||
const preview = (raw.content || '').replace(/\n/g, ' ').slice(0, 100);
|
||||
const href = `/mira/feed#page=${encodeURIComponent(raw.page_name || raw.id)}`;
|
||||
return (
|
||||
<li key={raw.id} style={{ marginBottom: 4 }}>
|
||||
<a
|
||||
href={href}
|
||||
style={{ color: '#9ab', textDecoration: 'none' }}
|
||||
title={raw.content || ''}
|
||||
>
|
||||
{preview}
|
||||
{(raw.content || '').length > 100 && '…'}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<footer
|
||||
style={{
|
||||
padding: '20px 0',
|
||||
borderTop: '1px solid #1f1f1f',
|
||||
color: '#555',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<div>id: <span style={{ fontFamily: 'monospace' }}>{block.id}</span></div>
|
||||
<div>type: {block.type}</div>
|
||||
{block.source && <div>source: {block.source}</div>}
|
||||
{block.parent_id && (
|
||||
<div>
|
||||
parent: <span style={{ fontFamily: 'monospace' }}>{block.parent_id}</span>
|
||||
</div>
|
||||
)}
|
||||
{paragraphs.length > 0 && (
|
||||
<div>
|
||||
{paragraphs.length} paragraph(s) ・ {triplets.length} triplet(s)
|
||||
</div>
|
||||
)}
|
||||
</footer>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function FacetSection({
|
||||
group,
|
||||
entitySet,
|
||||
collapsed,
|
||||
toggleCollapse,
|
||||
}: {
|
||||
group: FacetGroup;
|
||||
entitySet: Set<string>;
|
||||
collapsed: Record<string, boolean>;
|
||||
toggleCollapse: (key: string) => void;
|
||||
}) {
|
||||
const key = `facet:${group.facet}`;
|
||||
const isCollapsed = collapsed[key] ?? false; // 預設展開(leo 看一篇 wiki 時要看內容)
|
||||
|
||||
return (
|
||||
<section style={{ margin: '16px 0', borderLeft: '3px solid #2a3a4a', paddingLeft: 14 }}>
|
||||
<button
|
||||
onClick={() => toggleCollapse(key)}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#aab',
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
padding: '4px 0',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{isCollapsed ? '▸' : '▾'} {group.facet}
|
||||
<span style={{ color: '#555', fontWeight: 400, fontSize: 13, marginLeft: 8 }}>
|
||||
({group.paragraphs.length})
|
||||
</span>
|
||||
</button>
|
||||
{!isCollapsed &&
|
||||
group.paragraphs.map((p) => (
|
||||
<ParagraphBlock
|
||||
key={p.block.id}
|
||||
block={p.block}
|
||||
triplets={p.triplets}
|
||||
entitySet={entitySet}
|
||||
collapsed={collapsed}
|
||||
toggleCollapse={toggleCollapse}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ParagraphBlock({
|
||||
block,
|
||||
triplets,
|
||||
entitySet,
|
||||
collapsed,
|
||||
toggleCollapse,
|
||||
}: {
|
||||
block: Block;
|
||||
triplets: Block[];
|
||||
entitySet: Set<string>;
|
||||
collapsed: Record<string, boolean>;
|
||||
toggleCollapse: (key: string) => void;
|
||||
}) {
|
||||
const tripKey = `trip:${block.id}`;
|
||||
const tripsCollapsed = collapsed[tripKey] ?? true; // triplets 預設折疊
|
||||
|
||||
return (
|
||||
<div style={{ margin: '12px 0 16px', paddingLeft: 4 }}>
|
||||
<div style={{ color: '#ddd', lineHeight: 1.7 }}>
|
||||
<MarkdownView text={block.content} />
|
||||
</div>
|
||||
{triplets.length > 0 && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<button
|
||||
onClick={() => toggleCollapse(tripKey)}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#666',
|
||||
fontSize: 12,
|
||||
padding: '2px 0',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{tripsCollapsed ? '▸' : '▾'} 關係 ({triplets.length})
|
||||
</button>
|
||||
{!tripsCollapsed && (
|
||||
<ul style={{ listStyle: 'none', padding: '4px 0 0 12px', margin: 0 }}>
|
||||
{triplets.map((t) => (
|
||||
<li
|
||||
key={t.id}
|
||||
style={{
|
||||
color: '#888',
|
||||
fontSize: 13,
|
||||
padding: '2px 0',
|
||||
fontFamily: 'monospace',
|
||||
}}
|
||||
>
|
||||
<TripletRender content={t.content} entitySet={entitySet} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Render triplet "A >> 關係 >> B" with A/B linkified if they match an existing wiki entity */
|
||||
function TripletRender({
|
||||
content,
|
||||
entitySet,
|
||||
}: {
|
||||
content: string;
|
||||
entitySet: Set<string>;
|
||||
}) {
|
||||
// 切「>>」分 A / 關係 / B
|
||||
const parts = content.split('>>').map((s) => s.trim());
|
||||
if (parts.length !== 3) {
|
||||
return <>{content}</>;
|
||||
}
|
||||
const [a, rel, b] = parts;
|
||||
return (
|
||||
<>
|
||||
<EntityLink name={a} entitySet={entitySet} />{' '}
|
||||
<span style={{ color: '#666' }}>>> {rel} >></span>{' '}
|
||||
<EntityLink name={b} entitySet={entitySet} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function EntityLink({ name, entitySet }: { name: string; entitySet: Set<string> }) {
|
||||
if (entitySet.has(name)) {
|
||||
return (
|
||||
<Link
|
||||
href={`/mira/wiki/${encodeURIComponent(`wiki-${name}`)}`}
|
||||
style={{ color: '#88c0ff', textDecoration: 'none' }}
|
||||
>
|
||||
{name}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return <span style={{ color: '#ccc' }}>{name}</span>;
|
||||
}
|
||||
|
||||
function extractFacet(tags_json: string | null | undefined): string | null {
|
||||
if (!tags_json) return null;
|
||||
try {
|
||||
const tags = JSON.parse(tags_json) as string[];
|
||||
const facetTag = tags.find((t) => t.startsWith('facet:'));
|
||||
return facetTag ? facetTag.slice(6) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
'use client';
|
||||
|
||||
// Mira Wiki 索引頁
|
||||
// SDD: polaris/mira/.agents/specs/mira-app/design.md §5.2 + §3.5.10
|
||||
// 對應 task: 7C.1
|
||||
// 階段 7-A 已建:mira-wiki-schema、mira-wiki-index(+4 children)、mira-wiki-log(+1 child)
|
||||
// 此頁列出這些 infra block 與既有 wiki-page,方便 leo 在瀏覽器確認 schema 寫得對不對
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import '../mira.css';
|
||||
|
||||
const KBDB_BASE = 'https://kbdb.finally.click';
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? 'https://cypher.arcrun.dev';
|
||||
|
||||
type Block = {
|
||||
id: string;
|
||||
page_name: string;
|
||||
content: string;
|
||||
type: string;
|
||||
parent_id: string | null;
|
||||
tags_json: string | null;
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
export default function WikiIndexPage() {
|
||||
const [schema, setSchema] = useState<Block | null>(null);
|
||||
const [indexChildren, setIndexChildren] = useState<Block[]>([]);
|
||||
const [logEntries, setLogEntries] = useState<Block[]>([]);
|
||||
const [otherWikiPages, setOtherWikiPages] = useState<Block[]>([]);
|
||||
const [indexEntries, setIndexEntries] = useState<Block[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function load() {
|
||||
try {
|
||||
// 先拿 ak_ partner key(同 page.tsx pattern)
|
||||
const meRes = await fetch(`${API_BASE}/me`, { credentials: 'include' });
|
||||
if (!meRes.ok) throw new Error('未登入');
|
||||
const me = (await meRes.json()) as { api_key: string };
|
||||
const headers = { Authorization: `Bearer ${me.api_key}` };
|
||||
|
||||
// 撈所有 type=wiki-page,再 client 端過濾 tags 含 'mira-wiki'
|
||||
// 原本 ?tag=mira-wiki 撞 KBDB worker D1 bug(malformed JSON),改 type filter
|
||||
// 待 KBDB 修 tag filter 後可改回(SDD 待開 kbdb-tag-filter-fix)
|
||||
const res = await fetch(
|
||||
`${KBDB_BASE}/blocks?type=wiki-page&limit=200`,
|
||||
{ headers },
|
||||
);
|
||||
if (!res.ok) throw new Error(`KBDB ${res.status}`);
|
||||
const data = await res.json();
|
||||
if (cancelled) return;
|
||||
const allWikiBlocks: Block[] = data.blocks ?? [];
|
||||
// Client 端過濾:只留 tags 含 'mira-wiki'
|
||||
const blocks: Block[] = allWikiBlocks.filter((b) => {
|
||||
if (!b.tags_json) return false;
|
||||
try {
|
||||
const tags = JSON.parse(b.tags_json) as string[];
|
||||
return tags.includes('mira-wiki');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
const tagsOf = (b: Block): string[] => {
|
||||
if (!b.tags_json) return [];
|
||||
try {
|
||||
return JSON.parse(b.tags_json) as string[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
const hasSubtype = (b: Block, st: string) =>
|
||||
tagsOf(b).includes(`subtype:${st}`);
|
||||
const hasAnyInfraSubtype = (b: Block) =>
|
||||
['schema', 'index', 'index-child', 'log', 'log-child'].some((st) => hasSubtype(b, st));
|
||||
const hasMetaTag = (b: Block) =>
|
||||
tagsOf(b).some((t) => t === 'data-source-config' || t === 'source-skill');
|
||||
|
||||
setSchema(blocks.find((b) => hasSubtype(b, 'schema')) ?? null);
|
||||
setIndexChildren(
|
||||
blocks
|
||||
.filter((b) => hasSubtype(b, 'index-child'))
|
||||
.sort((a, b) => a.page_name.localeCompare(b.page_name)),
|
||||
);
|
||||
setLogEntries(
|
||||
blocks
|
||||
.filter((b) => hasSubtype(b, 'log-child'))
|
||||
.sort((a, b) => b.page_name.localeCompare(a.page_name)),
|
||||
);
|
||||
// 真正的 wiki-page paragraphs(排除 infra 跟 meta 配置)
|
||||
setOtherWikiPages(
|
||||
blocks
|
||||
.filter((b) => !hasAnyInfraSubtype(b) && !hasMetaTag(b))
|
||||
.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)),
|
||||
);
|
||||
|
||||
// 平行撈 index-entry blocks(per-entity 摘要,CC navigation entry point)
|
||||
// 對應 design.md §3.5.12.4.1 / 7B.3f
|
||||
const idxRes = await fetch(
|
||||
`${KBDB_BASE}/blocks?type=index-entry&limit=200`,
|
||||
{ headers },
|
||||
);
|
||||
if (idxRes.ok) {
|
||||
const idxData = await idxRes.json();
|
||||
if (!cancelled) {
|
||||
const idxBlocks: Block[] = idxData.blocks ?? [];
|
||||
setIndexEntries(
|
||||
idxBlocks.sort((a, b) => (a.page_name ?? '').localeCompare(b.page_name ?? '')),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (!cancelled) setError(e?.message ?? 'load failed');
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}
|
||||
load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Dedupe wiki-pages by entity(content)— 累積式設計每個 raw 各建一個 wiki-page,
|
||||
// 同 entity 多版只在 listing 顯示最新一張卡 + 版本數提示
|
||||
const dedupedWikiPages = useMemo(() => {
|
||||
const groups = new Map<string, { entity: string; latest: Block; versionCount: number }>();
|
||||
for (const p of otherWikiPages) {
|
||||
const entity = (p.content || '').trim() || p.page_name || '?';
|
||||
const existing = groups.get(entity);
|
||||
if (!existing) {
|
||||
groups.set(entity, { entity, latest: p, versionCount: 1 });
|
||||
} else {
|
||||
existing.versionCount++;
|
||||
if ((p.created_at ?? 0) > (existing.latest.created_at ?? 0)) {
|
||||
existing.latest = p;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(groups.values()).sort(
|
||||
(a, b) => (b.latest.created_at ?? 0) - (a.latest.created_at ?? 0),
|
||||
);
|
||||
}, [otherWikiPages]);
|
||||
|
||||
return (
|
||||
<main className="mira-page">
|
||||
<div className="mira-content">
|
||||
<header style={{ padding: '24px 0 16px', borderBottom: '1px solid #2a2a2a' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 4 }}>
|
||||
<Link
|
||||
href="/mira"
|
||||
style={{ color: '#888', fontSize: 14, textDecoration: 'none' }}
|
||||
>
|
||||
← Mira 首頁
|
||||
</Link>
|
||||
</div>
|
||||
<h1 style={{ fontSize: 28, fontWeight: 700, color: '#fff', margin: 0 }}>
|
||||
📚 Mira Wiki
|
||||
</h1>
|
||||
<p style={{ color: '#888', fontSize: 14, marginTop: 4 }}>
|
||||
leo 的個人觀點累積(Karpathy LLM Wiki 風格)
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{loading && <div style={{ padding: 24, color: '#666' }}>載入中⋯</div>}
|
||||
{error && (
|
||||
<div style={{ padding: 24, color: '#e66' }}>讀取失敗:{error}</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
<Section title="📋 Schema(合成規則)">
|
||||
{schema ? (
|
||||
<WikiCardLink page_name={schema.page_name} title="mira-wiki-schema" excerpt="ingest 規則手冊:cypher binding、17 predicates、entity normalize⋯" />
|
||||
) : (
|
||||
<Empty>尚未建立 schema</Empty>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="🗂 Index(4 個分類)">
|
||||
{indexChildren.length > 0 ? (
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
{indexChildren.map((b) => {
|
||||
const tags = b.tags_json ? (JSON.parse(b.tags_json) as string[]) : [];
|
||||
const key = tags.find((t) => t.startsWith('index-key:'))?.replace('index-key:', '') ?? '?';
|
||||
return (
|
||||
<WikiCardLink
|
||||
key={b.id}
|
||||
page_name={b.page_name}
|
||||
title={`${iconForKey(key)} ${key}`}
|
||||
excerpt={firstLineOf(b.content)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<Empty>index children 尚未建立</Empty>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title={`🧭 Index Entries(${indexEntries.length})— CC 看的 entity 摘要`}>
|
||||
{indexEntries.length > 0 ? (
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
{indexEntries.map((b) => {
|
||||
const entity = (b.page_name ?? '').replace(/^index-/, '');
|
||||
const firstLine = firstLineOf(b.content)
|
||||
.replace(/^#+\s*/, '')
|
||||
.slice(0, 80);
|
||||
return (
|
||||
<WikiCardLink
|
||||
key={b.id}
|
||||
page_name={b.page_name ?? ''}
|
||||
title={entity}
|
||||
excerpt={firstLine || `index for ${entity}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<Empty>尚未有 index-entry(wiki_synthesis 跑完後自動建)</Empty>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="📜 Log(每月一筆)">
|
||||
{logEntries.length > 0 ? (
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
{logEntries.map((b) => (
|
||||
<WikiCardLink
|
||||
key={b.id}
|
||||
page_name={b.page_name}
|
||||
title={b.page_name}
|
||||
excerpt={firstLineOf(b.content)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Empty>尚未有 log</Empty>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title={`📖 Wiki Pages(${dedupedWikiPages.length},原 ${otherWikiPages.length} 筆累積版本)`}>
|
||||
{dedupedWikiPages.length > 0 ? (
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
{dedupedWikiPages.map((g) => (
|
||||
<WikiCardLink
|
||||
key={g.latest.id}
|
||||
page_name={g.latest.page_name}
|
||||
title={g.entity}
|
||||
excerpt={
|
||||
g.versionCount > 1
|
||||
? `${g.versionCount} 版累積 ・ 最新 ${new Date((g.latest.created_at ?? 0) * 1000).toLocaleString('zh-TW')}`
|
||||
: `建立 ${new Date((g.latest.created_at ?? 0) * 1000).toLocaleString('zh-TW')}`
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Empty>尚未有 wiki page(待 7-B ai-canon-wiki workflow 跑出第一張)</Empty>
|
||||
)}
|
||||
</Section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section style={{ padding: '20px 0', borderBottom: '1px solid #1f1f1f' }}>
|
||||
<h2 style={{ fontSize: 16, fontWeight: 600, color: '#ddd', marginBottom: 12 }}>
|
||||
{title}
|
||||
</h2>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Empty({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{ color: '#555', fontStyle: 'italic', fontSize: 13 }}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WikiCardLink({
|
||||
page_name,
|
||||
title,
|
||||
excerpt,
|
||||
}: {
|
||||
page_name: string;
|
||||
title: string;
|
||||
excerpt: string;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={`/mira/wiki/${encodeURIComponent(page_name)}`}
|
||||
style={{
|
||||
display: 'block',
|
||||
padding: '12px 14px',
|
||||
background: '#1a1a1a',
|
||||
border: '1px solid #2a2a2a',
|
||||
borderRadius: 6,
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
}}
|
||||
>
|
||||
<div style={{ color: '#ddd', fontWeight: 500, marginBottom: 4 }}>{title}</div>
|
||||
{excerpt && (
|
||||
<div
|
||||
style={{
|
||||
color: '#888',
|
||||
fontSize: 13,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{excerpt}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function iconForKey(key: string): string {
|
||||
return (
|
||||
{
|
||||
entities: '🧩',
|
||||
topics: '📂',
|
||||
sources: '🔗',
|
||||
stale: '⚠️',
|
||||
}[key] ?? '•'
|
||||
);
|
||||
}
|
||||
|
||||
function firstLineOf(content: string): string {
|
||||
if (!content) return '';
|
||||
const firstNonHeader = content
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.find((l) => l && !l.startsWith('#') && !l.startsWith('>'));
|
||||
return firstNonHeader ?? '';
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import SiteNav from './components/SiteNav';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? 'https://cypher.arcrun.dev';
|
||||
|
||||
const CODE_DEMOS = {
|
||||
python: `pip install arcrun
|
||||
|
||||
from arcrun import Arcrun
|
||||
|
||||
client = Arcrun() # reads ARCRUN_API_KEY from env
|
||||
|
||||
# One-time setup: upload credential
|
||||
client.auth.setup("notion", token="secret_xxx")
|
||||
|
||||
# Every time after: just bind and use
|
||||
notion = client.auth.bind("notion")
|
||||
pages = notion.get("/pages").json()
|
||||
|
||||
# Works with any of 20+ services
|
||||
drive = client.auth.bind("google_drive_sa")
|
||||
files = drive.get("/files").json()`,
|
||||
|
||||
javascript: `npm install arcrun
|
||||
|
||||
import { Arcrun } from 'arcrun'
|
||||
|
||||
const client = new Arcrun() // reads ARCRUN_API_KEY from env
|
||||
|
||||
// One-time setup: upload credential
|
||||
await client.auth.setup('notion', { token: 'secret_xxx' })
|
||||
|
||||
// Every time after: just bind and use
|
||||
const notion = await client.auth.bind('notion')
|
||||
const pages = await (await notion.get('/pages')).json()
|
||||
|
||||
// Run a deployed workflow
|
||||
const result = await client.workflows.run('my-flow', {
|
||||
email: 'user@example.com'
|
||||
})`,
|
||||
|
||||
http: `# Works with any HTTP tool — curl, n8n, Make, Postman
|
||||
POST ${API_BASE}/webhooks/named/my-workflow/trigger
|
||||
X-Arcrun-API-Key: YOUR_API_KEY
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"email": "user@example.com"
|
||||
}
|
||||
|
||||
# n8n: HTTP Request 節點,貼上即用,不需要安裝任何東西`,
|
||||
};
|
||||
|
||||
export default function HomePage() {
|
||||
const [activeTab, setActiveTab] = useState<'python' | 'javascript' | 'http'>('python');
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/me`, { credentials: 'include' })
|
||||
.then(r => { if (r.ok) setIsLoggedIn(true); })
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-[#0a0a0a] text-[#ededed]">
|
||||
<SiteNav currentPath="/" />
|
||||
|
||||
{/* Hero */}
|
||||
<section className="flex flex-col items-center text-center px-6 pt-24 pb-16">
|
||||
<div className="inline-flex items-center gap-2 bg-[#1a1a2e] text-indigo-400 text-xs px-3 py-1 rounded-full mb-6 border border-indigo-900/50">
|
||||
<span className="w-1.5 h-1.5 bg-indigo-400 rounded-full animate-pulse inline-block"></span>
|
||||
Open Source · Free API Key · No Credit Card
|
||||
</div>
|
||||
|
||||
<h1 className="text-5xl md:text-6xl font-bold text-white mb-4 leading-tight max-w-3xl">
|
||||
Stop fighting OAuth.
|
||||
</h1>
|
||||
<p className="text-xl md:text-2xl text-[#888] mb-3 max-w-2xl">
|
||||
One API key. Every service. Works anywhere.
|
||||
</p>
|
||||
<p className="text-[#555] max-w-xl mb-10">
|
||||
arcrun handles Google, Notion, GitHub, Slack authentication
|
||||
so your Python / JS code doesn't have to.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-3 flex-wrap justify-center">
|
||||
<Link href={isLoggedIn ? '/dashboard' : '/login'}
|
||||
className="bg-indigo-600 hover:bg-indigo-500 text-white px-6 py-3 rounded-lg font-medium transition-colors">
|
||||
{isLoggedIn ? 'Go to Dashboard' : 'Get API Key — Free'}
|
||||
</Link>
|
||||
<a href="https://github.com/richblack/arcrun" target="_blank" rel="noopener noreferrer"
|
||||
className="border border-[#333] hover:border-[#555] text-[#aaa] hover:text-white px-6 py-3 rounded-lg font-medium transition-colors">
|
||||
View on GitHub
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Before / After */}
|
||||
<section className="max-w-4xl mx-auto px-6 pb-16 w-full">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
|
||||
<div className="bg-[#111] border border-[#222] rounded-xl p-6">
|
||||
<div className="text-[#555] text-xs mb-3 font-mono uppercase tracking-wider">Before</div>
|
||||
<div className="text-red-400 text-sm font-mono space-y-1 opacity-70">
|
||||
<div>40 行 OAuth 程式碼</div>
|
||||
<div>GCP Console 設定</div>
|
||||
<div>debug 兩天</div>
|
||||
<div>Service Account JSON</div>
|
||||
<div>token 過期再修一次</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-center text-[#333] text-4xl font-thin select-none">→</div>
|
||||
<div className="bg-[#111] border border-indigo-900/50 rounded-xl p-6">
|
||||
<div className="text-indigo-400 text-xs mb-3 font-mono uppercase tracking-wider">After</div>
|
||||
<pre className="text-green-400 text-sm font-mono leading-relaxed">
|
||||
{`from arcrun import auth
|
||||
|
||||
drive = auth.bind(
|
||||
"google_drive"
|
||||
)
|
||||
# done.`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Code Demo */}
|
||||
<section className="max-w-3xl mx-auto px-6 pb-20 w-full">
|
||||
<div className="bg-[#111] border border-[#222] rounded-xl overflow-hidden">
|
||||
<div className="flex border-b border-[#1e1e1e]">
|
||||
{(['python', 'javascript', 'http'] as const).map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`px-5 py-3 text-sm font-medium transition-colors cursor-pointer ${
|
||||
activeTab === tab
|
||||
? 'text-white border-b-2 border-indigo-500 bg-[#0d0d1a]'
|
||||
: 'text-[#555] hover:text-[#aaa]'
|
||||
}`}
|
||||
>
|
||||
{tab === 'python' ? 'Python' : tab === 'javascript' ? 'JavaScript' : 'HTTP / n8n'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="p-6 overflow-x-auto">
|
||||
<pre className="text-sm text-[#cdd6f4] leading-relaxed">
|
||||
<code>{CODE_DEMOS[activeTab]}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
{activeTab === 'http' && (
|
||||
<p className="text-[#444] text-sm mt-3 text-center">
|
||||
n8n 用戶:用 HTTP Request 節點,不需要安裝任何東西
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Features */}
|
||||
<section className="max-w-4xl mx-auto px-6 pb-20 w-full">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{[
|
||||
{ title: '20+ 服務開箱即用', desc: 'Google、Notion、GitHub、Slack、OpenAI、Stripe... 全部一個 bind() 搞定。', icon: '🔌' },
|
||||
{ title: 'AI-first 設計', desc: 'Token 消耗極小,YAML workflow 幾十個 token,讓 AI 直接讀寫執行。', icon: '🤖' },
|
||||
{ title: '完全開源', desc: 'MIT 授權。Self-host 在你自己的 Cloudflare,或使用我們的 hosted 服務。', icon: '🔓' },
|
||||
].map(f => (
|
||||
<div key={f.title} className="bg-[#111] border border-[#1e1e1e] rounded-xl p-6">
|
||||
<div className="text-2xl mb-3">{f.icon}</div>
|
||||
<h3 className="text-white font-semibold mb-2">{f.title}</h3>
|
||||
<p className="text-[#555] text-sm leading-relaxed">{f.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<section className="border-t border-[#1a1a1a] py-16 text-center px-6">
|
||||
<h2 className="text-3xl font-bold text-white mb-3">準備好了嗎?</h2>
|
||||
<p className="text-[#555] mb-8">免費取得 API Key,不需要信用卡。</p>
|
||||
<Link href={isLoggedIn ? '/dashboard' : '/login'}
|
||||
className="bg-indigo-600 hover:bg-indigo-500 text-white px-8 py-3 rounded-lg font-medium transition-colors">
|
||||
{isLoggedIn ? 'Go to Dashboard' : '免費取得 API Key'}
|
||||
</Link>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-[#1a1a1a] py-8 px-6 text-center text-[#333] text-sm mt-auto">
|
||||
<div className="flex items-center justify-center gap-6">
|
||||
<Link href="/integrations" className="hover:text-[#777] transition-colors">Integrations</Link>
|
||||
<Link href="/api-docs" className="hover:text-[#777] transition-colors">API Docs</Link>
|
||||
<a href="https://github.com/richblack/arcrun" target="_blank" rel="noopener noreferrer"
|
||||
className="hover:text-[#777] transition-colors">GitHub</a>
|
||||
</div>
|
||||
<p className="mt-4">arcrun — MIT License</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
// Protect /dashboard and /mira (login required; mira 額外白名單檢查在 layout)
|
||||
if (pathname.startsWith('/dashboard') || pathname.startsWith('/mira')) {
|
||||
const session = request.cookies.get('arcrun_session');
|
||||
if (!session?.value) {
|
||||
const loginUrl = new URL('/login', request.url);
|
||||
loginUrl.searchParams.set('redirect', pathname);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/dashboard', '/dashboard/:path*', '/mira', '/mira/:path*'],
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
// Cloudflare Pages edge runtime compatibility
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+4965
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "landing",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"build:pages": "npx @cloudflare/next-on-pages",
|
||||
"start": "next start",
|
||||
"deploy": "npm run build:pages && wrangler pages deploy"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cloudflare/next-on-pages": "^1.13.16",
|
||||
"next": "^15.5.15",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"wrangler": "^4.83.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
name = "arcrun-landing"
|
||||
compatibility_date = "2025-02-19"
|
||||
compatibility_flags = ["nodejs_compat"]
|
||||
pages_build_output_dir = ".vercel/output/static"
|
||||
|
||||
[vars]
|
||||
NEXT_PUBLIC_API_BASE = "https://cypher.arcrun.dev"
|
||||
@@ -0,0 +1,19 @@
|
||||
# 1. Vision — 設計哲學與前瞻
|
||||
|
||||
> 「為什麼」層:arcrun 的核心價值、設計目標、未來方向。
|
||||
|
||||
## 包含內容(待補充)
|
||||
|
||||
- arcrun 與傳統 low-code/n8n 的差異
|
||||
- Haiku 就能搞定是設計目標,不是 Sonnet 妥協
|
||||
- 薄殼原則:能力長在 API,介面只暴露
|
||||
- 市場機制:recipe 信任靠量和星數,不靠人工審核
|
||||
- 坦誠限制:不假綠、不偽造人類確認
|
||||
|
||||
相關詳細內容暫存於:
|
||||
- `.claude/rules/06-mindset.md`(待搬來)
|
||||
- `.claude/wiki/decisions-summary.md`(架構決策)
|
||||
|
||||
---
|
||||
|
||||
更新時間:2026-06-08
|
||||
@@ -0,0 +1,99 @@
|
||||
# SDD 協議(每次啟動必讀)
|
||||
|
||||
## 第零原則:沒讀 SDD 不准動 code
|
||||
|
||||
任何 `.go` / `.ts` / `.tsx` / `.wasm` 相關變動,**必須**按以下順序執行。**不得簡化,不得跳過**。
|
||||
|
||||
### 步驟 1:讀總進度
|
||||
|
||||
先讀 `docs/3-specs/arcrun/arcrun.md`,了解當前 Phase。
|
||||
|
||||
### 步驟 2:定位對應 SDD
|
||||
|
||||
根據任務性質找對應 SDD:
|
||||
|
||||
| 任務類型 | 對應 SDD |
|
||||
|---------|---------|
|
||||
| Auth primitive WASM 零件(static_key/oauth2/service_account/mtls) | `docs/3-specs/arcrun/credential-primitives-wasm/` |
|
||||
| 清除 cypher-executor 裡的 TS 業務邏輯 | `docs/3-specs/arcrun/credential-primitives-wasm/` |
|
||||
| WASI shim host functions(kv_get / crypto_decrypt / crypto_sign_rs256) | `docs/3-specs/arcrun/credential-primitives-wasm/` |
|
||||
| Auth Recipe 系統(recipe schema、KV 格式) | `docs/3-specs/arcrun/auth-recipe.md` |
|
||||
| Landing Page | `docs/3-specs/arcrun/landing-page.md` |
|
||||
| CLI / SDK(Python/JS) | `docs/3-specs/arcrun/sdk-and-website/` |
|
||||
| arcrun-core-mvp 整體架構 | `docs/3-specs/arcrun-core-mvp/` |
|
||||
| Platform Evolution | `docs/3-specs/arcrun-platform-evolution/` |
|
||||
| Credential 長期規格(需求源) | `docs/user_requirements/credential_parts.md` |
|
||||
|
||||
讀 `design.md` 和 `tasks.md` 兩份。
|
||||
|
||||
### 步驟 3:宣告(強制格式)
|
||||
|
||||
開始動手前,在回覆開頭**逐字**貼出以下宣告:
|
||||
|
||||
```
|
||||
📋 已讀 SDD:
|
||||
- docs/3-specs/arcrun/arcrun.md(當前 Phase:<phase 名稱>)
|
||||
- <對應 SDD 的 design.md 路徑>
|
||||
- <對應 SDD 的 tasks.md 路徑>
|
||||
|
||||
🎯 本次對應 task:<task 編號,例如 "Phase 1.3 實作 auth_static_key main.go">
|
||||
|
||||
📐 本次 task 的 SDD 規範摘要:
|
||||
- <重點 1>
|
||||
- <重點 2>
|
||||
- <重點 3>
|
||||
|
||||
🚧 執行範圍:
|
||||
- 會修改:<檔案清單>
|
||||
- 會建立:<檔案清單>
|
||||
- 會刪除:<檔案清單>
|
||||
```
|
||||
|
||||
**不做這個宣告 = 違反 SDD 協議 = 停手等 richblack**。
|
||||
|
||||
### 步驟 4:check tasks.md 狀態
|
||||
|
||||
動手前:在 tasks.md 把對應 task 的 `- [ ]` 改成 `- [🔄]`(進行中標記)。
|
||||
完成後:改成 `- [x]`,不批次更新,每完成一個就立刻改。
|
||||
|
||||
## 什麼算「任務超出 SDD 範圍」?
|
||||
|
||||
以下情況屬於 **change**,不是 **modify**,**必須停手並與 richblack 確認**:
|
||||
|
||||
- SDD 沒寫到的新功能
|
||||
- 新增頂層目錄
|
||||
- 新增新的 Worker(不管是 cypher-executor / registry / 零件 worker)
|
||||
- 修改架構決策(例如「改用 xxx 取代 yyy」)
|
||||
- 跨多個子系統的連鎖修改
|
||||
|
||||
**停手不是怯懦,是專業**。猜錯方向比慢一小時更糟。
|
||||
|
||||
## 新增 SDD 的完整程序(richblack 確認後怎麼往下走)
|
||||
|
||||
> 2026-06-03 補:之前協議只寫「停手等確認」,沒寫「確認後怎麼做」,導致 CC 被
|
||||
> `pre-write-guard.sh` 規則 4.3 擋下後不知正路、卡死。這節補完整程序。
|
||||
|
||||
當「新增一個 SDD 子系統」(在 `docs/3-specs/` 下開新頂層目錄):
|
||||
|
||||
1. **停手,向 richblack 說明要新建哪個 SDD、為什麼**(change,見上節)。
|
||||
2. **取得 richblack 明確確認**後,依序:
|
||||
1. **更新白名單**:在 `.claude/hooks/pre-write-guard.sh` 的 `KNOWN_SDDS` 陣列加一行
|
||||
`"docs/3-specs/<新目錄名>" # YYYY-MM-DD richblack 確認新建(<一句用途>)`。
|
||||
(這步是「執行 richblack 已授權任務的必要步驟」,不是 AI 擅自放寬 guardrail——
|
||||
授權脈絡要明確,分類器才放行;沒有明確授權就改白名單 = 越界。)
|
||||
2. **建目錄 + 寫 design.md / tasks.md**(白名單放行後才寫得進去)。
|
||||
3. **若沒先更新白名單就 Write** → 規則 4.3 會擋你(這是對的,表示你跳了步驟 2.i)。
|
||||
|
||||
**為什麼白名單而非全放行**:開新 SDD 子系統 = 宣告新架構範圍,是稀有的人類決策點。
|
||||
白名單讓「AI 自己無中生有開子系統」必停下(AI 改不了白名單,除非 richblack 明確授權該任務)。
|
||||
已許可的目錄內寫檔零摩擦。
|
||||
|
||||
## 發現 SDD 本身有問題怎麼辦?
|
||||
|
||||
- SDD 和實作不一致 → 停手,列出矛盾點,與 richblack 確認哪一邊是對的
|
||||
- SDD 規範之間互相矛盾(例如禁令 A 和設計 B 衝突)→ 停手,引用矛盾原文,與 richblack 確認
|
||||
- **不可以自行猜哪個是對的**。CC 之前兩天就是這樣走錯的。
|
||||
|
||||
## 為什麼這個協議存在
|
||||
|
||||
arcrun 規範已經足夠細緻,CC 之前出錯不是因為不懂,而是因為**沒讀**或**讀了覺得「大概是這個意思」就動手**。SDD 協議強制把「先讀 → 定位 → 宣告 → 執行 → 更新」做成一條死規矩,沒有繞過去的路徑。
|
||||
@@ -0,0 +1,76 @@
|
||||
# 技術棧硬限制
|
||||
|
||||
## 三層語言對應(絕對不可混用)
|
||||
|
||||
| 層級 | 語言 | 位置 | 職責 |
|
||||
|-----|------|------|------|
|
||||
| 零件(Component) | **TinyGo 或 AssemblyScript → WASM** | `registry/components/{name}/` | 所有業務邏輯 |
|
||||
| 零件 Worker 包裝 | TypeScript(固定模板,不寫業務邏輯) | `.component-builds/{name}/` | WASI shim,stdin/stdout JSON |
|
||||
| Orchestration Worker | TypeScript + Hono | `cypher-executor/` | HTTP routing、workflow 執行排程、host functions |
|
||||
| CLI | TypeScript + Node.js | `cli/` | `acr` 指令 |
|
||||
| Python SDK | Python | `python-sdk/` | HTTP thin wrapper + client 端加密 |
|
||||
| JS SDK | TypeScript + Web Crypto | `js-sdk/` | HTTP thin wrapper + client 端加密 |
|
||||
| Frontend | React 19 + Vite + Tailwind v4 | `landing/` | Cloudflare Pages |
|
||||
|
||||
## 零件實作規範
|
||||
|
||||
### 只有兩種合法語言
|
||||
- **TinyGo**:`tinygo build -target=wasi -o {name}.wasm main.go`
|
||||
- **AssemblyScript**:`asc main.ts --target release -o {name}.wasm`
|
||||
|
||||
### I/O 模型
|
||||
- **stdin**:JSON input
|
||||
- **stdout**:JSON output
|
||||
- 不用 HTTP server,不監聽 socket(WASI preview1 沒 socket)
|
||||
|
||||
### Host Functions(零件呼叫外部能力的唯一管道)
|
||||
在 `u6u` namespace 下:
|
||||
|
||||
| Host Function | 用途 |
|
||||
|---|---|
|
||||
| `u6u.http_request` | 發 HTTP 請求 |
|
||||
| `u6u.kv_get` | 讀 Cloudflare KV(Worker 側依 key 前綴路由到正確 KV) |
|
||||
| `u6u.crypto_decrypt` | AES-GCM 解密(encryption key 永不暴露給 WASM) |
|
||||
| `u6u.crypto_sign_rs256` | RSA-SHA256 簽章(PKCS8 bytes 傳入) |
|
||||
|
||||
**所有 host function 在 `cypher-executor/src/lib/wasi-shim.ts` 實作**。零件透過 WASI import 使用。
|
||||
|
||||
## 資料儲存
|
||||
|
||||
| 儲存 | 用途 | Key 格式 |
|
||||
|-----|------|---------|
|
||||
| Cloudflare KV `WEBHOOKS` | workflow 定義(cypher binding YAML) | `webhook:{api_key}:{name}` |
|
||||
| Cloudflare KV `CREDENTIALS_KV` | 加密 credential | `{api_key}:cred:{name}` |
|
||||
| Cloudflare KV `RECIPES` | auth recipe / 動態 API recipe | `auth_recipe:{service}`, `rec_{hash}` |
|
||||
| Cloudflare KV `USERS_KV` | 用戶資料 | `user:{api_key}` |
|
||||
| Cloudflare KV `SESSIONS_KV` | session | `session:{token}` |
|
||||
| Cloudflare KV `ANALYTICS_KV` | 執行分析 | `execution:{timestamp}:{id}` |
|
||||
| Cloudflare KV `EXEC_CONTEXT` | workflow 執行中的 context | `ctx:{execution_id}:{node_id}` |
|
||||
| Cloudflare R2 `WASM_BUCKET` | **只用於用戶自製零件**(Phase 5 以後啟用) | `{api_key}:cmp:{hash}` |
|
||||
|
||||
**警告:R2 不存平台內建零件的 WASM**。平台零件已 bundle 進各自的 Worker binary(`[[wasm_modules]]` 或 `import ... assert { type: 'webassembly' }`)。
|
||||
|
||||
## 加解密規範
|
||||
|
||||
- **演算法**:AES-GCM 256-bit
|
||||
- **加密位置**:Client 端(CLI / Python SDK / JS SDK)
|
||||
- Python:`cryptography` 套件
|
||||
- JS:Web Crypto API(`crypto.subtle`)
|
||||
- **解密位置**:Server 端 **WASM primitive**(透過 host function `crypto_decrypt`)
|
||||
- cypher-executor TS **不解密**,只提供 host function
|
||||
- `ENCRYPTION_KEY` 只在 Worker host function 內部讀取,**永不經 stdin / 回傳值傳給 WASM**
|
||||
- **傳輸格式**:`{ name, encrypted, iv }`(iv base64、encrypted base64)
|
||||
|
||||
## 網路部署
|
||||
|
||||
- **平台 API(對外)**:`cypher.arcrun.dev`(cypher-executor)
|
||||
- **Landing**:`arcrun.dev`
|
||||
- **每個零件 Worker**:
|
||||
- **對內(cypher-executor 用來 fetch component,避開同 zone 死鎖)**:`arcrun-{kebab}.{WORKER_SUBDOMAIN}.workers.dev`
|
||||
- 例:`arcrun-kbdb-get.uncle6-me.workers.dev`
|
||||
- cypher-executor 從 `wrangler.toml [vars] WORKER_SUBDOMAIN` 組出此 URL
|
||||
- **對外(可選,零件對全網開放被 curl 用)**:`{kebab}.arcrun.dev`
|
||||
- 例:`gmail.arcrun.dev`、`kbdb-get.arcrun.dev`
|
||||
- 仍允許保留,但**禁止 cypher-executor 透過此 URL fetch**(會撞同 zone 自循環,見 [docs/incidents/2026-05-13-cypher-outbound-522.md](../../docs/incidents/2026-05-13-cypher-outbound-522.md))
|
||||
- **新增 component worker 部署清單**:`name = "arcrun-{kebab}"` + `[[routes]]` 對外(可選)+ dashboard 啟用 workers.dev(必須)
|
||||
- **部署工具**:Wrangler
|
||||
@@ -0,0 +1,153 @@
|
||||
# 禁止行為清單(零容忍)
|
||||
|
||||
**這份清單由 `.claude/hooks/*.sh` 強制執行。違反會 block 工具呼叫(exit 2)**。
|
||||
|
||||
---
|
||||
|
||||
## 第一類:零件實作層級的禁令
|
||||
|
||||
### 1.1 禁止在 `registry/components/` 下建立 TypeScript 檔案
|
||||
零件**只能**用 TinyGo(`.go`)或 AssemblyScript(`.ts` 但需 `asconfig.json`)實作,並編譯成 `.wasm`。
|
||||
cypher-executor/registry Worker 或 `.component-builds/` 內的 TS 不算零件邏輯,那是 WASI shim。
|
||||
|
||||
**Hook 會擋**:新增 `registry/components/*/{檔案}.ts`(除非目錄內有 `asconfig.json` 明確標記為 AssemblyScript)。
|
||||
|
||||
### 1.2 禁止建立新的 `auth_*` 目錄以外的 auth 實作
|
||||
所有 auth 邏輯只能在:
|
||||
- `registry/components/auth_static_key/`
|
||||
- `registry/components/auth_oauth2/`
|
||||
- `registry/components/auth_service_account/`
|
||||
- `registry/components/auth_mtls/`
|
||||
|
||||
**不可以**出現 `cypher-executor/src/auth-primitive/`、`cypher-executor/src/lib/auth-*.ts`、`auth-worker/`、`credential-worker/` 等目錄。
|
||||
|
||||
**Hook 會擋**:`mkdir` 或 `Write` 到上述違規路徑。
|
||||
|
||||
### 1.3 禁止用 `wrangler init/generate` 建立 auth/credential/jwt 相關的 TS Worker
|
||||
Auth primitive 必須透過 `component-worker-template/` 搭配 WASM binary 部署。
|
||||
|
||||
**Hook 會擋**:bash 指令含 `wrangler (init|generate) ... auth_`、`... credential_`、`... jwt_` 的 pattern。
|
||||
|
||||
---
|
||||
|
||||
## 第二類:cypher-executor TS 的禁令
|
||||
|
||||
### 2.1 禁止新增任何 credential / auth / jwt 相關的 TS 檔案
|
||||
**已存在但要刪**(Phase 1-3 範圍):
|
||||
- `cypher-executor/src/actions/credential-injector.ts` → 刪除(走 WASM auth primitive)
|
||||
- `cypher-executor/src/lib/jwt-signer.ts` → 刪除(RS256 移入 auth_service_account WASM)
|
||||
- `cypher-executor/src/lib/component-loader.ts` 的 `BUILTIN_API_RECIPES` 和 `BUILTIN_CREDENTIALS_MAP` → 整段刪除
|
||||
|
||||
**Hook 會擋**:新增任何路徑含以下關鍵字的 `.ts` 檔案:
|
||||
- `credential-injector`、`credential_injector`
|
||||
- `jwt-signer`、`jwt_signer`
|
||||
- `auth-dispatcher` 的 TS 若嘗試在裡面實作 credential 解密 / template 展開 / JWT signing,block
|
||||
|
||||
### 2.2 禁止在 cypher-executor 任何 TS 裡實作以下邏輯
|
||||
這些邏輯全部屬於 WASM 零件職責:
|
||||
|
||||
- AES-GCM 解密(`crypto.subtle.decrypt`)— 只准出現在 `wasi-shim.ts` 的 `crypto_decrypt` host function
|
||||
- RSA-SHA256 簽章(`crypto.subtle.sign` with RSASSA-PKCS1-v1_5)— 只准出現在 `wasi-shim.ts` 的 `crypto_sign_rs256` host function
|
||||
- Template 展開(`{{secret.X}}` / `{{runtime.X}}` 替換)— 只能在 WASM 零件內
|
||||
- PEM → PKCS8 解析
|
||||
- JWT header/payload/signature 組裝
|
||||
- Token exchange(拿 service account JWT 換 access_token)
|
||||
- 具體 API call 實作(例如 gmail send / telegram sendMessage / google sheets append)
|
||||
|
||||
**Hook 會擋**:
|
||||
- Write/Edit 到 `cypher-executor/src/` 下的 `.ts` 時,內容含:
|
||||
- `crypto\.subtle\.decrypt` 且檔名不是 `wasi-shim.ts`
|
||||
- `crypto\.subtle\.sign.*RSASSA` 且檔名不是 `wasi-shim.ts`
|
||||
- `interpolateTemplate`、`\{\{secret\.` 的模板邏輯
|
||||
- `BUILTIN_API_RECIPES`、`BUILTIN_CREDENTIALS_MAP`(新增用)
|
||||
- `gmail.googleapis.com/gmail/v1/users/me/messages/send` 類 hard-code API URL
|
||||
- `api.telegram.org/bot.*sendMessage`
|
||||
- `sheets.googleapis.com/v4/spreadsheets`
|
||||
- `notify-api.line.me/api/notify`
|
||||
|
||||
### 2.3 cypher-executor TS 的合法職責(允許)
|
||||
- HTTP routing(Hono routes)
|
||||
- workflow 執行排程(`graph-executor.ts`)
|
||||
- 呼叫 WASM 零件(透過 HTTP fetch 到對應 Worker URL,或 Service Binding fallback)
|
||||
- 提供 host function(`wasi-shim.ts` 的 `kv_get` / `crypto_decrypt` / `crypto_sign_rs256`)
|
||||
- KV/R2/Service Binding 存取封裝
|
||||
|
||||
---
|
||||
|
||||
## 第三類:架構層級的禁令
|
||||
|
||||
### 3.1 禁止新增 Service Binding
|
||||
**Cypher binding 不是 Cloudflare service binding**。它是 YAML/KV 裡的 URL 清單。
|
||||
|
||||
零件串接(workflow 層)一律走 HTTP URL,不走 `[[services]]`。
|
||||
|
||||
13 個現有的 `SVC_*` 綁定(`cypher-executor/wrangler.toml`,邏輯零件)是歷史遺產(效能優化),**保留但不新增**。
|
||||
|
||||
> **2026-06-06 註(credential-primitives-wasm Phase 7)**:self-hosted 的 cypher 與 auth worker 同在 `{sub}.workers.dev` zone,cypher `fetch()` 打 auth 觸發 CF **same-zone 1042**(壓測階段 11)。**未用 service binding 解**(評估後廢:service binding 靜態、加/改要重 deploy cypher)。改用 **`global_fetch_strictly_public` compatibility flag**(cypher wrangler.toml)讓 same-zone fetch 走公網前門 → 同 zone 也通,**auth 維持 HTTP fetch、不加 binding**。故本禁令不變。
|
||||
|
||||
**Hook 會擋**:bash 指令含 `wrangler tail` 以外、涉及 `[[services]]` 新增的 pattern;Edit wrangler.toml 新增 `[[services]]` 區塊時警告確認。
|
||||
|
||||
### 3.2 禁止以「從 R2 取 WASM」為設計
|
||||
平台內建零件已 bundle 進各自 Worker,不從 R2 取。
|
||||
R2 只在 Phase 5(用戶自製零件)啟用。
|
||||
|
||||
**Hook 會警告**:TS 中出現 `env.WASM_BUCKET.get(` 的新增 code(除非在明確標註的 Phase 5 user-submit 路徑中)。
|
||||
|
||||
### 3.3 禁止複製貼上 Worker 程式碼到新目錄
|
||||
要改 `gmail` 零件 → 改 `registry/components/gmail/main.go`,重新編譯、部署。
|
||||
**不准**新建 `gmail-v2/`、`new-gmail/`、`gmail-worker/` 等目錄。
|
||||
|
||||
**Hook 會擋**:`mkdir` 或 `Write` 到 `{component-name}-v2/`、`new-{component-name}/`、`{component-name}-worker/` 類路徑。
|
||||
|
||||
### 3.4 禁止在 SDK 內做 server 職責
|
||||
- **禁止**:SDK 裡做 server 端解密、credential-injector 重實作、workflow executor、auth recipe 解析
|
||||
- **允許**:SDK 做 HTTP thin wrapper + client 端加密(AES-GCM)
|
||||
|
||||
---
|
||||
|
||||
## 第四類:流程層級的禁令
|
||||
|
||||
### 4.1 禁止沒讀 SDD 就動 code
|
||||
見 `00-sdd-protocol.md`。
|
||||
|
||||
### 4.2 禁止批次更新 tasks.md
|
||||
每完成一個 task 就立刻 mark `- [x]`。不准「先全部做完再一次更新」。
|
||||
|
||||
### 4.3 禁止新建 SDD 而不事先與 richblack 確認
|
||||
SDD 屬於架構決策,必須人確認。CC 不可以自行在 `docs/3-specs/` 底下建新目錄。
|
||||
例外:在現有 SDD 目錄內新增 `requirements.md` / `design.md` / `tasks.md` 的單檔補充(需在 CLAUDE.md 已註記的 SDD 範圍內)。
|
||||
|
||||
---
|
||||
|
||||
## 第五類:薄殼原則的禁令(詳見 07-thin-shell.md)
|
||||
|
||||
### 5.1 禁止在薄殼介面(cli/src/、arcrun-mcp/src/)實作業務邏輯
|
||||
能力只實作一次,放在 API(cypher-executor 端點)。薄殼只做介面轉換 + 暴露。
|
||||
|
||||
**Hook 會擋**(規則 7.x,範圍 `cli/src/` 與 `arcrun-mcp/src/` 的 .ts/.js):
|
||||
- 7.1 新增 `seedApiRecipes` / `seedAuthRecipes` / `seedRecipes` 編排函式(seed 是 API 行為,§4.1 反例)
|
||||
- 7.2 介面層拼裝 upsert(同函式內 PATCH + POST + upsert/找則改否則建 字樣)
|
||||
- 7.3 client 端「全部成功才做下一步」gate(`deployFullyOk` 類,§4.1 反例)
|
||||
|
||||
### 5.2 禁止用 recipe / 客製零件補 API 缺的能力
|
||||
缺能力 → 去補 API endpoint,不是在 recipe 層拼湊或用零件繞過(hook 偵測不到語意,靠 code review)。
|
||||
|
||||
### 5.3 禁止同一 API 能力在不同介面用不同參數簽名 / 連不同帳號
|
||||
`validate` 在 CLI 吃 YAML、MCP 卻要 `api_key`+`graph` = 底層分歧。薄殼差異只能來自介面慣例。
|
||||
所有薄殼讀同一份身份來源(見 07 §4)。
|
||||
|
||||
---
|
||||
|
||||
## Hook Block 訊息格式
|
||||
|
||||
當 hook 擋住一個操作時,訊息格式統一為:
|
||||
|
||||
```
|
||||
❌ BLOCKED by arcrun CLAUDE rules
|
||||
違反項:<禁令編號,例如 2.2>
|
||||
原因:<簡短說明>
|
||||
正確做法:<該改去哪裡、該用什麼方式>
|
||||
參考:.claude/rules/<對應檔案>
|
||||
```
|
||||
|
||||
這樣 CC 拿到錯誤訊息後有機會自行導正,不是被擋死就愣住。
|
||||
@@ -0,0 +1,165 @@
|
||||
# 零件架構與部署模式(必讀,CC 最常搞錯的地方)
|
||||
|
||||
## 第一核心概念:每個 WASM 零件 = 一個獨立 Worker = **兩個** URL
|
||||
|
||||
**不是**從 R2 即時載入 WASM 執行。
|
||||
**不是**用 service binding 串零件。
|
||||
**不是**一個 Worker 裡跑多個零件。
|
||||
|
||||
**是**:每個零件都是獨立部署的 Worker,每個都有**兩個 URL**:
|
||||
|
||||
| URL 類型 | Pattern | 用途 |
|
||||
|---|---|---|
|
||||
| 對內(cypher-executor 用)| `arcrun-{kebab}.{WORKER_SUBDOMAIN}.workers.dev` | cypher-executor fetch component 走這個,避開同 zone 自循環死鎖(P0 #9)|
|
||||
| 對外(直接 curl 用,可選)| `{kebab}.arcrun.dev` | 用戶單獨打 component 測試或 self-hosted 用法 |
|
||||
|
||||
例:`kbdb_get` 零件:
|
||||
- 對內:`arcrun-kbdb-get.uncle6-me.workers.dev`(cypher-executor 走這個)
|
||||
- 對外:`kbdb-get.arcrun.dev`(用戶 / 直 curl)
|
||||
|
||||
**為什麼這樣設計**:CF Workers 「同 zone 自循環防護」會讓綁 `cypher.arcrun.dev/*` 的 cypher-executor fetch 同 zone `*.arcrun.dev` 撞 522。完整事件報告:[docs/incidents/2026-05-13-cypher-outbound-522.md](../../docs/incidents/2026-05-13-cypher-outbound-522.md)。改走 workers.dev 子域繞過。
|
||||
|
||||
### 零件 Worker 的結構
|
||||
|
||||
```
|
||||
registry/components/{name}/
|
||||
├── main.go ← TinyGo 原始碼(實際零件邏輯)
|
||||
├── component.contract.yaml ← 輸入/輸出規格
|
||||
└── {name}.wasm ← TinyGo 編譯產物
|
||||
```
|
||||
|
||||
部署時,透過 `component-worker-template/` 把 WASM 包進一個 Hono Worker:
|
||||
```
|
||||
.component-builds/{name}/
|
||||
├── package.json
|
||||
├── wrangler.toml ← name = "arcrun-{name}",route = "{name}.arcrun.dev"
|
||||
├── component.wasm ← 從 registry/components/{name}/ 複製過來
|
||||
└── src/index.ts ← 固定的 WASI shim(POST / → stdin → WASM → stdout → JSON)
|
||||
```
|
||||
|
||||
**src/index.ts 是通用模板**,所有零件都用同一份。這個 TS 只做 WASI runtime,不是業務邏輯。
|
||||
|
||||
---
|
||||
|
||||
## R2(WASM_BUCKET)的真正用途
|
||||
|
||||
R2 存 WASM 只是**用戶自製零件上傳**用的。
|
||||
|
||||
**平台內建零件不從 R2 讀取**——它們在部署時就已 bundle 進 Worker 的 binary(透過 `[[wasm_modules]]` 或 `import` with `assert { type: 'webassembly' }`)。
|
||||
|
||||
Phase 5(封測後)才會啟用「用戶 push 自製零件 → 存 R2 → 動態執行」這條路徑。
|
||||
|
||||
**結論:當 CC 問「怎麼從 R2 取出 WASM」時,幾乎都是走錯路徑**。平台零件是獨立 Worker,走 HTTP 呼叫,不是 R2 動態載入。
|
||||
|
||||
---
|
||||
|
||||
## Cypher binding 的正確定義
|
||||
|
||||
**Cypher binding 不是 Cloudflare 的任何 binding 機制。**
|
||||
|
||||
Cypher binding 是一張 YAML 清單,內容是「一個 workflow 要呼叫哪些零件 URL」。存放在:
|
||||
- 本地:`workflow.yaml`(用戶寫的 workflow)
|
||||
- KV:`WEBHOOKS` KV(用戶 `acr push` 後存入)
|
||||
|
||||
Cypher executor 執行 workflow 時:
|
||||
1. 從 KV 讀出 workflow YAML
|
||||
2. 按 graph 順序解析每個節點的 `component`
|
||||
3. 用 HTTP fetch 打對應的零件 URL
|
||||
4. 把 output 當作下個節點的 input
|
||||
|
||||
**這就是 Cypher binding——用 HTTP URL 把零件串起來,存在 YAML/KV 裡**。
|
||||
|
||||
### 為什麼不能用 Service Binding?
|
||||
|
||||
Service binding 需要 `wrangler.toml` 裡寫死 `[[services]]`,且要 redeploy 才生效。arcrun 是類 n8n 服務,用戶建立新 workflow 時**絕對不可能**要他 redeploy。所以 workflow 層一定要 HTTP。
|
||||
|
||||
### Service Binding 的僅存合法用途
|
||||
|
||||
只在 `cypher-executor` 和**平台內建邏輯零件之間**保留(效能優化,避免公網往返)。看 `cypher-executor/wrangler.toml` 裡的 13 個 `[[services]]` 綁定就是這個用途。
|
||||
|
||||
**禁止新增任何 Service Binding**。所有新零件(含 auth primitive)都走 HTTP URL 路徑。
|
||||
|
||||
**same-zone 1042 的解(credential-primitives-wasm Phase 7,2026-06-06)**:self-hosted 的 cypher 與 auth worker 同在 `{sub}.workers.dev` zone,cypher `fetch()` 打 auth 觸發 CF **1042**(官方 docs:「fetch from another Worker on the **same zone**」;官方 cypher 在 `cypher.arcrun.dev`、打 `*.workers.dev` 屬跨 zone 故不踩——非官方有 flag)。**解法不是 service binding**(評估後廢:靜態、加/改要重 deploy),而是 cypher wrangler.toml 加 **`global_fetch_strictly_public` flag**——讓 same-zone fetch 走公網前門 → 同 zone 也通。auth 維持 HTTP fetch、不加 binding。官方加此 flag 行為不變(本就跨 zone),self-host 被修好 → **官方與 self-host 共用同一份 toml**。
|
||||
|
||||
**仍禁止**:為**用戶自製 / 服務專屬零件**(`gmail-worker`、`notion-worker` 之類)新增 binding——那些是 recipe 的事,不該有 binding。**workflow 層(用戶串零件)一律 HTTP URL 不變。**
|
||||
|
||||
---
|
||||
|
||||
## 零件之間怎麼串:實際流程
|
||||
|
||||
假設 workflow 是:webhook → gmail(要 auth)→ google_sheets(要 auth)
|
||||
|
||||
```
|
||||
用戶 POST https://cypher.arcrun.dev/webhooks/named/xxx/trigger
|
||||
│
|
||||
▼
|
||||
cypher-executor(Worker)讀 workflow YAML
|
||||
│
|
||||
├─ 節點 1: component = gmail
|
||||
│ a. 查 auth_recipe:gmail → primitive = static_key
|
||||
│ b. HTTP POST https://auth-static-key.arcrun.dev
|
||||
│ { action: "authenticate", api_key, service: "gmail" }
|
||||
│ → 回傳 { auth_headers: { Authorization: "Bearer ..." } }
|
||||
│ c. HTTP POST https://gmail.arcrun.dev
|
||||
│ { to, subject, body, _auth_headers }
|
||||
│ → gmail 零件 Worker 執行 WASM → 回傳 { success, data }
|
||||
│
|
||||
└─ 節點 2: component = google_sheets
|
||||
... 相同模式
|
||||
```
|
||||
|
||||
**cypher-executor 本身不做 credential 解密、不做 JWT signing、不做 auth header 組裝**。這些全在 auth primitive WASM 零件內,cypher-executor 只負責 HTTP routing 和工作流排程。
|
||||
|
||||
---
|
||||
|
||||
## 實際禁令(CC 看這裡)
|
||||
|
||||
### 禁止在 `registry/components/` 下建立 TypeScript 檔案
|
||||
零件邏輯一律 TinyGo 或 AssemblyScript,編譯成 `.wasm`。
|
||||
|
||||
### 禁止把 auth 邏輯寫在 `cypher-executor/src/` 裡
|
||||
credential 解密、JWT signing、template 展開(`{{secret.X}}`)全部屬於 auth primitive WASM 零件的職責。cypher-executor 只呼叫它們。
|
||||
|
||||
### 禁止問「怎麼從 R2 取 WASM」
|
||||
平台內建零件**不從 R2 取**。每個零件已部署成獨立 Worker,走 HTTP URL。用戶自製零件才用 R2(Phase 5,未啟用)。
|
||||
|
||||
### 禁止新增 Service Binding
|
||||
13 個現有的 SVC_*(邏輯零件)是歷史遺產,不新增。新零件(含 auth primitive)一律走 HTTP URL。self-hosted 的 same-zone 1042 用 `global_fetch_strictly_public` flag 解,不靠新增 binding(見上「合法用途」段,Phase 7)。
|
||||
|
||||
### 禁止重建已存在的零件 Worker
|
||||
要改 `gmail` 零件邏輯 → 改 `registry/components/gmail/main.go`,重新編譯 `.wasm`,重新部署對應 Worker。**不要**在 `cypher-executor/src/lib/` 或其他地方建「新的 gmail 實作」。
|
||||
|
||||
---
|
||||
|
||||
## 部署一個新零件的完整步驟(auth_static_key 為例)
|
||||
|
||||
1. 建立 `registry/components/auth_static_key/`:
|
||||
- `main.go`(TinyGo 實作)
|
||||
- `component.contract.yaml`(IO 規格)
|
||||
2. 編譯:`cd registry/components/auth_static_key && tinygo build -target=wasi -o auth_static_key.wasm main.go`
|
||||
3. 建立 `.component-builds/auth_static_key/`:
|
||||
- 複製 `component-worker-template/src/index.ts`
|
||||
- 複製 `component-worker-template/package.json`
|
||||
- 新建 `wrangler.toml`:
|
||||
```toml
|
||||
name = "arcrun-auth-static-key"
|
||||
main = "src/index.ts"
|
||||
compatibility_date = "2025-02-19"
|
||||
[vars]
|
||||
COMPONENT_ID = "auth_static_key"
|
||||
[[routes]]
|
||||
pattern = "auth-static-key.arcrun.dev/*"
|
||||
zone_name = "arcrun.dev"
|
||||
```
|
||||
- 複製 `auth_static_key.wasm` 到此目錄為 `component.wasm`
|
||||
4. `cd .component-builds/auth_static_key && pnpm install && pnpm deploy`
|
||||
5. **Dashboard 啟用 workers.dev URL**(必須,否則 cypher-executor fetch 不到):
|
||||
- Workers & Pages → `arcrun-auth-static-key` → Settings → Domains & Routes → workers.dev → Enable
|
||||
- 啟用後 URL:`arcrun-auth-static-key.{WORKER_SUBDOMAIN}.workers.dev`
|
||||
6. 驗證對外:`curl https://auth-static-key.arcrun.dev` → 應回 `{ok: true, component: "auth_static_key"}`
|
||||
7. 驗證對內:`curl https://arcrun-auth-static-key.{WORKER_SUBDOMAIN}.workers.dev` → 應同樣回 200
|
||||
8. cypher-executor 透過 `wasmWorkerUrl()` 自動組對內 URL 呼叫(不用手動註冊)
|
||||
|
||||
**這是唯一正確的部署流程**。任何偏離這個流程的「替代方案」都要先和 richblack 確認。
|
||||
|
||||
**Step 5 為什麼必須**:見 arcrun.md P0 #9(2026-05-13)。cypher-executor 走對內 URL 避開同 zone 自循環死鎖;若 workers.dev 未啟用,cypher-executor fetch 該 component 會 404。
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user