Compare commits

..

4 Commits

Author SHA1 Message Date
uncle6me-web fd94340eba portal-auth P4 收尾:tasks.md 全 [x]+wiki status P4 段(SDD P1-P4 全完成,待 T6-cloud 排練)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 15:38:46 +08:00
uncle6me-web 605204511f portal-auth P4:admin 測試 14 項(鎖死保護/一次性密碼不落庫/庫目錄/HTML 紅線)
last-admin 409(含另一 admin 是 disabled 不算數、停用一般 user 不多打 list);
generated_password/reset-password 一次性回傳且明碼不進 KBDB body;libraries
["*"] 與壞庫名 400;庫目錄子 namespace+graph_source;/portal HTML 殼加 admin
頁後仍零租戶字串/零 /kbdb//零 X-Arcrun-API-Key/零 Mira。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 15:32:42 +08:00
uncle6me-web fa64e71b58 portal-auth P4:/portal 管理頁 UI(帳號管理+庫目錄管理,admin-only nav)
- 帳號管理:同仁列表(email/顯示名/角色/狀態/可查庫)+新增(一次性密碼只顯示
  一次,server 不留明碼)+停用/啟用+重設密碼+每帳號勾選可查庫(含 ["*"] 全庫
  選項,勾選連動停用個別庫)
- 庫目錄管理:登記/停用庫+graph_source 圖譜來源標記(D-4)
- nav 只對 admin 顯示(/portal/session role);前端藏只是 UX,真閘在
  /portal/admin/* role 閘 403(P3 guard 精神)
- 工作流頁 P3 已交付(v-workflows+D-8 閘),本次不重做

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 15:32:42 +08:00
uncle6me-web 2b563b0fa8 portal-auth P4:最後一個 active admin 鎖死保護(停用/降級 → 409)
系統至少保留一個 active admin——bootstrap 只能跑一次(已有 admin 409),
若最後一個 admin 被停用/降級,帳號管理即永久鎖死。只在「目標是 active admin
且 patch 會使其失格」時多打一次 list(平時零成本)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 15:32:42 +08:00
332 changed files with 9180 additions and 86610 deletions
-20
View File
@@ -4,26 +4,6 @@
---
## 生命週期(單一活性鐵律,全文見 `system-dev/docs/3-specs/SDD-LIFECYCLE.md`
五條鐵律摘要:
1. **單一活性**:任何時刻整個 repo 只允許一份 `status: active` 的 SDD;所有開發任務對應它的 tasks,找不到對應任務 → 停下來問,不准直接做。
2. **禁止自行建立 SDD**:澄清問題→回答不動文件;任務層變更→更新現行 SDD 的 tasks(標日期與原因);規格層變更→走第 3 條。
3. **規格變更只有一條路**change proposal 寫進 `system-dev/docs/3-specs/pending-changes.md`(摘要+觸發原因+影響分析),然後**停止**等使用者「confirm」。
4. **開新 SDD 的唯一時機**:使用者 confirm 後——先把舊 SDD 未完成任務逐條搬入新 SDD(做完前不准寫 code)→ 舊的標 `closed` + `superseded_by` 移入 `archive/` → 新 SDD changelog 記繼承 → 列搬移/作廢清單請最終確認。
5. **每次 session 開始**先讀 active SDD 與 pending-changes.md,回報三個數字:
```
📐 現行規格:〈SDD 名稱〉
📋 未完成任務:N
⚖️ 待裁決 proposalM
```
若出現**兩份 active=規則已被違反,當場糾正**(收斂到一份,其餘 paused/closed)。
---
## 執行流程
### 第一步:理解任務
-161
View File
@@ -1,161 +0,0 @@
# /wiki-extract — vault 增量萃取(Logseq / Obsidian → system-dev/wiki
把**筆記 vault**Logseq graph 如 `notes`/`kb`、或 Obsidian)的原始筆記,**增量、冪等**地
萃成 `system-dev/wiki/` 的精耕卡+`[[wikilink]]`。這是知識一庫 ingest 的**前段**
AI 只產卡片檔,下游 Arcrun ingest 再從 wikilink 機械拉三元組進 KBDB。
> **跟 `/wiki-init` 的分工**
> - `/wiki-init` 是**首次**建結構 + 全庫首萃(一次性)。
> - `/wiki-extract` 是**之後每次**的增量重萃——vault 會被 Syncthing/cron 持續灌新筆記,
> 這支負責「只萃變動的、沒變的不碰、不浪費 AI run」。給 Routine / cloud-worker 反覆跑。
> - **跑它的是你(CC / Routine)=LLM 本人,不需任何 token**。
> **邊界(硬規矩,別越界)**
> - 只往 `system-dev/wiki/` 寫。**絕不寫入 KBDB、絕不拉三元組紀錄**——三元組是下游
> Arcrun 從你產的 `[[wikilink]]` + `## 關聯` 機械映射(另一張 issue),不是這支的事。
> - **原始筆記唯讀**`journals/`、`pages/`、Obsidian 根 `.md` 是 leo 的手寫真身,
> 改了會被 Syncthing 推回他手機污染筆記 App。萃取=只讀原文、只寫 wiki。
> - **D16 精耕非 RAG**:萃「知識點」成自包含原子卡 + 建 wikilink,**不地毯灌原文全文**。
---
## 執行流程
### 第一步:確認這是 vault repo,定位 raw source
偵測邏輯**同 install.sh / wiki-init**
| 偵測到 | 型態 | raw source(要掃的原文) |
|--------|------|--------------------------|
| 根目錄有 `logseq/` | Logseq vault | `journals/*.md` + `pages/*.md` |
| 根目錄有 `.obsidian/` | Obsidian vault | vault 根下所有 `.md` |
| 都沒有 | **不是 vault** | → 停手。這支只處理 vault;一般 dev repo 開發時就手寫 `.claude`/`system-dev/wiki`,不需萃取 |
沒有 `system-dev/wiki/`?→ 先跑 `/wiki-init`(首次建結構+首萃),再回來用這支做增量。
### 第二步:content_hash 冪等 —— 決定哪些檔要萃(省 run 的核心)
讀萃取 manifest`system-dev/wiki/.extract-manifest.json`(不存在=首次,視同全部要萃)。
格式:
```json
{
"version": 1,
"algo": "sha256",
"sources": {
"journals/2026_07_01.md": {
"content_hash": "<sha256 of file bytes>",
"extracted_at": "2026-07-06",
"cards": ["Prompt能力即拆解自己邏輯的能力", "程式化邏輯可圖解任何主題不限AI"],
"skipped_reason": null
},
"journals/2026_06_25.md": {
"content_hash": "<sha256>",
"extracted_at": "2026-07-06",
"cards": [],
"skipped_reason": "空檔/訊息量不足,無可萃知識點"
}
}
}
```
對每個 raw source 檔:
1. 算目前 `content_hash``sha256sum <file>`,取檔案 bytes 的 hash)。
2. 跟 manifest 裡該檔的 `content_hash` 比:
- **相同 → skip,不讀不萃、不呼叫任何 AI 推理**(就算它上次 `cards: []` 也 skip——空檔沒變還是空)。
- **不同或不在 manifest → 這檔要(重)萃**。
3. manifest 有、但檔已不存在 → 該檔被刪,把它的 entry 從 manifest 移除(卡片是否連帶處理見第五步)。
> **這一步是「省 run」的重點**:vault 每天可能只動 1~2 個 journal,其餘幾十個檔 hash 沒變
> 就整批跳過,AI 只對真正變動的檔動腦。**重跑一個沒變動的 vault = 零 AI 呼叫、零 diff。**
### 第三步:對「要萃」的檔,抓知識點 + 任務
逐個變動檔讀原文,分兩類抽取:
**(a) 知識點 → 概念原子卡**
判準與卡片格式**完全依 `/wiki-init` 第五步**frontmatter `tags:`/`gloss:`、H1、麵包屑
`← [[<bucket>/00-INDEX]]``**來源**``## 摘要``## 重點``## 實體``## 關聯`
typed-edge 三元組、TAXONOMY 受控標籤、硬自檢等)——**不在這裡重寫格式,一律回去讀那份**。
廢話/訊息量薄的段落略過(在 manifest 記 `skipped_reason`,誠實留痕、不留卡)。
**(b) Logseq 任務 marker → 任務卡(task_status**
解析**完全依** `system-dev/docs/4-guides/logseq-markers.md`(單一真相源,與 template#4
tasks 投影共用同一套;**別自己另寫 mapping**)。摘要:
- 任務行 regex`^\s*- (TODO|DOING|NOW|LATER|WAITING|DONE|CANCELED|CANCELLED)\s+`
- 狀態正規化:TODO/LATER→`todo`、DOING/NOW→`in-progress`、WAITING→`blocked`
DONE→`done`、CANCELED/CANCELLED→`closed`
- 跳過 `:LOGBOOK:…:END:` 區塊與 `key:: value` 屬性行(`collapsed::``id::`
`SCHEDULED::``DEADLINE::`…),**別把 marker 或屬性當任務內文**。
有實質內容的任務 → 產一張任務卡進 `cards/tasks/` bucketfrontmatter 帶 `task_status`
```markdown
---
tags: [<領域標籤,依 TAXONOMY>]
task_status: todo # ← 依上表正規名;這是任務卡才有的欄位
gloss: 一句話定義這個任務要達成什麼(供下游 normalize)
---
# <任務一句話標題(marker 後的內文,去掉 marker>
← [[tasks/00-INDEX]]
**來源**`journals/2026_07_01.md`TODO block
**最後更新**YYYY-MM-DD
## 摘要
[任務要做什麼、脈絡]
## 實體
- **<關鍵實體正規名>**<同義詞>)— <一句描述>
## 關聯
### 內文知識關係(端點=上方 `## 實體` 正規名,一字不差)
- <實體A> >> <謂詞> >> <實體B>
### 卡片關係(卡對卡)
- [[本任務卡]] >> 涉及 >> [[相關概念卡]]
```
> 純瑣事任務(「買菜」這種無知識量)不必成獨立卡——可在 `cards/tasks/00-INDEX.md`
> 列一行帶狀態即可,避免灌垃圾卡。判準同 D16:有沒有知識/專案價值。
### 第四步:更新桶索引與 INDEX
- 每個動到的 bucket(如 `cards/notes/``cards/tasks/`)更新其 `00-INDEX.md`
(容器:只連不重寫,H2/H3 分節)。
- 更新 `system-dev/wiki/INDEX.md` 的標籤視圖與卡片清單。
- 任務卡可在 INDEX 開一個「任務視圖」按 `task_status` 聚類。
### 第五步:寫回 manifest + 驗證原文 0 動
1. 把這次萃過的每個檔的**新 `content_hash`**、`extracted_at`、產出的 `cards`
(或 `skipped_reason`)寫回 `system-dev/wiki/.extract-manifest.json`
**沒動到的檔的 entry 原樣保留**(別整檔重寫掉別人的 hash)。
2. 驗證原文零異動(踩過的坑):
```
git status --short journals/ pages/ # Obsidian 則看根目錄 .md ——須 0 新增 0 修改
```
有任何原文變動 → 你誤寫了 raw source,回滾。
### 第六步:完成報告
```
✅ wiki-extract 完成(增量)
掃描:N 個 raw source 檔
萃取:M 個(content_hash 變動)→ 產出 X 張概念卡 + Y 張任務卡
跳過:K 個(hash 未變,零 AI 呼叫)
任務狀態分布:todo A / in-progress B / done C / …
原文驗證:journals/ pages/ git status 0 異動 ✅
manifestsystem-dev/wiki/.extract-manifest.json 已更新
```
---
## 冪等自檢(Routine 反覆跑必守)
- [ ] 跑之前先讀 manifesthash 相同的檔**完全不進 AI**(不是「讀了才發現一樣」,是靠 hash 先擋)。
- [ ] 對「同一個沒變動的 vault」連跑兩次:第二次應是**零萃取、零卡片 diff、零 manifest 變化**。
- [ ] 只有 `system-dev/wiki/` 有寫入;`journals/`、`pages/` git status 全乾淨。
- [ ] 任務狀態用正規名,marker/屬性沒混進內文(照 `logseq-markers.md` 自檢)。
-5
View File
@@ -228,8 +228,3 @@ git status --short pages/ journals/ # 或一般專案的 docs/ ——須 0
原文驗證:pages/ journals/ git status 0 異動 ✅
下一步:用 /wiki-capture 把重要決策存進 wiki
```
> **vault repo 首萃後的增量重萃**Logseq / Obsidian vault 會被持續灌新筆記。首萃(本命令)
> 之後,改用 **`/wiki-extract`** 做增量——它靠 content_hash 只萃變動的檔(沒變=零 AI 呼叫),
> 並解析 Logseq 大寫任務 markerTODO/DOING/DONE…→ `task_status`,見
> `system-dev/docs/4-guides/logseq-markers.md`)。適合掛給 Routine / cloud-worker 反覆跑。
-80
View File
@@ -1,80 +0,0 @@
#!/bin/bash
# publish-lag-check.sh — SessionStart hook:偵測「公開 mirror 落後工作區」並出聲
#
# 病根(leo 2026-07-21 點名的真實風險):
# Gitea(草稿/工作現場)與 GitHub(正稿/成品櫥窗)是**手動同步**的
# (靠人跑 scripts/publish-github.sh --push,且需 D20 arm)。
# → 改了零件、重編 wasm 後若沒人記得發佈,**用戶抓到舊版且沒有任何錯誤訊息,
# 只是行為不對**——這種靜默失敗只有外部使用者會撞到,我們自己永遠測不到。
#
# 實例:安裝器的懶載會從
# cdn.jsdelivr.net/gh/youlinhsieh/Arcrun@main/.component-builds/<名>/component.wasm
# 抓 wasm。那個位址永遠指向 GitHub 上的**最後一次發佈**,不是我們本機的最新版。
#
# 原理:比對「工作區 HEAD」與「.github-public 最後一個 release commit 記錄的 snapshot」。
# publish-github.sh 的 commit 訊息格式固定為:release: snapshot <短hash> (<日期>)
# → 從中取出 hash,看它是不是工作區 HEAD 的祖先/相同。
#
# 只提醒不阻擋(exit 0):發不發佈是人的決定(且 push GitHub 需 leo 親跑 arm),
# hook 的職責只是消滅「忘了」這個失敗模式。
set -euo pipefail
MIRROR_DIR=".github-public"
# 沒裝發佈管線的 repo 直接安靜退出
[ -d "$MIRROR_DIR/.git" ] || exit 0
[ -f "scripts/publish-github.sh" ] || exit 0
git rev-parse --git-dir >/dev/null 2>&1 || exit 0
HEAD_SHORT="$(git rev-parse --short HEAD 2>/dev/null || echo '')"
[ -z "$HEAD_SHORT" ] && exit 0
# 從 mirror 最後一個 commit 訊息取出它當初發佈的來源 hash
LAST_MSG="$(git -C "$MIRROR_DIR" log -1 --format=%s 2>/dev/null || echo '')"
PUBLISHED="$(printf '%s' "$LAST_MSG" | sed -n 's/.*snapshot \([0-9a-f]\{6,\}\).*/\1/p')"
if [ -z "$PUBLISHED" ]; then
# mirror 存在但沒有可辨識的 release commit(可能還沒發過)
echo "════════════════════════════════════════════════"
echo "📦 這個 repo 有公開發佈管線,但 mirror 還沒發過任何版本"
echo "════════════════════════════════════════════════"
echo " 若已有用戶依賴公開版(例如安裝器從 jsDelivr 抓 wasm),現在是空的。"
echo " 發佈:leo 在頂層跑 scripts/github-arm.sh,再於本 repo 跑"
echo " GITHUB_REMOTE=... bash scripts/publish-github.sh --push"
echo ""
exit 0
fi
# 已發佈的那個 commit 就是現在的 HEAD → 同步,安靜
if [ "$PUBLISHED" = "$HEAD_SHORT" ]; then
exit 0
fi
# 算出落後幾個 commit(發佈點 → HEAD)。取不到就不顯示數字。
BEHIND="$(git rev-list --count "${PUBLISHED}..HEAD" 2>/dev/null || echo '')"
# 落後 0 且 hash 不同 → 可能是 mirror 比工作區新(罕見,例如剛 rebase),一樣提醒
echo "════════════════════════════════════════════════"
if [ -n "$BEHIND" ] && [ "$BEHIND" != "0" ]; then
printf '📤 公開 mirror 落後工作區 %s 個 commit(最後發佈:%s,現在:%s\n' \
"$BEHIND" "$PUBLISHED" "$HEAD_SHORT"
else
printf '📤 公開 mirror 與工作區不一致(最後發佈:%s,現在:%s)\n' "$PUBLISHED" "$HEAD_SHORT"
fi
echo "════════════════════════════════════════════════"
echo "⚠️ 外部使用者拿到的仍是舊版,而且**不會有任何錯誤訊息**——只是行為不對。"
echo " (安裝器的懶載直接從公開位址抓 wasm,落後=裝到舊零件。)"
echo ""
echo " 要發佈:① leo 在頂層跑 bash scripts/github-arm.sh \"<任務描述>\" 30"
echo " ② 本 repo 跑 GITHUB_REMOTE=https://github.com/<帳號>/<repo>.git \\"
echo " bash scripts/publish-github.sh --push"
echo " 不急著發也沒關係——這只是提醒,別讓它靜默漏掉。"
# 若這次落後的內容碰到 wasm,額外警告(那是用戶會直接抓的東西)
if git diff --name-only "${PUBLISHED}..HEAD" 2>/dev/null | grep -q '\.wasm$'; then
echo ""
echo " 🔴 這批改動**包含 .wasm 變更** → 用戶抓到的零件會跟你本機不同,優先發佈。"
fi
echo ""
exit 0
+14 -82
View File
@@ -1,16 +1,10 @@
#!/bin/bash
# PreToolUse hook — 動 code 前檢查 SDD 單一活性 SDD 鐵律(issue #6
# PreToolUse hook — 動 code 前檢查有沒有對應 SDD
# wishlist §2:把 /sdd-check 從「命令要人打」升級成「hook 自動攔」。
# 生命週期規則全文:system-dev/docs/3-specs/SDD-LIFECYCLE.md
#
# 掛在 settings.json 的 PreToolUsematcher: Write|Edit)。
# stdin 收到 JSON{ tool_name, tool_input: { file_path, ... } }
# 行為:
# 1. status: active 的 SDD > 1 份 → 單一活性鐵律已被違反,**不論寫什麼檔**一律擋(exit 2),
# 先收斂到一份再說。
# 2. 動 code 檔(.ts/.go/...)→ 需要「恰好 1 份」active SDD;0 份 → 擋。
# 3. 向下相容:3-specs 下完全沒有任何 design.md 帶 frontmatter(老 repo 尚未遷移生命週期制度)
# → 退回舊行為:有 design.md 就放行+提醒,沒有才擋。避免 template update 後老 repo 立刻全紅。
# 行為:動到 code 檔(.ts/.go/...)但 system-dev/docs/3-specs/ 下沒有任何 SDD → 警告(exit 2 擋)。
#
# 誠實限制(抄 arcrun):只擋語法層明顯違規(直接寫 code 檔)。
# 藏在 helper 裡、用 bash 繞道的改動擋不到。
@@ -30,42 +24,6 @@ fi
# 拿不到路徑 → 不擋(容錯,寧可放過也不誤殺)
[ -z "$FILE_PATH" ] && exit 0
SPECS_DIR="system-dev/docs/3-specs"
# ── 統計 active / frontmatter ──────────────────────
# 排除 archive/(已封存)與 TEMPLATE(範本自帶 status: draft frontmatter,不算數——
# 否則 update 一鋪新版 TEMPLATE-sdd,老 repo 就被誤判「已遷移」而全紅,向下相容破功)。
# frontmatter 判定=design.md 前 10 行有 ^status: 行(機器可查,見 SDD-LIFECYCLE.md)。
ACTIVE_COUNT=0
FM_COUNT=0
ACTIVE_LIST=""
if [ -d "$SPECS_DIR" ]; then
while IFS= read -r f; do
[ -n "$f" ] || continue
HEAD10=$(head -10 "$f" 2>/dev/null || true)
if printf '%s\n' "$HEAD10" | grep -q '^status:[[:space:]]*'; then
FM_COUNT=$((FM_COUNT + 1))
if printf '%s\n' "$HEAD10" | grep -q '^status:[[:space:]]*active'; then
ACTIVE_COUNT=$((ACTIVE_COUNT + 1))
ACTIVE_LIST="${ACTIVE_LIST}${f}
"
fi
fi
done < <(find "$SPECS_DIR" -name 'design.md' -not -path '*TEMPLATE*' -not -path '*/archive/*' 2>/dev/null)
fi
# ── 鐵律 1:單一活性被違反(active > 1)→ 不論寫什麼檔一律擋 ──
if [ "$ACTIVE_COUNT" -gt 1 ]; then
cat >&2 <<EOF
🚫 SDD 單一活性鐵律違反:偵測到 ${ACTIVE_COUNT} 份 status: active 的 SDD(任何時刻整個 repo 最多一份):
${ACTIVE_LIST}
請先收斂到一份:其餘改 status: paused / closedclosed 且被取代者填 superseded_by 並移入 3-specs/archive/)。
規則全文見 system-dev/docs/3-specs/SDD-LIFECYCLE.md。收斂前擋下所有寫檔。
(本 hook 攔 Write/Edit;修 frontmatter 可用 bash 直改,或由人裁決哪份是現行。)
EOF
exit 2
fi
# 只管 code 檔。docs/markdown/設定檔等放行。
case "$FILE_PATH" in
*.ts|*.tsx|*.js|*.jsx|*.go|*.py|*.rs|*.java|*.rb|*.php|*.c|*.cpp|*.h|*.hpp|*.swift|*.kt) ;;
@@ -78,54 +36,28 @@ case "$FILE_PATH" in
*_test.*|*.test.*|*.spec.*|*/tests/*|*/test/*) exit 0 ;;
esac
# ── 向下相容:整個 3-specs 沒有任何帶 frontmatter 的 design.md ──
# =老 repo 還沒遷移生命週期制度 → 退回舊行為(有 design.md 就放行+提醒),
# 避免 template update 一裝新 hook,老 repo 所有 code 寫入立刻全紅。
if [ "$FM_COUNT" -eq 0 ]; then
SDD_COUNT=0
if [ -d "$SPECS_DIR" ]; then
SDD_COUNT=$(find "$SPECS_DIR" -name 'design.md' -not -path '*TEMPLATE*' -not -path '*/archive/*' 2>/dev/null | wc -l | tr -d ' ')
fi
# 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),但 ${SPECS_DIR}/ 下找不到任何 SDD。
if [ "$SDD_COUNT" -eq 0 ]; then
cat >&2 <<EOF
🚫 SDD 協議攔截:要動 code 檔 ($FILE_PATH),但 system-dev/docs/3-specs/ 下找不到任何 SDD。
絕對鐵律:任何 code 變動前必須有對應 SDDdesign.md,且遵守單一活性生命週期
system-dev/docs/3-specs/SDD-LIFECYCLE.md)。
絕對鐵律:任何 code 變動前必須有對應 SDDdesign.md
請先:
1. 確認這個改動屬於哪個子系統
2. 在 ${SPECS_DIR}/[子系統]/ 建立 design.md(可用 /sdd-check 協助)frontmatter 標 status: active
2. 在 system-dev/docs/3-specs/[子系統]/ 建立 design.md(可用 /sdd-check 協助)
3. 在回覆開頭宣告已讀 SDD + 對應 task
小修改(修 bug、改文字)若確定豁免,請明確說明範圍後由人放行。
EOF
exit 2
fi
# 舊行為放行 + 提醒遷移(stderr 警告,不擋)
echo "📋 提醒:${SPECS_DIR}/ 有 SDD 但尚未掛生命週期 frontmatter(老結構)。動手前確認已讀對應 design.md;建議依 SDD-LIFECYCLE.md 補 status 標記(現行那份標 active)。" >&2
exit 0
fi
# ── 新行為:寫 code 檔需「恰好 1 份」active SDD ──
if [ "$ACTIVE_COUNT" -eq 0 ]; then
cat >&2 <<EOF
🚫 SDD 協議攔截:要動 code 檔 ($FILE_PATH),但 ${SPECS_DIR}/ 下沒有任何 status: active 的 SDD。
單一活性鐵律:所有開發任務唯一對應源=那份 active SDD(規則見 system-dev/docs/3-specs/SDD-LIFECYCLE.md)。
請先(擇一,都是人的決定,CC 不得自行建 SDD):
1. 把現行規格的 design.md frontmatter 標成 status: active(一份、只能一份)
2. 或依 SDD-LIFECYCLE.md 第 3、4 條:proposal 進 pending-changes.md → 使用者 confirm → 開新 SDD 標 active
然後在回覆開頭宣告已讀 active SDD + 對應 task。
小修改(修 bug、改文字)若確定豁免,請明確說明範圍後由人放行。
EOF
exit 2
fi
# 恰好 1 份 active:放行,留痕提醒要宣告(stderr 警告,不擋)
printf '📋 提醒:現行 active SDD\n%s動手前請確認已讀它的 design.md、對應到 tasks,並在回覆宣告。\n' "$ACTIVE_LIST" >&2
# 有 SDD:放行,留痕提醒要宣告(stderr 警告,不擋)
echo "📋 提醒:system-dev/docs/3-specs/ 下有 SDD。動手前請確認已讀對應 design.md 並在回覆宣告。" >&2
exit 0
+16 -15
View File
@@ -18,10 +18,8 @@ cat <<'EOF'
→ 只能 TinyGo (main.go) 或 AssemblyScript,編譯成 .wasm
2. cypher-executor TS 裡禁止實作 credential/auth/JWT 業務邏輯
→ crypto.subtle.sign 只准出現在 wasi-shim.ts 的 host function
crypto_decrypt 已廢除成永遠回失敗的 stubcredential 走 CF Workers Secrets
→ crypto.subtle.decrypt / sign 只准出現在 wasi-shim.ts 的 host function
→ {{secret.X}} template 展開屬於 WASM 零件職責
(例外:*-seeds.ts 是資料宣告非呼叫實作,見 rule 07)
→ 禁止 hard-code gmail/telegram/sheets API endpoint
3. 禁止新增 Service Binding 綁零件
@@ -43,20 +41,23 @@ cat <<'EOF'
🚧 執行範圍:修改/建立/刪除 <檔案>
4. 每完成一個 task,立刻更新 tasks.md 的 [x],不批次
🔥 當前 active SDD:以 frontmatter `status: active` 為唯一判準(不靠本檔硬寫)
🔥 當前進行中 PhaseCredential Primitives TS → WASM
查法:bash system-dev/scripts/sdd-active-check.sh
規則:system-dev/docs/3-specs/SDD-LIFECYCLE.md(單一活性鐵律,D35
session 開場請回報三個數字:現行規格名稱 + 未完成任務數 + pending-changes 待裁決數
SDDdocs/3-specs/arcrun/credential-primitives-wasm/
已完成:Phase 0.1-0.5(核心合併 + u6u-core 刪除
未完成硬前置:
- Phase 0.6 wasi-shim 加 host functionskv_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(整段刪)
📦 已封存(closed,勿當進行中):
- credential-primitives-wasm → system-dev/docs/3-specs/archive/credential-primitives-wasm/
主線已達成:cypher-executor TS 不再有 credential/auth 業務邏輯
credential-injector.ts / jwt-signer.ts / BUILTIN_API_RECIPES 皆已移除)
credential 現行做法=CF Workers per-script Secrets + D1 目錄,arcrun 不自管加密金鑰
→ 見 .claude/rules/01-tech-stack.md「Credential 儲存規範」
⚠️ 殘留缺口(未隨封存視為完成):auth_mtls 從未實作、7.6 self-hosted auth 鏈
端到端未驗;要做需另立新 SDD。細節見該卷 tasks.md「封存時仍未完成的項目」
⛔ 封測狀態:推遲(richblack 2026-04-19 決定)
原因:違規 TS 未清,不封測。
📚 詳細規範:
.claude/rules/00-sdd-protocol.md — SDD 協議
-27
View File
@@ -32,33 +32,6 @@ SDD 協議要求:code 和 SDD 必須同步更新。
EOF
fi
# ── console-ui:對外網址上是不是還跑著舊世代?(2026-08-08)────────────────
#
# 病(leo:「已經發生過一次這個錯誤,把舊版界面上到 prod,你要確定不可再犯」):
# 前端改完、commit 了、甚至 wiki 都寫了,但**沒有人把它推上去**——
# 而線上不會報錯,只是繼續展示半個月前的介面。08-08 實測:三個對外網址的
# apiBase/profile 全綠,跑的卻是 07-22 那一代。**組態對 ≠ 世代對。**
#
# 為什麼掛在 Stop:這裡正是 CC 要說「做完了」的那一刻。
# 不連網(每回合都跑),只比對「手上這一代」與「最後一次**通過線上實測**的部署紀錄」
# .deploy-state.json 只在 deploy.mjs 驗過線上後才寫,不是跑過指令就寫)。
# 要問線上真實現況:cd console-ui && npm run verify(那支才連網)。
if [ -d console-ui/scripts ] && command -v node >/dev/null 2>&1; then
LAG="$(cd console-ui && node scripts/verify-live.mjs --offline-lag 2>/dev/null)"
if [ -n "$LAG" ]; then
cat >&2 <<EOF
🕰️ console-ui:手上這一代**還沒送出去過**
$(echo "$LAG" | sed 's/^/ · /')
對外網址不會因此報錯——它只會繼續展示舊介面,而所有只驗組態的檢查都會說它是綠的。
要看線上現在真的在跑哪一代: cd console-ui && npm run verify
要送出去(含推完自動回頭驗線上):cd console-ui && npm run deploy:personal
EOF
fi
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
-76
View File
@@ -1,76 +0,0 @@
#!/bin/bash
# subagent-wiki-guard.sh — PreToolUse(Task) hooksubagent 聽到「查」就自己先查 wiki
#
# 病根(2026-07-20):總管兩次派 agent 查 ENCRYPTION_KEYprompt 都只叫它「去查 repo 程式碼」。
# agent 於是從**稿子**推論出「這東西還活著、不能動」,總管照單全收去擋 leo 三輪。
#
# 🔑 設計轉向(leo 2026-07-21):
# 第一版是「上游沒交代讀 wiki 就擋下」——但那**還是依賴上游記得寫**,
# 跟「我記得讀 wiki」是同一個病。leo 點破:
# 「subagent 的問題跟你一樣。你叫它去查,就算你沒說要先查 wiki,
# 但它**只要聽到查,就應該主動查 wiki**,因為每個 repo 都有維護自己的 wiki。」
# → 改成 **注入式**:不擋、不要求上游改 prompt,直接把「先查 wiki」這條
# 以 additionalContext 注入給 subagent,讓它自己做。零依賴任何人記得。
#
# 行為:偵測到查證/實作類任務 → exit 0 並用 hookSpecificOutput 注入指示。
# 已含 wiki 指示、或非查證類任務 → 靜默放行(不重複注入)。
set -euo pipefail
INPUT=$(cat)
PROMPT=$(printf '%s' "$INPUT" | python3 -c "
import json,sys
try:
d=json.load(sys.stdin)
print(d.get('tool_input',{}).get('prompt',''))
except Exception: print('')
" 2>/dev/null || echo "")
[ -z "$PROMPT" ] && exit 0
# 上游已經交代了 → 不必重複注入
if printf '%s' "$PROMPT" | grep -qiE "wiki|agent-memory|mistakes\.md|decisions-summary"; then
exit 0
fi
# 只對「查證/實作」類任務注入(純寫作、計算、潤稿等不需要)
if ! printf '%s' "$PROMPT" | grep -qiE "查|盤點|核實|確認|調查|研究|找出|repo|程式碼|原始碼|source|實作|移除|刪除|重構|修|grep|codebase|\.ts|\.go|src/"; then
exit 0
fi
python3 - <<'PY'
import json
guidance = """【自動注入:查任何東西之前,先查 wiki】
你所在的 repo 有維護自己的 wiki(通常在 `system-dev/wiki/`,舊結構在 `.claude/wiki/`)。
**接到「查/盤點/核實/實作」類任務時,第一個動作是搜尋 wiki,不是翻程式碼。**
做法(30 秒,省下大量白工):
grep -rin "<本題關鍵字>" system-dev/wiki/ 2>/dev/null || grep -rin "<關鍵字>" .claude/wiki/
為什麼這是划算的:
• wiki 是前人已經查過、驗證過、被負責人糾正過的結論——**判準**。
• 程式碼與歷史文件是**稿子**:它反映「還沒清乾淨」,不等於「還在用」。
從稿子推論會系統性得出過時結論。
• wiki 沒記載,才值得花力氣翻原文。
三條硬規則:
1. **wiki 與程式碼衝突 → 以 wiki 為準**,並在回報中明確指出衝突,
不要自行用 code 推翻 wiki。
2. wiki 寫「不可動/待廢除/進行中」→ **讀它的解除條件並逐條核對**。
那是當時狀態,不是永久禁令;條件已滿足就是可動。
2026-07-20 實際事故:agent 只看到「不可動」就回報不能動,
實際上解除條件早已滿足,害負責人被擋三輪。)
3. 翻原文後若得到**新結論**,回報時明講「wiki 該更新」——wiki 過時是債,要還。
"""
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"additionalContext": guidance
}
}, ensure_ascii=False))
PY
exit 0
-88
View File
@@ -1,88 +0,0 @@
#!/bin/bash
# wiki-first-search.sh — PreToolUse hook:要去翻原文/程式碼前,先把 wiki 命中結果推到眼前
#
# 病根(2026-07-20 leo 點破,mistakes 第一鐵律):
# 總管 session 開頭讀了 agent-memory 前 50 行就開工,關鍵那條在第 56 行 → 拿過期記憶擋了 leo 三輪。
# leo:「如果你不是讀而是**搜尋** wiki 就不會只讀 50 行就下定論,
# 而是就像我直接在頁面 cmd+F,那些都會高亮。」
#
# 設計要點(為什麼是這個形狀):
# 1. **搜尋 ≠ 通讀**:開場 push 全文(session-start-recall.sh)解決不了這題——量大必然只讀開頭。
# 這支反過來:在「你正要去查 code/原文」的當下,用你自己的關鍵字 grep wiki,只推命中行。
# 2. **時機是關鍵**:不是開場推、不是寫入時擋,而是**查詢動作發生的那一刻**介入。
# 3. **提醒不阻擋**exit 0):wiki 沒記載時本來就該去翻原文,擋下來反而礙事。
# 唯一目的是消滅「不知道 wiki 有寫」這件事。
#
# 觸發:Grep / Glob / Read 打向 code 或 docs 時(見下方 should_check)。
# 輸出:stdout 注入 context(命中的 wiki 行 + 檔名:行號)。
set -euo pipefail
INPUT=$(cat)
TOOL=$(printf '%s' "$INPUT" | python3 -c "import json,sys;print(json.load(sys.stdin).get('tool_name',''))" 2>/dev/null || echo "")
# 取出這次查詢的關鍵字:Grep 用 patternGlob/Read 用路徑的檔名部分
QUERY=$(printf '%s' "$INPUT" | python3 -c "
import json,sys,os,re
try:
d=json.load(sys.stdin); ti=d.get('tool_input',{})
q = ti.get('pattern') or ''
if not q:
p = ti.get('file_path') or ti.get('path') or ''
q = os.path.splitext(os.path.basename(p))[0] if p else ''
if not q:
# Bash2026-07-21 補的破口——原版只掛 Grep|Glob|Read
# 但「用 curl/wrangler 亂試部署方法」走的是 Bash,整支 hook 不觸發。
# leo 當場點破:wiki 早記著「寄信已驗證可用」,我卻沒查又自創方法。
# 只認「會動到外部系統/部署」的高風險指令,避免每個 ls 都洗版。
cmd = ti.get('command') or ''
if re.search(r'\b(wrangler|curl|npx|acr|gh|deploy|push)\b', cmd):
# 取指令中最具識別度的詞(worker 名/資源名/子命令)當搜尋詞
cand = re.findall(r'[A-Za-z_][A-Za-z0-9_-]{4,}', cmd)
skip = {'https','http','client','accounts','workers','scripts',
'application','content','Authorization','Bearer','python3',
'curl','npx','bash','echo','grep','local','branch','origin'}
cand = [c for c in cand if c not in skip and not c.startswith('-')]
q = max(cand, key=len) if cand else ''
# grep pattern 常含 regex 元字元;取最長的英數/底線詞當搜尋詞
words = re.findall(r'[A-Za-z_][A-Za-z0-9_]{3,}', q)
print(max(words, key=len) if words else '')
except Exception:
print('')
" 2>/dev/null || echo "")
[ -z "$QUERY" ] && exit 0
WIKI_DIR="system-dev/wiki"
[ -d "$WIKI_DIR" ] || exit 0
# 只在「查程式碼/文件」時提醒;查 wiki 本身就不用了(已經在讀了)
TARGET=$(printf '%s' "$INPUT" | python3 -c "
import json,sys
try:
d=json.load(sys.stdin); ti=d.get('tool_input',{})
print(ti.get('file_path') or ti.get('path') or '')
except Exception: print('')
" 2>/dev/null || echo "")
case "$TARGET" in
*system-dev/wiki*) exit 0 ;;
esac
# grep wiki(不分大小寫、含行號),最多 12 行避免洗版
HITS=$(grep -rin --include="*.md" -- "$QUERY" "$WIKI_DIR" 2>/dev/null | head -12 || true)
[ -z "$HITS" ] && exit 0
COUNT=$(printf '%s\n' "$HITS" | wc -l | tr -d ' ')
echo "════════════════════════════════════════════════"
printf '📚 wiki 已有「%s」的記載(%s 處,先看這裡再翻原文)\n' "$QUERY" "$COUNT"
echo "════════════════════════════════════════════════"
printf '%s\n' "$HITS" | sed 's|^system-dev/wiki/| |'
echo ""
echo "⚠️ wiki 是判準,程式碼與歷史文件只是稿子(mistakes 第一鐵律)。"
echo " • 上面若與你將要查的原文衝突 → **以 wiki 為準**,別用 code 推翻 wiki。"
echo " • 看到「不可動/待廢除/進行中」→ 先讀它的**解除條件**並逐條核對,"
echo " 那是當時狀態不是永久禁令;條件已滿足就是可動。"
echo " • wiki 沒答案才值得翻原文——翻完若得到新結論,**回頭更新 wiki**。"
echo ""
exit 0
+3 -1
View File
@@ -14,7 +14,9 @@
| 任務類型 | 對應 SDD |
|---------|---------|
| Auth primitive WASM 零件 / credential 儲存 / WASI shim host functions | **已封存**`system-dev/docs/3-specs/archive/credential-primitives-wasm/``status: closed`)。現行規範見 `.claude/rules/01-tech-stack.md`「Credential 儲存規範」;**新工作需另立 SDD**(含未實作的 `auth_mtls`),不得掛在已封存的卷上 |
| 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 functionskv_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 / SDKPython/JS | `docs/3-specs/arcrun/sdk-and-website/` |
+10 -17
View File
@@ -30,8 +30,7 @@
|---|---|
| `u6u.http_request` | 發 HTTP 請求 |
| `u6u.kv_get` | 讀 Cloudflare KVWorker 側依 key 前綴路由到正確 KV) |
| `u6u.secret_get` | 讀 CF Workers Secrets(只放行 `CRED_` 前綴 |
| `u6u.crypto_decrypt` | ⚠️ 已廢除,保留成永遠回失敗的 stub(現役 wasm 仍宣告此 import |
| `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 使用。
@@ -51,22 +50,16 @@
**警告:R2 不存平台內建零件的 WASM**。平台零件已 bundle 進各自的 Worker binary`[[wasm_modules]]``import ... assert { type: 'webassembly' }`)。
## Credential 儲存規範
## 加解密規範
**arcrun 不自管加密金鑰。** credential 明文由 **Cloudflare Workers Secrets** 託管
per-script secret,掛在用戶自己的 cypher worker 上):
- secret 名稱 = `CRED_{NAME}_{sha256(api_key)[:8]}`(跨租戶命名隔離
- D1 `credentials` 表只存**目錄**api_key / name / service / secret_ref),不存值
- D19:擁有目錄,不擁有內容物——連 owner 都讀不回,只能覆寫/刪除
- 需要 worker 設 `CF_SECRETS_API_TOKEN`(機密,用戶手動 put+ `CF_ACCOUNT_ID`(自動注入)
> 註:`crypto_decrypt` host function 是**永遠回失敗的 stub**,因為現役三個 `auth_*`
> `.wasm` 仍宣告該 import(缺項會讓 WASM instantiate 失敗)。三個零件重編後即可刪除。
- **取用位置**Server 端 **WASM primitive**(透過 host function `secret_get(ref)`
- cypher-executor TS 不碰業務邏輯,只提供 host function
- `secret_get` 只放行 `CRED_` 前綴,WASM 讀不到 worker 本身的其他機密
- **傳輸格式**:明文值走 TLS`POST /credentials`),不做 client 端加密
- **演算法**AES-GCM 256-bit
- **加密位置**Client 端(CLI / Python SDK / JS SDK
- Python`cryptography` 套件
- JSWeb 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
## 網路部署
+5 -8
View File
@@ -33,13 +33,10 @@ Auth primitive 必須透過 `component-worker-template/` 搭配 WASM binary 部
## 第二類:cypher-executor TS 的禁令
### 2.1 禁止新增任何 credential / auth / jwt 相關的 TS 檔案
**清除已完成**2026-07-20credential-primitives-wasm 卷已封存)。下列曾違規的 TS **均已不存在**
列此僅為「禁止重新引入」的清單:
- ~~`cypher-executor/src/actions/credential-injector.ts`~~已刪(auth 走 WASM primitive
- ~~`cypher-executor/src/lib/jwt-signer.ts`~~ → 已刪(RS256 在 auth_service_account WASM
- ~~`component-loader.ts``BUILTIN_API_RECIPES` / `BUILTIN_CREDENTIALS_MAP`~~ → 已整段刪
**重新建立上述任一者 = 違規**
**已存在但要刪**(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`
@@ -86,7 +83,7 @@ Auth primitive 必須透過 `component-worker-template/` 搭配 WASM binary 部
13 個現有的 `SVC_*` 綁定(`cypher-executor/wrangler.toml`,邏輯零件)是歷史遺產(效能優化),**保留但不新增**。
> **2026-06-06 註**(來源:credential-primitives-wasm Phase 7,該卷已封存於 `system-dev/docs/3-specs/archive/`**本註記述的規則仍現行有效**self-hosted 的 cypher 與 auth worker 同在 `{sub}.workers.dev` zonecypher `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**。故本禁令不變。
> **2026-06-06 註credential-primitives-wasm Phase 7**self-hosted 的 cypher 與 auth worker 同在 `{sub}.workers.dev` zonecypher `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]]` 新增的 patternEdit wrangler.toml 新增 `[[services]]` 區塊時警告確認。
+1 -1
View File
@@ -79,7 +79,7 @@ Service binding 需要 `wrangler.toml` 裡寫死 `[[services]]`,且要 redeplo
**禁止新增任何 Service Binding**。所有新零件(含 auth primitive)都走 HTTP URL 路徑。
**same-zone 1042 的解**(來源:credential-primitives-wasm Phase 72026-06-06;該卷已封存於 `system-dev/docs/3-specs/archive/`**但本段規則仍現行有效**):self-hosted 的 cypher 與 auth worker 同在 `{sub}.workers.dev` zonecypher `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**
**same-zone 1042 的解credential-primitives-wasm Phase 72026-06-06**self-hosted 的 cypher 與 auth worker 同在 `{sub}.workers.dev` zonecypher `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 不變。**
+47 -45
View File
@@ -1,75 +1,77 @@
# 當前進度(SessionStart 會注入此檔重點)
> 更新時間:2026-07-21
> **權威來源design.md frontmatter `status: active`**(機器可查),不是本檔。
> 查法:`bash system-dev/scripts/sdd-active-check.sh`
> 本檔只是索引,任何與 frontmatter 衝突之處以 frontmatter 為準。
> 更新時間:2026-04-19
> 權威來源`docs/3-specs/arcrun/credential-primitives-wasm/tasks.md`
> 此檔僅摘要,詳細狀態以 tasks.md 為準。
---
## 現行 active SDD
## 封測狀態
**RAG Portal 多人授權**`system-dev/docs/3-specs/portal-auth/`
依 SDD 生命週期鐵律(D35,全文 `system-dev/docs/3-specs/SDD-LIFECYCLE.md`):
任何時刻整個 repo 只允許**一份** `status: active`。所有開發任務必須對應它的 tasks,
找不到對應 → 停下來問,不准直接做。
**session 開場請回報三個數字**:現行規格名稱 未完成任務數 `pending-changes.md` 待裁決 proposal 數。
**原定明天封測,richblack 決定推遲**,原因:cypher-executor 有三套 TS 業務邏輯違反「零件一律 WASM」架構原則(Phase 1-3 要清除的程式碼),在清除前不封測。
---
## 已封存(closed,勿當進行中)
## 目前 PhaseCredential Primitives TS → WASM
### Credential Primitives TS → WASM
**SDD 位置**`docs/3-specs/arcrun/credential-primitives-wasm/design.md` + `tasks.md`
`system-dev/docs/3-specs/archive/credential-primitives-wasm/`2026-07-21 封存)
### 已完成
**主線已達成**cypher-executor TS 不再實作任何 credential / auth 業務邏輯。
`credential-injector.ts``jwt-signer.ts``BUILTIN_API_RECIPES` / `BUILTIN_CREDENTIALS_MAP`
全數移除(最後一項 T10 於 2026-07-20 commit `20c7610` 完成)。
- **Phase 0.10.5**:核心合併(u6u-core 併入 arcrun、21 個零件 contract 完整、刪除重複 `credentials/` 目錄、CREDENTIALS_KV binding 確認、刪除 `matrix/u6u-core/`
- `registry/components/` 下 21 個零件(邏輯 + API)都有 `main.go` + `.wasm`
**credential 現行做法**(非被另一卷 supersede,是機制整個換掉):
- 密文 → **CF Workers per-script Secrets**`CRED_{NAME}_{hash}`),arcrun 讀不回明文
- 目錄 → **D1** `credentials` 表(name / service / secret_ref,不含值)
- **arcrun 不自管任何加密金鑰**;`crypto_decrypt` host function 已成永遠回失敗的 stub
- 完整規範:`.claude/rules/01-tech-stack.md`「Credential 儲存規範」
### 進行中 / 未完成
⚠️ **封存 ≠ 全部做完**。真實殘留缺口(要做需另立新 SDD,不得掛回已封存的卷):
- **`auth_mtls` 從未實作**`registry/components/auth_mtls/` 不存在,mTLS 認證不支援)
- **7.6 self-hosted auth 鏈端到端未驗**`global_fetch_strictly_public` flag 是否真解
same-zone 1042,在自架帳號上從未實測)
- 數項端到端測試(1.7/1.8/2.6/3.5/3.6/5.1)無驗證記錄
| 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) | ⬜ 未開始 | 封測啟動門檻 |
逐條核實見該卷 `tasks.md` 的「封存時仍未完成的項目」段。
### 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.6host functions)和 0.7WASM runner)是並列的前置工作,哪個先都可以,但都要在 Phase 1 開始之前完成。
---
## SDD 索引
> 「進行中」與否一律以 frontmatter 為準,下表僅路徑速查。
| 子系統 | SDD |
|--------|-----|
| **現行 active** RAG Portal 多人授權 | `system-dev/docs/3-specs/portal-auth/` |
| ~~Credential Primitives WASM~~closed | `system-dev/docs/3-specs/archive/credential-primitives-wasm/` |
| LLM InterfaceAI 操盤手使用體驗) | `system-dev/docs/3-specs/llm-interface/` |
| arcrun 總進度 | `system-dev/docs/3-specs/arcrun/arcrun.md` |
| Auth Recipe 系統(schema、預建服務) | `system-dev/docs/3-specs/arcrun/auth-recipe.md` |
| SDK + Website | `system-dev/docs/3-specs/arcrun/sdk-and-website/` |
| arcrun MVP 整體 | `system-dev/docs/3-specs/arcrun-core-mvp/` |
| Platform Evolution | `system-dev/docs/3-specs/arcrun-platform-evolution/` |
| Tech Stack 詳細 | `system-dev/docs/3-specs/tech.md` |
| 生命週期鐵律 | `system-dev/docs/3-specs/SDD-LIFECYCLE.md` |
| 待裁決 proposal | `system-dev/docs/3-specs/pending-changes.md` |
| **主要(正在動)** 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**。不是從 R2 動態讀。
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. **credential 不自管金鑰**:走 `secret_get(ref)` 讀 CF Workers Secrets`crypto_decrypt` 是 stub
6. 詳見 `.claude/rules/03-component-architecture.md``01-tech-stack.md`
5. 詳見 `.claude/rules/03-component-architecture.md`
+2 -5
View File
@@ -62,13 +62,10 @@ find . -name 'wrangler.toml' -not -path '*/node_modules/*' -not -name 'wrangler.
**CI 只提供 Cloudflare 驗證,不碰 runtime secret**
- GH Actions secrets:`CLOUDFLARE_API_TOKEN``CLOUDFLARE_ACCOUNT_ID`(一次性設好)
- Runtime secret(例:`CF_SECRETS_API_TOKEN``OPENAI_KEY``GOOGLE_API_KEY`):
- Runtime secret(例:`ENCRYPTION_KEY``OPENAI_KEY``GOOGLE_API_KEY`):
- **由 richblack 一次性手動** `wrangler secret put <KEY>` 設進各 Worker
- 不進 CI,不進 `wrangler.toml` `[vars]`
- 需要的 Worker:`arcrun-cypher-executor`(`CF_SECRETS_API_TOKEN`——寫 credential 進
Workers Secrets 用;`CF_ACCOUNT_ID` 非機密由 `acr init` 自動注入)
> `auth_static_key` / `auth_service_account` 不需要任何 secret。
- 需要的 Worker:`auth_static_key``auth_service_account`(兩個都要 `ENCRYPTION_KEY`)
---
+1 -33
View File
@@ -10,15 +10,6 @@
"timeout": 5
}
]
},
{
"matcher": "startup|resume|clear",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/publish-lag-check.sh"
}
]
}
],
"PreToolUse": [
@@ -34,11 +25,6 @@
"type": "command",
"command": "bash .claude/hooks/wiki-secret-scan.sh",
"timeout": 5
},
{
"type": "command",
"command": "bash .claude/hooks/sdd-guard.sh",
"timeout": 5
}
]
},
@@ -51,24 +37,6 @@
"timeout": 5
}
]
},
{
"matcher": "Grep|Glob|Read|Bash",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/wiki-first-search.sh"
}
]
},
{
"matcher": "Task",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/subagent-wiki-guard.sh"
}
]
}
],
"PostToolUse": [
@@ -95,4 +63,4 @@
}
]
}
}
}
@@ -19,3 +19,4 @@ id = "e7f4320f88d343f187e35e3543dd74c9"
binding = "RECIPES"
id = "9cf9db905c6241f78503199e58b2ffe0"
# ENCRYPTION_KEY 透過 wrangler secret put 設定
@@ -11,6 +11,7 @@
*
* 安全邊界:
* - api_key 經 stdin 傳進 WASM,同時綁到 host function 的 kv_get 做越權檢查
* - ENCRYPTION_KEY 只存在於 host function 的 closure 中,不會進入 WASM 記憶體
* - private key 只以 PKCS8 bytes 傳給 crypto_sign_rs256 host function,decrypt 後 plaintext 不離開 WASM
*/
@@ -20,3 +20,5 @@ id = "e7f4320f88d343f187e35e3543dd74c9"
binding = "RECIPES"
id = "9cf9db905c6241f78503199e58b2ffe0"
# ENCRYPTION_KEY 透過 wrangler secret set 設定
# wrangler secret put ENCRYPTION_KEY
@@ -8,6 +8,7 @@
*
* 安全邊界:
* - api_key 經 stdin 傳進 WASM,同時綁到 host function 的 kv_get 做越權檢查
* - ENCRYPTION_KEY 只存在於 host function 的 closure 中,不會進入 WASM 記憶體
*/
import componentWasm from '../component.wasm' assert { type: 'webassembly' };
@@ -20,3 +20,5 @@ id = "e7f4320f88d343f187e35e3543dd74c9"
binding = "RECIPES"
id = "9cf9db905c6241f78503199e58b2ffe0"
# ENCRYPTION_KEY 透過 wrangler secret set 設定
# wrangler secret put ENCRYPTION_KEY
+1 -5
View File
@@ -1,11 +1,7 @@
name = "arcrun-http-request"
main = "src/index.ts"
compatibility_date = "2025-02-19"
# global_fetch_strictly_publicself-hosted 同帳號部署時,http_request 零件外呼同 workers.dev
# zone 的目標會撞 CF same-zone 1042——此 flag 讓 fetch 走公網前門(Arcrun#33 同族;
# 正解同 cypher 前例,見 .claude/rules/03 的 1042 段)。官方部署本就跨 zonearcrun.dev route),
# 加此 flag 行為不變 → 官方/self-hosted 共用同一份 toml。
compatibility_flags = ["nodejs_compat", "global_fetch_strictly_public"]
compatibility_flags = ["nodejs_compat"]
workers_dev = true
[vars]
@@ -0,0 +1,14 @@
{
"name": "arcrun-kbdb-upsert-block",
"version": "1.0.0",
"private": true,
"type": "module",
"dependencies": {
"hono": "^4.7.0"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250408.0",
"typescript": "^5.4.0",
"wrangler": "^4.0.0"
}
}
+898
View File
@@ -0,0 +1,898 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
hono:
specifier: ^4.7.0
version: 4.12.18
devDependencies:
'@cloudflare/workers-types':
specifier: ^4.20250408.0
version: 4.20260511.1
typescript:
specifier: ^5.4.0
version: 5.9.3
wrangler:
specifier: ^4.0.0
version: 4.90.1(@cloudflare/workers-types@4.20260511.1)
packages:
'@cloudflare/kv-asset-handler@0.5.0':
resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==}
engines: {node: '>=22.0.0'}
'@cloudflare/unenv-preset@2.16.1':
resolution: {integrity: sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==}
peerDependencies:
unenv: 2.0.0-rc.24
workerd: '>1.20260305.0 <2.0.0-0'
peerDependenciesMeta:
workerd:
optional: true
'@cloudflare/workerd-darwin-64@1.20260508.1':
resolution: {integrity: sha512-IT3r6VgiSwIesL4AJbxjgxvIxwWZqM7BKkhYAzOKHl4GF2M0TxeOahUIXd+CYXVZgHX8ceEg+MXbEehPelJyNg==}
engines: {node: '>=16'}
cpu: [x64]
os: [darwin]
'@cloudflare/workerd-darwin-arm64@1.20260508.1':
resolution: {integrity: sha512-JTVsisOJPcNKw0qovPjqyBWYahfdhUh7/9NICiG5wxaEQ45PYKdoqNq0hOAAIqvqoxsKZBvTgcPTJREPqk7avA==}
engines: {node: '>=16'}
cpu: [arm64]
os: [darwin]
'@cloudflare/workerd-linux-64@1.20260508.1':
resolution: {integrity: sha512-zO38pCc27YlsZiPYcaZnosy0/t7abXrRU3VEO1oKfUvnaCpHgphDG+VsrmHL+kntda6hrtNwg2jLeMAqqIjnjw==}
engines: {node: '>=16'}
cpu: [x64]
os: [linux]
'@cloudflare/workerd-linux-arm64@1.20260508.1':
resolution: {integrity: sha512-XhJa780Ia6MNIrtxn/ruZHS79b9pu5EKPfRNReaUqxy8erPT2fs93axMfFoS9kIkcaRRj/1TOUKcTeAMoywY7w==}
engines: {node: '>=16'}
cpu: [arm64]
os: [linux]
'@cloudflare/workerd-windows-64@1.20260508.1':
resolution: {integrity: sha512-QdDOK3B/Ul1s3QmIwDrFyx9230to6LsNmWcVR8w+TYjNZuRPzqQBgusp78LO7MlqCoEl9dvIcN00jkJnLtBSfw==}
engines: {node: '>=16'}
cpu: [x64]
os: [win32]
'@cloudflare/workers-types@4.20260511.1':
resolution: {integrity: sha512-FA+si7cOq9i/gtCHhIc0XJL0l1F/ApF+m00752Aj7WZFJrj3ZulT2T8/+rT3BabMT0QEnqFEGIqCgrmqhgEfMg==}
'@cspotcode/source-map-support@0.8.1':
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
engines: {node: '>=12'}
'@emnapi/runtime@1.10.0':
resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
'@esbuild/aix-ppc64@0.27.3':
resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.27.3':
resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.27.3':
resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.27.3':
resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.27.3':
resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.27.3':
resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.27.3':
resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.27.3':
resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.27.3':
resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.27.3':
resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.27.3':
resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.27.3':
resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.27.3':
resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.27.3':
resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.27.3':
resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.27.3':
resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.27.3':
resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.27.3':
resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.27.3':
resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.27.3':
resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.27.3':
resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.27.3':
resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.27.3':
resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.27.3':
resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.27.3':
resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.27.3':
resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@img/colour@1.1.0':
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'}
'@img/sharp-darwin-arm64@0.34.5':
resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [darwin]
'@img/sharp-darwin-x64@0.34.5':
resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-darwin-arm64@1.2.4':
resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
cpu: [arm64]
os: [darwin]
'@img/sharp-libvips-darwin-x64@1.2.4':
resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-linux-arm64@1.2.4':
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [wasm32]
'@img/sharp-win32-arm64@0.34.5':
resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [win32]
'@img/sharp-win32-ia32@0.34.5':
resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ia32]
os: [win32]
'@img/sharp-win32-x64@0.34.5':
resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [win32]
'@jridgewell/resolve-uri@3.1.2':
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
engines: {node: '>=6.0.0'}
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@jridgewell/trace-mapping@0.3.9':
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
'@poppinss/colors@4.1.6':
resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==}
'@poppinss/dumper@0.6.5':
resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==}
'@poppinss/exception@1.2.3':
resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==}
'@sindresorhus/is@7.2.0':
resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==}
engines: {node: '>=18'}
'@speed-highlight/core@1.2.15':
resolution: {integrity: sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==}
blake3-wasm@2.1.5:
resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==}
cookie@1.1.1:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
error-stack-parser-es@1.0.5:
resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==}
esbuild@0.27.3:
resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==}
engines: {node: '>=18'}
hasBin: true
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
hono@4.12.18:
resolution: {integrity: sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==}
engines: {node: '>=16.9.0'}
kleur@4.1.5:
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
engines: {node: '>=6'}
miniflare@4.20260508.0:
resolution: {integrity: sha512-h3aG+PA8jEH76V4ZtBAbs3g7kjMfHJUF8hPvxeeajLTKwir+G+dqfBODg5yF9MT29LqrZKCRQRqzfHPWX4kCIg==}
engines: {node: '>=22.0.0'}
hasBin: true
path-to-regexp@6.3.0:
resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
semver@7.8.0:
resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==}
engines: {node: '>=10'}
hasBin: true
sharp@0.34.5:
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
supports-color@10.2.2:
resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
engines: {node: '>=18'}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
undici@7.24.8:
resolution: {integrity: sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==}
engines: {node: '>=20.18.1'}
unenv@2.0.0-rc.24:
resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==}
workerd@1.20260508.1:
resolution: {integrity: sha512-VlnjyH3AjVddpSK7J54nsCVgf8i2733pl8GjKttfNi7vN/hEjjAk20d2b1nDToOLKvRQpTewRnVkqaaeGHCaAw==}
engines: {node: '>=16'}
hasBin: true
wrangler@4.90.1:
resolution: {integrity: sha512-u2KrieKSMfRM0toTst/CfDtcRraeoVjmcExcMWgILM/ytq3qcDhuOAULoZSyPHzma43lfLJy1BC544drFyqe1A==}
engines: {node: '>=22.0.0'}
hasBin: true
peerDependencies:
'@cloudflare/workers-types': ^4.20260508.1
peerDependenciesMeta:
'@cloudflare/workers-types':
optional: true
ws@8.18.0:
resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
youch-core@0.3.3:
resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==}
youch@4.1.0-beta.10:
resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==}
snapshots:
'@cloudflare/kv-asset-handler@0.5.0': {}
'@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260508.1)':
dependencies:
unenv: 2.0.0-rc.24
optionalDependencies:
workerd: 1.20260508.1
'@cloudflare/workerd-darwin-64@1.20260508.1':
optional: true
'@cloudflare/workerd-darwin-arm64@1.20260508.1':
optional: true
'@cloudflare/workerd-linux-64@1.20260508.1':
optional: true
'@cloudflare/workerd-linux-arm64@1.20260508.1':
optional: true
'@cloudflare/workerd-windows-64@1.20260508.1':
optional: true
'@cloudflare/workers-types@4.20260511.1': {}
'@cspotcode/source-map-support@0.8.1':
dependencies:
'@jridgewell/trace-mapping': 0.3.9
'@emnapi/runtime@1.10.0':
dependencies:
tslib: 2.8.1
optional: true
'@esbuild/aix-ppc64@0.27.3':
optional: true
'@esbuild/android-arm64@0.27.3':
optional: true
'@esbuild/android-arm@0.27.3':
optional: true
'@esbuild/android-x64@0.27.3':
optional: true
'@esbuild/darwin-arm64@0.27.3':
optional: true
'@esbuild/darwin-x64@0.27.3':
optional: true
'@esbuild/freebsd-arm64@0.27.3':
optional: true
'@esbuild/freebsd-x64@0.27.3':
optional: true
'@esbuild/linux-arm64@0.27.3':
optional: true
'@esbuild/linux-arm@0.27.3':
optional: true
'@esbuild/linux-ia32@0.27.3':
optional: true
'@esbuild/linux-loong64@0.27.3':
optional: true
'@esbuild/linux-mips64el@0.27.3':
optional: true
'@esbuild/linux-ppc64@0.27.3':
optional: true
'@esbuild/linux-riscv64@0.27.3':
optional: true
'@esbuild/linux-s390x@0.27.3':
optional: true
'@esbuild/linux-x64@0.27.3':
optional: true
'@esbuild/netbsd-arm64@0.27.3':
optional: true
'@esbuild/netbsd-x64@0.27.3':
optional: true
'@esbuild/openbsd-arm64@0.27.3':
optional: true
'@esbuild/openbsd-x64@0.27.3':
optional: true
'@esbuild/openharmony-arm64@0.27.3':
optional: true
'@esbuild/sunos-x64@0.27.3':
optional: true
'@esbuild/win32-arm64@0.27.3':
optional: true
'@esbuild/win32-ia32@0.27.3':
optional: true
'@esbuild/win32-x64@0.27.3':
optional: true
'@img/colour@1.1.0': {}
'@img/sharp-darwin-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.2.4
optional: true
'@img/sharp-darwin-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.2.4
optional: true
'@img/sharp-libvips-darwin-arm64@1.2.4':
optional: true
'@img/sharp-libvips-darwin-x64@1.2.4':
optional: true
'@img/sharp-libvips-linux-arm64@1.2.4':
optional: true
'@img/sharp-libvips-linux-arm@1.2.4':
optional: true
'@img/sharp-libvips-linux-ppc64@1.2.4':
optional: true
'@img/sharp-libvips-linux-riscv64@1.2.4':
optional: true
'@img/sharp-libvips-linux-s390x@1.2.4':
optional: true
'@img/sharp-libvips-linux-x64@1.2.4':
optional: true
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
optional: true
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
optional: true
'@img/sharp-linux-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.2.4
optional: true
'@img/sharp-linux-arm@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.2.4
optional: true
'@img/sharp-linux-ppc64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-ppc64': 1.2.4
optional: true
'@img/sharp-linux-riscv64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-riscv64': 1.2.4
optional: true
'@img/sharp-linux-s390x@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-s390x': 1.2.4
optional: true
'@img/sharp-linux-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.2.4
optional: true
'@img/sharp-linuxmusl-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
optional: true
'@img/sharp-linuxmusl-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
optional: true
'@img/sharp-wasm32@0.34.5':
dependencies:
'@emnapi/runtime': 1.10.0
optional: true
'@img/sharp-win32-arm64@0.34.5':
optional: true
'@img/sharp-win32-ia32@0.34.5':
optional: true
'@img/sharp-win32-x64@0.34.5':
optional: true
'@jridgewell/resolve-uri@3.1.2': {}
'@jridgewell/sourcemap-codec@1.5.5': {}
'@jridgewell/trace-mapping@0.3.9':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@poppinss/colors@4.1.6':
dependencies:
kleur: 4.1.5
'@poppinss/dumper@0.6.5':
dependencies:
'@poppinss/colors': 4.1.6
'@sindresorhus/is': 7.2.0
supports-color: 10.2.2
'@poppinss/exception@1.2.3': {}
'@sindresorhus/is@7.2.0': {}
'@speed-highlight/core@1.2.15': {}
blake3-wasm@2.1.5: {}
cookie@1.1.1: {}
detect-libc@2.1.2: {}
error-stack-parser-es@1.0.5: {}
esbuild@0.27.3:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.3
'@esbuild/android-arm': 0.27.3
'@esbuild/android-arm64': 0.27.3
'@esbuild/android-x64': 0.27.3
'@esbuild/darwin-arm64': 0.27.3
'@esbuild/darwin-x64': 0.27.3
'@esbuild/freebsd-arm64': 0.27.3
'@esbuild/freebsd-x64': 0.27.3
'@esbuild/linux-arm': 0.27.3
'@esbuild/linux-arm64': 0.27.3
'@esbuild/linux-ia32': 0.27.3
'@esbuild/linux-loong64': 0.27.3
'@esbuild/linux-mips64el': 0.27.3
'@esbuild/linux-ppc64': 0.27.3
'@esbuild/linux-riscv64': 0.27.3
'@esbuild/linux-s390x': 0.27.3
'@esbuild/linux-x64': 0.27.3
'@esbuild/netbsd-arm64': 0.27.3
'@esbuild/netbsd-x64': 0.27.3
'@esbuild/openbsd-arm64': 0.27.3
'@esbuild/openbsd-x64': 0.27.3
'@esbuild/openharmony-arm64': 0.27.3
'@esbuild/sunos-x64': 0.27.3
'@esbuild/win32-arm64': 0.27.3
'@esbuild/win32-ia32': 0.27.3
'@esbuild/win32-x64': 0.27.3
fsevents@2.3.3:
optional: true
hono@4.12.18: {}
kleur@4.1.5: {}
miniflare@4.20260508.0:
dependencies:
'@cspotcode/source-map-support': 0.8.1
sharp: 0.34.5
undici: 7.24.8
workerd: 1.20260508.1
ws: 8.18.0
youch: 4.1.0-beta.10
transitivePeerDependencies:
- bufferutil
- utf-8-validate
path-to-regexp@6.3.0: {}
pathe@2.0.3: {}
semver@7.8.0: {}
sharp@0.34.5:
dependencies:
'@img/colour': 1.1.0
detect-libc: 2.1.2
semver: 7.8.0
optionalDependencies:
'@img/sharp-darwin-arm64': 0.34.5
'@img/sharp-darwin-x64': 0.34.5
'@img/sharp-libvips-darwin-arm64': 1.2.4
'@img/sharp-libvips-darwin-x64': 1.2.4
'@img/sharp-libvips-linux-arm': 1.2.4
'@img/sharp-libvips-linux-arm64': 1.2.4
'@img/sharp-libvips-linux-ppc64': 1.2.4
'@img/sharp-libvips-linux-riscv64': 1.2.4
'@img/sharp-libvips-linux-s390x': 1.2.4
'@img/sharp-libvips-linux-x64': 1.2.4
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
'@img/sharp-linux-arm': 0.34.5
'@img/sharp-linux-arm64': 0.34.5
'@img/sharp-linux-ppc64': 0.34.5
'@img/sharp-linux-riscv64': 0.34.5
'@img/sharp-linux-s390x': 0.34.5
'@img/sharp-linux-x64': 0.34.5
'@img/sharp-linuxmusl-arm64': 0.34.5
'@img/sharp-linuxmusl-x64': 0.34.5
'@img/sharp-wasm32': 0.34.5
'@img/sharp-win32-arm64': 0.34.5
'@img/sharp-win32-ia32': 0.34.5
'@img/sharp-win32-x64': 0.34.5
supports-color@10.2.2: {}
tslib@2.8.1:
optional: true
typescript@5.9.3: {}
undici@7.24.8: {}
unenv@2.0.0-rc.24:
dependencies:
pathe: 2.0.3
workerd@1.20260508.1:
optionalDependencies:
'@cloudflare/workerd-darwin-64': 1.20260508.1
'@cloudflare/workerd-darwin-arm64': 1.20260508.1
'@cloudflare/workerd-linux-64': 1.20260508.1
'@cloudflare/workerd-linux-arm64': 1.20260508.1
'@cloudflare/workerd-windows-64': 1.20260508.1
wrangler@4.90.1(@cloudflare/workers-types@4.20260511.1):
dependencies:
'@cloudflare/kv-asset-handler': 0.5.0
'@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260508.1)
blake3-wasm: 2.1.5
esbuild: 0.27.3
miniflare: 4.20260508.0
path-to-regexp: 6.3.0
unenv: 2.0.0-rc.24
workerd: 1.20260508.1
optionalDependencies:
'@cloudflare/workers-types': 4.20260511.1
fsevents: 2.3.3
transitivePeerDependencies:
- bufferutil
- utf-8-validate
ws@8.18.0: {}
youch-core@0.3.3:
dependencies:
'@poppinss/exception': 1.2.3
error-stack-parser-es: 1.0.5
youch@4.1.0-beta.10:
dependencies:
'@poppinss/colors': 4.1.6
'@poppinss/dumper': 0.6.5
'@speed-highlight/core': 1.2.15
cookie: 1.1.1
youch-core: 0.3.3
@@ -0,0 +1,8 @@
allowBuilds:
esbuild: true
sharp: true
workerd: true
onlyBuiltDependencies:
- esbuild
- sharp
- workerd
@@ -0,0 +1,81 @@
/**
* arcrun WASM 零件 Worker (kbdb_upsert_block)
* POST / → JSON input → WASM (WASI preview1) → JSON output
* SDD: polaris/mira/.agents/specs/mira-app/design.md §3.5.12.4.1
* matrix/arcrun/.agents/specs/arcrun/arcrun.md 三-B 新零件加入紀錄
*/
import componentWasm from '../component.wasm' assert { type: 'webassembly' };
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { createWasiShim, type WasiHostFunctions } from '../../../cypher-executor/src/lib/wasi-shim';
const app = new Hono();
app.use('*', cors());
app.get('/', (c) => c.json({ ok: true, component: 'kbdb_upsert_block' }));
app.post('/', async (c) => {
let input: unknown;
try {
input = await c.req.json();
} catch {
return c.json({ success: false, error: 'request body must be JSON' }, 400);
}
try {
const result = await runWasm(input);
return c.json(result);
} catch (e) {
return c.json(
{ success: false, error: e instanceof Error ? e.message : String(e) },
500,
);
}
});
export default app;
async function runWasm(input: unknown): Promise<unknown> {
const hostFunctions: WasiHostFunctions = {
http_request: async (url, method, headersJson, body) => {
const headers: Record<string, string> = {};
if (headersJson) {
try {
const parsed = JSON.parse(headersJson);
if (parsed && typeof parsed === 'object') {
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
if (typeof v === 'string') headers[k] = v;
}
}
} catch {}
}
const init: RequestInit = { method, headers };
if (body && method.toUpperCase() !== 'GET' && method.toUpperCase() !== 'HEAD') {
init.body = body;
}
const res = await fetch(url, init);
const text = await res.text();
// 修架構債(同 http_request):非 2xx 包成帶 "error" key 的 envelope
// 讓 WASM 端既有的 error 判定正確識別失敗(原本只回 body 丟掉 status → 4xx 被判 success)。
if (!res.ok) {
return JSON.stringify({ error: `HTTP ${res.status}`, status: res.status, body: text });
}
return text;
},
};
const shim = createWasiShim(JSON.stringify(input), hostFunctions);
const instance = await WebAssembly.instantiate(
componentWasm as WebAssembly.Module,
shim.imports,
);
shim.setMemory(instance.exports.memory as WebAssembly.Memory);
await shim.run(instance);
const stdout = shim.getStdout().trim();
const stderr = shim.getStderr().trim();
if (stderr) console.error('[kbdb_upsert_block wasm stderr]', stderr);
if (!stdout) throw new Error('WASM component produced no output');
return JSON.parse(stdout);
}
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"types": ["@cloudflare/workers-types"],
"strict": true,
"noEmit": true
}
}
@@ -0,0 +1,12 @@
name = "arcrun-kbdb-upsert-block"
main = "src/index.ts"
compatibility_date = "2025-02-19"
compatibility_flags = ["nodejs_compat"]
workers_dev = true
[vars]
COMPONENT_ID = "kbdb_upsert_block"
[[routes]]
pattern = "kbdb-upsert-block.arcrun.dev/*"
zone_name = "arcrun.dev"
+14
View File
@@ -0,0 +1,14 @@
{
"name": "arcrun-km-writer",
"version": "1.0.0",
"private": true,
"type": "module",
"dependencies": {
"hono": "^4.7.0"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250408.0",
"typescript": "^5.4.0",
"wrangler": "^4.0.0"
}
}
+898
View File
@@ -0,0 +1,898 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
hono:
specifier: ^4.7.0
version: 4.12.14
devDependencies:
'@cloudflare/workers-types':
specifier: ^4.20250408.0
version: 4.20260420.1
typescript:
specifier: ^5.4.0
version: 5.9.3
wrangler:
specifier: ^4.0.0
version: 4.83.0(@cloudflare/workers-types@4.20260420.1)
packages:
'@cloudflare/kv-asset-handler@0.4.2':
resolution: {integrity: sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==}
engines: {node: '>=18.0.0'}
'@cloudflare/unenv-preset@2.16.0':
resolution: {integrity: sha512-8ovsRpwzPoEqPUzoErAYVv8l3FMZNeBVQfJTvtzP4AgLSRGZISRfuChFxHWUQd3n6cnrwkuTGxT+2cGo8EsyYg==}
peerDependencies:
unenv: 2.0.0-rc.24
workerd: 1.20260301.1 || ~1.20260302.1 || ~1.20260303.1 || ~1.20260304.1 || >1.20260305.0 <2.0.0-0
peerDependenciesMeta:
workerd:
optional: true
'@cloudflare/workerd-darwin-64@1.20260415.1':
resolution: {integrity: sha512-dsxaKsQm3LnPGNPEdsRv09QN3Y4DqCw7kX5j6noKqbAtro2jTr95sVlYM1jUxZ5FkOl1f7SXgaKKB9t5H5Nkbg==}
engines: {node: '>=16'}
cpu: [x64]
os: [darwin]
'@cloudflare/workerd-darwin-arm64@1.20260415.1':
resolution: {integrity: sha512-+JgSgVA49KyKteHRA1SnonE4Zn5Ei5zdAp5FQMxFmXI8qulZw4Hl7safXxRyK4i9sTO8gl7TFOKO5Q64VPvSDQ==}
engines: {node: '>=16'}
cpu: [arm64]
os: [darwin]
'@cloudflare/workerd-linux-64@1.20260415.1':
resolution: {integrity: sha512-tU+9pwsqCy8afOVlGtiWrWQc/fedQK4SRm4KPIAt+zOiQWDxWASm6YGBUJis5c648WN80yz47qnmdDi8DQNOcA==}
engines: {node: '>=16'}
cpu: [x64]
os: [linux]
'@cloudflare/workerd-linux-arm64@1.20260415.1':
resolution: {integrity: sha512-bR9uITnV19r5NQ14xnypi2xHXu2iQvfYV8cVgx0JouFUmWwTEEAwFVojDdssGq93VHX9hr/pi2IRUZeegbYBog==}
engines: {node: '>=16'}
cpu: [arm64]
os: [linux]
'@cloudflare/workerd-windows-64@1.20260415.1':
resolution: {integrity: sha512-4NuMLlerI0Ijua3Ir8HXQ+qyNvCUDEG5gDco5Om+sAiK6rnWiz+aGoSlbB8W16yW9QAgzCstbmXLiVknUBflfQ==}
engines: {node: '>=16'}
cpu: [x64]
os: [win32]
'@cloudflare/workers-types@4.20260420.1':
resolution: {integrity: sha512-DHT9JnSn9cIiCSdL76OxW+Xvc1+ml1CWzWvgVwreoHQ+E604aeFxPPHp9X7nE+XRWm2NH4l0OgtxUI5T/nuI3g==}
'@cspotcode/source-map-support@0.8.1':
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
engines: {node: '>=12'}
'@emnapi/runtime@1.10.0':
resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
'@esbuild/aix-ppc64@0.27.3':
resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.27.3':
resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.27.3':
resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.27.3':
resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.27.3':
resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.27.3':
resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.27.3':
resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.27.3':
resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.27.3':
resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.27.3':
resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.27.3':
resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.27.3':
resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.27.3':
resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.27.3':
resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.27.3':
resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.27.3':
resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.27.3':
resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.27.3':
resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.27.3':
resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.27.3':
resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.27.3':
resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.27.3':
resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.27.3':
resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.27.3':
resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.27.3':
resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.27.3':
resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@img/colour@1.1.0':
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'}
'@img/sharp-darwin-arm64@0.34.5':
resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [darwin]
'@img/sharp-darwin-x64@0.34.5':
resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-darwin-arm64@1.2.4':
resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
cpu: [arm64]
os: [darwin]
'@img/sharp-libvips-darwin-x64@1.2.4':
resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-linux-arm64@1.2.4':
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [wasm32]
'@img/sharp-win32-arm64@0.34.5':
resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [win32]
'@img/sharp-win32-ia32@0.34.5':
resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ia32]
os: [win32]
'@img/sharp-win32-x64@0.34.5':
resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [win32]
'@jridgewell/resolve-uri@3.1.2':
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
engines: {node: '>=6.0.0'}
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@jridgewell/trace-mapping@0.3.9':
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
'@poppinss/colors@4.1.6':
resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==}
'@poppinss/dumper@0.6.5':
resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==}
'@poppinss/exception@1.2.3':
resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==}
'@sindresorhus/is@7.2.0':
resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==}
engines: {node: '>=18'}
'@speed-highlight/core@1.2.15':
resolution: {integrity: sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==}
blake3-wasm@2.1.5:
resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==}
cookie@1.1.1:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
error-stack-parser-es@1.0.5:
resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==}
esbuild@0.27.3:
resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==}
engines: {node: '>=18'}
hasBin: true
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
hono@4.12.14:
resolution: {integrity: sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==}
engines: {node: '>=16.9.0'}
kleur@4.1.5:
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
engines: {node: '>=6'}
miniflare@4.20260415.0:
resolution: {integrity: sha512-JoExRWN4YBI2luA5BoSMFEgi8rQWXUGzo3mtE+58VXCLV3jj/Xnk5Yeqs/IXWz8Es5GJIaq6BtsixDvAxXSIng==}
engines: {node: '>=18.0.0'}
hasBin: true
path-to-regexp@6.3.0:
resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
semver@7.7.4:
resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
engines: {node: '>=10'}
hasBin: true
sharp@0.34.5:
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
supports-color@10.2.2:
resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
engines: {node: '>=18'}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
undici@7.24.8:
resolution: {integrity: sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==}
engines: {node: '>=20.18.1'}
unenv@2.0.0-rc.24:
resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==}
workerd@1.20260415.1:
resolution: {integrity: sha512-phyPjRnx+mQDfkhN9ENPioL1L0SdhYs4S0YmJK/xF9Oga+ykNfdSy1MHnsOj8yqnOV96zcVQMx32dJ0r3pq0jQ==}
engines: {node: '>=16'}
hasBin: true
wrangler@4.83.0:
resolution: {integrity: sha512-gw5g3LCiuAqVWxaoKY6+quE0HzAUEFb/FV3oAlNkE1ttd4XP3FiV91XDkkzUCcdqxS4WjhQvPhIDBNdhEi8P0A==}
engines: {node: '>=20.3.0'}
hasBin: true
peerDependencies:
'@cloudflare/workers-types': ^4.20260415.1
peerDependenciesMeta:
'@cloudflare/workers-types':
optional: true
ws@8.18.0:
resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
youch-core@0.3.3:
resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==}
youch@4.1.0-beta.10:
resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==}
snapshots:
'@cloudflare/kv-asset-handler@0.4.2': {}
'@cloudflare/unenv-preset@2.16.0(unenv@2.0.0-rc.24)(workerd@1.20260415.1)':
dependencies:
unenv: 2.0.0-rc.24
optionalDependencies:
workerd: 1.20260415.1
'@cloudflare/workerd-darwin-64@1.20260415.1':
optional: true
'@cloudflare/workerd-darwin-arm64@1.20260415.1':
optional: true
'@cloudflare/workerd-linux-64@1.20260415.1':
optional: true
'@cloudflare/workerd-linux-arm64@1.20260415.1':
optional: true
'@cloudflare/workerd-windows-64@1.20260415.1':
optional: true
'@cloudflare/workers-types@4.20260420.1': {}
'@cspotcode/source-map-support@0.8.1':
dependencies:
'@jridgewell/trace-mapping': 0.3.9
'@emnapi/runtime@1.10.0':
dependencies:
tslib: 2.8.1
optional: true
'@esbuild/aix-ppc64@0.27.3':
optional: true
'@esbuild/android-arm64@0.27.3':
optional: true
'@esbuild/android-arm@0.27.3':
optional: true
'@esbuild/android-x64@0.27.3':
optional: true
'@esbuild/darwin-arm64@0.27.3':
optional: true
'@esbuild/darwin-x64@0.27.3':
optional: true
'@esbuild/freebsd-arm64@0.27.3':
optional: true
'@esbuild/freebsd-x64@0.27.3':
optional: true
'@esbuild/linux-arm64@0.27.3':
optional: true
'@esbuild/linux-arm@0.27.3':
optional: true
'@esbuild/linux-ia32@0.27.3':
optional: true
'@esbuild/linux-loong64@0.27.3':
optional: true
'@esbuild/linux-mips64el@0.27.3':
optional: true
'@esbuild/linux-ppc64@0.27.3':
optional: true
'@esbuild/linux-riscv64@0.27.3':
optional: true
'@esbuild/linux-s390x@0.27.3':
optional: true
'@esbuild/linux-x64@0.27.3':
optional: true
'@esbuild/netbsd-arm64@0.27.3':
optional: true
'@esbuild/netbsd-x64@0.27.3':
optional: true
'@esbuild/openbsd-arm64@0.27.3':
optional: true
'@esbuild/openbsd-x64@0.27.3':
optional: true
'@esbuild/openharmony-arm64@0.27.3':
optional: true
'@esbuild/sunos-x64@0.27.3':
optional: true
'@esbuild/win32-arm64@0.27.3':
optional: true
'@esbuild/win32-ia32@0.27.3':
optional: true
'@esbuild/win32-x64@0.27.3':
optional: true
'@img/colour@1.1.0': {}
'@img/sharp-darwin-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.2.4
optional: true
'@img/sharp-darwin-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.2.4
optional: true
'@img/sharp-libvips-darwin-arm64@1.2.4':
optional: true
'@img/sharp-libvips-darwin-x64@1.2.4':
optional: true
'@img/sharp-libvips-linux-arm64@1.2.4':
optional: true
'@img/sharp-libvips-linux-arm@1.2.4':
optional: true
'@img/sharp-libvips-linux-ppc64@1.2.4':
optional: true
'@img/sharp-libvips-linux-riscv64@1.2.4':
optional: true
'@img/sharp-libvips-linux-s390x@1.2.4':
optional: true
'@img/sharp-libvips-linux-x64@1.2.4':
optional: true
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
optional: true
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
optional: true
'@img/sharp-linux-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.2.4
optional: true
'@img/sharp-linux-arm@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.2.4
optional: true
'@img/sharp-linux-ppc64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-ppc64': 1.2.4
optional: true
'@img/sharp-linux-riscv64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-riscv64': 1.2.4
optional: true
'@img/sharp-linux-s390x@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-s390x': 1.2.4
optional: true
'@img/sharp-linux-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.2.4
optional: true
'@img/sharp-linuxmusl-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
optional: true
'@img/sharp-linuxmusl-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
optional: true
'@img/sharp-wasm32@0.34.5':
dependencies:
'@emnapi/runtime': 1.10.0
optional: true
'@img/sharp-win32-arm64@0.34.5':
optional: true
'@img/sharp-win32-ia32@0.34.5':
optional: true
'@img/sharp-win32-x64@0.34.5':
optional: true
'@jridgewell/resolve-uri@3.1.2': {}
'@jridgewell/sourcemap-codec@1.5.5': {}
'@jridgewell/trace-mapping@0.3.9':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@poppinss/colors@4.1.6':
dependencies:
kleur: 4.1.5
'@poppinss/dumper@0.6.5':
dependencies:
'@poppinss/colors': 4.1.6
'@sindresorhus/is': 7.2.0
supports-color: 10.2.2
'@poppinss/exception@1.2.3': {}
'@sindresorhus/is@7.2.0': {}
'@speed-highlight/core@1.2.15': {}
blake3-wasm@2.1.5: {}
cookie@1.1.1: {}
detect-libc@2.1.2: {}
error-stack-parser-es@1.0.5: {}
esbuild@0.27.3:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.3
'@esbuild/android-arm': 0.27.3
'@esbuild/android-arm64': 0.27.3
'@esbuild/android-x64': 0.27.3
'@esbuild/darwin-arm64': 0.27.3
'@esbuild/darwin-x64': 0.27.3
'@esbuild/freebsd-arm64': 0.27.3
'@esbuild/freebsd-x64': 0.27.3
'@esbuild/linux-arm': 0.27.3
'@esbuild/linux-arm64': 0.27.3
'@esbuild/linux-ia32': 0.27.3
'@esbuild/linux-loong64': 0.27.3
'@esbuild/linux-mips64el': 0.27.3
'@esbuild/linux-ppc64': 0.27.3
'@esbuild/linux-riscv64': 0.27.3
'@esbuild/linux-s390x': 0.27.3
'@esbuild/linux-x64': 0.27.3
'@esbuild/netbsd-arm64': 0.27.3
'@esbuild/netbsd-x64': 0.27.3
'@esbuild/openbsd-arm64': 0.27.3
'@esbuild/openbsd-x64': 0.27.3
'@esbuild/openharmony-arm64': 0.27.3
'@esbuild/sunos-x64': 0.27.3
'@esbuild/win32-arm64': 0.27.3
'@esbuild/win32-ia32': 0.27.3
'@esbuild/win32-x64': 0.27.3
fsevents@2.3.3:
optional: true
hono@4.12.14: {}
kleur@4.1.5: {}
miniflare@4.20260415.0:
dependencies:
'@cspotcode/source-map-support': 0.8.1
sharp: 0.34.5
undici: 7.24.8
workerd: 1.20260415.1
ws: 8.18.0
youch: 4.1.0-beta.10
transitivePeerDependencies:
- bufferutil
- utf-8-validate
path-to-regexp@6.3.0: {}
pathe@2.0.3: {}
semver@7.7.4: {}
sharp@0.34.5:
dependencies:
'@img/colour': 1.1.0
detect-libc: 2.1.2
semver: 7.7.4
optionalDependencies:
'@img/sharp-darwin-arm64': 0.34.5
'@img/sharp-darwin-x64': 0.34.5
'@img/sharp-libvips-darwin-arm64': 1.2.4
'@img/sharp-libvips-darwin-x64': 1.2.4
'@img/sharp-libvips-linux-arm': 1.2.4
'@img/sharp-libvips-linux-arm64': 1.2.4
'@img/sharp-libvips-linux-ppc64': 1.2.4
'@img/sharp-libvips-linux-riscv64': 1.2.4
'@img/sharp-libvips-linux-s390x': 1.2.4
'@img/sharp-libvips-linux-x64': 1.2.4
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
'@img/sharp-linux-arm': 0.34.5
'@img/sharp-linux-arm64': 0.34.5
'@img/sharp-linux-ppc64': 0.34.5
'@img/sharp-linux-riscv64': 0.34.5
'@img/sharp-linux-s390x': 0.34.5
'@img/sharp-linux-x64': 0.34.5
'@img/sharp-linuxmusl-arm64': 0.34.5
'@img/sharp-linuxmusl-x64': 0.34.5
'@img/sharp-wasm32': 0.34.5
'@img/sharp-win32-arm64': 0.34.5
'@img/sharp-win32-ia32': 0.34.5
'@img/sharp-win32-x64': 0.34.5
supports-color@10.2.2: {}
tslib@2.8.1:
optional: true
typescript@5.9.3: {}
undici@7.24.8: {}
unenv@2.0.0-rc.24:
dependencies:
pathe: 2.0.3
workerd@1.20260415.1:
optionalDependencies:
'@cloudflare/workerd-darwin-64': 1.20260415.1
'@cloudflare/workerd-darwin-arm64': 1.20260415.1
'@cloudflare/workerd-linux-64': 1.20260415.1
'@cloudflare/workerd-linux-arm64': 1.20260415.1
'@cloudflare/workerd-windows-64': 1.20260415.1
wrangler@4.83.0(@cloudflare/workers-types@4.20260420.1):
dependencies:
'@cloudflare/kv-asset-handler': 0.4.2
'@cloudflare/unenv-preset': 2.16.0(unenv@2.0.0-rc.24)(workerd@1.20260415.1)
blake3-wasm: 2.1.5
esbuild: 0.27.3
miniflare: 4.20260415.0
path-to-regexp: 6.3.0
unenv: 2.0.0-rc.24
workerd: 1.20260415.1
optionalDependencies:
'@cloudflare/workers-types': 4.20260420.1
fsevents: 2.3.3
transitivePeerDependencies:
- bufferutil
- utf-8-validate
ws@8.18.0: {}
youch-core@0.3.3:
dependencies:
'@poppinss/exception': 1.2.3
error-stack-parser-es: 1.0.5
youch@4.1.0-beta.10:
dependencies:
'@poppinss/colors': 4.1.6
'@poppinss/dumper': 0.6.5
'@speed-highlight/core': 1.2.15
cookie: 1.1.1
youch-core: 0.3.3
@@ -0,0 +1,8 @@
allowBuilds:
esbuild: true
sharp: true
workerd: true
onlyBuiltDependencies:
- esbuild
- sharp
- workerd
+83
View File
@@ -0,0 +1,83 @@
/**
* arcrun API component Worker (km_writer)
*
* POST / → JSON input → WASM (WASI preview1 stdin/stdout) → JSON output
*
* 提供 http_request host function,讓 WASM 零件呼叫 Mira /km/* API。
*/
import componentWasm from '../component.wasm' assert { type: 'webassembly' };
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { createWasiShim, type WasiHostFunctions } from '../../../cypher-executor/src/lib/wasi-shim';
const app = new Hono();
app.use('*', cors());
app.get('/', (c) => c.json({ ok: true, component: 'km_writer' }));
app.post('/', async (c) => {
let input: unknown;
try {
input = await c.req.json();
} catch {
return c.json({ success: false, error: 'request body must be JSON' }, 400);
}
try {
const result = await runWasm(input);
return c.json(result);
} catch (e) {
return c.json(
{ success: false, error: e instanceof Error ? e.message : String(e) },
500,
);
}
});
export default app;
async function runWasm(input: unknown): Promise<unknown> {
const hostFunctions: WasiHostFunctions = {
http_request: async (url, method, headersJson, body) => {
const headers: Record<string, string> = {};
if (headersJson) {
try {
const parsed = JSON.parse(headersJson);
if (parsed && typeof parsed === 'object') {
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
if (typeof v === 'string') headers[k] = v;
}
}
} catch {
// ignore header parse errors
}
}
const init: RequestInit = { method, headers };
if (body && method.toUpperCase() !== 'GET' && method.toUpperCase() !== 'HEAD') {
init.body = body;
}
const res = await fetch(url, init);
const text = await res.text();
// 修架構債(同 http_request):非 2xx 包成帶 "error" key 的 envelope
// 讓 WASM 端既有的 error 判定正確識別失敗(原本只回 body 丟掉 status → 4xx 被判 success)。
if (!res.ok) {
return JSON.stringify({ error: `HTTP ${res.status}`, status: res.status, body: text });
}
return text;
},
};
const shim = createWasiShim(JSON.stringify(input), hostFunctions);
const instance = await WebAssembly.instantiate(
componentWasm as WebAssembly.Module,
shim.imports,
);
shim.setMemory(instance.exports.memory as WebAssembly.Memory);
await shim.run(instance);
const stdout = shim.getStdout().trim();
if (!stdout) throw new Error('WASM component produced no output');
return JSON.parse(stdout);
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"types": ["@cloudflare/workers-types"],
"strict": true,
"noEmit": true
}
}
+12
View File
@@ -0,0 +1,12 @@
name = "arcrun-km-writer"
main = "src/index.ts"
compatibility_date = "2025-02-19"
compatibility_flags = ["nodejs_compat"]
workers_dev = true
[vars]
COMPONENT_ID = "km_writer"
[[routes]]
pattern = "km-writer.arcrun.dev/*"
zone_name = "arcrun.dev"
Binary file not shown.
@@ -0,0 +1,14 @@
{
"name": "arcrun-platform-crypto",
"version": "1.0.0",
"private": true,
"type": "module",
"dependencies": {
"hono": "^4.7.0"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250408.0",
"typescript": "^5.4.0",
"wrangler": "^4.0.0"
}
}
+898
View File
@@ -0,0 +1,898 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
hono:
specifier: ^4.7.0
version: 4.12.17
devDependencies:
'@cloudflare/workers-types':
specifier: ^4.20250408.0
version: 4.20260505.1
typescript:
specifier: ^5.4.0
version: 5.9.3
wrangler:
specifier: ^4.0.0
version: 4.87.0(@cloudflare/workers-types@4.20260505.1)
packages:
'@cloudflare/kv-asset-handler@0.5.0':
resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==}
engines: {node: '>=22.0.0'}
'@cloudflare/unenv-preset@2.16.1':
resolution: {integrity: sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==}
peerDependencies:
unenv: 2.0.0-rc.24
workerd: '>1.20260305.0 <2.0.0-0'
peerDependenciesMeta:
workerd:
optional: true
'@cloudflare/workerd-darwin-64@1.20260430.1':
resolution: {integrity: sha512-ADohZUHf7NBvPp2PdZig2Opxx+hDkk3ve7jrTne3JRx9kDSB73zc4LzcEeEN8LKkbAcqZmvfRJfpChSlusu0lA==}
engines: {node: '>=16'}
cpu: [x64]
os: [darwin]
'@cloudflare/workerd-darwin-arm64@1.20260430.1':
resolution: {integrity: sha512-/DoYC/1wHs+YRZzzqSQg1/EHB4hiv1yV5U8FnmapRRIzVaPtnt+ApeOXeMrIdKidgKOI8TqQzgBU8xbIM7Cl4Q==}
engines: {node: '>=16'}
cpu: [arm64]
os: [darwin]
'@cloudflare/workerd-linux-64@1.20260430.1':
resolution: {integrity: sha512-koJhBWvEVZPKCVFtMLp2iMHlYr+lFCF47wGbnlKdHVlemV0zTxJEyHI8aLlrhPLhBmOmYLp46rXw09/qJkRIhQ==}
engines: {node: '>=16'}
cpu: [x64]
os: [linux]
'@cloudflare/workerd-linux-arm64@1.20260430.1':
resolution: {integrity: sha512-hMdapNAzNQZDXGGkg4Slydc3fRJP5FUZLJVVcZCW/+imhhJro9Z1rv5n/wfR+txKoSWhTYR8eOp8Pyi2bzLzlw==}
engines: {node: '>=16'}
cpu: [arm64]
os: [linux]
'@cloudflare/workerd-windows-64@1.20260430.1':
resolution: {integrity: sha512-jS3ffixjb5USOwz4frw4WzCz0HrjVxkgyU3WiYb06N7hBAfN6eOrveAJ4QRef0+suK4V1vQFoB1oKdRBsXe9Dw==}
engines: {node: '>=16'}
cpu: [x64]
os: [win32]
'@cloudflare/workers-types@4.20260505.1':
resolution: {integrity: sha512-Uz9D2hcwB4/pdnmCU7RsgknY8TQ5st0cQMMN6h/hvWt1TCt99GUkbi6dMgWdP7jXfIfh+S/EI5zQugI9RZn4Bw==}
'@cspotcode/source-map-support@0.8.1':
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
engines: {node: '>=12'}
'@emnapi/runtime@1.10.0':
resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
'@esbuild/aix-ppc64@0.27.3':
resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.27.3':
resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.27.3':
resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.27.3':
resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.27.3':
resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.27.3':
resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.27.3':
resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.27.3':
resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.27.3':
resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.27.3':
resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.27.3':
resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.27.3':
resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.27.3':
resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.27.3':
resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.27.3':
resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.27.3':
resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.27.3':
resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.27.3':
resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.27.3':
resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.27.3':
resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.27.3':
resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.27.3':
resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.27.3':
resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.27.3':
resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.27.3':
resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.27.3':
resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@img/colour@1.1.0':
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'}
'@img/sharp-darwin-arm64@0.34.5':
resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [darwin]
'@img/sharp-darwin-x64@0.34.5':
resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-darwin-arm64@1.2.4':
resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
cpu: [arm64]
os: [darwin]
'@img/sharp-libvips-darwin-x64@1.2.4':
resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-linux-arm64@1.2.4':
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [wasm32]
'@img/sharp-win32-arm64@0.34.5':
resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [win32]
'@img/sharp-win32-ia32@0.34.5':
resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ia32]
os: [win32]
'@img/sharp-win32-x64@0.34.5':
resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [win32]
'@jridgewell/resolve-uri@3.1.2':
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
engines: {node: '>=6.0.0'}
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@jridgewell/trace-mapping@0.3.9':
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
'@poppinss/colors@4.1.6':
resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==}
'@poppinss/dumper@0.6.5':
resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==}
'@poppinss/exception@1.2.3':
resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==}
'@sindresorhus/is@7.2.0':
resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==}
engines: {node: '>=18'}
'@speed-highlight/core@1.2.15':
resolution: {integrity: sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==}
blake3-wasm@2.1.5:
resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==}
cookie@1.1.1:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
error-stack-parser-es@1.0.5:
resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==}
esbuild@0.27.3:
resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==}
engines: {node: '>=18'}
hasBin: true
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
hono@4.12.17:
resolution: {integrity: sha512-FbJJNb/XgX7YW0hX/V8w5oYLztKEsRLykCMZWt1WdLtsfjzMvmoqWBA4H4t5norinq8/rh20oiZYr+WSl4UzAQ==}
engines: {node: '>=16.9.0'}
kleur@4.1.5:
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
engines: {node: '>=6'}
miniflare@4.20260430.0:
resolution: {integrity: sha512-MWvMm3Siho9Yj7lbJZidLs8hbrRvIcOrif2mnsHQZdvoKfedpea+GaN8XJxbpRcq0B2WzNI1BB1ihdnqes3/ZA==}
engines: {node: '>=22.0.0'}
hasBin: true
path-to-regexp@6.3.0:
resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
semver@7.7.4:
resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
engines: {node: '>=10'}
hasBin: true
sharp@0.34.5:
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
supports-color@10.2.2:
resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
engines: {node: '>=18'}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
undici@7.24.8:
resolution: {integrity: sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==}
engines: {node: '>=20.18.1'}
unenv@2.0.0-rc.24:
resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==}
workerd@1.20260430.1:
resolution: {integrity: sha512-KEgIWyiw3Jmn+DCd/L3ePo5fmiiYb/UcwKvDWPf/nLLOiwShDFzDSsegU5NY/JcwgvO/QsLHVi2FYrbkcXNY5Q==}
engines: {node: '>=16'}
hasBin: true
wrangler@4.87.0:
resolution: {integrity: sha512-lfhfKwLfQlowwgV0xhlYgE9fU3n0I30d4ccGY/rTCEm/n42Mjvlr0Ng3ZPNqlsrsKBcDR531V7dsPkgELvrk/Q==}
engines: {node: '>=22.0.0'}
hasBin: true
peerDependencies:
'@cloudflare/workers-types': ^4.20260430.1
peerDependenciesMeta:
'@cloudflare/workers-types':
optional: true
ws@8.18.0:
resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
youch-core@0.3.3:
resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==}
youch@4.1.0-beta.10:
resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==}
snapshots:
'@cloudflare/kv-asset-handler@0.5.0': {}
'@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260430.1)':
dependencies:
unenv: 2.0.0-rc.24
optionalDependencies:
workerd: 1.20260430.1
'@cloudflare/workerd-darwin-64@1.20260430.1':
optional: true
'@cloudflare/workerd-darwin-arm64@1.20260430.1':
optional: true
'@cloudflare/workerd-linux-64@1.20260430.1':
optional: true
'@cloudflare/workerd-linux-arm64@1.20260430.1':
optional: true
'@cloudflare/workerd-windows-64@1.20260430.1':
optional: true
'@cloudflare/workers-types@4.20260505.1': {}
'@cspotcode/source-map-support@0.8.1':
dependencies:
'@jridgewell/trace-mapping': 0.3.9
'@emnapi/runtime@1.10.0':
dependencies:
tslib: 2.8.1
optional: true
'@esbuild/aix-ppc64@0.27.3':
optional: true
'@esbuild/android-arm64@0.27.3':
optional: true
'@esbuild/android-arm@0.27.3':
optional: true
'@esbuild/android-x64@0.27.3':
optional: true
'@esbuild/darwin-arm64@0.27.3':
optional: true
'@esbuild/darwin-x64@0.27.3':
optional: true
'@esbuild/freebsd-arm64@0.27.3':
optional: true
'@esbuild/freebsd-x64@0.27.3':
optional: true
'@esbuild/linux-arm64@0.27.3':
optional: true
'@esbuild/linux-arm@0.27.3':
optional: true
'@esbuild/linux-ia32@0.27.3':
optional: true
'@esbuild/linux-loong64@0.27.3':
optional: true
'@esbuild/linux-mips64el@0.27.3':
optional: true
'@esbuild/linux-ppc64@0.27.3':
optional: true
'@esbuild/linux-riscv64@0.27.3':
optional: true
'@esbuild/linux-s390x@0.27.3':
optional: true
'@esbuild/linux-x64@0.27.3':
optional: true
'@esbuild/netbsd-arm64@0.27.3':
optional: true
'@esbuild/netbsd-x64@0.27.3':
optional: true
'@esbuild/openbsd-arm64@0.27.3':
optional: true
'@esbuild/openbsd-x64@0.27.3':
optional: true
'@esbuild/openharmony-arm64@0.27.3':
optional: true
'@esbuild/sunos-x64@0.27.3':
optional: true
'@esbuild/win32-arm64@0.27.3':
optional: true
'@esbuild/win32-ia32@0.27.3':
optional: true
'@esbuild/win32-x64@0.27.3':
optional: true
'@img/colour@1.1.0': {}
'@img/sharp-darwin-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.2.4
optional: true
'@img/sharp-darwin-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.2.4
optional: true
'@img/sharp-libvips-darwin-arm64@1.2.4':
optional: true
'@img/sharp-libvips-darwin-x64@1.2.4':
optional: true
'@img/sharp-libvips-linux-arm64@1.2.4':
optional: true
'@img/sharp-libvips-linux-arm@1.2.4':
optional: true
'@img/sharp-libvips-linux-ppc64@1.2.4':
optional: true
'@img/sharp-libvips-linux-riscv64@1.2.4':
optional: true
'@img/sharp-libvips-linux-s390x@1.2.4':
optional: true
'@img/sharp-libvips-linux-x64@1.2.4':
optional: true
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
optional: true
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
optional: true
'@img/sharp-linux-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.2.4
optional: true
'@img/sharp-linux-arm@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.2.4
optional: true
'@img/sharp-linux-ppc64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-ppc64': 1.2.4
optional: true
'@img/sharp-linux-riscv64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-riscv64': 1.2.4
optional: true
'@img/sharp-linux-s390x@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-s390x': 1.2.4
optional: true
'@img/sharp-linux-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.2.4
optional: true
'@img/sharp-linuxmusl-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
optional: true
'@img/sharp-linuxmusl-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
optional: true
'@img/sharp-wasm32@0.34.5':
dependencies:
'@emnapi/runtime': 1.10.0
optional: true
'@img/sharp-win32-arm64@0.34.5':
optional: true
'@img/sharp-win32-ia32@0.34.5':
optional: true
'@img/sharp-win32-x64@0.34.5':
optional: true
'@jridgewell/resolve-uri@3.1.2': {}
'@jridgewell/sourcemap-codec@1.5.5': {}
'@jridgewell/trace-mapping@0.3.9':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@poppinss/colors@4.1.6':
dependencies:
kleur: 4.1.5
'@poppinss/dumper@0.6.5':
dependencies:
'@poppinss/colors': 4.1.6
'@sindresorhus/is': 7.2.0
supports-color: 10.2.2
'@poppinss/exception@1.2.3': {}
'@sindresorhus/is@7.2.0': {}
'@speed-highlight/core@1.2.15': {}
blake3-wasm@2.1.5: {}
cookie@1.1.1: {}
detect-libc@2.1.2: {}
error-stack-parser-es@1.0.5: {}
esbuild@0.27.3:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.3
'@esbuild/android-arm': 0.27.3
'@esbuild/android-arm64': 0.27.3
'@esbuild/android-x64': 0.27.3
'@esbuild/darwin-arm64': 0.27.3
'@esbuild/darwin-x64': 0.27.3
'@esbuild/freebsd-arm64': 0.27.3
'@esbuild/freebsd-x64': 0.27.3
'@esbuild/linux-arm': 0.27.3
'@esbuild/linux-arm64': 0.27.3
'@esbuild/linux-ia32': 0.27.3
'@esbuild/linux-loong64': 0.27.3
'@esbuild/linux-mips64el': 0.27.3
'@esbuild/linux-ppc64': 0.27.3
'@esbuild/linux-riscv64': 0.27.3
'@esbuild/linux-s390x': 0.27.3
'@esbuild/linux-x64': 0.27.3
'@esbuild/netbsd-arm64': 0.27.3
'@esbuild/netbsd-x64': 0.27.3
'@esbuild/openbsd-arm64': 0.27.3
'@esbuild/openbsd-x64': 0.27.3
'@esbuild/openharmony-arm64': 0.27.3
'@esbuild/sunos-x64': 0.27.3
'@esbuild/win32-arm64': 0.27.3
'@esbuild/win32-ia32': 0.27.3
'@esbuild/win32-x64': 0.27.3
fsevents@2.3.3:
optional: true
hono@4.12.17: {}
kleur@4.1.5: {}
miniflare@4.20260430.0:
dependencies:
'@cspotcode/source-map-support': 0.8.1
sharp: 0.34.5
undici: 7.24.8
workerd: 1.20260430.1
ws: 8.18.0
youch: 4.1.0-beta.10
transitivePeerDependencies:
- bufferutil
- utf-8-validate
path-to-regexp@6.3.0: {}
pathe@2.0.3: {}
semver@7.7.4: {}
sharp@0.34.5:
dependencies:
'@img/colour': 1.1.0
detect-libc: 2.1.2
semver: 7.7.4
optionalDependencies:
'@img/sharp-darwin-arm64': 0.34.5
'@img/sharp-darwin-x64': 0.34.5
'@img/sharp-libvips-darwin-arm64': 1.2.4
'@img/sharp-libvips-darwin-x64': 1.2.4
'@img/sharp-libvips-linux-arm': 1.2.4
'@img/sharp-libvips-linux-arm64': 1.2.4
'@img/sharp-libvips-linux-ppc64': 1.2.4
'@img/sharp-libvips-linux-riscv64': 1.2.4
'@img/sharp-libvips-linux-s390x': 1.2.4
'@img/sharp-libvips-linux-x64': 1.2.4
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
'@img/sharp-linux-arm': 0.34.5
'@img/sharp-linux-arm64': 0.34.5
'@img/sharp-linux-ppc64': 0.34.5
'@img/sharp-linux-riscv64': 0.34.5
'@img/sharp-linux-s390x': 0.34.5
'@img/sharp-linux-x64': 0.34.5
'@img/sharp-linuxmusl-arm64': 0.34.5
'@img/sharp-linuxmusl-x64': 0.34.5
'@img/sharp-wasm32': 0.34.5
'@img/sharp-win32-arm64': 0.34.5
'@img/sharp-win32-ia32': 0.34.5
'@img/sharp-win32-x64': 0.34.5
supports-color@10.2.2: {}
tslib@2.8.1:
optional: true
typescript@5.9.3: {}
undici@7.24.8: {}
unenv@2.0.0-rc.24:
dependencies:
pathe: 2.0.3
workerd@1.20260430.1:
optionalDependencies:
'@cloudflare/workerd-darwin-64': 1.20260430.1
'@cloudflare/workerd-darwin-arm64': 1.20260430.1
'@cloudflare/workerd-linux-64': 1.20260430.1
'@cloudflare/workerd-linux-arm64': 1.20260430.1
'@cloudflare/workerd-windows-64': 1.20260430.1
wrangler@4.87.0(@cloudflare/workers-types@4.20260505.1):
dependencies:
'@cloudflare/kv-asset-handler': 0.5.0
'@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260430.1)
blake3-wasm: 2.1.5
esbuild: 0.27.3
miniflare: 4.20260430.0
path-to-regexp: 6.3.0
unenv: 2.0.0-rc.24
workerd: 1.20260430.1
optionalDependencies:
'@cloudflare/workers-types': 4.20260505.1
fsevents: 2.3.3
transitivePeerDependencies:
- bufferutil
- utf-8-validate
ws@8.18.0: {}
youch-core@0.3.3:
dependencies:
'@poppinss/exception': 1.2.3
error-stack-parser-es: 1.0.5
youch@4.1.0-beta.10:
dependencies:
'@poppinss/colors': 4.1.6
'@poppinss/dumper': 0.6.5
'@speed-highlight/core': 1.2.15
cookie: 1.1.1
youch-core: 0.3.3
@@ -0,0 +1,8 @@
allowBuilds:
esbuild: true
sharp: true
workerd: true
onlyBuiltDependencies:
- esbuild
- sharp
- workerd
@@ -0,0 +1,108 @@
/**
* arcrun platform_crypto Worker
*
* POST / → JSON input {action, ...} → JSON output
*
* Actions:
* generate_api_key — HMAC-SHA256(email, ENCRYPTION_KEY) → ak_{hex[:32]}
* encrypt — AES-GCM(plaintext, ENCRYPTION_KEY) → {encrypted, iv}base64
* random_token — crypto random bytes → hex string
*
* 安全邊界:ENCRYPTION_KEY 只存在於 closure,永不進入外部(rule 02 §2.2)。
* 此 Worker 直接用 crypto.subtle 實作,不走 WASM runner。
* TinyGo WASM async host function 在 Cloudflare Workers 的 u6u namespace 不支援 Promise.
* WASM 零件 (registry/components/platform_crypto/) 保留作為 edge-Go 移植時的參考。
*/
import { Hono } from 'hono';
import { cors } from 'hono/cors';
type Env = {
ENCRYPTION_KEY: string;
};
type Input = {
action: string;
email?: string;
plaintext?: string;
bytes?: number;
};
const app = new Hono<{ Bindings: Env }>();
app.use('*', cors());
app.get('/', (c) => c.json({ ok: true, component: 'platform_crypto' }));
app.post('/', async (c) => {
let input: Input;
try {
input = await c.req.json() as Input;
} catch {
return c.json({ success: false, error: 'request body must be JSON' }, 400);
}
const encryptionKey = c.env.ENCRYPTION_KEY;
if (!encryptionKey) {
return c.json({ success: false, error: 'ENCRYPTION_KEY not configured' }, 503);
}
try {
switch (input.action) {
case 'generate_api_key': {
if (!input.email) return c.json({ success: false, error: 'email 必填' }, 400);
const apiKey = await generateApiKey(input.email, encryptionKey);
return c.json({ success: true, api_key: apiKey });
}
case 'encrypt': {
if (!input.plaintext) return c.json({ success: false, error: 'plaintext 必填' }, 400);
const { encrypted, iv } = await aesEncrypt(input.plaintext, encryptionKey);
return c.json({ success: true, encrypted, iv });
}
case 'random_token': {
const numBytes = (input.bytes ?? 32) > 0 ? (input.bytes ?? 32) : 32;
const token = randomHex(numBytes);
return c.json({ success: true, token });
}
default:
return c.json({ success: false, error: `不支援的 action: ${input.action}` }, 400);
}
} catch (e) {
return c.json(
{ success: false, error: e instanceof Error ? e.message : String(e) },
500,
);
}
});
export default app;
// ── Crypto implementations (rule 02 §2.2: crypto.subtle 只准在 wasi-shim.ts 或 platform_crypto) ──
async function generateApiKey(email: string, encryptionKey: string): Promise<string> {
const keyBytes = new TextEncoder().encode(encryptionKey.slice(0, 32));
const cryptoKey = await crypto.subtle.importKey(
'raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'],
);
const sig = await crypto.subtle.sign('HMAC', cryptoKey, new TextEncoder().encode(email));
const hex = Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, '0')).join('');
return 'ak_' + hex.slice(0, 32);
}
async function aesEncrypt(plaintext: string, encryptionKey: string): Promise<{ encrypted: string; iv: string }> {
const keyBytes = new TextEncoder().encode(encryptionKey.slice(0, 32));
const cryptoKey = await crypto.subtle.importKey('raw', keyBytes, { name: 'AES-GCM' }, false, ['encrypt']);
const iv = crypto.getRandomValues(new Uint8Array(12));
const enc = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
cryptoKey,
new TextEncoder().encode(plaintext),
);
const toB64 = (buf: ArrayBuffer | Uint8Array) =>
btoa(String.fromCharCode(...new Uint8Array(buf instanceof ArrayBuffer ? buf : buf)));
return { encrypted: toB64(enc), iv: toB64(iv) };
}
function randomHex(numBytes: number): string {
const arr = crypto.getRandomValues(new Uint8Array(numBytes));
return Array.from(arr).map(b => b.toString(16).padStart(2, '0')).join('');
}
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"types": ["@cloudflare/workers-types"],
"strict": true,
"noEmit": true
}
}
@@ -0,0 +1,15 @@
name = "arcrun-platform-crypto"
main = "src/index.ts"
compatibility_date = "2025-02-19"
compatibility_flags = ["nodejs_compat"]
workers_dev = true
[vars]
COMPONENT_ID = "platform_crypto"
[[routes]]
pattern = "platform-crypto.arcrun.dev/*"
zone_name = "arcrun.dev"
# ENCRYPTION_KEY 透過 wrangler secret set 設定
# wrangler secret put ENCRYPTION_KEY
+7 -3
View File
@@ -30,15 +30,19 @@ CLOUDFLARE_ACCOUNT_ID=
CLOUDFLARE_API_TOKEN=
# ── ② 身份(自架單人用)──────────────────────────────────────────────────────
# ── ② 身份與加密(自架單人用,這兩格你自己決定/保管)──────────────────────────
#
# NAMESPACE:你的資料分區標籤。隨便取個英數小名即可(例:leo、myteam)。
# 這不是密碼,只是用來分隔你的資料。
#
# credential 不再需要自管加密金鑰:明文由 CF Workers Secrets 託管,
# `acr creds push` 會直接寫進你自己的 worker。)
# ENCRYPTION_KEY:你的 credential 加密金鑰,64 個以上的 hex 字元。你自己保管。
# 不會的話,AI 可以幫你產一串:
# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# ⚠️ 這串忘了 = 你之前上傳加密的 credential 就解不開了,請留底。
# (安裝完還要把「同一串」設進你的 worker,acr init 會印確切指令給你跟著做。)
#
NAMESPACE=
ENCRYPTION_KEY=
# ── ③ 各服務的 token(要連哪個服務才填哪個;可之後再加)────────────────────────
-24
View File
@@ -6,10 +6,6 @@ dist/
# 例外:放行 .component-builds 的部署物 wasm — self-host 用戶 / acr init 從 repo 直接拿這份部署
# (推翻 rule 05 原「wasm 不 commit」慣例,見 .agents/specs/arcrun/sdk-and-website/self-hosted-init.md §6
!.component-builds/**/component.wasm
# 例外:Arcrun#80 tier2 worker 官方編譯成品(cypher-executor/kbdb/http_request/code/mcp 的
# esbuild bundle + 隨附 wasm part)——commit 進 repo 同一套理由:固定位置、any clone 都拿得到,
# 不必自己再編一次(見 scripts/build-worker-artifacts.mjs)。
!.worker-builds/**/*.wasm
# 例外:code 零件(自足 Worker)的 vendored quickjs.wasm 同屬部署物 —— acr init/update 從
# repo archive 直接部署(同上 .component-builds 放行邏輯)。來源=npm 套件
# @jitl/quickjs-wasmfile-release-sync 的 emscripten-module.wasm,由 postinstall vendor-wasm.mjs
@@ -52,23 +48,3 @@ scripts/__pycache__/
# D1 備份/匯出(wrangler d1 export 產物,含整庫全量資料=機敏,絕不 commit)
*.sql
backup-*.sql
# GitHub 公開 mirror 工作目錄(publish-github.sh 產物)
.github-public/
wrangler.leo21c.toml
# deploy-all.mjs 產的共用依賴(部署時 npm 安裝 wrangler 等,非 repo 內容)
# 2026-08-07:每次本機跑部署都會冒出來吵未推警察,且含不該進版控的鎖檔
/package.json
/package-lock.json
# console-ui 部署產物(deploy.mjs 依 deploy.targets.json 即時產生,不是原始碼)
console-ui/.staging/
# 「上一次通過線上實測的部署」紀錄——本機事實,不隨 repo 走
# (刻意不進版控:新 checkout 沒有紀錄 ⇒ 狀態未知 ⇒ 該被大聲提醒,而不是繼承別人的綠燈)
console-ui/.deploy-state.json
# Wrangler 本機開發用的密鑰檔——絕不進版控(2026-08-09 補:原本沒被擋,
# 而同目錄有 agent 在動工,一次 git add -A 就會把金鑰推上去)
.dev.vars
**/.dev.vars
Binary file not shown.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-174
View File
@@ -1,174 +0,0 @@
{
"schema": 1,
"built_for": "arcrun-tier2-worker-artifacts",
"generated_at": "2026-08-12T04:21:48.581Z",
"repo_head": "b302c03ea8076bcfe82bbcebb1523dcec2d1e830",
"repo_dirty": false,
"workers": [
{
"name": "arcrun-cypher-executor",
"source_dir": "cypher-executor",
"source_commit": "525faaf5d01e156a9b8f90808607bead92f40165",
"main_module": "worker.mjs",
"main_file": "arcrun-cypher-executor/worker.mjs",
"js_bytes": 570290,
"content_sha256": "49d59597c01b5264875e0295c86c7bb2212bf54d3c5858cd4a167752370fa716",
"modules": [],
"compat_date": "2025-02-19",
"compat_flags": [
"nodejs_compat",
"global_fetch_strictly_public"
],
"requires": {
"kv": [
"EXEC_CONTEXT",
"WEBHOOKS",
"CREDENTIALS_KV",
"ANALYTICS_KV",
"RECIPES",
"USERS_KV",
"SESSIONS_KV"
],
"d1": [
{
"binding": "CREDENTIALS_DB",
"database_name": "arcrun-kbdb"
}
],
"vectorize": 0,
"ai": true,
"vars": {
"ENVIRONMENT": "production",
"CF_ACCOUNT_ID": "",
"WORKER_SUBDOMAIN": "uncle6-me",
"KBDB_BASE_URL": "https://arcrun-kbdb.uncle6-me.workers.dev",
"CONSOLE_TENANT": "leo",
"PORTAL_SESSION_TTL": "604800",
"PORTAL_SHOW_WORKFLOWS": "admin",
"GITEA_BASE_URL": "https://git.uncle6.me",
"GITEA_SPRINT_REPO": "Leo/InkStoneCo",
"GITEA_SPRINT_DIR": "system-dev/docs/3-specs/autonomy-dispatch"
}
},
"stripped": {
"services": 13
},
"warnings": []
},
{
"name": "arcrun-kbdb",
"source_dir": "kbdb",
"source_commit": "c497ec418eba6cd94b1d5872671c51fd5812c11c",
"main_module": "worker.mjs",
"main_file": "arcrun-kbdb/worker.mjs",
"js_bytes": 149533,
"content_sha256": "ffb8d43467d0cefbd7545fdc0d347f2b965e3c3de20b3315eed7613f20266891",
"modules": [],
"compat_date": "2025-02-19",
"compat_flags": [
"nodejs_compat"
],
"requires": {
"kv": [],
"d1": [
{
"binding": "DB",
"database_name": "arcrun-kbdb"
}
],
"vectorize": 0,
"ai": false,
"vars": {
"ENVIRONMENT": "production"
}
},
"warnings": []
},
{
"name": "arcrun-http-request",
"source_dir": ".component-builds/http_request",
"source_commit": "1e85dfb49b0e8d81c0854781d93ee4e6a300c7b3",
"main_module": "worker.mjs",
"main_file": "arcrun-http-request/worker.mjs",
"js_bytes": 80079,
"content_sha256": "cdd97364f277587cbade69e09bb40812c68f26a1e8bc9aa632c65b1b962b0b85",
"modules": [
{
"name": "component.wasm",
"type": "application/wasm",
"file": "arcrun-http-request/component.wasm",
"sha256": "cc15cc785703e7bbb8dbff2d38dc84a4ac24e2f44316182730abae0f170ef133"
}
],
"compat_date": "2025-02-19",
"compat_flags": [
"nodejs_compat",
"global_fetch_strictly_public"
],
"requires": {
"kv": [],
"d1": [],
"vectorize": 0,
"ai": false,
"vars": {
"COMPONENT_ID": "http_request"
}
},
"warnings": []
},
{
"name": "arcrun-code",
"source_dir": "registry/components/code",
"source_commit": "621cb8d948d61be6202063fd02effb3f538437fe",
"main_module": "worker.mjs",
"main_file": "arcrun-code/worker.mjs",
"js_bytes": 153758,
"content_sha256": "751634a3fc9a99cc2da662026818d754c48f031d10bff3b3be2d3a8ee2311bd6",
"modules": [
{
"name": "quickjs.wasm",
"type": "application/wasm",
"file": "arcrun-code/quickjs.wasm",
"sha256": "105c3bed22d457e43e3d1c3c1c6959fda62a8fe06f0fc8a985303c3a2be72232"
}
],
"compat_date": "2025-02-19",
"compat_flags": [],
"requires": {
"kv": [],
"d1": [],
"vectorize": 0,
"ai": false,
"vars": {
"COMPONENT_ID": "code"
}
},
"warnings": []
},
{
"name": "arcrun-mcp",
"source_dir": "mcp",
"source_commit": "035e8b255b0dcbd4238707f7d2ac8ccf9ee1ba72",
"main_module": "worker.mjs",
"main_file": "arcrun-mcp/worker.mjs",
"js_bytes": 1165388,
"content_sha256": "c5ff10f9b9d5a77217be343af12d2be3ee8f9792d3e1e091e48e5e6c8d24ca9d",
"modules": [],
"compat_date": "2024-11-27",
"compat_flags": [
"nodejs_compat"
],
"requires": {
"kv": [
"OAUTH_KV"
],
"d1": [],
"vectorize": 0,
"ai": false,
"vars": {}
},
"warnings": []
}
],
"notes": []
}
+4 -11
View File
@@ -19,7 +19,7 @@
- [~] **credential 注入 401 修復**`{{credential.X}}` 注入失敗,用戶被迫把 token 明文寫進 workflow。
**根因**:此語法系統沒實裝(三條 template 路徑都不認 `credential.` namespace)。
**修法**design §8richblack 2026-06-10 確認):auth_static_key 加 `resolve_credentials` actionWASM 解密)
+ graph-executor `resolveCredentialRefs` 偵測回填(不碰金鑰rule 02 §2.2)。
+ graph-executor `resolveCredentialRefs` 偵測回填(不碰 ENCRYPTION_KEYrule 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。
@@ -27,21 +27,14 @@
### 🟡 P1(封測門檻 / 技術債)
- [x] ~~credential-primitives-wasm Phase 0.7 / Phase 1-2 / Phase 3~~**全數完成**,該卷已封存
`system-dev/docs/3-specs/archive/credential-primitives-wasm/`)。auth_static_key /
auth_service_account / auth_oauth2 WASM 零件到位;credential-injector.ts、jwt-signer.ts、
`BUILTIN_API_RECIPES` / `BUILTIN_CREDENTIALS_MAP` 全數移除(grep 0 筆,最後一項 T10 於
2026-07-20 commit `20c7610`)。credential 現行做法=CF Workers Secrets + D1 目錄。
- [ ] **`auth_mtls` 零件從未實作**(原 Phase 4.3/4.4 遺留,`registry/components/auth_mtls/` 不存在)。
mTLS 認證目前不支援;要做需**另立新 SDD**(封存卷不得復用)。
- [ ] **self-hosted auth 鏈端到端驗收**(原 Phase 7.6 遺留):`global_fetch_strictly_public` flag
是否真解 same-zone 1042,在自架帳號上從未實測。同屬需另立 SDD 的殘留缺口。
- [ ] credential-primitives-wasm **Phase 0.7**component-loader WASM runner)→ **Phase 1-2**auth_static_key / auth_service_account WASM 零件)。0.6 已完成。
- [ ] **Phase 3** 清除違規 TScredential-injector.ts / jwt-signer.ts / BUILTIN_*)—— 須先有 Phase 1-2 WASM 頂上。
- [ ] 4 份 inline http_request host fn 抽共用 helperdedup;假綠修是逐份改的)。
### ⚪ P2(不擋封測)
- [ ] `arcrun.dev/llms.txt` servelanding/public 補檔)
- [ ] MCP account-source、recipe submit uuid 回傳
- [ ] ENCRYPTION_KEY 冪等性、MCP account-source、recipe submit uuid 回傳
- [ ] 文件遷移階段二/三 + wiki modules/
- [ ] 下方第一期殘項:步驟 2acr recipe test)、步驟 5b(資料外流警示 SDD)、步驟 6(搬家拆 matrix
+2 -41
View File
@@ -21,7 +21,7 @@
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,該卷已封存,規則仍有效
4. **Cypher binding = YAML 裡的 URL 清單**,不是 Cloudflare service binding;零件串接走 HTTP URL(含 auth primitive)。self-hosted same-zone 1042 用 `global_fetch_strictly_public` flag 解,不新增 bindingcredential-primitives-wasm Phase 7
5. **每個 WASM 零件 = 獨立 Worker = 公開 URL**;不從 R2 動態讀(R2 只 Phase 5 啟用)
6. **修改現有程式碼,不是新建資料夾重做**
7. **每完成一個 task 立刻更新 tasks.md 的 `[x]`**,不批次
@@ -29,19 +29,6 @@
---
## SDD 生命週期鐵律(2026-07-17 leo 拍板,全文見 `system-dev/docs/3-specs/SDD-LIFECYCLE.md`
> 現行規格以 design.md frontmatter `status: active` 為準(機器可查),
> hook `.claude/hooks/sdd-guard.sh` `system-dev/scripts/sdd-active-check.sh` 強制。
1. **單一活性**:任何時刻整個 repo 最多一份 `status: active` 的 SDD;所有開發任務必須對應這份 SDD 的 tasks,找不到對應 → 停下來問。
2. **CC 禁止自行建立 SDD**:任何情況下不得主動建新 SDD(既有 hook 規則 4.3 同源)。
3. **規格層變更只有一條路**:寫 change proposal 進 `system-dev/docs/3-specs/pending-changes.md`(變更摘要+影響分析)然後**停止**,等使用者明說「confirm」;沒 confirm 就照現行 SDD 繼續。任務層小改直接更新現行 tasks 並標日期原因。
4. **開新 SDDconfirm 後)**:先把舊 SDD 未完成且仍有效的任務**逐條搬入**新 SDD——搬完前不准寫任何程式碼;舊 SDD 改 `closed``superseded_by` 並移入 `3-specs/archive/`;向使用者列「已搬移/已作廢」清單請最終確認。
5. **每次 session 開始**:先讀現行 active SDD 與 pending-changes.md,回報三個數字——「現行規格〈名稱〉+未完成任務 N+待裁決 proposal M」——再開工。
---
## 工作流程(強制)
開始任一任務,按順序:
@@ -63,29 +50,6 @@
---
## 🔴 第一鐵律:wiki 是判準,不准跳過(2026-07-20/21 leo 兩度點破)
**要查任何東西之前,先搜尋 wiki——用 grep,不是只讀開頭幾行。**
> leo:「花很多力氣去產生 wiki,最重要的就是要可以查詢,**結果要查的時候就跳過,那就白寫了**。」
> 「重點是你自己的記憶對嗎?而你有按照規定去切實讀 wiki 嗎?」
```bash
grep -rin "<本題關鍵字>" system-dev/wiki/
```
**三條硬規則**
1. **wiki 與程式碼/歷史文件衝突 → 以 wiki 為準**。程式碼反映「還沒清乾淨」,不等於「還在用」。
2. wiki 寫「不可動/待廢除/進行中」→ **讀它的解除條件並逐條核對**。那是當時狀態,不是永久禁令。
3. 翻原文後得到新結論 → **回頭更新 wiki**wiki 過時是債,要還)。
**動外部系統(部署/curlwrangleracrgh)前**:先找 repo 有沒有**現成腳本或 README 部署段**
別自創方法。(實例:2026-07-21 明明有 `npx wrangler deploy` 這條驗過的路,卻自己 curl 硬幹踩坑。)
> hook `wiki-first-search.sh` 會在你查 code/下高風險指令時自動推 wiki 命中行;
> **但機制只是提醒,判斷是你的責任**
## Wiki(每次 session 的讀取順序)
| 檔案 | 時機 | 用途 |
@@ -114,12 +78,9 @@ grep -rin "<本題關鍵字>" system-dev/wiki/
## SDD 位置速查
> **現行(activeSDD 唯一判準=frontmatter `status: active`**2026-07-17 起),查法:`bash system-dev/scripts/sdd-active-check.sh`。下表僅路徑索引,「進行中」標記以 frontmatter 為準。
| 子系統 | 路徑 |
|-------|------|
| **現行 active** RAG Portal 多人授權 | `system-dev/docs/3-specs/portal-auth/` |
| ~~Credential Primitives WASM~~**closed,已封存** | `system-dev/docs/3-specs/archive/credential-primitives-wasm/` — credential 現行做法見 `.claude/rules/01-tech-stack.md`;殘留缺口(`auth_mtls` 未實作等)要做需另立新 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` |
-88
View File
@@ -1,88 +0,0 @@
# 想貢獻零件(component)?先確認你真的需要
> **99% 的需求不需要新零件。** 零件是**專業等級**、走 PR 審核;
> **recipe / workflow / app 誰都可以做**,隨建隨用、不必部署。
---
## 先照這個順序找,多半不用寫零件
**1. 語意搜尋知識庫**(最強——它能找到你沒猜中的用詞)
```
kbdb_search(q="我想達成什麼(用一句話描述)", mode="semantic")
kbdb_get_map() # 不確定該查哪個庫,先看藏書地圖
```
**2. 看現成的服務整合**26 個:GitHubNotionGeminiSlack…)
```bash
acr auth-recipe list
acr auth-recipe scaffold github # 直接吐出 credentials 範本 workflow 範例
```
**3. 看零件全集**(21 顆通用零件)
```bash
acr parts
```
特別注意 **`http_request`**:它能打**任意** HTTP API。
「平台沒有 XX 服務的零件」通常不成立——用 `http_request` 一份 recipe 就有了。
**4. 看有沒有現成 workflow 可以直接接**
```bash
acr list
```
---
## 三層責任分工
| 層 | 誰做 | 怎麼做 |
|---|---|---|
| **通用能力** | 平台提供 | `http_request` auth-recipe 機制=**能打任何 API**,這是地基 |
| **熱門服務 recipe** | 平台預鋪 | 減少常見情境的摩擦(現 26 個) |
| **冷門/特殊** | **誰用到誰開發** | recipe 是**設定不是程式**,門檻低 |
> 我們不會包辦全世界所有服務的 API。**用到就自己補一份 recipe**,那是設定檔不是程式碼。
---
## 什麼時候才真的需要新零件
**只有這種情況**:需要**新的原語能力**,而且**無法用既有零件組合出來**。例如——
- 一種新的控制流(現有 `if_control``switch``foreach_control``filter``try_catch` 都表達不了)
- 一種新的資料轉換原語(`code` 零件的沙箱做不到)
- 需要 WASM 層才能做的事(純計算、特殊編解碼)
**不算的情況**(這些都用 recipeworkflow 解):
- 「我要接 XX 服務的 API」→ `http_request` recipe
- 「我要做 XX 業務邏輯」→ workflow 組合既有零件
- 「我要處理某種資料格式」→ `code` 零件(沙箱 JS
---
## 真的要貢獻零件的話
零件是 WASMTinyGoAssemblyScript),有嚴格的沙箱約束
(禁網路 syscall、禁檔案系統、禁 goroutine、體積上限 2MB、
唯一 I/O 模型是 stdin/stdout JSON)。
**流程**:走 Arcrun repo 的 PR,過 `docs/component-pr-review-standard.md` 審核。
撰寫規範與 contract schema 見 `registry/` 底下的既有零件範例。
---
## 為什麼 MCP 不再暴露 `publish_component` / `get_component_guide`
2026-07-21 leo 拍板停用)
那兩個工具對一般使用者是**誤導危機**:搜不到東西時,系統會建議「去提交新零件」,
把人推向最難、最該擋的那條路。
**實測**:總管想寫一支「定期打 API 然後通知」的 workflowPython 約 10 行),
`foreach_control` 怎麼用,MCP 回傳的是**TinyGo 寫 WASM 零件的教學**
(白名單、syscall 限制、contract schema)——完全是另一件事,導致 40 分鐘未完成。
**設計判準**leo):
> 前端界面要**人類友善**Arcrun 要 **AI 友善**——都要**從終點看**。
> Arcrun **讓 AI 輕易建立程式碼**AI 要覺得 **Arcrun 比 Python 還簡單**
> 因此沒有寫 Python 的慾望。**絕不可迷路、搞不懂。**
+12 -11
View File
@@ -2,11 +2,6 @@
**讓 AI 用的工作流軟體(目前只支援 Claude Code**
> 想先看用它做出來的產品?**[Arcrun RAG](https://github.com/youlinhsieh/arcrun-rag)** —— 企業知識庫(丟檔案自動長出可查詢、可問答的知識庫)。
>
> 目前**沒有公開試玩站**(早期那個共用示範站已於 2026-08-08 退場)。想直接看產出長什麼樣,
> 可以看示範知識庫的公開鏡像 [arcrun-rag-demo-knowledge](https://github.com/youlinhsieh/arcrun-rag-demo-knowledge)——純靜態、免登入。
AI 很會寫程式,就要除錯,過程浪費很多 Token 及時間,但絕大部分是重複內容,例如登入認證、存取資料庫等。
既然做的差不多,何不預先寫好?AI 只要把除錯完成的「零件」粘起來,只除錯粘膠程式部分,就省時、省錢。
@@ -164,21 +159,27 @@ acr init --self-hosted
你不需要懂 git、不需要懂 tinygo、不需要手動建任何東西——預編譯好的零件(`.wasm`)直接從 GitHub 下載,用**你自己的** CF token 部署到**你的**帳號。
**最後一步:身份設定(你自己持有,工具不碰)。** self-hosted 是單租戶——你不需要平台發的 API Key,只需要個你自己填的值。在專案建一個 `.env`
**最後一步:身份設定(你自己持有,工具不碰)。** self-hosted 是單租戶——你不需要平台發的 API Key,只需要個你自己填的值。在專案建一個 `.env`
```bash
# .env(已被 gitignoreCLI 會自動讀)
NAMESPACE=leo # 你的資料分區標籤(明碼即可,這不是密碼)
ENCRYPTION_KEY=<64+ hex> # credential 加密金鑰,你自己保管(忘了 = 解不開已上傳的 credential
# 生成 keynode -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
```
> `NAMESPACE` 只是「你的資料放哪個分區」的標籤,不是密碼——要防別人呼叫你的 webhook,請對 webhook 加保護(見下)。
credential **不需要**你自管加密金鑰:`acr creds push` 走 TLS 把值送進你自己的 worker,由 **Cloudflare Workers Secrets** 託管(連你自己都讀不回,只能覆寫/刪除)。這需要 cypher worker 有一把能打 Workers Scripts secrets API 的 CF token`acr init` 會印確切指令):
把**同一把** `ENCRYPTION_KEY` 也設進你的 workerruntime 解密要用,CLI 會印確切指令):
```bash
wrangler secret put CF_SECRETS_API_TOKEN --name arcrun-cypher-executor
wrangler secret put ENCRYPTION_KEY --name arcrun-cypher-executor
wrangler secret put ENCRYPTION_KEY --name arcrun-auth-static-key
wrangler secret put ENCRYPTION_KEY --name arcrun-auth-service-account
```
> 不想自己 put?跑 `acr init` 時明示同意,AI 可代你設——但預設由你自己 put(金鑰是你持有的)。
完成。之後有新版零件,跑 `acr update` 一樣自動拉新、重部署。
> 想先不碰 Cloudflare、純在本機感受語法?`acr init --local` 然後直接跳到下面「寫一個工作流」。
@@ -313,7 +314,7 @@ acr update self-hosted:拉新版零件/引擎並重新
acr update --force 強制重部所有 worker(忽略未變動跳過快取)
```
> 給 AI 操盤手:開始前讀 [`llms.txt`](llms.txt)——它說明 arcrun 的世界觀(工作流是 default、零件稀有且不自製、一切外部 API 皆 recipe),讓你一開始就走在正路上。跑過 `acr install-harness` 之後,同一套規範會裝進你的專案(arcrun-mindset Skill)。
> 給 AI 操盤手:開始前讀 `.claude/rules/06-mindset.md`(或 arcrun-mindset Skill——它說明 arcrun 的世界觀(工作流是 default、零件稀有且不自製、一切外部 API 皆 recipe),讓你一開始就走在正路上。
---
@@ -336,7 +337,7 @@ MIT
arcrun 的核心架構、WASM 零件、CLI 工具鏈與這份文件,由以下貢獻者共同打造:
- **[@youlinhsieh](https://github.com/youlinhsieh)** — 創始人,產品設計與架構決策
- **[@richblack](https://github.com/richblack)** — 創始人,產品設計與架構決策
- **ClaudeAnthropic** — 實作夥伴:零件開發、executor 架構、CLI 實作與程式碼審查
想投稿零件?看 [CONTRIBUTING-components.md](CONTRIBUTING-components.md)。
歡迎加入:[CONTRIBUTING.md](CONTRIBUTING.md)
+25 -49
View File
@@ -1,65 +1,41 @@
<!-- arcrun-harness:start -->
## 這個專案用 Arcrun 做自動化
## 這個專案用 arcrun 做自動化
當需要「串服務/排程/打 API資料處理自動化」時,用 Arcrun 工作流,**不要自己寫 Python/Node 一次性腳本**。
Arcrun 工作流是純文字、可複用、跑在使用者自己的 Cloudflare。
當需要「串服務 / 排程 / 打 API / 資料處理自動化」時,用 arcrun 工作流,**不要自己寫 Python/Node 一次性腳本**。arcrun 工作流是純文字、可複用、跑在使用者自己的 Cloudflare。
⚠️ **不要上網搜 Arcrun 文件**——網路上沒有,找到的都是錯的。
語法與範本全在 **arcrun-mindset** Skill 裡,`acr` 指令查得到現況。
### 怎麼開始:先寫「意圖」,不要先寫 YAML
你**不需要先知道有哪些零件**。把需求寫成一串「誰接誰」,丟去查,系統會告訴你哪些存在:
```
input >> ON_SUCCESS >> fetch_rows
fetch_rows >> 對每個 row >> notify
```
- 第一個節點固定是 `input`(觸發時帶進來的資料)
- **邊有這些**`ON_SUCCESS``對每個 <變數>`FOREACH)、`ON_TRUE``ON_FALSE`(配 `if_control`)、`ON_BRANCH``branch:`(配 `switch``try_catch`
- **沒有** `ON_FAILURE`——要處理失敗用 `try_catch` `ON_BRANCH(catch)`
完整語法、四份實跑過的範本、節點命名慣例 → 讀 **arcrun-mindset** Skill。
### Arcrun 只有三種東西,先分清楚就不會做歪
arcrun 只有三種東西,先分清楚就不會做歪:
| 東西 | 是什麼 | 你能做的 |
|---|---|---|
| **工作流(workflow** | 把零件recipe 串起來的純文字流程 | **預設就寫這個**,自由寫 |
| **recipe** | 打「一個固定外部 API」的設定(endpointheaderbody 模板) | 自由寫、**而且該投稿** |
| **零件(component** | WASM 程式(流程控制資料處理`http_request`auth),固定一小套 | **你不自製** PR |
| **工作流(workflow** | 把零件/recipe 串起來的純文字流程 | **預設就寫這個**,自由寫 |
| **recipe** | 打「一個固定外部 API」的設定(http_request + endpoint/header/body 模板) | 自由寫、**而且該投稿**(見下) |
| **零件(component** | WASM 程式(流程控制 / 資料處理 / http_request / auth),固定一小套 | **你不自製**由維護者管,走 GitHub PR |
> **一句話判準**:打一個固定外部 endpoint → 寫 **recipe**;流程控制資料處理通用 HTTP → 用既有**零件**;其他 → 寫**工作流**串起來。
>
> ⚠️ 常見錯誤:把 `telegram_send``gmail_send``kbdb_get` 當**零件**寫。它們是 **recipe**
> **一句話判準**:打一個固定外部 endpoint → 寫 **recipe**;流程控制 / 資料處理 / 通用 HTTP → 用既有**零件**;其他 → 寫**工作流**串起來。
### 查詢回 `not_found` 時:兩條路,都不准改寫成 code
### 三個最常踩的坑(讀懂這三條,就不會像前人誤解四輪)
查詢會誠實回報(兩庫都查過才點名):
**① recipe 是公共資產,發現缺的就自己做一個投稿,不必問人。**
`acr recipe push` = 把 recipe **投稿到公共庫**,不是存私人腳本。公開/私有只是 recipe 的一個屬性(同一身份可有公私版本並存),不是兩條不同的路。
→ 所以「想打某個 API 但沒有現成 recipe」時,**正解是自己寫一個 recipe 並 push 上去**(會 inject credential、push 時幫你檢查打不打得通)。這是被鼓勵的,別誤以為要自用、不上傳。
| status | 意思 | 你該做什麼 |
|---|---|---|
| `found` / `resolved` | 有現成的可用 | **只填 payload** |
| `not_found` | 零件庫與 recipe 庫都沒有 | 照回應的 `suggestion` 走(見下兩條路),並看 `similar_components``similar_recipes` 有沒有能直接用的 |
| `unknown` | 查不到 registry(未部署/網路失敗) | **不代表不存在**,別據此改寫成 code |
**② 缺一個能力 → 去補 APIcypher endpoint),不准用 recipe / 多步工作流拼裝出來。**
判準口訣:**「這段邏輯換一個介面(CLI→MCP)要不要重寫?要重寫 → 它是『能力』,該長在 API。」**
- ❌ 缺 `upsert` → 在工作流裡拼「先查、沒有再建」、或寫個 recipe 假裝補上。
- ✅ 缺 `upsert` → 去 API 加一個 `upsert` endpointCLI/MCP/recipe 都呼叫它。
recipe 只負責「打一個固定外部 API」這件單純事;它不是用來補 arcrun 自己缺的能力的。缺能力就回報 / 補在 API,不要繞。
- **缺外部 API****自己寫一個 recipe**`acr recipe push`(幾行 YAML,不用部署 Worker、不用寫程式)。
recipe 是公共資產,發現缺的就補一個投稿,不必問人。
- **缺計算能力**(加解密/壓縮這類純運算) → 投稿**零件 PR**(要人類確認,罕見)
🔴 **查不到就改寫成 `code` 節點 =「腹語術」**(表面用 Arcrun、實際全寫 JS)。
`code` 只用於**局部整形**(例:剝掉 LLM 回應的雜訊、切段落),不用來取代零件與流程控制。
> 實錄:每一個寫進 `code``if` 都是沒被測過的新 bug;零件的價值是「被測過 1000 次」,寫進 code 就歸零。
**③ 已經有自製零件(例如 mira 的那幾個)→ 讓它退場,別再加新的。**
你不該自製零件;既有的自製零件要往這三條退場:
- `claude_api` 之類「工作流回頭叫 LLM」→ **刪掉**,需要 AI 判斷時是**你(操盤的 CC)自己做**,再叫工作流做確定性的下一步。arcrun 是 AI 用的工具,不是工具回頭用 AI
- `kbdb_*` 之類資料存取 → 改走已備好的 **`acr kbdb` 薄殼 / `kbdb_*` MCP 工具**template + record 模型),不要當零件。
- 純粹打某個固定外部 API 的假零件 → **改寫成 recipe** 投稿(見①)。
### 其餘鐵律
- **先查能力再動手**`acr search <關鍵字>`(一次掃零件/recipeauth-recipeworkflow)、
`acr parts`(零件)、`acr recipe list`recipe)、`acr auth-recipe list`(支援的認證)
- **需要 AI 判斷時你自己做**,不要讓工作流回頭呼叫 LLM。Arcrun 是 AI 用的工具,不是工具回頭用 AI
- **金鑰只拿名字**:定義裡只寫 `{{credential.<名字>}}`,真身絕不寫進 workflowrecipe 檔案。
- **暴露資料要人類同意**`acr push``acr recipe push` 會讓東西可被外部呼叫 → 停下來讓使用者明示同意,不替他決定公開。
- **誠實**:沒打通就誠實說(缺 credential 標「未驗收:缺 X」),不假裝成功;完成以 HTTP 2xx/trace 為證,不口頭宣布。
- **先查能力再動手**`acr parts`(看可用零件)、`acr auth-recipe list`(看支援的認證服務)、`acr kbdb`(資料存取)。
- **暴露資料要人類同意**:部署對外 webhook / push recipe 會讓東西可被外部呼叫 → 停下來讓使用者明示同意,不替他決定公開
- **誠實**:沒打通就誠實說(缺 credential 標「未驗收:缺 X」),不假裝成功;完成以 HTTP 2xx / trace 為證,不口頭宣布
開始前讀 **arcrun-mindset** Skill意圖語法+範本+世界觀)。使用者技術細節交給你,CLI 操作你來做。
開始前讀 **arcrun-mindset** Skill(世界觀)。使用者技術細節交給你,CLI 操作你來做。
<!-- arcrun-harness:end -->
+15 -49
View File
@@ -1,60 +1,26 @@
# 用 Arcrun 完成這個自動化需求
# 用 arcrun 完成這個自動化需求
使用者想做一個自動化。你的任務:用 Arcrun 做出來,全程不要讓使用者自己寫程式。
⚠️ **不要上網搜 Arcrun 文件**(網路上沒有)。先讀 **arcrun-mindset** Skill,再用 `acr` 指令查現況。
使用者想做一個自動化。你的任務:用 arcrun 做出來,全程不要讓使用者自己寫程式。
## 鐵則
- **用 Arcrun 工作流recipe,絕不自己寫 Python/Node 腳本。** 使用者選 Arcrun 就是不想要一次性腳本。
- **打外部 API → 寫 recipe**`acr recipe push`),不自刻 HTTP client。缺 recipe 就自己補一個,不必問人。
- **不自製零件(WASM**——零件由 Arcrun 維護。你能用的是現有零件 recipe 工作流。
- **需要 AI 判斷時你自己做**,不要讓工作流回頭呼叫 LLM。
- 🔴 **查不到零件就改寫成 `code` 節點 = 腹語術**,禁止。缺 API 寫 recipe、缺能力投稿零件。
- **用 arcrun 工作流 / recipe,絕不自己寫 Python/Node 腳本。** 使用者選 arcrun 就是不想要一次性腳本。
- 打外部 API → 寫 recipe`acr recipe push`),不自刻 HTTP client。
- 不自製零件(WASM)—— 零件由 arcrun 維護。你能用的是現有零件 + recipe + 工作流。
- 需要 AI 判斷時你自己做,不要讓工作流回頭呼叫 LLM。
## 步驟
1. 先讀 **arcrun-mindset** Skill(世界觀 + 資源去哪取)。
2. 跑 `acr parts` 看零件、`acr auth-recipe list` 看支援的認證。**先查再動手。**
3. 把使用者需求拆成工作流(哪些零件、什麼順序、什麼條件),寫成 `.yaml`
4. 需要 credentialAPI key / token)→ 用 `acr auth-recipe scaffold <service>` 看要哪些,
明確告訴使用者去哪取得、怎麼 `acr creds push`
5. `acr validate` 通過後 `acr push` 部署,告訴使用者 webhook URL / 怎麼 `acr run`
6. 完成給客觀證據(HTTP 2xx / trace),不要只說「做好了」。
### 1. 先寫「意圖」,不要先寫 YAML
把使用者的需求寫成一串「誰接誰」(**不必是真實零件名**,用你想得到的名字即可):
```
input >> ON_SUCCESS >> fetch_rows
fetch_rows >> 對每個 row >> notify
```
- 第一個節點固定是 `input`
- 邊有 `ON_SUCCESS``對每個 <變數>`FOREACH)、`ON_TRUE``ON_FALSE`(配 `if_control`)、`ON_BRANCH``branch:`(配 `switch``try_catch`);**沒有** `ON_FAILURE`
- 需要判斷 → 用條件邊(`if_control``ON_TRUE``ON_FALSE`),不要寫 code 判斷
語法細節、四份實跑過的範本、節點命名慣例 → **arcrun-mindset** Skill。
### 2. 丟去查,讓系統告訴你有什麼
`acr search <關鍵字>` 一次掃零件/recipeauth-recipeworkflow
或把意圖串丟 `/cypher/search`,逐節點拿 `found` / `resolved` / `not_found` / `unknown`
- `found``resolved` → **只填 payload**
- `not_found` → 照回應的 `suggestion` 走(缺 API 寫 recipe、缺計算能力投稿零件),
並看 `similar_components``similar_recipes` 有沒有現成能用的
- `unknown`**不代表不存在**,別據此改寫成 code
### 3. 把意圖變成 workflow YAML
節點填上查到的真實零件/recipe + payload。
需要 credential 時:`acr auth-recipe scaffold <service>` 看要哪些,明確告訴使用者去哪取得、怎麼 `acr creds push`
🔑 定義裡只寫 `{{credential.<名字>}}`**真身絕不寫進檔案**。
### 4. 驗證 → 部署 → 給證據
```bash
acr validate <workflow>.yaml # 先驗
acr push <workflow>.yaml # 部署(暴露動作,見下)
acr run <workflow> # 觸發一次
acr logs <workflow> # 看執行紀錄
```
完成要給客觀證據(HTTP 2xx/trace),不要只說「做好了」。
## 遇到要暴露資料(對外 webhook/recipe 投稿)
## 遇到要暴露資料(對外 webhook)
停下來,明確告訴使用者「這會讓 X 可被外部呼叫」,要他同意。不要替他決定公開。
非互動環境下把完整指令印給使用者自己貼上跑。
## 還沒設定好 Arcrun
## 還沒設定好 arcrun
`acr` 指令不存在或還沒 `acr init`:先帶使用者完成前置設定
(裝 CLI → 拿 Cloudflare 帳號的兩串憑證 → `acr init --self-hosted`)。
拿 Cloudflare 憑證時用白話照抄式引導,不要對使用者講 KV / Worker / R2 等術語。
+5 -19
View File
@@ -66,7 +66,7 @@ if echo "$CMD" | grep -qE "acr (push|recipe push)\b"; then
if echo "$EXEC_PART" | grep -qE "(^|[;&|][[:space:]]*)acr[[:space:]]+(push|recipe[[:space:]]+push)\b"; then
if [ ! -t 0 ] && [ "${ARCRUN_HUMAN_CONFIRMED:-}" != "1" ]; then
block "在非互動環境自動執行暴露動作(acr push / recipe push 會讓東西可被外部呼叫)" \
"交人類在終端機執行(真 TTY 會自動放行)。可把指令完整複製給使用者貼上自己跑:\`acr push <你的 workflow.yaml>\`。或使用者先在對話明示同意後親自於終端機執行。不要替使用者決定公開。(部署前的正路見 arcrun-mindset Skill:先 \`acr validate\`"
"交人類在終端機執行(真 TTY 會自動放行)。可把指令完整複製給使用者貼上自己跑:\`acr push <你的 workflow.yaml>\`。或使用者先在對話明示同意後親自於終端機執行。不要替使用者決定公開。"
fi
fi
fi
@@ -76,29 +76,15 @@ fi
if echo "$CMD" | grep -qE "(^|[;&| ])(python3?|node)[ ]+[^ ]+\.(py|js|mjs|ts)\b"; then
# 排除明顯的測試 / 既有工具呼叫(pytest / npm test / jest 等)降低誤判
if ! echo "$CMD" | grep -qE "(pytest|jest|vitest|npm (run )?test|mocha|\btest_)"; then
remind "偵測到用 python/node 跑腳本。這專案用 Arcrun,串服務/自動化不要自刻一次性腳本。" \
"讀 arcrun-mindset Skill,先把需求寫成「意圖」串(\`input >> ON_SUCCESS >> <下一步>\`,邊只有 ON_SUCCESS 與「對每個 X」),再用 \`acr search <關鍵字>\` 哪些零件/recipe 存在,最後才寫 workflow.yaml → \`acr validate\` → \`acr run\`。若這確實不是自動化(例如跑測試/別的工具),忽略本提醒。"
remind "偵測到用 python/node 跑腳本。這專案用 arcrun,串服務/自動化不要自刻一次性腳本。" \
"先跑 \`acr parts\` 看有哪些零件,把需求寫成 workflow.yaml 用 \`acr run\`。若這確實不是自動化(例如跑測試/別的工具),忽略本提醒。"
fi
fi
# ── 提醒(不硬擋):自寫打固定 API 的 script,而非 recipe ──────────────
if echo "$CMD" | grep -qE "(curl|fetch|requests\.(get|post)|axios).*https?://"; then
remind "偵測到自己打外部 API。Arcrun 裡「打固定 endpoint」應寫成 recipe,不自刻 HTTP 呼叫。" \
" \`acr recipe search <服務名>\` 看有沒有現成的;沒有就自己寫幾行 YAMLcanonical_id/endpoint/method/auth_service)用 \`acr recipe push\` 投稿,workflow 裡用 \`http_request\` 該 recipe 引用它。缺 recipe 就自己補,不必問人。寫法見 arcrun-mindset Skill。"
fi
# ── 提醒(不硬擋):把 code 節點當成缺零件的替代品(「腹語術」)──────────────
# 查詢回 not_found 就改寫成 code = 表面用 Arcrun、實際全寫 JS。這是現世代最常見的走歪。
if [ "$TOOL" = "Write" ] || [ "$TOOL" = "Edit" ] || [ "$TOOL" = "MultiEdit" ]; then
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // ""')
CONTENT=$(echo "$INPUT" | jq -r '.tool_input.content // .tool_input.new_string // ""')
if echo "$FILE" | grep -qE '\.(ya?ml)$' && echo "$CONTENT" | grep -qE 'component:[[:space:]]*["'"'"']?code\b'; then
# 只在 code 內容看起來在做流程控制/取代零件時提醒(含 if/for/fetch),單純整形不吵
if echo "$CONTENT" | grep -qE '\b(if[[:space:]]*\(|for[[:space:]]*\(|fetch\(|await[[:space:]]+fetch)'; then
remind "workflow 裡的 \`code\` 節點含流程控制/HTTP 呼叫——這可能是「腹語術」(表面用 Arcrun、實際全寫 JS)。" \
"\`code\` 只用於局部整形(例:剝掉 LLM 回應的雜訊、切段落)。缺外部 API → 寫 recipe\`acr recipe push\`);缺計算能力 → 投稿零件 PR;要判斷 → 用條件邊(\`if_control\` 配 \`ON_TRUE\`\`ON_FALSE\`,或 \`switch\`\`try_catch\` 配 \`ON_BRANCH\`),不要寫 code 判斷。每個寫進 code 的 if 都是沒被測過的新 bug。見 arcrun-mindset Skill。"
fi
fi
remind "偵測到自己打外部 API。arcrun 裡「打固定 endpoint」應寫成 recipe,不自刻 HTTP 呼叫。" \
" \`acr recipe push\` 把這個 API 包成 recipeworkflow 裡用 component 引用它。見 arcrun-mindset Skill。"
fi
exit 0
-10
View File
@@ -10,16 +10,6 @@
"timeout": 5
}
]
},
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/arcrun-guard.sh",
"timeout": 5
}
]
}
]
}
+38 -245
View File
@@ -1,285 +1,78 @@
---
name: arcrun-mindset
description: >-
在 Arcrun 上做任何事時使用(用戶說「幫我用 Arcrun 做 X」「用 arcrun 寫一個工作流」「把 X 自動化」)。
Arcrun 是跑在 Cloudflare 上的工作流引擎——你用 `>>` 寫「意圖」,系統告訴你有哪些現成零件與 recipe,
你只填 payload,不必自己寫程式。**不要上網搜 Arcrun 文件**(網路上沒有),也不要自己猜 YAML 格式:
先讀本 skill,再用 `acr` 指令(或 MCP 工具)查現成零件。
涵蓋:意圖工作流語法、四份實跑過的範本、零件 vs recipe 的分別、缺件的兩條路、已知的坑。
arcrun 的世界觀 — 用 arcrun 開發自動化時的預設心態 + 資源去哪取。當你(AI 操盤手)要在
arcrun 上做任何事(串服務、處理資料、認證、把東西開放給人用)前讀這個。它讓你做出「方向對」
的選擇、知道資源在哪,避免技術上能跑但架構上錯、或自己重刻 arcrun 已有的東西。
---
# Arcrun:怎麼寫意圖工作流
# arcrun mindset(給 AI 操盤手)
> **你已經配備 Arcrun**(此專案裝了 `acr` CLI,可能另有 `arcrun_*` MCP 工具)。
> **別上網找文件**——網路上沒有 Arcrun 的文件,找到的都是錯的。答案都在本 skill 與 `acr` 指令裡
## 先做這三件(照順序)
1. `acr whoami` — 確認連到哪個帳號(**勿自行 curl 猜帳號 URL**
2. 讀本 skill 下面的語法與範本 → 寫出 `>>` 意圖
3. `acr parts``acr recipe list`(或 `acr search <關鍵字>` 一次掃全部)— 確認零件與 recipe 真的存在
**卡住時**`acr search <關鍵字>` 跨類搜尋;有 MCP 就 `arcrun_get_skill('INDEX')` 拿全館導航。
你在 arcrun 上幫使用者開發自動化。arcrun 很簡單,簡單到你常會把它想複雜、或退回自己熟悉的
Python/Node 自刻。這份幫你在岔路上選對方向,並告訴你資源在哪
---
## 0. 一句話世界觀
**Arcrun 裡幾乎所有東西都是工作流(workflow)。** 工作流 一張紙,寫「用哪些零件、什麼順序、什麼條件」。
你大部分時間在**寫紙、改紙**,不是在造新零件、也不是自己寫腳本。
**Arcrun 只有三種東西,先分清楚就不會做歪:**
| 東西 | 是什麼 | 你能做的 |
|---|---|---|
| **工作流(workflow** | 把零件/recipe 串起來的純文字流程 | **預設就寫這個**,自由寫 |
| **recipe** | 打「一個固定外部 API」的設定(endpointheaderbody 模板) | 自由寫、**而且該投稿**(缺就自己補) |
| **零件(component** | WASM 程式(流程控制/資料處理/`http_request`auth),固定一小套 | **你不自製**,走 PR 由維護者管 |
> **一句話判準**:打一個固定外部 endpoint → 寫 **recipe**;流程控制/資料處理/通用 HTTP → 用既有**零件**;其他 → 寫**工作流**串起來。
**arcrun 裡幾乎所有東西都是工作流(workflow)。** 工作流 = 一張紙,寫「用哪些零件、什麼順序、什麼條件」。
你大部分時間在寫紙、改紙,不是在造新零件、也不是自己寫腳本。
---
<!-- 以下正文由 registry/skills/write_intent_workflow.md 於建置期複製而來(單一真相源)。
不要直接編輯本段——改 registry 那份,然後跑 `npm run build:harness`。 -->
## 1. 工作流是 default,不要退回自己寫 Python
## 1. 意圖工作流的語法
使用者選 arcrun,就是不要「每次重刻、跑完即丟」的腳本。所以你的預設順序:
一串「誰接誰」,每行一個關係:
1. **先想能不能用工作流做**(串現有零件 / recipe + 流程控制)。99% 可以。
2. 要打的服務有 HTTP API、但沒有對應 recipe → **寫一個 recipe**http_request + 固定設定 YAML,不用部署、不用審核)。
3. **只有**封閉純邏輯(流程控制 / 資料處理)、現有零件不夠、且值得全 arcrun 重用 → 才考慮零件(而零件走 PR,不是你現在做)。
```
<節點A> >> <邊> >> <節點B>
```
> 典型走歪:「我先用 Python 測一下」。停。使用者要的是 arcrun 工作流。先 `acr parts` 看有什麼,用工作流串。
- **節點**=一個步驟。用你想得到的名字(中文可以),**不必是真實零件名**
- **邊**=什麼情況下往下走
## 2. 邊有這些
| 邊 | 意思 | 真例 |
|---|---|---|
| `ON_SUCCESS` | 上一步成功就往下 | `input >> ON_SUCCESS >> prep` |
| `對每個 <變數>` | 上一步產出清單,逐項處理(FOREACH)| `parse_card >> 對每個 block >> post_block` |
| `ON_TRUE` / `ON_FALSE` | 條件成立/不成立各走一條(配 `if_control`| `判斷有沒有新資料 >> ON_TRUE >> 傳到 telegram` |
| `ON_BRANCH``branch:` | 依標籤選路(配 `switch` 每個 case、`try_catch` 的 try/catch| `my_switch >> ON_BRANCH(branch_active) >> 處理啟用` |
### 2.1 條件分支怎麼寫(2026-08-01 起引擎支援)
**需要判斷時,用分支邊,不要寫 `code` 判斷。**
三顆流程控制零件都輸出 `data.branch` 標籤,引擎依標籤選路:
| 零件 | 輸出的標籤 | 接法 |
|---|---|---|
| `if_control` | `"true"` / `"false"` | `ON_TRUE``ON_FALSE` 各一條 |
| `switch` | 你在 `cases[].branch` 取的名字(沒中則 `default_branch`| 每條路一條 `ON_BRANCH`,邊上標 `branch` |
| `try_catch` | `"try"`(沒錯)/`"catch"`(有錯)| 兩條 `ON_BRANCH`,標 `try``catch` |
```
判斷有沒有新資料 >> ON_TRUE >> 傳到 telegram
判斷有沒有新資料 >> ON_FALSE >> 結束
```
中文語意詞亦可:「成立時」=`ON_TRUE`、「否則」=`ON_FALSE`
💡 **不必背**:查零件時回應會附 `branch_hint`(有哪些標籤、用哪些邊型、可照抄的範例),
照著接就對了。
⚠️ 仍然**不要寫 `ON_FAILURE`**(沒有這種邊;要處理失敗用 `try_catch` `ON_BRANCH(catch)`)。
### 2.2 怎麼確認分支真的走對了(**別看不懂就以為壞掉**)
分支工作流「有沒有成功」看兩件事,**不是看某條沒走的路沒有輸出**:
1. **`verdict`**`GET /workflows/<name>/executions?limit=1`
`data.executions[0].verdict === "success"` 就是成功了。
2. **`trace` 裡有沒有出現該走的節點**:走 TRUE 路時 FALSE 路的節點**本來就不該出現**
——**那是正確行為,不是失敗**。
```
# 條件成立 → 只有 true 那條的節點在 trace
{"amount": 5000} → if_control 回 branch="true" → 走 ON_TRUE 那條
{"amount": 100} → if_control 回 branch="false" → 走 ON_FALSE 那條
```
🔴 **實撞(2026-08-01 考試)**:有考生的分支工作流**其實完全正常**
`amount=5000`→true、`amount=100`→false 都對),但它以為「跑不通」而放棄改寫成 code。
**看到只有一條路有輸出=分支正在正確運作**,不要因此判定失敗。
## 3. 第一個節點固定是 `input`
所有真範本都以 `input` 起頭——那是「觸發時帶進來的資料」。
---
## 4. 真範本(照抄結構、改內容)
> 以下四份**全部是實際部署且 `verdict=success` 的 workflow**,不是簡化示範。
> 用 `acr logs <name>`(有 MCP 則 `arcrun_get_workflow(<name>)` 可以拿完整定義。
### A. 最短:取資料 → 處理 (`graph_neighbors`
```
input >> ON_SUCCESS >> fetch_triplets
fetch_triplets >> ON_SUCCESS >> bfs_neighbors
```
### B. 長鏈:多次查詢 → 組裝 → 問 AI → 收尾 (`rag_chat`
```
input >> ON_SUCCESS >> prep
prep >> ON_SUCCESS >> kw_search
kw_search >> ON_SUCCESS >> sem_search
sem_search >> ON_SUCCESS >> fetch_triplets
fetch_triplets >> ON_SUCCESS >> fetch_blocks_a
fetch_blocks_a >> ON_SUCCESS >> assemble
assemble >> ON_SUCCESS >> ask_llm
ask_llm >> ON_SUCCESS >> finalize
```
`prep` 前處理/`assemble` 組 prompt`finalize` 收拾回應——三個常見的整形節點。
### C. 一節點分岔兩條 FOREACH `rag_ingest_card`
```
input >> ON_SUCCESS >> parse_card
parse_card >> 對每個 block >> post_block
parse_card >> 對每個 rel >> post_triplet
```
同一節點可有多條出邊,各自處理不同清單。
### D. 混合:直線 兩段 FOREACH (`rag_takedown_direct`
```
input >> ON_SUCCESS >> prep
prep >> ON_SUCCESS >> list_dead_blocks
list_dead_blocks >> ON_SUCCESS >> build_deprecations
build_deprecations >> 對每個 dead_entry >> deprecate_entry
build_deprecations >> ON_SUCCESS >> list_triplets
list_triplets >> ON_SUCCESS >> pick_dead_triplets
pick_dead_triplets >> 對每個 dead_record >> deprecate_triplet
```
`build_deprecations` 同時有 FOREACH 出邊與 `ON_SUCCESS` 出邊——
前者處理清單、後者繼續主線。
---
## 5. 節點怎麼命名(照真範本的模式,查詢較容易媒合)
| 意圖 | 模式 | 真例 |
|---|---|---|
| 前處理/正規化 | `prep` | `rag_chat.prep` |
| 取一批資料 | `fetch_*``list_*` | `fetch_triplets``list_dead_blocks` |
| 搜尋 | `*_search` | `kw_search``sem_search` |
| 解析/切塊 | `parse_*` | `parse_card` |
| 寫入 | `post_*` | `post_block``post_triplet` |
| 組裝 | `assemble``build_*` | `assemble``build_deprecations` |
| 問 AI | `ask_llm` | `rag_chat.ask_llm` |
| 收尾整形 | `finalize` | `rag_chat.finalize` |
---
## 6. 寫完一定要查(**不要直接部署**)
```bash
curl -s -X POST https://arcrun-cypher-executor.<subdomain>.workers.dev/cypher/search \
-H 'content-type: application/json' -H 'X-Arcrun-API-Key: <namespace>' \
-d '{"triplets":["input >> ON_SUCCESS >> fetch_data","fetch_data >> ON_SUCCESS >> notify"]}'
```
回應的每個節點會有:
| status | 意思 | 你該做什麼 |
|---|---|---|
| `found` | 有這個節點。`source: component``input_schema`(怎麼填 payload)與 `success_rate``source: recipe` 附 description/endpoint | **只填 payload** |
| `not_found` | **兩庫(零件 registry+recipe 庫)都查過,確定沒有** | 照 `suggestion` 欄走:缺 API → 寫 recipeskill `write_recipe`);缺計算能力 → 投稿零件 PR(skill `add_new_wasm_component`)。`similar_components`/`similar_recipes` 是相近候選——先看有沒有現成的能直接用 |
| `unknown` | 查不到 registry | **不代表不存在**,別據此改寫成 code |
> 註(2026-07-31):`/cypher/search` 曾對任何節點名都回假 `found`,已修為真查兩庫。
> 舊實例(未更新部署)仍可能假 found——status 可信度以該實例部署版本為準。
---
## 7. 常犯的錯
1. **用不存在的邊**`ON_FAILURE`)→ 沒有這種邊;要處理失敗用 `try_catch` `ON_BRANCH(catch)`
⚠️ `ON_TRUE``ON_FALSE``ON_BRANCH` **是存在的**2026-08-01 起),見 §2.1——
本行以前寫「ON_TRUE 不存在」是舊世代,已更正
2. **第一個節點不是 `input`**
3. **把 recipe 當零件寫**——`telegram_send``gmail``kbdb_get`**recipe** 不是零件
→ 寫成 `http_request` 該 recipe
4. 🔴 **查詢回 `not_found` 就改寫成 `code` 節點**
→ 那叫「腹語術」(表面用 Arcrun、實際全寫 JS)。正解:缺 API 寫 recipe、缺能力投稿零件。
`code` 只用在**局部整形**(例:剝掉 LLM 回應的雜訊),不用來取代零件與流程控制。
---
## 8. 相關
- 完整版指引與十題考卷(含 haiku 實測 10/10):
頂層 repo `system-dev/docs/3-specs/arcrun-usable/`
- 下一步該讀哪支 skill(需 MCP):`arcrun_list_skills()`
- 定期掃資料 → `build_watcher_workflow`
- RAG 檢索問答 → `rag_with_arcrun`
- workflow 卡住不動 → `debug_paused_workflow`
---
## 9. 資源去哪取(不要自己重造 Arcrun 已有的)
## 2. 資源去哪取(不要自己重造 arcrun 已有的)
| 你想知道 | 跑這個 |
|---|---|
| 有哪些零件可用 | `acr parts` |
| 某零件的設定範本 | `acr parts scaffold <name>` |
| 有哪些 recipe | `acr recipe list``acr recipe search <關鍵字>` |
| 支援哪些服務的認證 | `acr auth-recipe list` |
| 某服務認證要哪些 credential 範例 | `acr auth-recipe scaffold <service>` |
| **一次掃全部**(零件/recipeauth-recipeworkflow | `acr search <關鍵字>` |
| 已部署的 workflow | `acr list` |
| 某次執行為什麼失敗 | `acr logs <workflow>` |
| 某服務認證要哪些 credential + 範例 | `acr auth-recipe scaffold <service>` |
| 已上傳的 recipe | `acr recipe list` |
| 工作流語法、指令 | `acr --help` |
**先查再動手**——Arcrun 多半已經有你要的零件recipe認證,不要自刻。
**先查再動手**——arcrun 多半已經有你要的零件 / recipe / 認證,不要自刻。
## 10. 做出來以後:驗證 → 部署
## 3. arcrun 是你(AI)用的工具,不是工具回頭呼叫 AI
```bash
acr validate <workflow>.yaml # 先驗,別直接部署
acr push <workflow>.yaml # 部署(暴露動作,見 §12)
acr run <workflow> # 觸發一次,看實際結果
acr logs <workflow> # 看執行紀錄/失敗原因
```
需要智慧判斷 / 自然語言轉換時,**你自己做**,再呼叫工作流執行確定性的下一步。
**不要在工作流中間放零件回頭呼叫 LLM**。arcrun 的大腦就是操盤的你。
需要 credentialAPI keytoken)時:`acr auth-recipe scaffold <service>` 看要哪些,
明確告訴使用者去哪取得、怎麼 `acr creds push`
🔑 **金鑰只拿名字**workflowrecipe 裡只寫 `{{credential.<名字>}}`
**真身絕不寫進定義檔**(執行前才由系統回填)。
## 4. arcrun 不替你做授權判斷
## 11. Arcrun 是你(AI)用的工具,不是工具回頭呼叫 AI
API 打不打得通由發 key 的服務決定。401/403 是對方服務在行使授權,**不是 arcrun 的 bug、不是你做錯**。
不要在 arcrun 裡建「允許/禁止某 endpoint」的二次授權清單。
需要智慧判斷/自然語言轉換時,**你自己做**,再呼叫工作流執行確定性的下一步。
**不要在工作流中間放零件回頭呼叫 LLM**——Arcrun 的大腦就是操盤的你。
(唯一例外:`ask_llm` 這種「內容生成本身就是流程的一步」,見範本 B。)
## 5. 把東西開放給別人用 = 要使用者明示同意
## 12. 把東西開放給別人用 = 要使用者明示同意
`acr push`(部署 workflow)與 `acr recipe push`(投稿 recipe)會讓資料/能力**可被外部呼叫**:
部署對外 webhook、push recipe 會讓資料/能力**可被外部呼叫**(暴露面):
- 停下來,明確告訴使用者「這會讓 X 可被外部呼叫」,要他同意。**不替他決定公開。**
- 非互動環境(你直跑)遇到 → 停,把完整指令印給使用者自己貼上跑,絕不自己塞 confirm 假裝同意。
- Arcrun 可提供保護(要求呼叫者帶 key限流)——提醒使用者。
- 非互動環境(你直跑)遇到 → 停,要人類確認,絕不自己塞 confirm 假裝同意。
- arcrun 可提供保護(要求呼叫者帶 key / 限流)——提醒使用者。
## 13. Arcrun 不替你做授權判斷
API 打不打得通由發 key 的服務決定。401/403 是對方服務在行使授權,**不是 Arcrun 的 bug、不是你做錯**。
不要在 Arcrun 裡建「允許/禁止某 endpoint」的二次授權清單。
## 14. 誠實(最重要)
## 6. 誠實(最重要)
- **不假綠**:沒打通就誠實說。缺 credential 打不到 2xx → 標「未驗收:缺 X」,不 mock 充綠燈。
- **不假裝防偽不代替人類確認**有風險的動作(暴露資料)。
- **完成 客觀證據**HTTP 2xx trace),不是口頭「做好了」。
- **不假裝防偽 / 不代替人類確認**有風險的動作(暴露資料)。
- **完成 = 客觀證據**HTTP 2xx + trace),不是口頭「做好了」。
---
## 動手前的自檢清單
## 怎麼用這份 mindset
1. 我把意圖寫成 `>>` 串了嗎?(還是直接跳去寫 YAML/寫程式)
2. 我查過 `acr search` / `acr parts` / `acr recipe list` 了嗎?
3. 查詢回 `not_found` 時,我走的是 recipe/零件 PR 兩條路,**還是偷偷改寫成 `code`**?(後者=腹語術
4. 我是不是讓工作流回頭呼叫 AI 做判斷?(是 → 改成我自己做)
5. 這動作會把資料開放給別人嗎?(會 → 要使用者明示同意)
6. 我有沒有假裝(假綠/假防偽/代替人類確認)?(有 → 停,誠實標明)
每次準備動手,先過一遍:
1. 這能用工作流 / recipe 做嗎?(多半能 → 別自己寫 Python、別造零件)
2. 我查過 `acr parts` / `acr auth-recipe` 了嗎?(arcrun 可能已有
3. 我是不是讓工作流回頭呼叫 AI?(是 → 改成我自己做)
4. 這動作會把資料開放給別人嗎?(會 → 要使用者明示同意)
5. 我有沒有假裝(假綠 / 假防偽 / 代替人類確認)?(有 → 停,誠實標明)
@@ -1,41 +0,0 @@
---
name: arcrun-mindset
description: >-
在 Arcrun 上做任何事時使用(用戶說「幫我用 Arcrun 做 X」「用 arcrun 寫一個工作流」「把 X 自動化」)。
Arcrun 是跑在 Cloudflare 上的工作流引擎——你用 `>>` 寫「意圖」,系統告訴你有哪些現成零件與 recipe,
你只填 payload,不必自己寫程式。**不要上網搜 Arcrun 文件**(網路上沒有),也不要自己猜 YAML 格式:
先讀本 skill,再用 `acr` 指令(或 MCP 工具)查現成零件。
涵蓋:意圖工作流語法、四份實跑過的範本、零件 vs recipe 的分別、缺件的兩條路、已知的坑。
---
# Arcrun:怎麼寫意圖工作流
> **你已經配備 Arcrun**(此專案裝了 `acr` CLI,可能另有 `arcrun_*` MCP 工具)。
> **別上網找文件**——網路上沒有 Arcrun 的文件,找到的都是錯的。答案都在本 skill 與 `acr` 指令裡。
## 先做這三件(照順序)
1. `acr whoami` — 確認連到哪個帳號(**勿自行 curl 猜帳號 URL**
2. 讀本 skill 下面的語法與範本 → 寫出 `>>` 意圖
3. `acr parts``acr recipe list`(或 `acr search <關鍵字>` 一次掃全部)— 確認零件與 recipe 真的存在
**卡住時**`acr search <關鍵字>` 跨類搜尋;有 MCP 就 `arcrun_get_skill('INDEX')` 拿全館導航。
---
## 0. 一句話世界觀
**Arcrun 裡幾乎所有東西都是工作流(workflow)。** 工作流 = 一張紙,寫「用哪些零件、什麼順序、什麼條件」。
你大部分時間在**寫紙、改紙**,不是在造新零件、也不是自己寫腳本。
**Arcrun 只有三種東西,先分清楚就不會做歪:**
| 東西 | 是什麼 | 你能做的 |
|---|---|---|
| **工作流(workflow** | 把零件/recipe 串起來的純文字流程 | **預設就寫這個**,自由寫 |
| **recipe** | 打「一個固定外部 API」的設定(endpointheaderbody 模板) | 自由寫、**而且該投稿**(缺就自己補) |
| **零件(component** | WASM 程式(流程控制/資料處理/`http_request`auth),固定一小套 | **你不自製**,走 PR 由維護者管 |
> **一句話判準**:打一個固定外部 endpoint → 寫 **recipe**;流程控制/資料處理/通用 HTTP → 用既有**零件**;其他 → 寫**工作流**串起來。
---
@@ -1,67 +0,0 @@
---
## 9. 資源去哪取(不要自己重造 Arcrun 已有的)
| 你想知道 | 跑這個 |
|---|---|
| 有哪些零件可用 | `acr parts` |
| 某零件的設定範本 | `acr parts scaffold <name>` |
| 有哪些 recipe | `acr recipe list``acr recipe search <關鍵字>` |
| 支援哪些服務的認證 | `acr auth-recipe list` |
| 某服務認證要哪些 credential 範例 | `acr auth-recipe scaffold <service>` |
| **一次掃全部**(零件/recipeauth-recipeworkflow | `acr search <關鍵字>` |
| 已部署的 workflow | `acr list` |
| 某次執行為什麼失敗 | `acr logs <workflow>` |
| 工作流語法、指令 | `acr --help` |
**先查再動手**——Arcrun 多半已經有你要的零件/recipe/認證,不要自刻。
## 10. 做出來以後:驗證 → 部署
```bash
acr validate <workflow>.yaml # 先驗,別直接部署
acr push <workflow>.yaml # 部署(暴露動作,見 §12)
acr run <workflow> # 觸發一次,看實際結果
acr logs <workflow> # 看執行紀錄/失敗原因
```
需要 credentialAPI keytoken)時:`acr auth-recipe scaffold <service>` 看要哪些,
明確告訴使用者去哪取得、怎麼 `acr creds push`。
🔑 **金鑰只拿名字**workflowrecipe 裡只寫 `{{credential.<名字>}}`
**真身絕不寫進定義檔**(執行前才由系統回填)。
## 11. Arcrun 是你(AI)用的工具,不是工具回頭呼叫 AI
需要智慧判斷/自然語言轉換時,**你自己做**,再呼叫工作流執行確定性的下一步。
**不要在工作流中間放零件回頭呼叫 LLM**——Arcrun 的大腦就是操盤的你。
(唯一例外:`ask_llm` 這種「內容生成本身就是流程的一步」,見範本 B。)
## 12. 把東西開放給別人用 = 要使用者明示同意
`acr push`(部署 workflow)與 `acr recipe push`(投稿 recipe)會讓資料/能力**可被外部呼叫**:
- 停下來,明確告訴使用者「這會讓 X 可被外部呼叫」,要他同意。**不替他決定公開。**
- 非互動環境(你直跑)遇到 → 停,把完整指令印給使用者自己貼上跑,絕不自己塞 confirm 假裝同意。
- Arcrun 可提供保護(要求呼叫者帶 key/限流)——提醒使用者。
## 13. Arcrun 不替你做授權判斷
API 打不打得通由發 key 的服務決定。401/403 是對方服務在行使授權,**不是 Arcrun 的 bug、不是你做錯**。
不要在 Arcrun 裡建「允許/禁止某 endpoint」的二次授權清單。
## 14. 誠實(最重要)
- **不假綠**:沒打通就誠實說。缺 credential 打不到 2xx → 標「未驗收:缺 X」,不 mock 充綠燈。
- **不假裝防偽/不代替人類確認**有風險的動作(暴露資料)。
- **完成 客觀證據**HTTP 2xx + trace),不是口頭「做好了」。
---
## 動手前的自檢清單
1. 我把意圖寫成 `>>` 串了嗎?(還是直接跳去寫 YAML/寫程式)
2. 我查過 `acr search` / `acr parts` / `acr recipe list` 了嗎?
3. 查詢回 `not_found` 時,我走的是 recipe/零件 PR 兩條路,**還是偷偷改寫成 `code`**?(後者=腹語術)
4. 我是不是讓工作流回頭呼叫 AI 做判斷?(是 → 改成我自己做)
5. 這動作會把資料開放給別人嗎?(會 → 要使用者明示同意)
6. 我有沒有假裝(假綠/假防偽/代替人類確認)?(有 → 停,誠實標明)
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "arcrun",
"version": "1.3.14",
"version": "1.3.13",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "arcrun",
"version": "1.3.14",
"version": "1.3.13",
"license": "MIT",
"dependencies": {
"chalk": "^5.3.0",
+2 -4
View File
@@ -8,9 +8,7 @@
"main": "./dist/index.js",
"type": "module",
"scripts": {
"build": "npm run build:harness && npm run check:harness && tsc",
"build:harness": "node scripts/build-harness-skill.mjs",
"check:harness": "node scripts/check-harness-generation.mjs",
"build": "tsc",
"dev": "tsc --watch",
"test": "node --test \"tests/**/*.test.ts\"",
"prepublishOnly": "npm run build && chmod +x dist/index.js"
@@ -44,6 +42,6 @@
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/youlinhsieh/Arcrun.git"
"url": "git+https://github.com/uncle6me-web/Arcrun.git"
}
}
-64
View File
@@ -1,64 +0,0 @@
#!/usr/bin/env node
/**
* build-harness-skill.mjs registry/skills/ 組出 harness arcrun-mindset SKILL.md
*
* 為什麼是建置期複製而不是人工維護兩份
* `registry/skills/write_intent_workflow.md` 是意圖語法的**單一真相源**它同時是
* MCP `arcrun_get_skill()` 回給雲端 AI 的內容harness skill 若人工再抄一份
* 兩份必然漂移2026-07-31 實錄harness 那份停在上一代grep意圖>>= 0 命中
* 只講世界觀害新裝的用戶 AI 學不到 `>>`
*
* 作法harness skill = 三段拼接
* SKILL.md.head harness 專屬frontmatterCLI 入口三種東西的分型
* registry write_intent_workflow.md 正文 單一真相源只此一份被維護
* SKILL.md.tail harness 專屬acr 指令表暴露同意誠實鐵律
*
* 為什麼不用 symlink / npm 打包直接引用npm `files` 只收 `harness/`
* registry/ 不進套件symlink npm pack Windows 上不可靠建置期複製最單純
*
* 產物 `SKILL.md` ** commit repo**npm 套件裝的是它不會跑 build
* check-harness-generation.mjs 驗證它與 registry 沒有漂移
*/
import { readFileSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const here = dirname(fileURLToPath(import.meta.url)); // cli/scripts
const repoRoot = join(here, '..', '..'); // repo 根
const skillDir = join(here, '..', 'harness', 'skills', 'arcrun-mindset');
const registrySkill = join(repoRoot, 'registry', 'skills', 'write_intent_workflow.md');
const head = readFileSync(join(skillDir, 'SKILL.md.head'), 'utf8').trimEnd();
const tail = readFileSync(join(skillDir, 'SKILL.md.tail'), 'utf8').trimEnd();
const body = readFileSync(registrySkill, 'utf8');
// 取 registry skill 的正文:去掉它自己的 H1 標題與「何時用這個 skill」那段
// harness 的 head 已用 CLI 語境寫過入口),從第一個 `## 1.` 章節起收。
const idx = body.indexOf('## 1. 意圖工作流的語法');
if (idx < 0) {
console.error('❌ registry/skills/write_intent_workflow.md 找不到「## 1. 意圖工作流的語法」章節;');
console.error(' registry skill 結構變了 → 請同步更新 cli/scripts/build-harness-skill.mjs 的取段規則。');
process.exit(1);
}
const middle = body
.slice(idx)
// registry 版把 MCP 工具當預設介面;harness 裝在有 acr CLI 的專案 → 補上 CLI 等價指令
.replace(/`arcrun_get_workflow\(<name>\)`/g, '`acr logs <name>`(有 MCP 則 `arcrun_get_workflow(<name>)`')
.replace(/`arcrun_list_components` \/ `arcrun_search_components`/g, '`acr parts` / `acr search`')
.replace(/下一步該讀哪支 skill`arcrun_list_skills\(\)`/g, '下一步該讀哪支 skill(需 MCP):`arcrun_list_skills()`')
.trimEnd();
const out = [
head,
'',
'<!-- 以下正文由 registry/skills/write_intent_workflow.md 於建置期複製而來(單一真相源)。',
' 不要直接編輯本段——改 registry 那份,然後跑 `npm run build:harness`。 -->',
'',
middle,
'',
tail,
'',
].join('\n');
writeFileSync(join(skillDir, 'SKILL.md'), out, 'utf8');
console.log(`✓ harness skill 已由 registry 重建:${out.length} bytes`);
-136
View File
@@ -1,136 +0,0 @@
#!/usr/bin/env node
/**
* check-harness-generation.mjs 世代閘harness 內容脫節就讓 build/publish 失敗
*
* 為什麼要這道閘
* 2026-07-31 實錄`acr install-harness` 的管道一直是好的但它鋪出去的**內容停在上一代**
* harness skill grep意圖>>= 0 命中只講世界觀管道綠燈交付物過時
* 沒有任何機械檢查會抱怨 世代脫節可以無聲存在好幾個月
*
* 這道閘檢查四件交付物的現世代指紋缺指紋 = exit 1擋掉 build npm publish
* 指紋要挑上一代絕不會有現世代一定有的字串不是隨便的關鍵字
*/
import { readFileSync, existsSync, statSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { execFileSync } from 'node:child_process';
const here = dirname(fileURLToPath(import.meta.url));
const harness = join(here, '..', 'harness');
const repoRoot = join(here, '..', '..');
/** @type {{file: string, must: [string, string][], mustNot?: [string,string][]}[]} */
const CHECKS = [
{
file: 'skills/arcrun-mindset/SKILL.md',
must: [
['>>', '意圖語法(`A >> 邊 >> B`)——步驟 1 的核心教材'],
['ON_SUCCESS', '合法邊之一'],
['對每個', 'FOREACH 邊(十題裡有四題要用)'],
['input', '第一個節點固定是 input'],
['not_found', '現世代查詢狀態(舊版寫 missing/假 found'],
['腹語術', '缺件不准改寫成 code 的紅線'],
['recipe', '零件 vs recipe 分型'],
// 條件邊自 2026-08-01 起引擎已支援(cypher-executor/src/graph-executor.ts
// case 'ON_TRUE'/'ON_FALSE'/'ON_BRANCH'31 個測試全過)。教材該教會怎麼用,
// 不是教「不存在」——這條 must 同時防「哪天又被改回舊世代說法」的回歸。
['ON_TRUE', '條件邊(配 if_control)自 2026-08-01 起引擎已支援,教材須教會用法'],
],
mustNot: [
// ON_FAILURE 才是真的不存在(VALID_EDGE_TYPES 只有 ON_FAIL,見
// cypher-executor/src/lib/constants.ts)。只准出現在「教它不存在」的脈絡。
// 2026-08-10 修正:這道閘原本擋的是 ON_TRUE——但 ON_TRUE/ON_FALSE/ON_BRANCH
// 已是引擎現世代能力,正確教材反而被這道閘擋下,是閘的判準過時了,不是教材寫錯。
['ON_FAILURE', '引擎沒有這種邊(只有 ON_FAIL);教材不該把它教成可用的邊', /不要寫|不存在|沒有這種|❌|非法/],
],
},
{
file: 'CLAUDE.block.md',
must: [
['>>', '意圖語法要在 CLAUDE.md 就先亮相'],
['not_found', '缺件兩條路的觸發點'],
],
},
{
file: 'commands/arcrun.md',
must: [
['>>', '/arcrun 的第一步就該是寫意圖'],
['acr search', '現世代的跨類搜尋指令'],
],
},
{
file: 'hooks/arcrun-guard.sh',
must: [
['arcrun-mindset', 'hook 被擋下時要把 AI 導向 skill,而不是叫它去翻 repo 文件'],
['>>', 'hook 的正路提示要提到意圖語法'],
],
},
];
let fail = 0;
const say = (s) => console.log(s);
say('\n 世代閘:檢查 harness 交付物是否為現世代內容\n');
for (const c of CHECKS) {
const p = join(harness, c.file);
if (!existsSync(p)) {
say(`${c.file} — 檔案不存在`);
fail++;
continue;
}
const text = readFileSync(p, 'utf8');
const missing = c.must.filter(([needle]) => !text.includes(needle));
const badNot = (c.mustNot ?? []).filter(([needle, , allowIfNear]) => {
if (!text.includes(needle)) return false;
if (!allowIfNear) return true;
// 允許「在教『不要用』的脈絡裡」出現:看該字串所在行是否有豁免詞
return !text
.split('\n')
.filter((l) => l.includes(needle))
.every((l) => allowIfNear.test(l));
});
if (missing.length === 0 && badNot.length === 0) {
say(`${c.file}`);
} else {
fail++;
say(`${c.file}`);
for (const [needle, why] of missing) say(` 缺指紋「${needle}」— ${why}`);
for (const [needle, why] of badNot) say(` 不該出現「${needle}」— ${why}`);
}
}
// harness skill 必須是由 registry 重建的最新版(防「改了 registry 忘了重跑 build」)
const skillPath = join(harness, 'skills', 'arcrun-mindset', 'SKILL.md');
const registrySkill = join(repoRoot, 'registry', 'skills', 'write_intent_workflow.md');
if (existsSync(skillPath) && existsSync(registrySkill)) {
try {
execFileSync(process.execPath, [join(here, 'build-harness-skill.mjs')], { stdio: 'pipe' });
const rebuilt = readFileSync(skillPath, 'utf8');
const before = statSync(skillPath); // 重建後內容即為期望值
void before;
// 重建是冪等的:若重建後與 git 中的版本不同,git diff 會在 CI 顯示;
// 這裡直接比對「重建結果是否含 registry 當前的關鍵段落」
const reg = readFileSync(registrySkill, 'utf8');
const marker = reg.includes('## 7. 常犯的錯') ? '## 7. 常犯的錯' : null;
if (marker && !rebuilt.includes(marker)) {
say(` ❌ harness skill 與 registry 漂移:registry 有「${marker}」但重建產物沒有`);
fail++;
} else {
say(' ✓ harness skill 與 registry/skills/write_intent_workflow.md 同步');
}
} catch (e) {
say(` ❌ 無法由 registry 重建 harness skill${e.message}`);
fail++;
}
}
say('');
if (fail) {
say(` 🔴 世代閘擋下(${fail} 項)。harness 交付的內容落後於現世代。`);
say(' 修法:改 registry/skills/write_intent_workflow.md(單一真相源)或對應的');
say(' cli/harness/ 檔案,然後跑 `npm run build:harness` 重建,再跑本檢查。\n');
process.exit(1);
}
say(' ✅ 世代閘通過:四件交付物都帶現世代指紋\n');
+1 -1
View File
@@ -18,7 +18,7 @@ const SOURCE_LABEL: Record<ConfigSource, string> = {
};
/** 敏感欄位只印前綴,避免把 token 完整印到終端 / log。*/
const SENSITIVE = new Set(['api_key', 'cf_api_token']);
const SENSITIVE = new Set(['api_key', 'encryption_key', 'cf_api_token']);
function mask(field: string, value: string): string {
if (SENSITIVE.has(field) && value.length > 8) return `${value.slice(0, 8)}`;
+5 -3
View File
@@ -1,9 +1,11 @@
/**
* acr creds push/list/replace/delete
*
* + TLS CF Workers Secrets CLI
* list/replace/delete argv/yaml cypher-executor API
* rule 07
* credential-store-migration T52026-07-03Arcrun#2 server
* client AES-GCM + {name,encrypted,iv} + TLS §2.4
* arcrun ENCRYPTION_KEY CF Workers Secrets T9§3
* CLI list/replace/delete argv/yaml
* cypher-executor API rule 07
*
* credential D19 owner
*/
+87 -45
View File
@@ -10,6 +10,8 @@ import chalk from 'chalk';
import { saveConfig, type ArcrunConfig } from '../lib/config.js';
import { CfAccountClient } from '../lib/cf-api.js';
import {
REQUIRED_KV_NAMESPACES,
SECRET_TARGET_WORKERS,
downloadAndDeploy,
type DeployContext,
} from '../lib/deploy.js';
@@ -17,7 +19,7 @@ import { cmdInstallHarness } from './install-harness.js';
import { cmdMcpSetup } from './mcp-setup.js';
import { detectEnvironment, printPreflight, verifyInstall } from '../lib/preflight.js';
const ARCRUN_LOGIN_URL = 'https://arcrun.dev/login';
const ARCRUN_REGISTER_URL = 'https://cypher.arcrun.dev/register';
async function prompt(rl: ReturnType<typeof createInterface>, question: string): Promise<string> {
const answer = await rl.question(chalk.cyan(`? ${question}: `));
@@ -101,23 +103,40 @@ async function initLocal(): Promise<void> {
}
async function initStandard(rl: ReturnType<typeof createInterface>): Promise<void> {
console.log(chalk.gray(' Standard 模式:用 arcrun.dev 帳號登入取得 API Key\n'));
console.log(chalk.gray(' Standard 模式:只需要 email,不需要 Cloudflare 帳號\n'));
// API Key 發放走網站 OAuth 登入(/auth/google/start、/auth/github/start)。
// CLI 是薄殼,不自己發 key(rule 07),只引導用戶去拿再貼回來。
console.log(' 1. 開啟 ' + chalk.cyan(ARCRUN_LOGIN_URL) + ' 用 Google / GitHub 登入');
console.log(' 2. 在 Dashboard 複製你的 API Keyak_ 開頭)\n');
const email = await prompt(rl, 'Email(用來取得 API Key');
const apiKey = (await prompt(rl, 'API Keyak_...')).trim();
process.stdout.write(chalk.gray('\n → 向 arcrun.dev 取得 API Key...'));
if (!apiKey.startsWith('ak_')) {
console.log(chalk.yellow('\n ✗ API Key 應以 ak_ 開頭,請重新執行 acr init\n'));
let apiKey = '';
let encryptionKey = '';
try {
const res = await fetch(ARCRUN_REGISTER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
if (!res.ok) {
const err = await res.text();
throw new Error(`取得失敗(${res.status}):${err}`);
}
const data = await res.json() as { api_key: string; encryption_key: string };
apiKey = data.api_key;
encryptionKey = data.encryption_key;
console.log(chalk.green(' ✓'));
} catch (e) {
console.log(chalk.yellow(`${e instanceof Error ? e.message : e}`));
console.log(chalk.yellow(' 請確認網路連線後重新執行 acr init\n'));
process.exit(1);
}
const config: ArcrunConfig = {
mode: 'standard',
api_key: apiKey,
encryption_key: encryptionKey,
};
saveConfig(config);
@@ -134,7 +153,7 @@ async function initStandard(rl: ReturnType<typeof createInterface>): Promise<voi
/**
* Self-hosted installer CF Account ID + API Token
* token subdomain 沿 Worker
* token KV REQUIRED_KV_NAMESPACES subdomain release Worker
* seed auth+api recipe config secret
* SDD.agents/specs/arcrun/sdk-and-website/self-hosted-init.md
*/
@@ -184,13 +203,41 @@ async function initSelfHosted(
process.exit(1);
}
// 2. KV / D1 / Vectorize 不在這裡預先建(Arcrun#97)。
// 舊版在這一步「照名字 ensure」一輪再往下傳,acr update 沿用同一段程式碼
// ⇒ 對一台安裝器裝出來的實例(資源名字不同)等於每次更新都重建一整套空的綁上去。
// 現在資源解析統一在 downloadAndDeploy 內:**先看已部署的 worker 綁著什麼**,
// 對得上就沿用、確定沒人綁過才建、說不準就停手。init 走 mode:'init'(允許從零建起)。
// 2. KV namespace(冪等)
// 不建 R2R2 是 dead storageregistry-canon Phase 1.5),且 CF R2 首次啟用強制綁信用卡,
// 違背 arcrun「開源免費自架,Workers + KV 免費額度即可運行」核心理念(壓測 2026-06-04 #3)。
const kvNamespaceIds: Record<string, string> = {};
try {
const existing = await cf.listKvNamespaces();
for (const title of REQUIRED_KV_NAMESPACES) {
process.stdout.write(chalk.gray(` → KV ${title}...`));
const id = await cf.ensureKvNamespace(title, existing);
kvNamespaceIds[title] = id;
console.log(chalk.green(' ✓'));
}
} catch (e) {
console.log(chalk.yellow(`\n ✗ 建立資源失敗:${e instanceof Error ? e.message : e}\n`));
process.exit(1);
}
// 2.5 build D1 for KBDB Base (atomic universal table). Free on Workers Free, no credit card
// (kbdb-base SDD Q4). idempotent: reuse if exists.
let d1DatabaseId = '';
try {
process.stdout.write(chalk.gray(' → D1 arcrun-kbdb...'));
d1DatabaseId = await cf.ensureD1Database('arcrun-kbdb');
console.log(chalk.green(' ✓'));
} catch (e) {
const em = e instanceof Error ? e.message : String(e);
console.log(chalk.yellow(`\n ⚠ D1 build failed (${em})`));
if (/auth/i.test(em)) {
// 最常見根因:CF token 沒勾 D1 權限(KV/Worker 建得起來但 D1 報 Authentication error)。
console.log(chalk.yellow(' 多半是 CF token 缺 D1 權限 → 去 token 補勾「Account / D1 / Edit」'));
console.log(chalk.gray(' 重產 token 填回 .env 後跑 acr update。D1 存 workflow/recipe,沒它後續會受限。'));
} else {
console.log(chalk.gray(' KBDB Base 暫不可用,可 acr update 重試。'));
}
}
// 3. 查 workers.dev subdomaincypher-executor WORKER_SUBDOMAIN 用)
let workerSubdomain = '';
@@ -201,35 +248,22 @@ async function initSelfHosted(
console.log(chalk.yellow(` ⚠ 查 subdomain 失敗(${e instanceof Error ? e.message : e}),稍後可手動補`));
}
// 3.5 語義查詢(issue #7 / T2.4):**預設開**2026-08-09 翻轉,leo:「語義搜尋已經
// 確定是一安裝就提供的功能」——預設關會產出一批「看起來裝好了、其實少一條腿」的
// 實例,之後畫面上還被誤說成「沒開通」)。顯式回答 n 才關(極端省額度者自選)。
// 開 → deploy 建 CF Vectorize index + 注入 binding。關 → base 維持 LIKE keyword。
// 3.5 語義查詢開關issue #7 / T2.4):問用戶要不要開(預設關,free-tier 友善)。
// 開 → deploy 建 CF Vectorize index + 注入 binding。關 → base 維持 LIKE keyword,零花費。
// 之後想開:跟 CC 說「幫我開語義查詢」或設 kbdb_embed:true + acr update(不必重 init)。
const embedAns = (await prompt(
rl,
'要開語義查詢嗎?(內建功能,建議保持開啟;用 CF Vectorize有免費額度) [Y/n]',
'要開語義查詢嗎?(KBDB 加 AI 向量搜尋;用 CF Vectorize可能多花費;預設關,之後可隨時開) [y/N]',
)).trim().toLowerCase();
const kbdbEmbed = !(embedAns === 'n' || embedAns === 'no');
if (!kbdbEmbed) console.log(chalk.yellow(' → 已選語義查詢:這台實例將只有關鍵字搜尋(之後可設 kbdb_embed:true + acr update 補開)。'));
const kbdbEmbed = embedAns === 'y' || embedAns === 'yes';
if (kbdbEmbed) console.log(chalk.gray(' → 已選語義查詢:部署時會建 Vectorize index。'));
// 4. 下載 repo 部署物(含預編譯 wasm+ 注入 KV id + wrangler deploy 全部 Worker
console.log(chalk.gray('\n → 下載部署物 + 部署 Worker(從 GitHub 拉預編譯 wasm,用你的 CF token 部署)...'));
// selfHosted: true → deploy 注入 MULTI_TENANT="false"mcp-account-source §5.5,修 MCP 401)。
// init.ts 這條本就是 --self-hosted 分支(config.mode 稍後寫 'self-hosted')。
const deployCtx: DeployContext = { accountId, apiToken: cfApiToken, workerSubdomain, selfHosted: true, kbdbEmbed };
const deploy = await downloadAndDeploy(deployCtx, 'main', { mode: 'init', api: cf });
// 資源解析喊停(例:這台其實已經裝過、但某顆綁著的資源不見了)→ 什麼都沒建、什麼都沒部。
if (deploy.blocked) {
console.log(chalk.yellow('\n ⚠ 安裝沒有進行,你的 Cloudflare 帳號維持原樣。\n'));
console.log(' ' + deploy.message.split('\n').join('\n '));
console.log('');
process.exit(1);
}
// 實際用上的資源(沿用既有的,或這次新建的)——寫 config / 驗收都以這份為準,不再自己查名字。
const kvNamespaceIds = deployCtx.kvNamespaceIds ?? {};
const d1DatabaseId = deployCtx.d1DatabaseId ?? '';
const deployCtx: DeployContext = { accountId, apiToken: cfApiToken, workerSubdomain, kvNamespaceIds, d1DatabaseId, selfHosted: true, kbdbEmbed };
const deploy = await downloadAndDeploy(deployCtx);
const cypherUrl = deploy.cypherExecutorUrl
?? (workerSubdomain ? `https://arcrun-cypher-executor.${workerSubdomain}.workers.dev` : '');
// self-hosted 自己的 MCP worker URLmcp-account-source §3.mcp.json 指自己,不 fallback 官方)。
@@ -273,8 +307,8 @@ async function initSelfHosted(
// + 給一鍵補裝指令(不靜默印灰字)。假綠零容忍(mindset §7):看實際狀態,非看 config 寫了沒。
const verify = await verifyInstall({
cf,
kvNamespaceIds,
d1DatabaseId: d1DatabaseId || undefined,
requiredKv: REQUIRED_KV_NAMESPACES,
expectD1Name: d1DatabaseId ? 'arcrun-kbdb' : undefined,
cypherUrl,
});
printPreflight('安裝驗收(裝完檢查)', verify.items);
@@ -284,24 +318,32 @@ async function initSelfHosted(
}
// 結果回報(誠實:部分失敗時明說,不假綠 — mindset §7)
console.log(chalk.green(`\n ✓ Cloudflare 資源就緒(${Object.keys(kvNamespaceIds).length} KV,免費額度即可,無需綁卡)`));
console.log(chalk.green(`\n ✓ Cloudflare 資源就緒(${REQUIRED_KV_NAMESPACES.length} KV,免費額度即可,無需綁卡)`));
console.log(chalk.green(' ✓ 設定寫入 ~/.arcrun/config.yaml'));
console.log(chalk.green(' ✓ 建立 credentials.yaml'));
// 下一步:身份設定(self-hosted 單租戶——namespace 明碼用戶自填)。
// 下一步:身份設定(self-hosted 單租戶——namespace 明碼用戶自填、encryption_key 用戶自保管)。
// 工具不生成、不 hash、不外傳任何 key(守 rule 05 精神:secret 不進自動化,由用戶持有)。
console.log(chalk.bold('\n 下一步 ①:在這個專案建 .env(你自己填,工具不碰):'));
console.log(chalk.cyan(' NAMESPACE=leo # 你的資料分區標籤(明碼即可,不是密碼)'));
console.log(chalk.cyan(' ENCRYPTION_KEY=<64+ hex> # credential 加密金鑰,你自己保管'));
console.log(chalk.gray(' 生成 keynode -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"'));
console.log(chalk.gray(' NAMESPACE 是分區標籤非密碼;要防外部呼叫請對 webhook 加保護。'));
console.log(chalk.gray(' .env 已被 gitignore。)'));
console.log(chalk.gray(' credential 不需要自管加密金鑰:明文由 CF Workers Secrets 託管。\n'));
console.log(chalk.gray(' ENCRYPTION_KEY 忘了 = 解不開已上傳的 credential。.env 已被 gitignore。)'));
console.log(chalk.bold('\n 下一步 ②:把同一把 ENCRYPTION_KEY 設進你的 workerruntime 解密要用):'));
for (const w of SECRET_TARGET_WORKERS) {
console.log(chalk.cyan(` wrangler secret put ENCRYPTION_KEY --name ${w}`));
}
console.log(chalk.gray(` ${SECRET_TARGET_WORKERS.length} 個 Worker 共用同一把(與 .env 的 ENCRYPTION_KEY 一致)。`));
console.log(chalk.gray(' 不想自己跑?跑 acr init 時授權(明示同意)我可代設——但預設由你自己 put(你持有 key)。\n'));
// credential-store-migration T3(§2.3):cypher worker 要有一把「能打 CF Workers Scripts
// secrets 管理 API 的 token」才能讓 POST/PUT /credentials 把密文寫進 Workers Secrets。
// 印手動指令而非工具自動 put——CF_ACCOUNT_ID 非機密,已由 downloadAndDeploy/
// injectWranglerConfig 自動注入(同 WORKER_SUBDOMAIN 模式),
// 比照 ENCRYPTION_KEY 的既有模式(印手動指令,不是工具自動 put——CF_ACCOUNT_ID 非機密,
// 已由 downloadAndDeploy/injectWranglerConfig 自動注入(同 WORKER_SUBDOMAIN 模式),
// 只有 CF_SECRETS_API_TOKEN(機密)需要用戶手動 put。
console.log(chalk.bold(' 下一步 :把能打 Workers Scripts secrets API 的 CF token 設進 cypher worker'));
console.log(chalk.bold(' 下一步 :把能打 Workers Scripts secrets API 的 CF token 設進 cypher worker'));
console.log(chalk.cyan(` wrangler secret put CF_SECRETS_API_TOKEN --name arcrun-cypher-executor`));
console.log(chalk.gray(' 貼你剛才用來部署的同一個 CF API Token(需含 Workers Scripts:Edit 權限)。'));
console.log(chalk.gray(' 用途:POST/PUT /credentials 把密文寫進 Workers per-script Secretscredential-store-migration T5)。'));
+1 -8
View File
@@ -110,18 +110,11 @@ function mergeSettings(cwd: string, src: string): void {
writeFileSync(path, JSON.stringify(settings, null, 2) + '\n', 'utf8');
}
/** 建置期產物的來源片段(`SKILL.md.head` / `.tail` build-harness-skill.mjs
* 使使 `SKILL.md` */
function isBuildSource(name: string): boolean {
return name.endsWith('.head') || name.endsWith('.tail');
}
/** 遞迴複製目錄樹(覆蓋同名檔;跳過建置期來源片段)。 */
/** 遞迴複製目錄樹(覆蓋同名檔)。 */
function copyTree(srcDir: string, dstDir: string): void {
if (!existsSync(srcDir)) return;
mkdirSync(dstDir, { recursive: true });
for (const name of readdirSync(srcDir, { withFileTypes: true })) {
if (isBuildSource(name.name)) continue;
const s = join(srcDir, name.name);
const d = join(dstDir, name.name);
if (name.isDirectory()) copyTree(s, d);
+5 -5
View File
@@ -55,12 +55,10 @@ export async function cmdPush(filePath: string): Promise<void> {
const searchSpinner = ora('取得執行圖').start();
let graph: unknown;
try {
// t158「部署≠發現」(leo:「這裡只是複製工作流的 data 過去,沒有要在這裡驗證」):
// push=複製路徑,帶 mode:compile 純編圖——寫錯的 workflow 照樣部署,錯在執行時現形。
const res = await fetch(`${executorUrl}/cypher/search`, {
method: 'POST',
headers,
body: JSON.stringify({ triplets: workflow.flow, mode: 'compile' }),
body: JSON.stringify({ triplets: workflow.flow }),
});
if (!res.ok) {
@@ -70,8 +68,10 @@ export async function cmdPush(filePath: string): Promise<void> {
}
const data = await res.json() as { cypher: { nodes: unknown[]; edges: unknown[] }; missing: string[] };
// t158push 不看 missingcompile 模式亦恆空)——存在性由執行時 component-loader 決定;
// 要「先問有沒有」用 acr validateMCP 查詢(discover 路徑)。
if (data.missing?.length > 0) {
searchSpinner.fail(chalk.red(`以下零件不存在:${data.missing.join(', ')}\n執行 acr parts 查看可用零件。`));
process.exit(1);
}
// 附上 id / name,並將 workflow.config 套入節點(componentId + data
const rawGraph = data.cypher as { nodes: Array<{ id: string; componentId?: string; data?: Record<string, unknown> }>; edges: unknown[] };
+39 -24
View File
@@ -14,9 +14,11 @@
import chalk from 'chalk';
import { loadConfig } from '../lib/config.js';
import { CfAccountClient } from '../lib/cf-api.js';
import {
wranglerAvailable,
downloadAndDeploy,
REQUIRED_KV_NAMESPACES,
type DeployContext,
} from '../lib/deploy.js';
@@ -42,39 +44,52 @@ export async function cmdUpdate(opts: { force?: boolean } = {}): Promise<void> {
console.log(chalk.bold('\n acr update — 拉新 release 並重新部署\n'));
// 🔴 Arcrun#97:這裡**曾經**先「照名字 ensure」一輪 KV + D1 再往下傳
// binding 名(WEBHOOKS)被當成 CF 上的資源標題去找,安裝器建的資源不叫那個名字
// ⇒ 每次都對不上 ⇒ 每次都新建一顆空的綁上去 ⇒ 使用者的工作流/登入/子庫從畫面上消失
// 現在資源解析整段搬進 downloadAndDeploy:先讀「你已部署的 worker 現在綁著什麼」再決定,
// 而且是**下載完、看得到這版要哪些 binding 之後**才決定,不再由這裡預先造一批。
// 重新解析「全部」KV namespace id(冪等:已存在則重用),不只 config 存的兩個
// 壓測 §4.1.3:舊版 update 只注入 WEBHOOKS+CREDENTIALS_KV,其餘 6 個注入成空字串 →
// 重部署反而可能弄壞需要 RECIPES/EXEC_CONTEXT/... 的 worker。改為與 init 同樣全建妥
const cf = new CfAccountClient(config.cloudflare_account_id, config.cf_api_token);
const kvNamespaceIds: Record<string, string> = {};
try {
const existing = await cf.listKvNamespaces();
for (const title of REQUIRED_KV_NAMESPACES) {
kvNamespaceIds[title] = await cf.ensureKvNamespace(title, existing);
}
} catch (e) {
console.log(chalk.yellow(`\n ✗ 解析 KV namespace 失敗:${e instanceof Error ? e.message : e}\n`));
process.exit(1);
}
// D1KBDB Base)冪等補建——之前只在 init 建,update 漏了,導致「init 時 D1 失敗(如 token 缺權限)
// → 補好權限後沒有任何指令會補建 D1」(壓測 2026-06-09:D1 一直建不起來的真根因)。
// update 既是「冪等重部署」就該與 init 一致把 D1 也 ensure 上。
let d1DatabaseId = '';
try {
process.stdout.write(chalk.gray(' → D1 arcrun-kbdb(冪等)...'));
d1DatabaseId = await cf.ensureD1Database('arcrun-kbdb');
console.log(chalk.green(' ✓'));
} catch (e) {
const em = e instanceof Error ? e.message : String(e);
console.log(chalk.yellow(`${em}`));
if (/auth/i.test(em)) {
console.log(chalk.yellow(' CF token 缺 D1 權限 → 補勾「Account / D1 / Edit」重產 token 填回 .env 再 acr update'));
}
}
const ctx: DeployContext = {
accountId: config.cloudflare_account_id,
apiToken: config.cf_api_token,
workerSubdomain: extractSubdomain(config.cypher_executor_url),
kvNamespaceIds,
d1DatabaseId: d1DatabaseId || undefined,
// self-hosted → 注入 MULTI_TENANT="false"mcp-account-source §5.5,修 acr update 部署的 MCP 401)。
// config 源頭:init 寫 multi_tenant:false + mode:'self-hosted'。acr update 只在 self-hosted 跑。
selfHosted: config.mode === 'self-hosted' || config.multi_tenant === false,
// 語義查詢(issue #7):預設**開**,只有 config 顯式寫 kbdb_embed:false 才關
// 🔴 2026-08-09 翻轉預設(leo:「語義搜尋已經確定是一安裝就提供的功能」)
// 舊判斷 `=== true` 的實害:config 沒這個欄位(舊 config / 一鍵安裝實例本機補跑 update)
// 時 redeploy 會把 kbdb 的 [[vectorize]]+[ai] binding 靜默剝掉——一台**原本正常**的
// 實例就這樣失去語意搜尋,畫面上還被說成「還沒開通」。wrangler deploy 是整份覆蓋,
// binding 不在 toml 裡=直接消失,這正是「裝好的實例壞掉」的機制之一。
kbdbEmbed: config.kbdb_embed !== false,
// 語義查詢開關issue #7):config.kbdb_embed:true → 部署建 Vectorize index + 注入 binding
// 這也是「CC 幫開」的落地路徑:CC 寫 kbdb_embed:true 進 config → acr update redeploy 即生效
kbdbEmbed: config.kbdb_embed === true,
};
// mode:'update' → 資源解析在「一顆該更新的 worker 都找不到」時會停手而不是重建一整套
//Arcrun#97 的另一道門:名字對不上時別假裝這是全新安裝)。
const result = await downloadAndDeploy(ctx, 'main', { force: opts.force, mode: 'update' });
// 資源解析階段喊停:什麼都沒建、什麼都沒部。原文照印,然後非零離開——
// 不能混進「部分失敗」的黃字裡帶過(那正是使用者不會發現的那種失敗)。
if (result.blocked) {
console.log(chalk.yellow('\n ⚠ 更新沒有進行,你的實例維持原樣。\n'));
console.log(' ' + result.message.split('\n').join('\n '));
console.log('');
process.exit(1);
}
const result = await downloadAndDeploy(ctx, 'main', { force: opts.force });
if (result.implemented) {
// message 含部分失敗清單(「部署 X/Y 成功,N 失敗:✗ ...」)——必須印出來,
-128
View File
@@ -1,128 +0,0 @@
/**
* acr workflow export <name> / acr workflow import <file> workflow t158
*
* leo 07-31 調 export import
* import export
* import search yaml
* KBDB
*
* - exportGET /webhooks/named/:name/definition .workflow.yaml
* name/description/flow[ graph.edges ]/config/graph[]
* - import ** POST /webhooks/named** /cypher/search
* V2 workflow
* yaml graph acr push
* - workflows.json graphpushWorkflow POST
*
*/
import chalk from 'chalk';
import ora from 'ora';
import yaml from 'js-yaml';
import { readFileSync, writeFileSync } from 'node:fs';
import { loadConfig, getCypherExecutorUrl } from '../lib/config.js';
type GraphShape = {
nodes?: Array<{ id?: string }>;
edges?: Array<{ from?: string; to?: string; type?: string }>;
};
/** graph.edges → flow 三元組(人讀用;graph 才是可執行真相)。 */
function flowFromGraph(graph: GraphShape): string[] {
return (graph.edges ?? [])
.filter(e => e.from && e.to)
.map(e => `${e.from} >> ${e.type ?? 'ON_SUCCESS'} >> ${e.to}`);
}
function requireStandardConfig(): { executorUrl: string; apiKey: string } {
const config = loadConfig();
if (config.mode === 'local') {
console.error(chalk.red('Local 模式不支援 workflow export/import(需要連上實例)。'));
process.exit(1);
}
if (!config.api_key) {
console.error(chalk.red('缺少 api_keyNAMESPACE,請先 acr init。'));
process.exit(1);
}
return { executorUrl: getCypherExecutorUrl(config), apiKey: config.api_key };
}
export async function cmdWorkflowExport(name: string, options: { output?: string }): Promise<void> {
const { executorUrl, apiKey } = requireStandardConfig();
const spinner = ora(`${executorUrl} 匯出 "${name}"`).start();
try {
const res = await fetch(`${executorUrl}/webhooks/named/${encodeURIComponent(name)}/definition`, {
headers: { 'X-Arcrun-API-Key': apiKey },
});
if (!res.ok) {
const err = await res.text();
spinner.fail(chalk.red(`匯出失敗(${res.status}):${err.slice(0, 200)}`));
process.exit(1);
}
const def = await res.json() as {
name: string; description: string;
graph: GraphShape; config: Record<string, unknown>;
};
const out = options.output ?? `${def.name}.workflow.yaml`;
const doc = {
name: def.name,
description: def.description,
// flow=從 graph 反推的可讀視圖;import 用的是 graph(可執行真相)
flow: flowFromGraph(def.graph),
config: def.config ?? {},
graph: def.graph,
};
writeFileSync(out, yaml.dump(doc, { lineWidth: 120, noRefs: true }), 'utf8');
spinner.succeed(chalk.green(`✓ 已匯出 → ${out}`));
console.log(chalk.gray(` 給同事:把這個檔傳過去,對方 acr workflow import ${out} 即可。`));
} catch (e) {
spinner.fail(chalk.red(`網路錯誤:${e instanceof Error ? e.message : e}`));
process.exit(1);
}
}
export async function cmdWorkflowImport(filePath: string): Promise<void> {
const { executorUrl, apiKey } = requireStandardConfig();
let doc: { name?: string; description?: string; config?: Record<string, unknown>; graph?: GraphShape };
try {
doc = yaml.load(readFileSync(filePath, 'utf8')) as typeof doc;
} catch (e) {
console.error(chalk.red(`讀不了 ${filePath}${e instanceof Error ? e.message : e}`));
process.exit(1);
}
if (!doc?.name) {
console.error(chalk.red('檔案缺 name 欄位。'));
process.exit(1);
}
if (!doc.graph || !Array.isArray(doc.graph.nodes)) {
// 手寫 yaml(只有 flow 沒 graph)=acr push 的場景(那條會編圖)。import 專吃 export 檔。
console.error(chalk.red('這個檔沒有 graph 欄位(不是 export 產物)。'));
console.log(chalk.gray('手寫的 workflow.yaml 請改用:acr push ' + filePath));
process.exit(1);
}
const spinner = ora(`匯入 "${doc.name}" → ${executorUrl}`).start();
try {
// 純複製:graph 直接送,不編圖、不打 /cypher/search、不驗零件存在(跑錯再改)。
const res = await fetch(`${executorUrl}/webhooks/named`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Arcrun-API-Key': apiKey },
body: JSON.stringify({
name: doc.name,
graph: { ...doc.graph, id: doc.name, name: doc.name },
config: doc.config ?? {},
description: doc.description ?? '',
}),
});
if (!res.ok) {
const err = await res.text();
spinner.fail(chalk.red(`匯入失敗(${res.status}):${err.slice(0, 200)}`));
process.exit(1);
}
const data = await res.json() as { webhook_url?: string };
spinner.succeed(chalk.green(`✓ "${doc.name}" 已匯入`));
if (data.webhook_url) console.log(chalk.bold(` Webhook URL${chalk.cyan(data.webhook_url)}`));
console.log(chalk.gray(' 沒驗零件存在——跑起來若報「找不到零件」,補上零件/recipe 或改 config 再跑。'));
} catch (e) {
spinner.fail(chalk.red(`網路錯誤:${e instanceof Error ? e.message : e}`));
process.exit(1);
}
}
+49 -98
View File
@@ -3,8 +3,6 @@
* 使 CF REST API KV namespace Wrangler CLI
*/
import type { LiveBinding, ResourceApi, ScriptBindings } from './resource-resolver.js';
const CF_API_BASE = 'https://api.cloudflare.com/client/v4';
export interface CfKvClientOptions {
@@ -85,7 +83,7 @@ export class CfKvClient {
* CfKvClient namespace KV
* SDD.agents/specs/arcrun/sdk-and-website/self-hosted-init.md §3 step 1-2
*/
export class CfAccountClient implements ResourceApi {
export class CfAccountClient {
private accountBase: string;
private headers: Record<string, string>;
@@ -98,16 +96,6 @@ export class CfAccountClient implements ResourceApi {
}
private async cf<T>(path: string, init?: RequestInit): Promise<T> {
const { ok, status, result, error } = await this.cfRaw<T>(path, init);
if (!ok) throw new Error(`CF API ${path} 失敗:${error ?? `HTTP ${status}`}`);
return result as T;
}
/** 同 cf(),但把 HTTP status 交回呼叫端自己判斷(要區分「404 不存在」和「其他錯誤」時用)。 */
private async cfRaw<T>(
path: string,
init?: RequestInit,
): Promise<{ ok: boolean; status: number; result?: T; error?: string }> {
const res = await fetch(`${this.accountBase}${path}`, {
...init,
headers: { ...this.headers, ...(init?.headers ?? {}) },
@@ -116,13 +104,10 @@ export class CfAccountClient implements ResourceApi {
| { success: boolean; result: T; errors?: Array<{ message: string }> }
| null;
if (!res.ok || !data?.success) {
return {
ok: false,
status: res.status,
error: data?.errors?.map(e => e.message).filter(Boolean).join('; ') || `HTTP ${res.status}`,
};
const msg = data?.errors?.map(e => e.message).join('; ') ?? `HTTP ${res.status}`;
throw new Error(`CF API ${path} 失敗:${msg}`);
}
return { ok: true, status: res.status, result: data.result };
return data.result;
}
/** 驗證 token 能存取此 account(權限不足會在後續建立操作報錯,這裡先確認 account 可達)。*/
@@ -141,16 +126,12 @@ export class CfAccountClient implements ResourceApi {
return map;
}
/**
* KV namespace
*
* 🔴 Arcrun#97**** ensure
* 使
* binding
* resource-resolver planResources
* worker binding create
*/
async createKvNamespace(title: string): Promise<string> {
/** 建立 KV namespace(若同名已存在則回傳既有 id,冪等)。*/
async ensureKvNamespace(title: string, existing?: Map<string, string>): Promise<string> {
const known = existing ?? (await this.listKvNamespaces());
const found = known.get(title);
if (found) return found;
const result = await this.cf<{ id: string; title: string }>(
'/storage/kv/namespaces',
{ method: 'POST', body: JSON.stringify({ title }) },
@@ -158,24 +139,6 @@ export class CfAccountClient implements ResourceApi {
return result.id;
}
/**
* worker **使**Arcrun#97
* CF`GET /accounts/{id}/workers/scripts/{script}/settings` `result.bindings[]`
*
* - script 404 `{ deployed: false }`
* - throw****
* #97
*/
async getScriptBindings(script: string): Promise<ScriptBindings> {
const path = `/workers/scripts/${encodeURIComponent(script)}/settings`;
const res = await this.cfRaw<{ bindings?: RawWorkerBinding[] }>(path);
if (!res.ok) {
if (res.status === 404) return { deployed: false, bindings: [] };
throw new Error(`${script} 綁定失敗:${res.error}`);
}
return { deployed: true, bindings: normalizeBindings(res.result?.bindings ?? []) };
}
/** 查 workers.dev subdomaincypher-executor WORKER_SUBDOMAIN 用,組對內 component URL)。*/
async getWorkersSubdomain(): Promise<string> {
const result = await this.cf<{ subdomain: string }>('/workers/subdomain');
@@ -190,66 +153,54 @@ export class CfAccountClient implements ResourceApi {
return map;
}
/** 無條件新建 D1。沒有 ensure 版本,理由同 createKvNamespaceArcrun#97)。 */
async createD1Database(name: string): Promise<string> {
async ensureD1Database(name: string, existing?: Map<string, string>): Promise<string> {
const known = existing ?? (await this.listD1Databases());
const found = known.get(name);
if (found) return found;
const result = await this.cf<{ uuid: string; name: string }>(
'/d1/database',
{ method: 'POST', body: JSON.stringify({ name }) },
);
return result.uuid;
}
/** 帳號上現有的 Vectorize index 名單(判斷「綁著的那顆還在不在」用)。 */
async listVectorizeIndexes(): Promise<string[]> {
const result = await this.cf<Array<{ name: string }>>('/vectorize/v2/indexes');
return (result ?? []).map(i => i.name);
}
/**
* KBDB embed Vectorize index**bge-m3 = 1024 / cosine** deploy.ts
* 409 / already exists ensure
* planResources Arcrun#97
*/
async createVectorizeIndex(name: string): Promise<string> {
const res = await this.cfRaw<{ name: string }>('/vectorize/v2/indexes', {
method: 'POST',
body: JSON.stringify({
name,
config: { dimensions: 1024, metric: 'cosine' },
description: 'arcrun KBDB embed module — bge-m3 1024d (issue #7 / #59)',
}),
});
if (res.ok) return name;
const detail = (res.error ?? '').toLowerCase();
if (res.status === 409 || /already exists|duplicate|conflict/.test(detail)) return name;
throw new Error(`建 Vectorize index ${name} 失敗:${res.error}`);
}
}
/** CF `/settings` 回的 binding 原始形狀(同一種資源在不同 API 版本欄位名不一,故全都收)。 */
interface RawWorkerBinding {
type?: string;
name?: string;
namespace_id?: string;
id?: string;
database_id?: string;
index_name?: string;
/** AES-GCM 加密 credential(與 cypher-executor credential-injector 解密邏輯對應)*/
export async function encryptCredential(value: string, encryptionKey: string): Promise<string> {
if (!encryptionKey || encryptionKey.length < 64) {
throw new Error(
'ARCRUN_ENCRYPTION_KEY 未設定或長度不足(需要 256-bit hex,即 64 個十六進位字元)\n' +
'生成指令:node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"'
);
}
const keyBytes = hexToUint8Array(encryptionKey);
const cryptoKey = await crypto.subtle.importKey(
'raw',
keyBytes.buffer as ArrayBuffer,
{ name: 'AES-GCM' },
false,
['encrypt'],
);
const iv = crypto.getRandomValues(new Uint8Array(12));
const encoded = new TextEncoder().encode(value);
const cipherBuffer = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, cryptoKey, encoded);
return JSON.stringify({
encrypted: uint8ArrayToBase64(new Uint8Array(cipherBuffer)),
iv: uint8ArrayToBase64(iv),
});
}
/** 把 CF 的 binding 陣列收斂成 resolver 認得的三種資源。不認得的型別直接略過。 */
function normalizeBindings(raw: RawWorkerBinding[]): LiveBinding[] {
const out: LiveBinding[] = [];
for (const b of raw) {
if (!b?.name) continue;
if (b.type === 'kv_namespace') {
const value = b.namespace_id ?? b.id;
if (value) out.push({ kind: 'kv_namespace', binding: b.name, value });
} else if (b.type === 'd1' || b.type === 'd1_database') {
const value = b.id ?? b.database_id;
if (value) out.push({ kind: 'd1', binding: b.name, value });
} else if (b.type === 'vectorize') {
if (b.index_name) out.push({ kind: 'vectorize', binding: b.name, value: b.index_name });
}
function hexToUint8Array(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);
}
return out;
return bytes;
}
function uint8ArrayToBase64(arr: Uint8Array): string {
return Buffer.from(arr).toString('base64');
}
+8 -7
View File
@@ -12,6 +12,7 @@ export interface ArcrunConfig {
mode: 'local' | 'standard' | 'self-hosted';
// Standard 模式
api_key?: string; // arcrun.dev API Keyak_前綴)
encryption_key?: string; // AES-GCM key,與 cypher-executor ENCRYPTION_KEY secret 一致
// Self-hosted 模式
cloudflare_account_id?: string;
user_kv_namespace_id?: string;
@@ -28,12 +29,10 @@ export interface ArcrunConfig {
mcp_url?: string;
multi_tenant?: boolean;
// 語義查詢開關(issue #7 / SDD T2.4self-hosted 從零做)。
// 🔴 2026-08-09 預設翻轉(leo:「語義搜尋已經確定是一安裝就提供的功能」):
// 未設 → **視同開**init/update 皆以 `!== false` 判斷)。只有顯式 false 才關。
// true/未設 → deploy 時建 CF Vectorize index 並注入 kbdb worker 的 [[vectorize]]+[ai] binding
// true → deploy 時建 CF Vectorize index 並注入 kbdb worker 的 [[vectorize]]+[ai] binding
// kbdb embed 模組啟用(寫入時對標記 embed 的 entry embed、search 支援 mode=semantic)。
// false → base 維持 LIKE keyword顯式選擇才有這個狀態;缺欄位不再等於關——
// 舊語意會讓 acr update 把正常實例的 binding 靜默剝掉,畫面再謊稱「沒開通」)
// 未設/false → base 維持 LIKE keywordfree-tier 友善,不建 index、不花費)。
// 開法:設 kbdb_embed:true → redeployacr update)。「CC 幫開」=CC 寫此欄 true + 跑 acr update
kbdb_embed?: boolean;
// 暴露 consent 閘已移除(leo 2026-06-29Arcrun#13)。此欄位保留只為向後相容舊 config.yaml
// (讀到不報錯,不再寫入/檢查)。
@@ -59,6 +58,8 @@ const ENV_MAP: Record<string, keyof ArcrunConfig> = {
NAMESPACE: 'api_key',
ARCRUN_NAMESPACE: 'api_key',
ARCRUN_API_KEY: 'api_key',
ARCRUN_ENCRYPTION_KEY: 'encryption_key',
ENCRYPTION_KEY: 'encryption_key',
ARCRUN_CYPHER_EXECUTOR_URL: 'cypher_executor_url',
ARCRUN_MCP_URL: 'mcp_url',
CLOUDFLARE_ACCOUNT_ID: 'cloudflare_account_id',
@@ -116,7 +117,7 @@ function readProjectConfig(): Partial<ArcrunConfig> | undefined {
/**
* .env .arcrun.yaml process.env Node/Python
* .env NAMESPACE / CLOUDFLARE_* shell envshell > .env
* .env NAMESPACE / ENCRYPTION_KEY shell envshell > .env
* dotenv KEY=VALUE//
*/
let _envFileLoaded = false;
@@ -192,7 +193,7 @@ export function resolveConfigSources(): Array<{ field: keyof ArcrunConfig; value
const project = readProjectConfig() ?? {};
const env = readEnvOverrides();
const fields: (keyof ArcrunConfig)[] = [
'mode', 'api_key', 'cloudflare_account_id',
'mode', 'api_key', 'encryption_key', 'cloudflare_account_id',
'cf_api_token', 'cypher_executor_url', 'mcp_url',
];
const rows: Array<{ field: keyof ArcrunConfig; value: string; source: ConfigSource }> = [];
+86 -295
View File
@@ -20,19 +20,6 @@ import { tmpdir, homedir } from 'node:os';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
import chalk from 'chalk';
import { CfAccountClient } from './cf-api.js';
import {
applyResourcePlan,
bindingKey,
parseWranglerRequirements,
planResources,
ResourcePlanBlocked,
TABLE_KIND,
type BindingRequirement,
type ResourceApi,
type ResourceKind,
type ResolvedResource,
} from './resource-resolver.js';
/** manifest worker content hash
* acr update worker 2026-06-1222/23
@@ -120,15 +107,7 @@ export function buildDownloadHeaders(token = giteaToken()): Record<string, strin
}
/**
* arcrun worker KV **binding **
*
* 🔴 Arcrun#97 ** CF **
* wrangler.toml parseWranglerRequirements
* resource-resolver worker
* **** toml
* ensure
*
*
* init KV namespacetitle
* 7 .claude/rules/01-tech-stack.md cypher-executor
* SUBMISSIONS_KVregistry worker component 稿 registry deploy
* §2.6/#1120/21registry/wrangler.toml SUBMISSIONS_KV
@@ -150,6 +129,13 @@ export const REQUIRED_KV_NAMESPACES = [
'OAUTH_KV',
] as const;
/** 部署後要提示用戶手動 `wrangler secret put ENCRYPTION_KEY` 的 Worker。*/
export const SECRET_TARGET_WORKERS = [
'arcrun-cypher-executor',
'arcrun-auth-static-key',
'arcrun-auth-service-account',
] as const;
/** downloadAndDeploy 2.5tarball root worker resolve
* worker runtime depstier1 component honotier2 cypher/registry/mcp/kbdb
* zod / @hono/zod-openapi / @modelcontextprotocol/sdk / js-yaml / yaml
@@ -172,11 +158,8 @@ export interface DeployContext {
accountId: string;
apiToken: string;
workerSubdomain: string;
/** binding KV namespace id** downloadAndDeploy **
* Arcrun#97 ensure 使*/
kvNamespaceIds?: Record<string, string>;
/** KBDB Base D1 id;同上,由資源解析填入。*/
d1DatabaseId?: string;
kvNamespaceIds: Record<string, string>; // title → id
d1DatabaseId?: string; // KBDB Base D1 (arcrun-kbdb); injected into kbdb wrangler.toml
// self-hosted 單租戶旗標。trueself-hosted)→ 注入 MULTI_TENANT="false" 到 worker [vars]
// 讓 MCP partner-auth 走 namespace 明碼分支(mcp-account-source §5.5)。
// 未設 / false → 不注入(官方 SaaS 多租戶,行為不變)。
@@ -187,38 +170,14 @@ export interface DeployContext {
kbdbEmbed?: boolean;
}
/**
* Vectorize index kbdb embed **bge-m3 = 1024 cosine**
*
* 🔴 2026-08-03 leo 5 `bge-base-en-v1.5` 2/5
* margin 0.0413****`bge-m3` 5/5+0.1410959ms
* leo 08-05 embed model vectorize
*
* ** index**
* 7681024 index
* 沿 index
* #58Vectorize vector delete
* ** index #58**
*
* `ensureVectorizeMetadataIndexes()` 使deploy.ts:426
* t36 metadata indexowner_id/entry_type/source/libraryArcrun#11
* index ****
*
* `POST /embed/backfill {"reindex":true}` remaining=0
* `arcrun-kbdb-embed`
*/
export const KBDB_VECTORIZE_INDEX = 'arcrun-kbdb-embed-m3';
/** Vectorize index 名(kbdb embed 模組用)。bge-base-en-v1.5 = 768 維、cosine。 */
export const KBDB_VECTORIZE_INDEX = 'arcrun-kbdb-embed';
export interface DeployResult {
implemented: boolean;
cypherExecutorUrl?: string;
mcpUrl?: string; // self-hosted 自己的 MCP worker URLmcp-account-source §3
message: string;
/** true = Arcrun#97** worker **
* message */
blocked?: boolean;
/** 這趟實際用上的資源(沿用/新建各是哪一顆)。呼叫端寫 config 用這個,不要自己再查一次。*/
resources?: Map<string, ResolvedResource>;
}
/** 偵測 wrangler 是否已安裝(用戶前置:裝 CF CLI)。*/
@@ -248,10 +207,8 @@ export function wranglerAvailable(): boolean {
export async function downloadAndDeploy(
ctx: DeployContext,
ref = 'main',
opts: { force?: boolean; mode?: 'init' | 'update'; api?: ResourceApi } = {},
opts: { force?: boolean } = {},
): Promise<DeployResult> {
const mode = opts.mode ?? 'update';
const api = opts.api ?? new CfAccountClient(ctx.accountId, ctx.apiToken);
// 1. 下載 + 解壓 Gitea archive tarball
let root: string;
try {
@@ -293,123 +250,28 @@ export async function downloadAndDeploy(
}
const failures: string[] = [];
const allDirs = [...tier1, ...tier2];
// ── 2.6 資源解析:先看「這些 worker 現在綁著什麼」,再決定沿用還是新建(Arcrun#97)──────
//
// 🔴 這一段取代了舊的「照名字 ensure 一輪 KV/D1/Vectorize 再注入」
// 舊做法用 binding 名當資源標題去找,對不上就新建一顆空的綁上去——
// 安裝器建的資源本來就不叫那個名字,於是**每次更新都對不上、每次都新建**:
// 2026-08-12 一次更新生了 9 顆 KV + 1 顆 D1,使用者的工作流/登入/子庫全部從畫面上消失。
//
// 現在:已部署 worker 上的綁定=事實,原樣沿用;只有「確定沒人綁過」才建;
// 任何說不準的情況(讀不到綁定/綁著的資源不見了/同名綁定指向兩顆/一顆 worker 都找不到)
// → 整趟停手,**在動任何東西之前**。
//
// 需求是從「注入後的 toml」解析的(renderWranglerToml 帶空 map 當預覽),
// 所以「解析看到的」和「最後寫進去的」保證是同一份檔案的同一種樣子。
const requirements: BindingRequirement[] = [];
const tomlPreviews = new Map<string, string>(); // dir → 注入前的原文
for (const dir of allDirs) {
const tomlPath = join(dir, 'wrangler.toml');
if (!existsSync(tomlPath)) continue;
const raw = readFileSync(tomlPath, 'utf8');
tomlPreviews.set(dir, raw);
const preview = renderWranglerToml(raw, ctx, new Map());
const parsed = parseWranglerRequirements(preview);
if (!parsed.script) continue; // 沒宣告 name 的 toml 不該存在;跳過而非亂猜
for (const b of parsed.bindings) {
requirements.push({ ...b, worker: parsed.script });
}
}
let resolved = new Map<string, ResolvedResource>();
if (requirements.length > 0) {
process.stdout.write(chalk.gray(' → 對照你帳號上已部署的 worker,確認每個綁定該用哪顆資源...'));
let plan;
// 2.6 語義查詢(issue #7 / T2.4):開 kbdb_embed → 先確保 Vectorize index 存在(REST,冪等),
// 再由 injectWranglerConfig 取消 kbdb toml 的 [[vectorize]]+[ai] 註解 → embed 模組上線。
// 失敗不致命(收進 failuresbase 仍可部署、維持 keyword
if (ctx.kbdbEmbed) {
try {
plan = await planResources(api, requirements, mode);
} catch (e) {
console.log(chalk.yellow(' ✗'));
return {
implemented: true,
blocked: true,
message:
`資源解析失敗(${e instanceof Error ? e.message : String(e)})。\n` +
`沒有建立任何資源、沒有部署任何 worker——你現在的實例維持原樣。`,
};
}
if (plan.blockers.length > 0) {
console.log(chalk.yellow(' ✗'));
return {
implemented: true,
blocked: true,
message:
`停手:有 ${plan.blockers.length} 件事我不敢自己決定。\n` +
plan.blockers.map((b) => `${b}`).join('\n') +
`\n\n沒有建立任何資源、沒有部署任何 worker——你現在的實例維持原樣。`,
};
}
try {
resolved = await applyResourcePlan(api, plan);
} catch (e) {
console.log(chalk.yellow(' ✗'));
const raw = e instanceof Error ? e.message : String(e);
const detail = e instanceof ResourcePlanBlocked
? e.blockers.map((b) => `${b}`).join('\n')
: `${raw}`;
// D1 建不起來最常見的根因是 token 沒勾 D1 權限(KV/Worker 建得起來、只有 D1 報 auth error)。
// 這句提示在改版前就有,別隨著搬家弄丟——它是使用者唯一能自己解掉的那個錯。
const hint = /d1/i.test(raw) && /auth/i.test(raw)
? '\n → CF token 缺 D1 權限:補勾「Account / D1 / Edit」重產 token 填回 .env 再跑一次。'
: '';
return {
implemented: true,
blocked: true,
message: `停手:\n${detail}${hint}\n\n沒有部署任何 worker——你現在的實例維持原樣。`,
};
}
console.log(chalk.green(' ✓'));
const adopted = [...resolved.values()].filter((r) => r.origin === 'adopted');
const created = [...resolved.values()].filter((r) => r.origin === 'created');
if (adopted.length > 0) {
console.log(chalk.gray(` 沿用你既有的 ${adopted.length} 個資源(不論它們叫什麼名字):`));
for (const r of adopted) console.log(chalk.gray(` = ${r.binding}${r.value}(讀自 ${r.from}`));
}
if (created.length > 0) {
console.log(chalk.yellow(` 新建 ${created.length} 個(目前沒有任何已部署的 worker 綁著它們):`));
for (const r of created) console.log(chalk.yellow(` + ${r.binding}${r.value}`));
}
}
// 解析結果回填 ctx,供 applyD1Migration / 呼叫端寫 config 使用。
// KBDB 的 migration 打 kbdb worker 的 `DB`;沒有它才退回 cypher 的 `CREDENTIALS_DB`(同一顆庫)。
ctx.kvNamespaceIds = Object.fromEntries(
[...resolved.values()].filter((r) => r.kind === 'kv_namespace').map((r) => [r.binding, r.value]),
);
ctx.d1DatabaseId =
resolved.get(bindingKey('d1', 'DB'))?.value
?? resolved.get(bindingKey('d1', 'CREDENTIALS_DB'))?.value;
// 2.7 語義查詢(issue #7 / T2.4):index 本體已由上面的資源解析處理(沿用既有 / 需要才新建)。
// 這裡只補 metadata index——Vectorize 要 filter 某欄位必須先為該欄建 index,
// 否則帶 owner_id/entry_type/source 過濾的語意查詢一律回 0 命中(Arcrun#11 根因)。
// 冪等;失敗不致命(收進 failures,base 仍可部署、維持 keyword)。
const vectorizeIndex = resolved.get(bindingKey('vectorize', 'VECTORIZE'))?.value;
if (vectorizeIndex) {
try {
process.stdout.write(chalk.gray(` → 語義查詢 metadata index${vectorizeIndex}...`));
await ensureVectorizeMetadataIndexes(ctx, vectorizeIndex);
process.stdout.write(chalk.gray(' → 開語義查詢:確保 Vectorize index 存在...'));
await ensureVectorizeIndex(ctx);
// Arcrun#11 根因修復:光建 index 不夠——Vectorize 要 filter 某 metadata 欄位,該欄必須先建
// metadata index,否則帶 owner_id/entry_type/source 過濾的語意查詢一律回 0。冪等,隨 index 一起確保。
await ensureVectorizeMetadataIndexes(ctx);
console.log(chalk.green(' ✓'));
} catch (e) {
console.log(chalk.yellow(' ⚠'));
failures.push(`Vectorize metadata index (${vectorizeIndex}): ${e instanceof Error ? e.message : String(e)}`);
failures.push(`Vectorize index (${KBDB_VECTORIZE_INDEX}): ${e instanceof Error ? e.message : String(e)}`);
}
}
// 3. 對每個 worker:注入 KV id+ cypher WORKER_SUBDOMAIN)→ wrangler deploy。tier1 先 tier2 後。
// 逐 worker 串流進度(每個含 pnpm install + wrangler deploy,沉默會讓人以為卡住——
// 壓測 2026-06-11 richblack 觀察:「D1 ✓」後停很久其實在這個迴圈靜默部署 20+ worker)。
const allDirs = [...tier1, ...tier2];
let deployed = 0;
let skipped = 0;
// 內容指紋 manifest:未變動且上次成功的 worker 跳過(key 用 worker 名,不用 temp 絕對路徑)。
@@ -422,7 +284,7 @@ export async function downloadAndDeploy(
const label = dir.replace(/^.*\.component-builds\//, '').replace(/^.*\//, '');
process.stdout.write(chalk.gray(` [${i + 1}/${allDirs.length}] ${label} ...`));
try {
injectWranglerConfig(tomlPath, ctx, resolved, tomlPreviews.get(dir));
injectWranglerConfig(tomlPath, ctx);
// 注入後算指紋:與 manifest 比,相同 = 上次成功部過且內容沒變 → 跳過。
const hash = dirContentHash(dir, ctx.accountId);
if (manifest[label] === hash) {
@@ -462,49 +324,20 @@ export async function downloadAndDeploy(
failures.push(`D1 migration: 部署物缺 kbdb/migrations/0001_base.sql${migPath}`);
}
// 3.6 credential template seedD38 圍牆修復,總管交辦,2026-08-07):credential 目錄改走
// KBDB template 機制(entries 表 entry_type='credential',比照 recipe_stat/execution_log
// 慣例),取代舊的獨立 credentials 表(0002,已退役,見該檔頭部說明)。冪等,套用機制
// 與 0001_base.sql 完全相同。密文本體仍住 Workers per-script Secrets(見
// cypher-executor/src/routes/credentials.tsD19「擁有目錄不擁有內容物」不變
const credTplMigPath = join(root, 'kbdb', 'migrations', '0005_credential_template.sql');
if (existsSync(credTplMigPath)) {
// 3.6 credential-primitives-wasm T2credential-store-migration.md §2.2):
// credentials 目錄表(api_key/name/service/sensitivity/secret_ref/created_at/last_used_at)。
// 同一顆 D1(與 KBDB base 共用),冪等 IF NOT EXISTS,套用機制與 0001_base.sql 完全相同
// (同一個 applyD1Migration helper,同一支 CF D1 query API)。D19:這張表不含密文,
// 密文本體住在 Workers per-script Secrets(見 cypher-executor/src/routes/credentials.ts)。
const credMigPath = join(root, 'kbdb', 'migrations', '0002_credentials.sql');
if (existsSync(credMigPath)) {
try {
await applyD1Migration(ctx, readFileSync(credTplMigPath, 'utf8'));
await applyD1Migration(ctx, readFileSync(credMigPath, 'utf8'));
} catch (e) {
failures.push(`D1 migration 0005_credential_template (${ctx.d1DatabaseId}): ${e instanceof Error ? e.message : String(e)}`);
failures.push(`D1 migration 0002_credentials (${ctx.d1DatabaseId}): ${e instanceof Error ? e.message : String(e)}`);
}
} else {
failures.push(`D1 migration: 部署物缺 kbdb/migrations/0005_credential_template.sql${credTplMigPath}`);
}
// 3.6b 退役舊 credentials 表(D382026-08-07):把該表殘留資料(若有)搬進 entries 後
// 拆表,讓 KBDB 回到「只有三張核心表」的狀態。冪等且對「從未跑過 0002」的全新實例
// 無害(表不存在時本檔第一步先補空殼再立刻拆掉,詳見檔頭)。每次部署都會重跑,
// 但真資料只搬一次(NOT EXISTS 判斷防重複)。
const dropCredMigPath = join(root, 'kbdb', 'migrations', '0006_drop_credentials_table.sql');
if (existsSync(dropCredMigPath)) {
try {
await applyD1Migration(ctx, readFileSync(dropCredMigPath, 'utf8'));
} catch (e) {
failures.push(`D1 migration 0006_drop_credentials_table (${ctx.d1DatabaseId}): ${e instanceof Error ? e.message : String(e)}`);
}
} else {
failures.push(`D1 migration: 部署物缺 kbdb/migrations/0006_drop_credentials_table.sql${dropCredMigPath}`);
}
// 3.7 execution_log template seedKV 額度事故修復,2026-08-07):workflow 執行紀錄改走
// KBDB template 機制(entries 表 entry_type='execution_log',比照 recipe_stat 慣例;
// schema 零異動,只 seed 一列 template 定義,同 0001_base.sql §3 手法,self-hosted 同步套用)。
const execLogMigPath = join(root, 'kbdb', 'migrations', '0004_execution_log_template.sql');
if (existsSync(execLogMigPath)) {
try {
await applyD1Migration(ctx, readFileSync(execLogMigPath, 'utf8'));
} catch (e) {
failures.push(`D1 migration 0004_execution_log_template (${ctx.d1DatabaseId}): ${e instanceof Error ? e.message : String(e)}`);
}
} else {
failures.push(`D1 migration: 部署物缺 kbdb/migrations/0004_execution_log_template.sql${execLogMigPath}`);
failures.push(`D1 migration: 部署物缺 kbdb/migrations/0002_credentials.sql${credMigPath}`);
}
}
@@ -560,6 +393,33 @@ async function applyD1Migration(ctx: DeployContext, sql: string): Promise<void>
}
}
/**
* KBDB embed Vectorize index issue #7 / T2.4
* REST `POST /accounts/{id}/vectorize/v2/indexes`dimensions=768/metric=cosine bge-base-en-v1.5
* CF already exists init apiToken+accountId
*/
async function ensureVectorizeIndex(ctx: DeployContext): Promise<void> {
const url = `https://api.cloudflare.com/client/v4/accounts/${ctx.accountId}/vectorize/v2/indexes`;
const res = await fetch(url, {
method: 'POST',
headers: { Authorization: `Bearer ${ctx.apiToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
name: KBDB_VECTORIZE_INDEX,
config: { dimensions: 768, metric: 'cosine' },
description: 'arcrun KBDB optional embed module (issue #7)',
}),
signal: AbortSignal.timeout(60_000),
});
if (res.ok) return;
// 冪等:已存在 → 視為成功(CF 回 409 或 errors 含 already exists / duplicate)。
const json = (await res.json().catch(() => null)) as
| { success?: boolean; errors?: Array<{ message?: string; code?: number }> }
| null;
const msg = (json?.errors?.map(e => e.message).filter(Boolean).join('; ') || `HTTP ${res.status}`).toLowerCase();
if (res.status === 409 || /already exists|duplicate|conflict/.test(msg)) return;
throw new Error(msg);
}
/** embed 過濾用的 Vectorize metadata index 欄位(型別 string;對齊 embedOnWrite 寫入的 metadata)。 */
export const KBDB_VECTORIZE_META_FIELDS = ['owner_id', 'entry_type', 'source'] as const;
@@ -568,12 +428,9 @@ export const KBDB_VECTORIZE_META_FIELDS = ['owner_id', 'entry_type', 'source'] a
* Vectorize v2 metadata filter metadata index 0
* REST `POST /accounts/{id}/vectorize/v2/indexes/{index}/metadata_index/create`indexType=string
* 409 / already existsasync upsert reindex
*
* 🔴 index = 沿**** KBDB_VECTORIZE_INDEX
* 使 index metadata index
*/
async function ensureVectorizeMetadataIndexes(ctx: DeployContext, indexName: string): Promise<void> {
const url = `https://api.cloudflare.com/client/v4/accounts/${ctx.accountId}/vectorize/v2/indexes/${indexName}/metadata_index/create`;
async function ensureVectorizeMetadataIndexes(ctx: DeployContext): Promise<void> {
const url = `https://api.cloudflare.com/client/v4/accounts/${ctx.accountId}/vectorize/v2/indexes/${KBDB_VECTORIZE_INDEX}/metadata_index/create`;
for (const propertyName of KBDB_VECTORIZE_META_FIELDS) {
const res = await fetch(url, {
method: 'POST',
@@ -696,31 +553,21 @@ export function discoverWorkerDirs(root: string): { tier1: string[]; tier2: stri
* - worker toml `workers_dev = true` strip routes workers.dev URL
* - R2`[[r2_buckets]]` dead storageregistry-canon Phase 1.5
*/
function injectWranglerConfig(
tomlPath: string,
ctx: DeployContext,
resolved: Map<string, ResolvedResource>,
original?: string,
): void {
function injectWranglerConfig(tomlPath: string, ctx: DeployContext): void {
if (!existsSync(tomlPath)) return;
// original = 資源解析階段讀到的原文。用它而不是重讀檔案,確保「解析看到的」與「寫回去的」同源。
const toml = original ?? readFileSync(tomlPath, 'utf8');
writeFileSync(tomlPath, renderWranglerToml(toml, ctx, resolved), 'utf8');
}
let toml = readFileSync(tomlPath, 'utf8');
// 對每個已建立的 KV namespace:把對應 binding 的 id 換成用戶的。
// 匹配 `[[kv_namespaces]] ... binding = "NAME" ... id = "OLD"` 的 id 行。
for (const [binding, id] of Object.entries(ctx.kvNamespaceIds)) {
if (!id) continue;
const re = new RegExp(
`(binding\\s*=\\s*"${binding}"\\s*\\n\\s*id\\s*=\\s*")[^"]*(")`,
'g',
);
toml = toml.replace(re, `$1${id}$2`);
}
/**
* repo wrangler.toml
*
* `resolved` =
* id toml
* binding Arcrun#97
*
*/
export function renderWranglerToml(
toml: string,
ctx: DeployContext,
resolved: Map<string, ResolvedResource>,
): string {
// cypher-executor 的 WORKER_SUBDOMAINvars)換成用戶帳號 subdomain
if (ctx.workerSubdomain && /WORKER_SUBDOMAIN/.test(toml)) {
toml = toml.replace(
@@ -739,6 +586,14 @@ export function renderWranglerToml(
);
}
// KBDB Base: inject user's D1 database_id into [[d1_databases]] (placeholder in repo toml)
if (ctx.d1DatabaseId && /database_id\s*=/.test(toml)) {
toml = toml.replace(
/(database_id\s*=\s*")[^"]*(")/,
`$1${ctx.d1DatabaseId}$2`,
);
}
// self-hosted:注入 MULTI_TENANT="false" 到 [vars]mcp-account-source §5.5)。
// 修「部署沒注入 → worker c.env.MULTI_TENANT===undefined → MCP 走 partner-key → 401」。
// 只對有 [vars] 的 workermcp / cypher-executor)生效;其餘無 [vars] 的不動。
@@ -770,71 +625,7 @@ export function renderWranglerToml(
toml = toml.replace(/# (\[ai\])\n# (binding = "AI")/, '$1\n$2');
}
// 資源 id 一律最後注入,且**照 binding 名逐個對號**(不是「檔案裡第一個 database_id」那種盲換)。
// 空 map = 預覽模式,這步什麼也不做。
return applyResolvedBindings(toml, resolved);
}
/**
* id binding
*
* `[[table]]` `binding = "X"`
* KV`id`D1`database_id`Vectorize`index_name`
* 🔴 **** database_idcypher`CREDENTIALS_DB`
* kbdb`DB` D1 西使
* 使
*/
export function applyResolvedBindings(
toml: string,
resolved: Map<string, ResolvedResource>,
): string {
if (resolved.size === 0) return toml;
const VALUE_KEY: Record<ResourceKind, string> = {
kv_namespace: 'id',
d1: 'database_id',
vectorize: 'index_name',
};
const out: string[] = [];
let block: string[] = [];
let kind: ResourceKind | null = null;
const flush = (): void => {
if (kind) {
const binding = block
.map((l) => l.trim())
.filter((l) => !l.startsWith('#'))
.map((l) => l.match(/^binding\s*=\s*"([^"]*)"/)?.[1])
.find((b): b is string => !!b);
const hit = binding ? resolved.get(bindingKey(kind, binding)) : undefined;
if (hit) {
const key = VALUE_KEY[kind];
const re = new RegExp(`^(\\s*${key}\\s*=\\s*")[^"]*(")(.*)$`);
const at = block.findIndex((l) => !l.trim().startsWith('#') && re.test(l));
if (at >= 0) {
block[at] = block[at].replace(re, `$1${hit.value}$2$3`);
} else {
// 區塊裡本來沒有這個欄位(例如新版 toml 只寫 binding)→ 補一行,不要靜默略過。
block.push(`${key} = "${hit.value}"`);
}
}
}
out.push(...block);
block = [];
};
for (const line of toml.split('\n')) {
const table = line.trim().match(/^\[\[?([A-Za-z0-9_]+)\]?\]$/);
if (table) {
flush();
kind = TABLE_KIND[table[1]] ?? null;
}
block.push(line);
}
flush();
return out.join('\n');
writeFileSync(tomlPath, toml, 'utf8');
}
/**
+20 -38
View File
@@ -6,11 +6,9 @@
* test_arcrun/4 D1
* - ****KV / D1 / migration / cypher
* + harness/MCP console.log
* - ****
* - ****ensureKvNamespace / ensureD1Database
*
* + resource-resolver deploy.ts
* 🔴 Arcrun#97 fix acr update
* KV使
* + cf-api ensure* init
*/
import { execFileSync } from 'node:child_process';
@@ -79,58 +77,42 @@ export function printPreflight(title: string, items: PreflightItem[]): void {
*/
export async function verifyInstall(opts: {
cf: CfAccountClient;
/** binding → KV namespace id(部署實際用上的那幾顆)。*/
kvNamespaceIds: Record<string, string>;
/** 部署實際用上的 D1 id(沒有 D1 就不傳)。*/
d1DatabaseId?: string;
requiredKv: readonly string[];
expectD1Name?: string;
cypherUrl?: string;
}): Promise<{ items: PreflightItem[]; allOk: boolean }> {
const items: PreflightItem[] = [];
// KV核對「部署實際綁上去的那幾顆 id」在帳號上還在不在。
// 🔴 Arcrun#97:這裡**不能**用「帳號上有沒有叫 WEBHOOKS 的 namespace」來驗。
// 安裝器裝出來的實例,資源名字是 arcrun-rag-<instance>-kv-webhooks——照名字驗會誤報「缺」,
// 而那句誤報底下就寫著「fix: acr update(冪等重建)」⇒ 使用者照做,就被重建成空的。
// 驗的對象永遠是 id(我們真的綁上去的那顆),不是名字。
const kvBindings = Object.entries(opts.kvNamespaceIds);
// KV實查 CF 上現有 namespace,比對必需清單
try {
const ids = new Set((await opts.cf.listKvNamespaces()).values());
const missing = kvBindings.filter(([, id]) => !ids.has(id)).map(([b]) => b);
const existing = await opts.cf.listKvNamespaces();
const have = new Set(existing.keys());
const missing = opts.requiredKv.filter((t) => !have.has(t));
items.push(
missing.length === 0
? { name: `KV namespaces (${kvBindings.length})`, ok: true }
: {
name: 'KV namespaces',
ok: false,
detail: `這幾個 binding 綁著的 namespace 在帳號上找不到:${missing.join(', ')}`,
fix: '先確認那幾顆是被刪了還是 token 看不到——不要直接重跑安裝(會綁到空的)',
},
? { name: `KV namespaces (${opts.requiredKv.length})`, ok: true }
: { name: 'KV namespaces', ok: false, detail: `${missing.join(', ')}`, fix: 'acr update(冪等重建)' },
);
} catch (e) {
items.push({ name: 'KV namespaces', ok: false, detail: msg(e), fix: '檢查 CF token 的 KV 讀取權限' });
items.push({ name: 'KV namespaces', ok: false, detail: msg(e), fix: 'acr update' });
}
// D1同理,核對實際綁上去的那顆 id 還在不在(不是核對有沒有叫 arcrun-kbdb 的庫)。
if (opts.d1DatabaseId) {
// D1實查 CF 上是否有該庫
if (opts.expectD1Name) {
try {
const ids = new Set((await opts.cf.listD1Databases()).values());
const dbs = await opts.cf.listD1Databases();
items.push(
ids.has(opts.d1DatabaseId)
? { name: `D1 ${opts.d1DatabaseId}`, ok: true }
: {
name: `D1 ${opts.d1DatabaseId}`,
ok: false,
detail: '這顆 D1 在帳號上找不到',
fix: '先確認它是被刪了還是 token 看不到——不要直接重跑安裝(會綁到空的)',
},
dbs.has(opts.expectD1Name)
? { name: `D1 ${opts.expectD1Name}`, ok: true }
: { name: `D1 ${opts.expectD1Name}`, ok: false, detail: '不存在', fix: 'CF token 補勾「Account / D1 / Edit」權限 → 重產 token 填回 .env → acr update' },
);
} catch (e) {
// D1 讀不到最常見根因:CF token 沒勾 D1 權限(KV/Worker 能建但 D1 報 Authentication error)。
// D1 建失敗最常見根因:CF token 沒勾 D1 權限(KV/Worker 能建但 D1 報 Authentication error)。
const m = msg(e);
const fix = /auth/i.test(m)
? 'token 缺 D1 權限:CF token 補勾「Account / D1 / Edit」→ 重產 token 填回 .env → acr update'
: '檢查 CF token 的 D1 讀取權限';
items.push({ name: `D1 ${opts.d1DatabaseId}`, ok: false, detail: m, fix });
: 'acr update(冪等重試)';
items.push({ name: `D1 ${opts.expectD1Name}`, ok: false, detail: m, fix });
}
}
-408
View File
@@ -1,408 +0,0 @@
/**
* resource-resolver.ts worker
*
* 🔴 Arcrun#972026-08-12 leo
* ensure`acr update` **binding **`WEBHOOKS` Cloudflare
* ****** worker **
* `arcrun-rag-<instance>-kv-webhooks`
* 9 KV1 D1使****
* worker 使西
*
* KV **使**
* **使**
*
* ****
*
*
* 1. ** worker ** 沿
* 2. **** binding
* 3. **** binding
* worker ****
*
* plan / apply
* `planResources()` ****沿
* `applyResourcePlan()` blocker
* ****
* early return#97
*/
/** R2/Queue/Hyperdrive
* ensure */
export type ResourceKind = 'kv_namespace' | 'd1' | 'vectorize';
/** 從已部署 worker 上讀回來的一條綁定。`value`KV/D1 是資源 idVectorize 是 index 名。 */
export interface LiveBinding {
kind: ResourceKind;
binding: string;
value: string;
}
export interface ScriptBindings {
/** false = 這顆 worker 在帳號上還不存在(全新部署),不是「讀取失敗」。讀取失敗要 throw。 */
deployed: boolean;
bindings: LiveBinding[];
}
/** resolver 需要的 CF 能力(收窄成介面,方便離線測試餵假帳號)。 */
export interface ResourceApi {
getScriptBindings(script: string): Promise<ScriptBindings>;
/** title → id */
listKvNamespaces(): Promise<Map<string, string>>;
/** name → uuid */
listD1Databases(): Promise<Map<string, string>>;
listVectorizeIndexes(): Promise<string[]>;
createKvNamespace(title: string): Promise<string>;
createD1Database(name: string): Promise<string>;
createVectorizeIndex(name: string): Promise<string>;
}
/** 「這顆 worker 需要這個 binding」。createName 只在**真的要新建**時才會被拿來當名字用。 */
export interface BindingRequirement {
kind: ResourceKind;
binding: string;
/** 需要它的 worker script 名(= wrangler.toml 的 `name`)。 */
worker: string;
createName: string;
}
export interface PlannedAdopt {
kind: ResourceKind;
binding: string;
value: string;
/** 從哪顆已部署的 worker 上讀到的 */
from: string;
}
export interface PlannedCreate {
kind: ResourceKind;
binding: string;
createName: string;
wantedBy: string[];
/** 其他也指向同一顆資源的 binding(見 shareSameResource)。建一顆,大家共用。 */
alsoBind: string[];
}
export interface ResourcePlan {
adopt: PlannedAdopt[];
create: PlannedCreate[];
/** 非空 = 整趟停手。applyResourcePlan 會拒絕執行。 */
blockers: string[];
}
export interface ResolvedResource {
kind: ResourceKind;
binding: string;
value: string;
origin: 'adopted' | 'created';
from?: string;
}
/** plan 被擋下時丟這個,讓呼叫端能把每一條原因原文轉給使用者。 */
export class ResourcePlanBlocked extends Error {
constructor(readonly blockers: string[]) {
super(`資源解析被擋下(${blockers.length} 項)`);
this.name = 'ResourcePlanBlocked';
}
}
export function bindingKey(kind: ResourceKind, binding: string): string {
return `${kind}:${binding}`;
}
const KIND_LABEL: Record<ResourceKind, string> = {
kv_namespace: 'KV namespace',
d1: 'D1 資料庫',
vectorize: 'Vectorize index',
};
function msg(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
/**
* binding 沿**西**
*
* @param mode 'update' = 'init' =
*/
export async function planResources(
api: ResourceApi,
requirements: readonly BindingRequirement[],
mode: 'update' | 'init',
): Promise<ResourcePlan> {
const blockers: string[] = [];
const adopt: PlannedAdopt[] = [];
const create: PlannedCreate[] = [];
// ── 1. 先讀「即將被覆蓋的每一顆 worker」現在綁著什麼 ──────────────────
// 讀取失敗 ≠ 沒有綁。#97 的災情就是把「我查不到」當成「它不存在」。
const scripts = [...new Set(requirements.map((r) => r.worker))].sort();
const live = new Map<string, LiveBinding[]>();
let readFailed = false;
for (const script of scripts) {
try {
const res = await api.getScriptBindings(script);
if (res.deployed) live.set(script, res.bindings);
} catch (e) {
readFailed = true;
blockers.push(
`讀不到已部署的 worker「${script}」目前綁著哪些資源(${msg(e)})。` +
`不確定它現在用的是哪一顆,就不能重新綁——整趟更新停手,沒有動任何東西。`,
);
}
}
// 「這台照定義已經裝過了,卻一顆 worker 都找不到」= 我對不上它的實例(名字不同/token 看不到)。
// 這種時候繼續走下去,等於把一整套資源重新生一遍再綁上去——正是 #97 的形狀,只是換一道門進來。
if (mode === 'update' && !readFailed && live.size === 0 && scripts.length > 0) {
blockers.push(
`在這個 Cloudflare 帳號上找不到任何一顆要更新的 worker(找過:${scripts.join('、')})。` +
`acr update 的前提是「這台已經裝好了」——對不上就不猜:` +
`可能是 API token 看得到的帳號不對,或這台實例的 worker 用了別的名字。` +
`已停手,沒有新建任何資源。`,
);
}
// ── 2. 逐個 binding 決定:沿用 / 新建 / 停手 ─────────────────────────
const byKey = new Map<string, BindingRequirement[]>();
for (const req of requirements) {
const key = bindingKey(req.kind, req.binding);
const list = byKey.get(key);
if (list) list.push(req);
else byKey.set(key, [req]);
}
const existingCache = new Map<ResourceKind, Set<string>>();
const listExisting = async (kind: ResourceKind): Promise<Set<string>> => {
const hit = existingCache.get(kind);
if (hit) return hit;
let set: Set<string>;
if (kind === 'kv_namespace') set = new Set((await api.listKvNamespaces()).values());
else if (kind === 'd1') set = new Set((await api.listD1Databases()).values());
else set = new Set(await api.listVectorizeIndexes());
existingCache.set(kind, set);
return set;
};
for (const [, reqs] of byKey) {
const { kind, binding } = reqs[0];
const found: Array<{ value: string; script: string }> = [];
for (const [script, bindings] of live) {
const hit = bindings.find((b) => b.kind === kind && b.binding === binding);
if (hit) found.push({ value: hit.value, script });
}
const distinct = [...new Set(found.map((f) => f.value))];
// 2a. 同一個 binding 名在不同 worker 上指向不同資源 → 分不出哪個才是使用者要的。
// 自己挑一個 = 有一半機率把另外那半的資料從畫面上抹掉。不猜。
if (distinct.length > 1) {
blockers.push(
`綁定「${binding}」在不同 worker 上指向不同的 ${KIND_LABEL[kind]}` +
`${found.map((f) => `${f.script}${f.value}`).join('、')})。` +
`分不出哪一顆才是你在用的,不猜——停手。`,
);
continue;
}
// 2b. 有人綁著它 → 這就是事實,沿用。名字長什麼樣完全不看。
if (distinct.length === 1) {
const value = distinct[0];
let existing: Set<string>;
try {
existing = await listExisting(kind);
} catch (e) {
blockers.push(
`查不到帳號上的 ${KIND_LABEL[kind]} 清單,無法確認「${binding}」綁著的 ${value} 還在不在` +
`${msg(e)})。不確定就不動——停手。`,
);
continue;
}
if (!existing.has(value)) {
// 這正是 #97 的入口:舊版在這裡會安靜地新建一顆空的頂上去。
blockers.push(
`worker「${found[0].script}」的「${binding}」綁著 ${KIND_LABEL[kind]} ${value}` +
`但這顆在你的 Cloudflare 帳號上找不到了。` +
`這裡**不會**幫你新建一顆空的頂上去(Arcrun#97 的災情就是那樣來的)——` +
`請先確認那顆資源是被刪掉了,還是這把 API token 看不到它。`,
);
continue;
}
adopt.push({ kind, binding, value, from: found[0].script });
continue;
}
// 2c. 沒有任何已部署的 worker 綁過它 → 新版本新增的 binding,或全新帳號。
// 這種情況下新建不會弄丟任何東西(本來就沒有東西可丟)。
create.push({
kind,
binding,
createName: reqs[0].createName,
wantedBy: [...new Set(reqs.map((r) => r.worker))],
alsoBind: [],
});
}
return { adopt, create: shareSameResource(adopt, create, byKey), blockers };
}
/**
* binding
*
* **toml **`database_name` / `index_name`使
* cypher `CREDENTIALS_DB` kbdb `DB` `database_name = "arcrun-kbdb"`
* **** #97 使
*
*
* D1KBDB credential
* 沿
*/
function shareSameResource(
adopt: PlannedAdopt[],
create: PlannedCreate[],
byKey: Map<string, BindingRequirement[]>,
): PlannedCreate[] {
const declaredName = (kind: ResourceKind, binding: string): string | undefined =>
byKey.get(bindingKey(kind, binding))?.[0]?.createName;
const out: PlannedCreate[] = [];
const groups = new Map<string, PlannedCreate>();
for (const c of create) {
const groupKey = `${c.kind}${c.createName}`;
// ① 已經有 binding 沿用到同一顆(依 toml 宣告)→ 跟著沿用,不要另外建一顆。
const twin = adopt.find(
(a) => a.kind === c.kind && declaredName(a.kind, a.binding) === c.createName,
);
if (twin) {
adopt.push({ kind: c.kind, binding: c.binding, value: twin.value, from: twin.from });
continue;
}
// ② 同一趟裡有多個 binding 要建同一顆 → 建一次,其他人共用。
const head = groups.get(groupKey);
if (head) {
head.alsoBind.push(c.binding);
head.wantedBy = [...new Set([...head.wantedBy, ...c.wantedBy])];
continue;
}
groups.set(groupKey, c);
out.push(c);
}
return out;
}
/**
* plan 沿
* blocker ResourcePlanBlocked****
*/
export async function applyResourcePlan(
api: ResourceApi,
plan: ResourcePlan,
): Promise<Map<string, ResolvedResource>> {
if (plan.blockers.length > 0) throw new ResourcePlanBlocked(plan.blockers);
const out = new Map<string, ResolvedResource>();
for (const a of plan.adopt) {
out.set(bindingKey(a.kind, a.binding), {
kind: a.kind,
binding: a.binding,
value: a.value,
origin: 'adopted',
from: a.from,
});
}
const madeSoFar: string[] = [];
for (const c of plan.create) {
let value: string;
try {
if (c.kind === 'kv_namespace') value = await api.createKvNamespace(c.createName);
else if (c.kind === 'd1') value = await api.createD1Database(c.createName);
else value = await api.createVectorizeIndex(c.createName);
} catch (e) {
// 半途失敗:已經建出來的那幾顆還沒被綁到任何 worker 上。**要講出來**——
// 不講的話它們就是帳號上一批沒人認得的孤兒,而且下次重跑會再建一批。
const orphans = madeSoFar.length > 0
? `\n 已經建好但還沒綁上任何 worker 的:${madeSoFar.join('、')}(重跑前可先刪掉,或留著讓下次沿用)`
: '';
throw new Error(`${KIND_LABEL[c.kind]}${c.createName}」失敗:${msg(e)}${orphans}`);
}
madeSoFar.push(`${KIND_LABEL[c.kind]} ${c.createName}`);
for (const binding of [c.binding, ...c.alsoBind]) {
out.set(bindingKey(c.kind, binding), { kind: c.kind, binding, value, origin: 'created' });
}
}
return out;
}
// ─────────────────────────────────────────────────────────────────────────────
// wrangler.toml → 需求清單
// ─────────────────────────────────────────────────────────────────────────────
export interface WranglerRequirements {
/** worker script 名(toml 頂層 `name`)。空字串 = 這份 toml 沒宣告 name(不該發生)。 */
script: string;
bindings: Array<{ kind: ResourceKind; binding: string; createName: string }>;
}
/** wrangler.toml 的 table 名 → 資源種類。需求解析與注入共用同一張表,兩邊才不會對不上。 */
export const TABLE_KIND: Record<string, ResourceKind> = {
kv_namespaces: 'kv_namespace',
d1_databases: 'd1',
vectorize: 'vectorize',
};
/**
* wrangler.toml worker
*
* TOML parserinjectWranglerConfig
* ****
* kbdb `[[vectorize]]`
*/
export function parseWranglerRequirements(toml: string): WranglerRequirements {
let script = '';
let seenTable = false;
const bindings: WranglerRequirements['bindings'] = [];
let kind: ResourceKind | null = null;
let binding = '';
let createName = '';
const flush = (): void => {
if (kind && binding) {
bindings.push({ kind, binding, createName: createName || binding });
}
kind = null;
binding = '';
createName = '';
};
for (const raw of toml.split('\n')) {
const line = raw.trim();
if (line === '' || line.startsWith('#')) continue;
const table = line.match(/^\[\[?([A-Za-z0-9_]+)\]?\]$/);
if (table) {
flush();
seenTable = true;
kind = TABLE_KIND[table[1]] ?? null;
continue;
}
const kv = line.match(/^([A-Za-z0-9_]+)\s*=\s*"([^"]*)"/);
if (!kv) continue;
const [, key, value] = kv;
if (!seenTable && key === 'name') {
script = value;
continue;
}
if (!kind) continue;
if (key === 'binding') binding = value;
// 只有 D1Vectorize 在 toml 裡帶得出「名字」;KV 沒有,退回用 binding 名(見 flush)。
else if (key === 'database_name' || key === 'index_name') createName = value;
}
flush();
return { script, bindings };
}
-532
View File
@@ -1,532 +0,0 @@
/**
* Arcrun#97 使西
*
* 2026-08-12 leo portal
* worker 9 KV + 1 D1
*
* binding WEBHOOKS CF
* `arcrun-rag-<instance>-kv-webhooks`
*
* **** leo leo21c
* planResources / applyResourcePlan / renderWranglerToml
*
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
planResources,
applyResourcePlan,
parseWranglerRequirements,
bindingKey,
ResourcePlanBlocked,
type BindingRequirement,
type ResourceApi,
type ScriptBindings,
type LiveBinding,
type ResolvedResource,
} from '../src/lib/resource-resolver.ts';
import {
renderWranglerToml,
REQUIRED_KV_NAMESPACES,
type DeployContext,
} from '../src/lib/deploy.ts';
import { CfAccountClient } from '../src/lib/cf-api.ts';
const REPO = join(fileURLToPath(new URL('.', import.meta.url)), '..', '..');
/** 這台假實例上跑著的四顆 worker(有資源綁定的那幾顆)。 */
const WORKER_TOMLS = [
'cypher-executor/wrangler.toml',
'registry/wrangler.toml',
'mcp/wrangler.toml',
'kbdb/wrangler.toml',
];
const CTX: DeployContext = {
accountId: 'acct-test',
apiToken: 'tok-test',
workerSubdomain: 'yuga3bse',
selfHosted: true,
kbdbEmbed: true,
};
// ─────────────────────────────────────────────────────────────────────────────
// 假的 Cloudflare 帳號:完全照「安裝器裝出來」的樣子命名
// ─────────────────────────────────────────────────────────────────────────────
const INSTANCE = 'yuga3bse';
interface FakeOpts {
/** 讓某顆 worker 的綁定讀取失敗(模擬 API 掛掉 / 權限不足)。 */
failBindingsFor?: string;
/** 從帳號上「弄不見」某顆 KV,但 worker 上還綁著它(模擬資源被刪)。 */
deleteKvTitle?: string;
/** 完全沒有任何已部署的 worker(模擬名字對不上 / token 看錯帳號)。 */
nothingDeployed?: boolean;
}
class FakeCloudflare implements ResourceApi {
/** title → id */
kv = new Map<string, string>();
/** name → uuid */
d1 = new Map<string, string>();
vectorize: string[] = [];
/** script → bindings */
scripts = new Map<string, LiveBinding[]>();
/** 使用者的東西:kvId → (key → value) */
kvData = new Map<string, Map<string, string>>();
/** d1Id → 子庫名單 */
d1Libraries = new Map<string, string[]>();
/** 這趟總共建立了什麼(驗「顆數不增加」用)。 */
createdKv: string[] = [];
createdD1: string[] = [];
createdVectorize: string[] = [];
constructor(private opts: FakeOpts = {}) {
// 安裝器的命名慣例:arcrun-rag-<instance>-kv-<binding 小寫>
for (const binding of REQUIRED_KV_NAMESPACES) {
const title = `arcrun-rag-${INSTANCE}-kv-${binding.toLowerCase()}`;
const id = `kvid-${binding.toLowerCase()}`;
this.kv.set(title, id);
this.kvData.set(id, new Map());
}
this.d1.set(`arcrun-rag-${INSTANCE}-kbdb`, 'd1id-kbdb');
this.vectorize.push(`arcrun-rag-${INSTANCE}-embed`);
// 使用者的東西
this.kvData.get('kvid-webhooks')!.set('webhook:leo:daily-digest', '{}');
this.kvData.get('kvid-webhooks')!.set('webhook:leo:inbox-sync', '{}');
this.kvData.get('kvid-webhooks')!.set('webhook:leo:rag-ingest', '{}');
this.kvData.get('kvid-sessions_kv')!.set('session:leo-abc123', '{"user":"leo"}');
this.d1Libraries.set('d1id-kbdb', ['general', '課程', '客戶', '研究']);
if (!opts.nothingDeployed) {
const kvB = (b: string): LiveBinding =>
({ kind: 'kv_namespace', binding: b, value: `kvid-${b.toLowerCase()}` });
this.scripts.set('arcrun-cypher-executor', [
kvB('EXEC_CONTEXT'), kvB('WEBHOOKS'), kvB('CREDENTIALS_KV'), kvB('ANALYTICS_KV'),
kvB('RECIPES'), kvB('USERS_KV'), kvB('SESSIONS_KV'),
{ kind: 'd1', binding: 'CREDENTIALS_DB', value: 'd1id-kbdb' },
]);
this.scripts.set('arcrun-registry', [kvB('SUBMISSIONS_KV'), kvB('ANALYTICS_KV')]);
this.scripts.set('arcrun-mcp', [kvB('OAUTH_KV')]);
this.scripts.set('arcrun-kbdb', [
{ kind: 'd1', binding: 'DB', value: 'd1id-kbdb' },
{ kind: 'vectorize', binding: 'VECTORIZE', value: `arcrun-rag-${INSTANCE}-embed` },
]);
}
if (opts.deleteKvTitle) this.kv.delete(opts.deleteKvTitle);
}
async getScriptBindings(script: string): Promise<ScriptBindings> {
if (this.opts.failBindingsFor === script) throw new Error('HTTP 500 (CF API 暫時掛掉)');
const b = this.scripts.get(script);
return b ? { deployed: true, bindings: b } : { deployed: false, bindings: [] };
}
async listKvNamespaces(): Promise<Map<string, string>> { return new Map(this.kv); }
async listD1Databases(): Promise<Map<string, string>> { return new Map(this.d1); }
async listVectorizeIndexes(): Promise<string[]> { return [...this.vectorize]; }
async createKvNamespace(title: string): Promise<string> {
const id = `NEW-kvid-${this.createdKv.length}`;
this.kv.set(title, id);
this.kvData.set(id, new Map()); // 新建的是**空的**——災情就是綁到這種東西上
this.createdKv.push(title);
return id;
}
async createD1Database(name: string): Promise<string> {
const id = `NEW-d1id-${this.createdD1.length}`;
this.d1.set(name, id);
this.d1Libraries.set(id, []);
this.createdD1.push(name);
return id;
}
async createVectorizeIndex(name: string): Promise<string> {
this.vectorize.push(name);
this.createdVectorize.push(name);
return name;
}
}
// ─────────────────────────────────────────────────────────────────────────────
// 共用:從真的 wrangler.toml 解析需求(走與 downloadAndDeploy 相同的路徑)
// ─────────────────────────────────────────────────────────────────────────────
function collectRequirements(): { requirements: BindingRequirement[]; tomls: Map<string, string> } {
const requirements: BindingRequirement[] = [];
const tomls = new Map<string, string>();
for (const rel of WORKER_TOMLS) {
const raw = readFileSync(join(REPO, rel), 'utf8');
tomls.set(rel, raw);
const parsed = parseWranglerRequirements(renderWranglerToml(raw, CTX, new Map()));
for (const b of parsed.bindings) requirements.push({ ...b, worker: parsed.script });
}
return { requirements, tomls };
}
/** 模擬「部署」:把解析結果注入 toml,再從注入後的 toml 讀回 worker 實際會綁到的資源。 */
function deployAndReadBindings(
tomls: Map<string, string>,
resolved: Map<string, ResolvedResource>,
): Map<string, Map<string, string>> {
const out = new Map<string, Map<string, string>>();
for (const [rel, raw] of tomls) {
const rendered = renderWranglerToml(raw, CTX, resolved);
const script = parseWranglerRequirements(rendered).script;
const bound = new Map<string, string>();
let kind: string | null = null;
let binding = '';
for (const line of rendered.split('\n')) {
const t = line.trim();
if (t.startsWith('#')) continue;
const table = t.match(/^\[\[?([A-Za-z0-9_]+)\]?\]$/);
if (table) { kind = table[1]; binding = ''; continue; }
const m = t.match(/^([A-Za-z0-9_]+)\s*=\s*"([^"]*)"/);
if (!m) continue;
if (m[1] === 'binding') binding = m[2];
else if (binding && (
(kind === 'kv_namespaces' && m[1] === 'id')
|| (kind === 'd1_databases' && m[1] === 'database_id')
|| (kind === 'vectorize' && m[1] === 'index_name')
)) bound.set(binding, m[2]);
}
out.set(script, bound);
}
return out;
}
// ═════════════════════════════════════════════════════════════════════════════
// ① 更新前後:工作流數、登入狀態、子庫數 —— 三個都不能少
// ═════════════════════════════════════════════════════════════════════════════
test('#97 ①:安裝器裝出來的實例跑更新——工作流/登入/子庫更新前後完全一致', async () => {
const cf = new FakeCloudflare();
const { requirements, tomls } = collectRequirements();
const before = {
workflows: cf.kvData.get('kvid-webhooks')!.size,
sessions: cf.kvData.get('kvid-sessions_kv')!.size,
libraries: cf.d1Libraries.get('d1id-kbdb')!.length,
};
assert.deepEqual(before, { workflows: 3, sessions: 1, libraries: 4 }, '前置資料要先擺好');
const plan = await planResources(cf, requirements, 'update');
assert.deepEqual(plan.blockers, [], '一台健康的實例不該有任何 blocker');
const resolved = await applyResourcePlan(cf, plan);
const bound = deployAndReadBindings(tomls, resolved);
// 更新後,worker 綁到的還是使用者原本那幾顆(名字完全沒對上,但那不重要)
const cypher = bound.get('arcrun-cypher-executor')!;
assert.equal(cypher.get('WEBHOOKS'), 'kvid-webhooks');
assert.equal(cypher.get('SESSIONS_KV'), 'kvid-sessions_kv');
assert.equal(cypher.get('CREDENTIALS_DB'), 'd1id-kbdb');
assert.equal(bound.get('arcrun-kbdb')!.get('DB'), 'd1id-kbdb');
assert.equal(bound.get('arcrun-mcp')!.get('OAUTH_KV'), 'kvid-oauth_kv');
assert.equal(bound.get('arcrun-registry')!.get('SUBMISSIONS_KV'), 'kvid-submissions_kv');
assert.equal(bound.get('arcrun-kbdb')!.get('VECTORIZE'), `arcrun-rag-${INSTANCE}-embed`);
const after = {
workflows: cf.kvData.get(cypher.get('WEBHOOKS')!)!.size,
sessions: cf.kvData.get(cypher.get('SESSIONS_KV')!)!.size,
libraries: cf.d1Libraries.get(bound.get('arcrun-kbdb')!.get('DB')!)!.length,
};
assert.deepEqual(after, before, '更新後使用者看到的東西必須跟更新前一模一樣');
});
// ═════════════════════════════════════════════════════════════════════════════
// ② 帳號上的資源顆數不增加(災情當天:9 顆 KV → 18 顆、1 顆 D1 → 2 顆)
// ═════════════════════════════════════════════════════════════════════════════
test('#97 ②:更新不會在帳號上多生任何資源', async () => {
const cf = new FakeCloudflare();
const kvBefore = cf.kv.size;
const d1Before = cf.d1.size;
const vecBefore = cf.vectorize.length;
assert.deepEqual([kvBefore, d1Before, vecBefore], [9, 1, 1]);
const { requirements } = collectRequirements();
const plan = await planResources(cf, requirements, 'update');
await applyResourcePlan(cf, plan);
assert.deepEqual(cf.createdKv, [], '不該新建任何 KV');
assert.deepEqual(cf.createdD1, [], '不該新建任何 D1');
assert.deepEqual(cf.createdVectorize, [], '不該新建任何 Vectorize index');
assert.deepEqual([cf.kv.size, cf.d1.size, cf.vectorize.length], [9, 1, 1]);
});
test('#97 ②對照組:舊的「照名字 ensure」在同一台實例上會生 9 顆 KV + 1 顆 D1', async () => {
// 這段是**修好之前**的演算法(commit e69d6bb 時的 cli/src/commands/update.ts:52-68 與
// cf-api.ts 的 ensureKvNamespace/ensureD1Database),照原樣重寫在這裡當對照組。
// 目的:把「災情是怎麼發生的」釘成可執行的事實,而不是只留在 issue 的文字裡。
const cf = new FakeCloudflare();
const existing = await cf.listKvNamespaces();
for (const title of REQUIRED_KV_NAMESPACES) {
if (!existing.get(title)) await cf.createKvNamespace(title); // ← 名字對不上 ⇒ 每個都新建
}
const d1s = await cf.listD1Databases();
if (!d1s.get('arcrun-kbdb')) await cf.createD1Database('arcrun-kbdb');
assert.equal(cf.createdKv.length, 9, '舊做法:9 顆 KV 全部重建(對上災情當天的數字)');
assert.equal(cf.createdD1.length, 1, '舊做法:D1 也重建一顆');
assert.equal(cf.kv.size, 18, '9 → 18');
assert.equal(cf.d1.size, 2, '1 → 2');
// 而且新建的那幾顆是空的 —— 使用者的工作流就是這樣「不見」的
assert.equal(cf.kvData.get(cf.kv.get('WEBHOOKS')!)!.size, 0);
});
// ═════════════════════════════════════════════════════════════════════════════
// ③ 反向驗證:找不到既有資源 → 停下來說清楚,不是安靜新建一顆綁上去
// ═════════════════════════════════════════════════════════════════════════════
test('#97 ③-aworker 綁著的 KV 在帳號上不見了 → 停手,一顆都不建', async () => {
const cf = new FakeCloudflare({ deleteKvTitle: `arcrun-rag-${INSTANCE}-kv-webhooks` });
const { requirements } = collectRequirements();
const plan = await planResources(cf, requirements, 'update');
assert.ok(plan.blockers.length > 0, '要有 blocker');
const said = plan.blockers.join('\n');
assert.match(said, /WEBHOOKS/, '要指名是哪個綁定');
assert.match(said, /kvid-webhooks/, '要指名是哪一顆資源');
assert.match(said, /找不到/, '要說清楚發生什麼事');
assert.ok(!plan.create.some((c) => c.binding === 'WEBHOOKS'), '絕不能把它排進「要新建」');
await assert.rejects(() => applyResourcePlan(cf, plan), ResourcePlanBlocked);
assert.deepEqual(cf.createdKv, [], '被擋下時一顆資源都不能被建出來');
assert.deepEqual(cf.createdD1, []);
});
test('#97 ③-b:讀不到某顆 worker 現在綁什麼 → 當「我不知道」而不是「它沒有」', async () => {
const cf = new FakeCloudflare({ failBindingsFor: 'arcrun-cypher-executor' });
const { requirements } = collectRequirements();
const plan = await planResources(cf, requirements, 'update');
assert.match(plan.blockers.join('\n'), /arcrun-cypher-executor/);
await assert.rejects(() => applyResourcePlan(cf, plan), ResourcePlanBlocked);
assert.deepEqual(cf.createdKv, []);
});
test('#97 ③-cupdate 卻一顆 worker 都找不到 → 停手,不當成全新安裝重建一整套', async () => {
const cf = new FakeCloudflare({ nothingDeployed: true });
const { requirements } = collectRequirements();
const plan = await planResources(cf, requirements, 'update');
assert.match(plan.blockers.join('\n'), /找不到任何一顆要更新的 worker/);
await assert.rejects(() => applyResourcePlan(cf, plan), ResourcePlanBlocked);
assert.deepEqual(cf.createdKv, []);
});
test('#97 ③-d:同一個 binding 在不同 worker 上指向不同資源 → 不猜,停手', async () => {
const cf = new FakeCloudflare();
// registry 的 ANALYTICS_KV 被指到別顆(真實情境:有人手動改過其中一邊)
cf.scripts.get('arcrun-registry')!.find((b) => b.binding === 'ANALYTICS_KV')!.value = 'kvid-other';
cf.kv.set('some-other-kv', 'kvid-other');
const { requirements } = collectRequirements();
const plan = await planResources(cf, requirements, 'update');
assert.match(plan.blockers.join('\n'), /ANALYTICS_KV/);
assert.deepEqual(cf.createdKv, []);
});
// ═════════════════════════════════════════════════════════════════════════════
// 合法的新建:只有「確定沒人綁過」時才准
// ═════════════════════════════════════════════════════════════════════════════
test('#97:新版本新增的 binding(沒有任何已部署 worker 綁過)才准新建', async () => {
const cf = new FakeCloudflare();
cf.scripts.set('arcrun-mcp', []); // mcp 已部署,但還沒有 OAUTH_KV(舊版本裝的)
cf.kv.delete(`arcrun-rag-${INSTANCE}-kv-oauth_kv`);
const { requirements } = collectRequirements();
const plan = await planResources(cf, requirements, 'update');
assert.deepEqual(plan.blockers, []);
assert.deepEqual(plan.create.map((c) => c.binding), ['OAUTH_KV'], '只有這一個該建');
await applyResourcePlan(cf, plan);
assert.deepEqual(cf.createdKv, ['OAUTH_KV']);
assert.equal(cf.kv.size, 9, '刪掉一顆、補建一顆 → 還是 9 顆');
});
test('#97:全新帳號跑 init → 該建的都建(不會被 update 的停手規則卡住)', async () => {
const cf = new FakeCloudflare({ nothingDeployed: true });
cf.kv.clear(); cf.d1.clear(); cf.vectorize.length = 0;
const { requirements } = collectRequirements();
const plan = await planResources(cf, requirements, 'init');
assert.deepEqual(plan.blockers, [], 'init 在空帳號上不該停手');
await applyResourcePlan(cf, plan);
assert.equal(cf.createdKv.length, REQUIRED_KV_NAMESPACES.length);
assert.deepEqual(cf.createdD1, ['arcrun-kbdb']);
assert.equal(cf.createdVectorize.length, 1);
});
test('#97:一邊已部署一邊沒有 → 跟著沿用同一顆,不要另外建一顆空的', async () => {
const cf = new FakeCloudflare();
cf.scripts.delete('arcrun-cypher-executor'); // cypher 還沒部(kbdb 已部,DB → d1id-kbdb
const { requirements, tomls } = collectRequirements();
const plan = await planResources(cf, requirements, 'update');
assert.deepEqual(plan.blockers, []);
assert.ok(!plan.create.some((c) => c.kind === 'd1'), 'CREDENTIALS_DB 不該被當成新資源建一顆');
const resolved = await applyResourcePlan(cf, plan);
const bound = deployAndReadBindings(tomls, resolved);
assert.equal(bound.get('arcrun-cypher-executor')!.get('CREDENTIALS_DB'), 'd1id-kbdb',
'credential 目錄要跟 KBDB 在同一顆庫');
assert.deepEqual(cf.createdD1, []);
});
test('#97:部署出去的 toml 不得殘留官方 prod 的資源 id(自架寫進官方庫 = 跨租戶外洩)', async () => {
// repo 的 toml 裡 database_id 預設是官方 prod D1。舊版在「D1 解析失敗」時只是把它跳過不注入,
// 於是自架用戶的 kbdb worker 就這樣綁著官方那顆庫部署出去。現在不是失敗就跳過,是整趟停手。
const cf = new FakeCloudflare();
const { requirements, tomls } = collectRequirements();
const resolved = await applyResourcePlan(cf, await planResources(cf, requirements, 'update'));
const OFFICIAL_D1 = '0c580910-e00b-4f8e-9c57-ac54ea52242f';
for (const [rel, raw] of tomls) {
const rendered = renderWranglerToml(raw, CTX, resolved);
assert.doesNotMatch(rendered, new RegExp(OFFICIAL_D1), `${rel} 還帶著官方 prod D1 的 id`);
assert.doesNotMatch(rendered, /REPLACE_WITH_REAL_KV_ID/, `${rel} 還留著占位 KV id`);
}
});
// ═════════════════════════════════════════════════════════════════════════════
// 做法本身的看守:不准再出現「照名字 ensure」這種原語
// ═════════════════════════════════════════════════════════════════════════════
test('#97 紅線:cf-api 不得再提供任何「找不到同名就順手建一顆」的 ensure 原語', () => {
const src = readFileSync(join(REPO, 'cli/src/lib/cf-api.ts'), 'utf8');
assert.doesNotMatch(src, /\bensureKvNamespace\b|\bensureD1Database\b|\bensureVectorizeIndex\b/,
'ensure* 是 #97 的凶器:把「查不到」當成「不存在」再自作主張新建。'
+ '要建資源一律先過 resource-resolver 的 planResources。');
});
test('#97 紅線:只有 resource-resolver 能決定「要不要建」,指令層不得自己呼叫 create*', () => {
for (const rel of ['cli/src/commands/init.ts', 'cli/src/commands/update.ts']) {
const src = readFileSync(join(REPO, rel), 'utf8');
assert.doesNotMatch(src, /\.create(KvNamespace|D1Database|VectorizeIndex)\s*\(/,
`${rel} 不該自己建資源——那樣就繞過了「先看已部署的 worker 綁著什麼」這道判斷。`);
}
});
// ═════════════════════════════════════════════════════════════════════════════
// 底層零件
// ═════════════════════════════════════════════════════════════════════════════
test('parseWranglerRequirements:讀得出 script 名與三種資源綁定,且不把註解掉的區塊當需求', () => {
const toml = [
'name = "arcrun-kbdb" # 註解不影響',
'',
'[[d1_databases]]',
'binding = "DB"',
'database_name = "arcrun-kbdb"',
'database_id = "placeholder"',
'',
'[vars]',
'ENVIRONMENT = "production"',
'',
'# [[vectorize]]',
'# binding = "VECTORIZE"',
'# index_name = "arcrun-kbdb-embed-m3"',
].join('\n');
const r = parseWranglerRequirements(toml);
assert.equal(r.script, 'arcrun-kbdb');
assert.deepEqual(r.bindings, [{ kind: 'd1', binding: 'DB', createName: 'arcrun-kbdb' }]);
});
test('KV 沒有 title 欄位 → 真要新建時用 binding 名', () => {
const r = parseWranglerRequirements('name = "w"\n[[kv_namespaces]]\nbinding = "WEBHOOKS"\nid = "x"');
assert.deepEqual(r.bindings, [{ kind: 'kv_namespace', binding: 'WEBHOOKS', createName: 'WEBHOOKS' }]);
});
test('注入是照 binding 對號,不是盲換「檔案裡第一個 database_id」', () => {
const cypher = readFileSync(join(REPO, 'cypher-executor/wrangler.toml'), 'utf8');
const resolved = new Map<string, ResolvedResource>([
[bindingKey('d1', 'CREDENTIALS_DB'), { kind: 'd1', binding: 'CREDENTIALS_DB', value: 'MINE', origin: 'adopted' }],
[bindingKey('kv_namespace', 'WEBHOOKS'), { kind: 'kv_namespace', binding: 'WEBHOOKS', value: 'KV-MINE', origin: 'adopted' }],
]);
const out = renderWranglerToml(cypher, CTX, resolved);
const bound = parseWranglerRequirements(out);
assert.ok(bound.bindings.some((b) => b.binding === 'CREDENTIALS_DB'));
assert.match(out, /binding = "CREDENTIALS_DB"\ndatabase_name = "arcrun-kbdb"\ndatabase_id = "MINE"/);
assert.match(out, /binding = "WEBHOOKS"\nid = "KV-MINE"/);
// 沒被解析到的綁定不能被亂改(EXEC_CONTEXT 這次沒進 resolved
assert.match(out, /binding = "EXEC_CONTEXT"\nid = "616967a852eb450a8c01731f71ac8edd"/);
});
test('renderWranglerToml 帶空 map = 預覽:解析看到的 binding 與注入後的完全一致', () => {
for (const rel of WORKER_TOMLS) {
const raw = readFileSync(join(REPO, rel), 'utf8');
const preview = parseWranglerRequirements(renderWranglerToml(raw, CTX, new Map()));
const resolved = new Map<string, ResolvedResource>(
preview.bindings.map((b) => [
bindingKey(b.kind, b.binding),
{ kind: b.kind, binding: b.binding, value: `v-${b.binding}`, origin: 'adopted' as const },
]),
);
const after = parseWranglerRequirements(renderWranglerToml(raw, CTX, resolved));
assert.deepEqual(
after.bindings.map((b) => `${b.kind}:${b.binding}`).sort(),
preview.bindings.map((b) => `${b.kind}:${b.binding}`).sort(),
`${rel}: 預覽與實際注入看到的綁定必須一致`,
);
}
});
test('repo 的 toml 綁定總集合 = REQUIRED_KV_NAMESPACES(漏綁會讓某顆 worker 部署失敗)', () => {
const { requirements } = collectRequirements();
const kv = [...new Set(requirements.filter((r) => r.kind === 'kv_namespace').map((r) => r.binding))];
assert.deepEqual(kv.sort(), [...REQUIRED_KV_NAMESPACES].sort());
});
test('CfAccountClient.getScriptBindings404 = 還沒部署;其他錯誤要 throw(不能當成「沒有綁」)', async () => {
const orig = globalThis.fetch;
try {
globalThis.fetch = (async () =>
new Response(JSON.stringify({ success: false, errors: [{ message: 'not found' }] }), { status: 404 })
) as typeof fetch;
const cf = new CfAccountClient('a', 't');
assert.deepEqual(await cf.getScriptBindings('nope'), { deployed: false, bindings: [] });
globalThis.fetch = (async () =>
new Response(JSON.stringify({ success: false, errors: [{ message: 'boom' }] }), { status: 500 })
) as typeof fetch;
await assert.rejects(() => new CfAccountClient('a', 't').getScriptBindings('x'), /boom/);
} finally {
globalThis.fetch = orig;
}
});
test('CfAccountClient.getScriptBindings:讀得懂 CF 回的 kv/d1/vectorize 三種綁定形狀', async () => {
const orig = globalThis.fetch;
try {
globalThis.fetch = (async () => new Response(JSON.stringify({
success: true,
result: {
bindings: [
{ type: 'kv_namespace', name: 'WEBHOOKS', namespace_id: 'kv1' },
{ type: 'd1', name: 'DB', id: 'db1' },
{ type: 'vectorize', name: 'VECTORIZE', index_name: 'idx1' },
{ type: 'plain_text', name: 'ENVIRONMENT', text: 'production' },
{ type: 'service', name: 'SVC_SET', service: 'arcrun-set' },
],
},
}), { status: 200 })) as typeof fetch;
const res = await new CfAccountClient('a', 't').getScriptBindings('arcrun-cypher-executor');
assert.equal(res.deployed, true);
assert.deepEqual(res.bindings, [
{ kind: 'kv_namespace', binding: 'WEBHOOKS', value: 'kv1' },
{ kind: 'd1', binding: 'DB', value: 'db1' },
{ kind: 'vectorize', binding: 'VECTORIZE', value: 'idx1' },
]);
} finally {
globalThis.fetch = orig;
}
});
-94
View File
@@ -1,94 +0,0 @@
{
"_readme": [
"部署目標定義檔(leo 2026-07-22 立)。一個目標=一組『帳號+profile+apiBase+專案名+對外網址』。",
"",
"為什麼要這個檔:5a16484 把 UI 搬 CF Pages 後,這些值從 worker 環境變數變成部署期參數。",
"誰部署誰要記得帶 → 帶漏了就退回預設,而預設值對兩邊都不對。實際踩過的:",
" · demo 站漏 CONSOLE_PROFILE=rag → 顯示個人版 7 頁駕駛艙(leo 看到『Mira 介面』的真因)",
" · 兩站都漏 ARCRUN_API_BASE → apiBase 空字串 → 前端打自己回 405 → 登不進去",
" · 兩個帳號有同名 arcrun-console-ui 專案,wrangler 又登入在 uncle6",
" → 不指定帳號直接 deploy 會部到 demo 站上(差點蓋掉)",
"",
"🔴 第四次(2026-08-08 發現,同一種病換了形式):",
" 上面三次的『解』是 deploy.targets.json build.mjs 在 build 時把 profile/apiBase",
" 烤進產物。但 t160e744ad1)為了清世代債把 build.mjs 整支刪掉、改成直接託管 public/,",
" **沒有人把『把宣告值寫進產物』這件事接手過去** ⇒ deploy.mjs 照樣在終端機印",
" 『profilefull / apiBase:…leo21c…』,推上去的卻是 public/config.js 裡凍住的",
" cypher.arcrun.dev 凍在 4 頁的 VIEWS。也就是說:",
" **`npm run deploy:personal` 會把個人站的 API 打到企業 demo 的後端、頁面砍成 4 頁**",
" 而終端機從頭到尾顯示『成功』。(第三次的 accountId 是靠 env 傳的,倖存;前兩次的解等於被還原。)",
"",
" → 現在的規矩:**產物由 deploy.mjs 依本檔即時產生(.staging/<目標>),",
" 推之前驗產物、推之後驗線上網址**。public/ 裡不再放任何跟目標有關的值。",
" · public/config.js 已刪除——它是產物不是原始碼(自架站的 /config.js 由",
" arcrun-rag 的 build-ui-bundle 動態產生,不吃這個檔)",
" · public/console/index.html 的 VIEWS/HOME 只是本機 preview 的預設值,",
" 部署時一律被 _profiles 覆寫,覆寫沒命中就中止部署",
"",
"🔴 第五次(2026-08-08 同日,leo:「已經發生過一次這個錯誤,把舊版界面上到 prod,",
" 你要確定不可再犯」):**組態對 ≠ 世代對**。",
" 當天實測:三個對外網址的 apiBaseviewshome **三項全過**",
" 但它們跑的是 07-22 那一代的 portal82,911 bytes、舊金色 serif 品牌、Songti 12 處),",
" repo 已是 343,969 bytes 的新品牌世代。**組態全綠、介面落後半個月,沒有任何檢查會叫。**",
" → 故 verify-live 加第二層「世代指紋」:逐一抓線上資產、遮掉本來就該隨目標不同的",
" 那兩行(VIEWS/HOME),其餘按位元組比對 repo public/。",
" 不用關鍵字清單——清單要人維護,而舊世代能無聲上線正是因為沒人記得維護它。",
"",
"版本差異(leo 2026-07-22 定調):頁面都存在,由 profile 決定顯示哪些。",
" personal(full) 個人版:7 頁全開,落地駕駛艙",
" enterprise(rag) 企業版:只留 搜尋/工作流/設定/card,落地搜尋頁",
" 未來擴充:個人版新用戶上限 1、知識庫權限不可用 → 加在對應目標的欄位裡,別再散進部署指令。",
"",
"🧊 frozen 欄位(2026-08-08 leo 立):標了 frozen 的目標=**這個帳號的資源不歸我們動**。",
" deploy 拒絕部署它,verify 連抓都不抓(不 curl、不探測)。",
" 它不是「壞掉所以跳過」,是刻意的邊界;要解凍是人的決定(拿掉欄位並說明理由)。",
" 目標本身**保留不刪**——刪掉就變成下一個 AI 眼中「從來沒有過這個站」的失憶。",
"",
"用法:npm run deploy:personal",
" npm run deploy:personal -- --dry-run (只產出並驗產物,不推)",
" npm run verify (不部署,只驗線上:組態=宣告值、世代=當代)",
" npm run verify -- --url <網址> (只問某個網址:它跑的是不是當代的)"
],
"_profiles": {
"full": {
"description": "個人版:7 頁全開,落地駕駛艙",
"views": ["cockpit", "search", "card", "workflows", "creds", "inbox", "settings"],
"home": "cockpit"
},
"rag": {
"description": "企業版:搜尋/card/工作流/設定,落地搜尋頁",
"views": ["search", "card", "workflows", "settings"],
"home": "search"
}
},
"personal": {
"description": "leo 私人實例(原 Mira)。入口 mira.uncle6.me → leo21c worker。",
"accountId": "51a01bfa2665bd7bc3fd080dc40cf3e1",
"projectName": "arcrun-console-ui",
"profile": "full",
"brand": "Arcrun",
"apiBase": "https://arcrun-cypher-executor.leo21c.workers.dev",
"verifyUrls": ["https://mira.uncle6.me", "https://arcrun-console-ui.pages.dev"],
"limits": {
"maxUsers": 1,
"libraryPermissions": false
}
},
"enterprise": {
"frozen": "leo 2026-08-08:「要看範例只在 youlin 網站,不要去碰 uncle6」——這站是 uncle6 帳號的資源,已廢。不更新、不下架、不探測。要動它是 leo 的閘。",
"description": "【已凍結・沿革】企業版 demo 站(uncle6 帳號)。保留紀錄用,不是現行部署對象。",
"accountId": "58309bb90fd93ad6d0fe0aae99170e9d",
"projectName": "arcrun-console-ui",
"profile": "rag",
"brand": "Arcrun",
"apiBase": "https://cypher.arcrun.dev",
"verifyUrls": ["https://rag-demo.arcrun.dev"],
"limits": {
"maxUsers": null,
"libraryPermissions": true
}
}
}
-12
View File
@@ -1,12 +0,0 @@
{
"name": "arcrun-console-ui",
"version": "0.1.0",
"private": true,
"description": "Arcrun Console / Portal 靜態前端——public/ 是唯一世代真身(t160:舊 src/+build 已 git rm);部署時由 deploy.mjs 依 deploy.targets.json 產出 .staging/<目標> 再推",
"scripts": {
"deploy": "node scripts/deploy.mjs",
"deploy:personal": "node scripts/deploy.mjs personal",
"verify": "node scripts/verify-live.mjs",
"preview": "node scripts/deploy.mjs personal --dry-run && npx serve .staging/personal"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

@@ -1,300 +0,0 @@
<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Arcrun 駕駛艙</title>
<script>
// 主題預載(防閃色):預設淺色(leo 2026-07-04 明示),與 /console 共用同一 localStorage key
document.documentElement.setAttribute('data-theme', (function () {
try { return localStorage.getItem('arcrun_console_theme') === 'dark' ? 'dark' : 'light'; } catch (e) { return 'light'; }
})());
</script>
<style>
/* Mira Console 定稿視覺(紙感「2a」,Mira Style Guide 2026-07-04):
紙紋底 repeating-linear-gradient、明體標題級聯、琥珀強調、呼吸狀態球嵌單字。
2026-07-04 二輪:CSS custom properties 兩份色板——預設淺色(宣紙米白+墨字),深色=原定稿暖黑不動。 */
* { box-sizing: border-box; }
:root {
--paper-a: #f4eddc; --paper-b: #f1e9d6;
--ink: #2f2a20; --ink-rgb: 30,24,14;
--amber: #8a5f1e; --amber-rgb: 138,95,30;
--ok: #1d7a48; --ok-rgb: 29,122,72;
--err: #b03a26; --err-rgb: 176,58,38;
--track: rgba(30,24,14,.12);
}
:root[data-theme="dark"] {
--paper-a: #191410; --paper-b: #1b1611;
--ink: #ede4d3; --ink-rgb: 237,228,211;
--amber: #e8b45a; --amber-rgb: 232,180,90;
--ok: #7fe0a8; --ok-rgb: 63,190,120;
--err: #e58575; --err-rgb: 217,95,76;
--track: rgba(255,255,255,.08);
}
html, body { margin: 0; background: repeating-linear-gradient(0deg,var(--paper-a) 0px,var(--paper-a) 3px,var(--paper-b) 3px,var(--paper-b) 4px); color: var(--ink);
font-family: -apple-system, "PingFang TC", "Microsoft JhengHei", system-ui, sans-serif; font-size: 16px; -webkit-font-smoothing: antialiased; }
.serif { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; }
main { max-width: 560px; margin: 0 auto; padding: 0 20px 40px; }
.pagehead { padding: 22px 2px 14px; border-bottom: 2px solid rgba(var(--amber-rgb),.4); display: flex; justify-content: space-between; align-items: baseline; }
.pagehead .title { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 23px; letter-spacing: .2em; }
.pagehead .title small { font-size: 14px; letter-spacing: .3em; color: rgba(var(--ink-rgb),.5); }
.pagehead .date { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 14px; color: rgba(var(--ink-rgb),.55); }
.orb-row { display: flex; align-items: center; gap: 20px; padding: 26px 2px 20px; }
.orb { width: 84px; height: 84px; border-radius: 50%; flex: none; display: grid; place-items: center; }
.orb span { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 30px; font-weight: 600; color: rgba(10,20,14,.85); text-shadow: 0 1px 0 rgba(255,255,255,.25); }
.orb-title { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 23px; font-weight: 600; }
.orb-sub { margin-top: 5px; font-size: 15px; color: rgba(var(--ink-rgb),.6); line-height: 1.55; }
@keyframes breatheGreen { 0%,100% { box-shadow: 0 0 24px 6px rgba(var(--ok-rgb),.35); } 50% { box-shadow: 0 0 42px 14px rgba(var(--ok-rgb),.55); } }
@keyframes breatheAmber { 0%,100% { box-shadow: 0 0 24px 6px rgba(var(--amber-rgb),.35); } 50% { box-shadow: 0 0 42px 14px rgba(var(--amber-rgb),.6); } }
@keyframes breatheRed { 0%,100% { box-shadow: 0 0 24px 6px rgba(var(--err-rgb),.4); } 50% { box-shadow: 0 0 44px 16px rgba(var(--err-rgb),.65); } }
.bricks { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.brick { padding: 16px; border-radius: 12px; }
.brick.amber { background: rgba(var(--amber-rgb),.07); border: 1px solid rgba(var(--amber-rgb),.22); }
.brick.plain { background: rgba(var(--ink-rgb),.04); border: 1px solid rgba(var(--ink-rgb),.14); }
.brick .lbl { font-size: 13.5px; color: rgba(var(--ink-rgb),.55); margin-bottom: 6px; }
.brick .num { font-family: ui-monospace, Menlo, monospace; font-size: 26px; color: var(--amber); }
.brick .num small { font-size: 15px; color: rgba(var(--ink-rgb),.5); }
.bar { margin-top: 10px; height: 6px; border-radius: 3px; background: var(--track); }
.bar > i { display: block; height: 100%; border-radius: 3px; background: linear-gradient(90deg,#b98330,#e8b45a); transition: width .6s; }
.wait-box { margin-top: 14px; padding: 20px; border-radius: 12px; border: 1px dashed rgba(var(--ok-rgb),.3); background: rgba(var(--ok-rgb),.05); }
.wait-box.has { border-color: rgba(var(--amber-rgb),.45); background: rgba(var(--amber-rgb),.05); }
.wait-head { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 16px; letter-spacing: .2em; color: rgba(var(--ink-rgb),.6); margin-bottom: 10px; text-align: center; }
.wait-none { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 20px; color: var(--ok); letter-spacing: .08em; text-align: center; }
.wait-item { display: flex; align-items: center; gap: 12px; padding: 12px 14px; margin-top: 8px; border-radius: 10px; background: rgba(var(--amber-rgb),.1); border: 1px solid rgba(var(--amber-rgb),.3); font-size: 16px; line-height: 1.5; }
.wait-item .dm { color: var(--amber); font-size: 17px; flex: none; }
.wait-meta { margin-top: 10px; text-align: center; font-size: 12.5px; color: rgba(var(--ink-rgb),.45); line-height: 1.7; }
.wait-meta .warn { color: var(--err); }
.subhead { display: flex; justify-content: space-between; align-items: baseline; margin: 24px 0 10px; }
.subhead .t { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 16px; letter-spacing: .2em; color: rgba(var(--ink-rgb),.6); }
.subhead .m { font-size: 13px; color: rgba(var(--ink-rgb),.4); }
ul.route { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
ul.route li { display: flex; align-items: flex-start; gap: 12px; padding: 13px 16px; border-radius: 11px; background: rgba(var(--ink-rgb),.045); border: 1px solid transparent; font-size: 16px; line-height: 1.4; }
ul.route li.doing { background: rgba(var(--amber-rgb),.09); border-color: rgba(var(--amber-rgb),.3); }
ul.route li .ic { flex: none; font-size: 15px; margin-top: 2px; }
ul.route li.done { color: rgba(var(--ink-rgb),.65); }
ul.route li.done .ic { color: var(--ok); }
ul.route li.doing .ic { color: var(--amber); }
ul.route li.todo { color: rgba(var(--ink-rgb),.6); }
ul.route li.todo .ic { color: rgba(var(--ink-rgb),.35); }
ul.route li.blocked .ic { color: var(--err); }
ul.route.faded li { opacity: .55; }
.sys { margin-top: 6px; display: flex; flex-direction: column; gap: 6px; }
.sys .row { display: flex; justify-content: space-between; align-items: baseline; padding: 10px 14px; border-radius: 10px; background: rgba(var(--ink-rgb),.04); border: 1px solid rgba(var(--ink-rgb),.12); font-size: 14.5px; }
.sys .row .k { color: rgba(var(--ink-rgb),.6); }
.sys .row .v { font-family: ui-monospace, Menlo, monospace; font-size: 14px; }
.sys .ok { color: var(--ok); }
.sys .bad { color: var(--err); }
.sys .off { color: rgba(var(--ink-rgb),.5); }
.muted { color: rgba(var(--ink-rgb),.45); font-size: 14px; }
.err { color: var(--err); font-size: 14px; }
.stamp { margin: 16px 0 8px; text-align: center; font-size: 12.5px; color: rgba(var(--ink-rgb),.35); line-height: 1.8; }
.enter { display: block; text-align: center; font-size: 13.5px; color: rgba(var(--amber-rgb),.75); text-decoration: none; margin-top: 6px; }
.theme-btn { flex: none; margin-left: 12px; width: 34px; height: 34px; border-radius: 50%; border: 1px solid rgba(var(--ink-rgb),.25); background: none; color: rgba(var(--ink-rgb),.65); font-size: 16px; cursor: pointer; line-height: 1; align-self: center; }
</style>
<script src="/config.js"></script>
<script>
// 2026-08-01arcrun-rag#10 同族):拔掉寫死中央位址的 fallback。
// apiBase 由 worker 動態產生的 /config.js 注入;缺它就讓它明顯壞掉,
// **不要靜默把請求(可能含金鑰)送去中央實例**。
window.ARCRUN_API_BASE = (window.ARCRUN_CONFIG && window.ARCRUN_CONFIG.apiBase) || "";
</script>
</head>
<body>
<main>
<div class="pagehead">
<div class="title serif">Arcrun<small> 駕駛艙</small></div>
<div style="display:flex;align-items:baseline">
<div class="date serif" id="date-str"></div>
<button class="theme-btn" id="theme-btn" title="切換深/淺色"></button>
</div>
</div>
<div class="orb-row">
<div class="orb" id="orb" style="background:radial-gradient(circle at 36% 30%,#8fe8b4,#3fbe78 55%,#22754a 100%)"><span id="orb-char"></span></div>
<div>
<div class="orb-title" id="orb-title">載入中</div>
<div class="orb-sub" id="orb-sub"></div>
</div>
</div>
<div class="bricks">
<div class="brick amber">
<div class="lbl">今日完成</div>
<div class="num"><span id="done-n"></span><small> / <span id="total-n"></span></small></div>
<div class="bar"><i id="bar-fill" style="width:0%"></i></div>
</div>
<div class="brick plain">
<div class="lbl">收件匣未處理</div>
<div class="num"><span id="inbox-n"></span><small></small></div>
<div class="lbl" style="margin:10px 0 0">來自 Telegram</div>
</div>
</div>
<div class="wait-box" id="wait-box">
<div class="wait-head">等你的事</div>
<div id="wait-body" class="wait-none">載入中…</div>
<div class="wait-meta" id="wait-meta"></div>
</div>
<div class="subhead"><span class="t">今日路線</span><span class="m" id="route-m"></span></div>
<ul class="route" id="today-list"><li class="todo"><span class="ic"></span>載入中…</li></ul>
<div class="subhead" id="week-head" style="display:none"><span class="t">本週</span></div>
<ul class="route" id="week-list"></ul>
<div class="subhead"><span class="t">系統狀況</span><span class="m">live 健康信號</span></div>
<div class="sys" id="sys-list"><div class="row"><span class="k">載入中…</span></div></div>
<div class="stamp" id="stamp">每 60 秒自動刷新</div>
<a class="enter" href="/console">進入完整控制台 </a>
</main>
<script>
(function () {
var API_BASE = window.ARCRUN_API_BASE || '';
// 台北時間 helperlib/taipei-time.ts 注入,與 server 判定同一套——顯示不隨看的裝置時區漂移)
var TAIPEI_OFFSET_MS = 28800000; // UTC+8,台北無 DST
function tpePad(n) { n = String(n); return n.length < 2 ? '0' + n : n; }
function taipeiDayKey(ms) { return new Date(ms + TAIPEI_OFFSET_MS).toISOString().slice(0, 10); }
function taipeiDateStr(ms) { return taipeiDayKey(ms); }
function taipeiDateTimeStr(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return taipeiDayKey(ms) + ' ' + tpePad(d.getUTCHours()) + ':' + tpePad(d.getUTCMinutes()); }
function taipeiTimeStr(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return tpePad(d.getUTCHours()) + ':' + tpePad(d.getUTCMinutes()) + ':' + tpePad(d.getUTCSeconds()); }
function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { month: d.getUTCMonth() + 1, day: d.getUTCDate() }; }
const $ = (id) => document.getElementById(id);
const LIGHT = {
green: { ch: '安', title: '系統運轉中', grad: 'radial-gradient(circle at 36% 30%,#8fe8b4,#3fbe78 55%,#22754a 100%)', anim: 'breatheGreen' },
yellow: { ch: '趕', title: '落後趕工中', grad: 'radial-gradient(circle at 36% 30%,#f2d194,#e8b45a 55%,#8a5f1e 100%)', anim: 'breatheAmber' },
red: { ch: '滯', title: '卡住或斷訊', grad: 'radial-gradient(circle at 36% 30%,#f0a094,#d95f4c 55%,#7e2c20 100%)', anim: 'breatheRed' }
};
const ICONS = { done: '✓', doing: '◐', todo: '○', blocked: '●' };
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
function taskLine(t) {
const cls = ICONS[t.status] ? t.status : 'blocked';
const ic = ICONS[t.status] || '●';
return '<li class="' + cls + '"><span class="ic">' + ic + '</span><span>' + esc(t.title) + '</span></li>';
}
function humanAge(m) {
if (m == null || m < 0) return '時間不明';
if (m < 60) return m + ' 分鐘前';
if (m < 2880) return Math.round(m / 60) + ' 小時前';
return Math.round(m / 1440) + ' 天前';
}
const CNUM = ['零','一','二','三','四','五','六','七','八','九','十'];
function cnDay(n) { return n <= 10 ? CNUM[n] : (n < 20 ? '' + (n % 10 ? CNUM[n % 10] : '') : CNUM[Math.floor(n / 10)] + '' + (n % 10 ? CNUM[n % 10] : '')); }
// 頁首日期=台北日(原本用瀏覽器本地時區,換裝置會漂)
const nowTpe = taipeiMonthDay(Date.now());
$('date-str').textContent = CNUM[nowTpe.month] + '月' + cnDay(nowTpe.day) + '日';
// 深/淺切換(與 /console 共用 arcrun_console_theme;預設淺色)
function syncThemeBtn() { $('theme-btn').textContent = document.documentElement.getAttribute('data-theme') === 'dark' ? '☀' : '☾'; }
$('theme-btn').addEventListener('click', () => {
const next = document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
try { localStorage.setItem('arcrun_console_theme', next); } catch (e) { /* 私密模式忽略 */ }
syncThemeBtn();
});
syncThemeBtn();
// fetch 失敗(斷網)的裸訊息 → 友善誠實文案;60 秒定時器常駐,網路恢復自動刷回
function friendlyErr(e) {
const m = e && e.message ? String(e.message) : String(e);
return /failed to fetch|load failed|networkerror|network request failed/i.test(m) ? '連線中斷' : m;
}
function sysRow(k, v, cls) {
return '<div class="row"><span class="k">' + esc(k) + '</span><span class="v ' + cls + '">' + esc(v) + '</span></div>';
}
async function load() {
try {
const res = await fetch(API_BASE + '/console/dashboard-data');
if (!res.ok) throw new Error('HTTP ' + res.status);
const d = await res.json();
const cfg = LIGHT[d.light] || LIGHT.green;
const orb = $('orb');
orb.style.background = cfg.grad;
orb.style.animation = cfg.anim + ' 3.4s ease-in-out infinite';
$('orb-char').textContent = cfg.ch;
$('orb-title').textContent = cfg.title;
$('orb-sub').textContent = (d.last_beat
? d.last_beat.actor + '・' + d.last_beat.ago_minutes + ' 分鐘前' + (d.last_beat.note ? '・' + d.last_beat.note : '')
: '尚無心跳資料') + (d.light !== 'green' && d.light_reason ? '' + d.light_reason + '' : '');
const done = d.today_done || 0, total = d.today_total || 0;
$('done-n').textContent = done; $('total-n').textContent = total;
$('bar-fill').style.width = (total ? Math.round((done / total) * 100) : 0) + '%';
$('inbox-n').textContent = d.inbox_new || 0;
// ── 等你的事:來源 + 維護時間攤開講,stale 一定警示 ──
const wb = $('wait-box'), body = $('wait-body'), wmeta = $('wait-meta');
const wm = d.waiting_meta || {};
if (d.waiting && d.waiting.length) {
wb.classList.add('has');
body.className = '';
body.innerHTML = d.waiting.map((w) =>
'<div class="wait-item"><span class="dm">' + (w.urgency ? esc(w.urgency) : '◆') + '</span><span>' +
(w.id ? '<b>#' + esc(w.id) + '</b> ' : '') + esc(w.title) + '</span></div>').join('');
} else {
wb.classList.remove('has');
body.className = 'wait-none';
body.textContent = wm.source === 'none' ? '(管線未接)' : '無,你不用做任何事';
}
let metaTxt = '';
if (wm.source === 'gitea_sprint') {
metaTxt = '來源:sprint 等leo清單(' + esc((wm.sprint_files || []).join('、')) + ')・清單維護於 ' + humanAge(wm.updated_ago_minutes);
if (wm.stale) metaTxt += '<br><span class="warn">⚠ 清單超過 2 天沒維護,可能過時</span>';
} else if (wm.source === 'kbdb_dash_wait') {
metaTxt = '<span class="warn">⚠ ' + esc(wm.note || 'dash_wait 殘資料') + '・上次寫入 ' + humanAge(wm.updated_ago_minutes) + ',可能過時</span>';
} else {
metaTxt = '<span class="warn">管線未接:Gitea sprint 清單與 dash_wait 皆無資料</span>';
}
wmeta.innerHTML = metaTxt;
// ── 今日路線:sprint 任務板優先(來源攤開講);dash_task fallback 沿舊誠實降級 ──
const rm = d.route_meta || {};
const today = (d.tasks || []).filter((t) => t.scope === 'today');
const week = (d.tasks || []).filter((t) => t.scope === 'week');
if (rm.source === 'gitea_sprint_board') {
$('route-m').textContent = '來源 sprint 任務板・更新於 ' + humanAge(rm.updated_ago_minutes);
$('today-list').className = 'route';
const staleHead = rm.is_today ? '' :
'<li class="todo"><span class="ic"></span><span class="muted">⚠ 今日任務板未更新(最後 ' + humanAge(rm.updated_ago_minutes) + ')——以下是板上現況</span></li>';
$('today-list').innerHTML = staleHead + (today.length
? today.map(taskLine).join('')
: '<li class="todo"><span class="ic"></span><span class="muted">任務板上沒有可解析的事項</span></li>');
} else if (rm.is_today) {
$('route-m').textContent = '更新於 ' + humanAge(rm.updated_ago_minutes);
$('today-list').className = 'route';
$('today-list').innerHTML = today.length ? today.map(taskLine).join('') : '<li class="todo"><span class="ic"></span><span class="muted">今日無排定項目</span></li>';
} else if (today.length) {
$('route-m').textContent = '最後路線・' + humanAge(rm.updated_ago_minutes) + '寫入';
$('today-list').className = 'route faded';
$('today-list').innerHTML =
'<li class="todo"><span class="ic"></span><span class="muted">今日尚無路線寫入——以下是 ' + humanAge(rm.updated_ago_minutes) +
'的殘留路線(sprint 任務板→dashboard 投影管線未接,等leo清單#15 裁決中)</span></li>' + today.map(taskLine).join('');
} else {
$('route-m').textContent = '';
$('today-list').className = 'route';
$('today-list').innerHTML = '<li class="todo"><span class="ic"></span><span class="muted">無資料——dash_task 管線未接</span></li>';
}
$('week-head').style.display = week.length ? '' : 'none';
$('week-list').innerHTML = week.map(taskLine).join('');
// ── 系統狀況 + 總庫規模(全 live,讀不到就標讀不到)──
const sys = d.system || {}, kb = d.kb || {};
const rows = [];
rows.push(sysRow('KBDB 基本盤', sys.kbdb_ok ? '● 正常' : '● 打不通', sys.kbdb_ok ? 'ok' : 'bad'));
if (sys.embed) {
rows.push(sys.embed.enabled
? sysRow('語意嵌入', '● 啟用(已嵌 ' + (sys.embed.embedded ?? '?') + '・待嵌 ' + (sys.embed.pending ?? '?') + '', 'ok')
: sysRow('語意嵌入', '○ 停用(已嵌 ' + (sys.embed.embedded ?? '?') + '・待嵌 ' + (sys.embed.pending ?? '?') + '', 'bad'));
} else {
rows.push(sysRow('語意嵌入', '狀態讀不到', 'off'));
}
rows.push(sys.graph && sys.graph.ok
? sysRow('知識圖譜', '● 正常・三元組 ' + (sys.graph.triplets == null ? '?' : sys.graph.triplets), 'ok')
: sysRow('知識圖譜', '● 打不通', 'bad'));
rows.push(sysRow('工作流', sys.workflow_total == null ? '讀不到' : sys.workflow_total + ' 條', sys.workflow_total == null ? 'off' : ''));
// 精耕層 wiki 卡(leo 2026-07-07 裁:14-E 遺產總數 deprecated 不再顯示,只顯示真的新的;
// 三元組/已嵌入 已各有一列)
rows.push(sysRow('精耕層 wiki 卡', kb.wiki_card_total == null ? '讀不到' : kb.wiki_card_total + ' 張', kb.wiki_card_total == null ? 'off' : ''));
$('sys-list').innerHTML = rows.join('');
$('stamp').innerHTML = '每 60 秒自動刷新・上次 ' + esc(taipeiTimeStr(Date.parse(d.generated_at))) + '(台北)<br>此頁不含機敏內容,免登入';
} catch (e) {
$('orb-char').textContent = '';
$('orb-title').textContent = '讀不到狀態';
$('orb-sub').innerHTML = '<span class="err">' + esc(friendlyErr(e)) + '・每 60 秒自動重試</span>';
}
}
load();
setInterval(load, 60000);
})();
</script>
</body>
</html>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" role="img" aria-label="arcrun icon"><title>arcrun icon</title><rect width="1024" height="1024" fill="#17181A"/><path fill="#FDFCFB" fill-rule="nonzero" d="M463.01,612.91 L436.06,612.91 L436.06,485.41 L435.86,477.78 L435.27,470.46 L434.28,463.44 L432.89,456.73 L431.11,450.31 L428.93,444.20 L426.36,438.39 L423.39,432.88 L420.02,427.68 L416.26,422.78 L412.10,418.18 L407.55,413.88 L402.62,409.91 L397.31,406.28 L391.65,402.99 L385.61,400.06 L379.21,397.47 L372.44,395.22 L365.30,393.32 L357.79,391.76 L349.92,390.55 L341.68,389.69 L333.08,389.17 L324.10,389.00 L317.39,389.10 L310.90,389.40 L304.63,389.89 L298.59,390.58 L292.77,391.47 L287.17,392.56 L281.80,393.85 L276.65,395.33 L271.72,397.02 L267.02,398.90 L262.53,400.98 L258.28,403.25 L254.20,405.69 L250.26,408.26 L246.45,410.95 L242.78,413.76 L239.25,416.71 L235.86,419.77 L232.60,422.97 L229.48,426.29 L226.50,429.74 L223.65,433.31 L220.94,437.01 L218.37,440.83 L257.76,476.08 L259.43,473.77 L261.16,471.52 L262.96,469.32 L264.81,467.18 L266.73,465.09 L268.71,463.05 L270.75,461.07 L272.85,459.15 L275.01,457.27 L277.23,455.45 L279.51,453.69 L281.86,451.98 L284.30,450.36 L286.86,448.89 L289.55,447.55 L292.37,446.36 L295.31,445.31 L298.38,444.39 L301.58,443.62 L304.90,442.99 L308.34,442.50 L311.91,442.15 L315.61,441.94 L319.44,441.87 L323.74,441.95 L327.85,442.21 L331.75,442.65 L335.45,443.25 L338.95,444.03 L342.24,444.98 L345.34,446.10 L348.23,447.40 L350.93,448.87 L353.42,450.51 L355.71,452.32 L357.79,454.31 L359.70,456.45 L361.44,458.74 L363.01,461.18 L364.42,463.75 L365.66,466.47 L366.73,469.34 L367.64,472.35 L368.39,475.50 L368.97,478.80 L369.38,482.24 L369.63,485.82 L369.71,489.55 L369.71,509.25 L323.58,509.25 L314.52,509.39 L305.80,509.82 L297.44,510.53 L289.43,511.52 L281.78,512.80 L274.47,514.37 L267.52,516.22 L260.93,518.35 L254.68,520.77 L248.79,523.47 L243.25,526.45 L238.06,529.72 L233.26,533.28 L228.88,537.14 L224.91,541.30 L221.36,545.76 L218.23,550.52 L215.52,555.57 L213.22,560.93 L211.34,566.58 L209.88,572.53 L208.84,578.78 L208.21,585.33 L208.00,592.18 L208.15,598.13 L208.62,603.87 L209.39,609.41 L210.48,614.75 L211.87,619.89 L213.57,624.83 L215.58,629.57 L217.91,634.11 L220.54,638.44 L223.48,642.57 L226.73,646.50 L230.29,650.23 L234.14,653.71 L238.25,656.88 L242.63,659.75 L247.28,662.32 L252.19,664.59 L257.37,666.56 L262.82,668.22 L268.53,669.58 L274.51,670.64 L280.75,671.40 L287.26,671.85 L294.04,672.00 L299.07,671.91 L303.96,671.63 L308.72,671.17 L313.33,670.53 L317.81,669.71 L322.16,668.70 L326.37,667.50 L330.44,666.13 L334.37,664.57 L338.17,662.82 L341.83,660.89 L345.35,658.78 L348.71,656.49 L351.88,654.01 L354.85,651.35 L357.62,648.50 L360.20,645.47 L362.59,642.26 L364.78,638.87 L366.78,635.29 L368.58,631.52 L370.19,627.58 L371.60,623.45 L372.82,619.13 L375.93,619.13 L376.52,622.61 L377.24,625.98 L378.09,629.22 L379.07,632.35 L380.19,635.36 L381.44,638.24 L382.83,641.01 L384.34,643.67 L385.99,646.20 L387.78,648.61 L389.69,650.91 L391.74,653.08 L393.92,655.11 L396.23,656.96 L398.66,658.64 L401.22,660.14 L403.90,661.46 L406.71,662.61 L409.64,663.58 L412.71,664.37 L415.89,664.99 L419.21,665.43 L422.65,665.69 L426.21,665.78 L463.01,665.78 L463.01,612.91 Z M475.77,630.42 L546.23,713.58 L762.31,530.50 L546.23,347.42 L475.77,430.58 L593.69,530.50 L475.77,630.42 Z M667.77,630.42 L738.23,713.58 L954.31,530.50 L738.23,347.42 L667.77,430.58 L785.69,530.50 L667.77,630.42 Z"/></svg>

Before

Width:  |  Height:  |  Size: 3.4 KiB

-32
View File
@@ -1,32 +0,0 @@
<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8">
<title>Arcrun RAG</title>
<!--
根目錄直接導向搜尋 Portal。
為什麼不做「選擇介面」的導覽頁(2026-07-21 leo 實際撞到):
這份 UI 部署出去的網址是給**使用者**的入口(個人站 mira.uncle6.me
以及自架用戶自己的網址),進站就是要能用——多一層選擇=多一個困惑點,
2026-08-08 更正:原註解寫「這個網域=rag-demo.arcrun.dev 是客戶測試入口」,
那是 uncle6 帳號那個已廢的 demo 站,leo 已定案不再拿它當範例;
註解留著會把下一個人導向錯的環境,故改寫。理由本身仍然成立。)
而且會讓客戶看到 Admin Console 這個維運介面(不該對客戶露出)。
維運者要進 console 直接打 /console/ 即可。
-->
<meta http-equiv="refresh" content="0; url=/portal/">
<link rel="canonical" href="/portal/">
<script>location.replace('/portal/');</script>
<style>
body{margin:0;min-height:100vh;display:grid;place-items:center;
background:#faf8f5;color:#6b635a;
font:15px/1.7 system-ui,-apple-system,"Noto Sans TC",sans-serif}
@media(prefers-color-scheme:dark){body{background:#1a1816;color:#9a9186}}
</style>
</head>
<body>
<p>正在前往搜尋頁… <a href="/portal/">沒有自動跳轉請點這裡</a></p>
</body>
</html>
File diff suppressed because one or more lines are too long
@@ -1,56 +0,0 @@
import fs from 'node:fs';
const html = fs.readFileSync(new URL('./index.html', import.meta.url).pathname,'utf8');
// 抽出 daemonPick 相關函式(從 DAEMON_BASE_DEFAULT 到 daemonHint 結尾)
//
// 🔴 2026-08-05:結尾標記本來寫死 daemonHint 的**整句文案**,於是同日改 Mac 提示語
// (zip→DMG 的步驟不同)就讓這支自測直接炸「抽不到函式區塊」,而且沒人發現。
// ⇒ 改成錨定「函式結束」這個結構,不再綁文案——文案本來就會改,測試不該為此壞掉。
const start = html.indexOf('var DAEMON_BASE_DEFAULT');
const hintAt = html.indexOf('function daemonHint', start);
const endMark = '\n }';
const end = hintAt < 0 ? -1 : html.indexOf(endMark, hintAt) + endMark.length;
if (start < 0 || hintAt < 0 || end < start) throw new Error('抽不到函式區塊');
const src = html.slice(start, end);
const cases = [
['Windows', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36'],
['Mac', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Safari/605.1.15'],
['iPhone', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Safari/604.1'],
['Linux', 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120 Safari/537.36'],
];
let pass=0, fail=0;
const chk=(l,c,extra='')=>{ if(c){console.log('PASS:',l);pass++;} else {console.log('FAIL:',l,extra);fail++;} };
for (const [name, ua] of cases) {
const fn = new Function('navigator','window', src + '; return {daemonPick:daemonPick, daemonHint:daemonHint, daemonBase:daemonBase};');
const api = fn({userAgent: ua}, {});
const d = api.daemonPick();
const label = d.sure ? d.pick.label : '(兩個都給)';
const url = d.sure ? d.pick.url : d.mac.url + ' + ' + d.win.url;
console.log(`\n[${name}] sure=${d.sure}${label}`);
console.log(` url: ${url}`);
if (name==='Windows') {
chk('Windows 給 win zip', d.sure && d.pick.url.endsWith('ArcrunRAG-win-unsigned.zip'), d.pick&&d.pick.url);
chk('Windows 另一版是 Mac', d.other && d.other.url.endsWith('ArcrunRAG-mac.dmg'));
chk('Windows 話術提 藍色視窗', api.daemonHint('win').includes('仍要執行'));
}
if (name==='Mac') {
// 2026-08-05Mac 一律給 DMG(拖進 Applications 的標準安裝畫面),不再給 zip
// ——zip 解開就是一個裸 .app,使用者會直接在「下載」資料夾雙擊執行,自更新會蓋錯位置。
chk('Mac 給 dmg(不是 zip', d.sure && d.pick.url.endsWith('ArcrunRAG-mac.dmg'));
chk('Mac 另一版是 Windows', d.other && d.other.url.endsWith('win-unsigned.zip'));
chk('Mac 話術提 右鍵打開', api.daemonHint('mac').includes('右鍵'));
}
if (name==='iPhone' || name==='Linux') {
// iPhone 含 "Mac OS X" 但不是桌機 Mac;Linux 兩者皆非 → 都該落在「不確定=兩個都給」
if (name==='Linux') chk('Linux 判不出來→兩個都給', d.sure===false);
if (name==='iPhone') chk('iPhone 不該被判成 Mac(手機→兩個都給)', d.sure===false, 'sure='+d.sure);
}
}
// 舊 key 相容
const fn2 = new Function('navigator','window', src + '; return daemonBase();');
console.log('\n[相容] daemonDownload 舊 key →', fn2({userAgent:''},{ARCRUN_CONFIG:{daemonDownload:'https://x.dev/d/ArcrunRAG-mac-unsigned.zip'}}));
chk('舊 key 推得出目錄', fn2({userAgent:''},{ARCRUN_CONFIG:{daemonDownload:'https://x.dev/d/ArcrunRAG-mac-unsigned.zip'}})==='https://x.dev/d/');
chk('daemonBase 新 key 優先', fn2({userAgent:''},{ARCRUN_CONFIG:{daemonBase:'https://y.dev/z'}})==='https://y.dev/z/');
console.log(`\n=== ${pass} passed, ${fail} failed ===`);
process.exit(fail?1:0);
@@ -1,46 +0,0 @@
import fs from 'node:fs';
const html = fs.readFileSync(new URL('./index.html', import.meta.url).pathname,'utf8');
// 抽出 safeJson 與 friendlyErr 求值
const grab = (name) => {
const i = html.indexOf(`function ${name}(`);
if (i < 0) throw new Error(`找不到 ${name}`);
let d=0, j=html.indexOf('{', i);
for (let k=j;k<html.length;k++){ if(html[k]==='{')d++; if(html[k]==='}'){d--; if(!d){ return html.slice(i,k+1);} } }
throw new Error('括號不平衡');
};
const fn = new Function(grab('safeJson') + '\n' + grab('friendlyErr') + '\nreturn {safeJson, friendlyErr};')();
let pass=0, fail=0;
const t=(l,c,e='')=>{c?(console.log('PASS:',l),pass++):(console.log('FAIL:',l,e),fail++)};
// ① safeJson:非 JSON 不可拋例外(同事撞到的 404 HTML 頁)
const html404 = '<!DOCTYPE html><html><body>404 Not Found</body></html>';
await fn.safeJson({ text: () => Promise.resolve(html404) })
.then(d => t('404 HTML → 回空物件不拋錯', typeof d === 'object' && d !== null))
.catch(e => t('404 HTML → 不該拋錯', false, e.message));
await fn.safeJson({ text: () => Promise.resolve('') })
.then(d => t('空回應 → 回空物件', JSON.stringify(d)==='{}'))
.catch(() => t('空回應 → 不該拋錯', false));
await fn.safeJson({ text: () => Promise.resolve('{"error":"帳號或密碼不對"}') })
.then(d => t('正常 JSON 仍要解析得出來', d.error === '帳號或密碼不對'), )
.catch(() => t('正常 JSON 不該拋錯', false));
// ② friendlyErr:不可把技術訊息噴給使用者
const leak = fn.friendlyErr(new Error('Unexpected non-whitespace character after JSON at position 4'));
t('JSON 錯誤 → 不外洩原文', !/JSON|position/i.test(leak), `實得: ${leak}`);
t('JSON 錯誤 → 說人話', /伺服器回應異常/.test(leak), `實得: ${leak}`);
const net = fn.friendlyErr(new Error('Failed to fetch'));
t('網路錯誤 → 既有訊息保留', /連線中斷/.test(net), `實得: ${net}`);
const ours = fn.friendlyErr(new Error('帳號或密碼不對——用你在知識庫網站設定的那組'));
t('我們自己的中文訊息 → 原樣顯示', /帳號或密碼不對/.test(ours), `實得: ${ours}`);
const stack = fn.friendlyErr(new Error('TypeError: Cannot read properties of undefined'));
t('英文技術訊息 → 收斂不外洩', !/TypeError|undefined/.test(stack), `實得: ${stack}`);
console.log(`\n=== ${pass} passed, ${fail} failed ===`);
process.exit(fail?1:0);
-108
View File
@@ -1,108 +0,0 @@
/**
* deploy.mjs 依具名目標部署 console-ui Cloudflare Pages
*
* 用法npm run deploy:personal
* npm run deploy:personal -- --dry-run 只產出並驗產物不推
*
* 為什麼不直接用 `wrangler pages deploy`2026-07-22 leo 實際踩到才補
* **兩個帳號都有名為 arcrun-console-ui Pages 專案**
* wrangler OAuth 登入在別的帳號`--project-name arcrun-console-ui` 會部到別人的站上
* 本腳本強制帶目標的 accountId並在部署前印出目標避免部錯帳號
*
* 同時把 profile/apiBase 綁進目標deploy.targets.json不再靠部署者記得帶環境變數
* 帶漏過三次 profile 顯示成錯的版本 apiBase 導致登入 405
*
* 🔴 三道閘全部**讀磁碟上真的要被推的那份**不看本腳本自己印了什麼
* 2026-08-08 事故的形狀正是印的是 A推的是 B
* 產物閘 宣告值有沒有真的寫進產物apiBase / VIEWS / HOME
* 世代閘 產物是不是當代指紋t160 的文字指紋
* 線上閘 推完回頭抓線上組態世代都要對上否則本次部署算失敗
* 三閘都過才寫 .deploy-state.json那份紀錄是經過線上實測的意思不是我跑過指令
*/
import { spawnSync } from 'node:child_process';
import { join } from 'node:path';
import { ROOT, assertArtifact, buildArtifact, loadTargets, resolveTarget, writeState } from './targets.mjs';
import { printReport, verifyTarget } from './verify-live.mjs';
const args = process.argv.slice(2);
const dryRun = args.includes('--dry-run');
const name = args.find((a) => !a.startsWith('--'));
let t;
try {
if (!name) throw Object.assign(new Error('沒有指定部署目標'), { usage: true });
t = resolveTarget(name);
} catch (e) {
console.error(`${e.message}`);
if (e.usage) console.error(`用法:npm run deploy:<target>\n可用目標:${loadTargets().active.join(' / ')}`);
process.exit(1);
}
if (t.frozen) {
console.error(`✘ 目標 ${name} 已凍結,拒絕部署。\n ${t.frozen}`);
console.error(' (要解凍是人的決定:改 deploy.targets.json 拿掉 frozen 欄位,並說明理由。)');
process.exit(1);
}
console.log(`\n部署目標:${name}`);
console.log(` 說明 ${t.description}`);
console.log(` 帳號 ${t.accountId}`);
console.log(` 專案 ${t.projectName}`);
console.log(` profile ${t.profile}`);
console.log(` apiBase ${t.apiBase}`);
// ── ①② 產出 + 驗產物 ────────────────────────────────────────────────
const outDir = join(ROOT, '.staging', name);
try {
buildArtifact(t, outDir);
} catch (e) {
console.error(`\n✘ 產出失敗:${e.message}`);
process.exit(1);
}
const gate = assertArtifact(t, outDir);
console.log(`\n產物:${outDir}`);
console.log(` 世代指紋:${gate.generation.slice(0, 12)}`);
if (!gate.ok) {
console.error('\n✘ 產物閘不通過——推上去的會跟宣告的不一樣,拒絕部署:');
for (const p of gate.problems) console.error(` · ${p}`);
process.exit(1);
}
console.log(' ✅ 產物閘:宣告值確實寫進產物,且是當代。');
if (dryRun) {
console.log('\n--dry-run:到此為止,沒有推任何東西。)');
process.exit(0);
}
// ── 推 ───────────────────────────────────────────────────────────────
const env = { ...process.env, DEPLOY_TARGET: name, CLOUDFLARE_ACCOUNT_ID: t.accountId };
// --commit-dirty:本地部署常有未提交變更,不因此中斷
const deploy = spawnSync(
'npx',
['wrangler', 'pages', 'deploy', outDir, '--project-name', t.projectName, '--commit-dirty=true'],
{ stdio: 'inherit', cwd: ROOT, env },
);
if (deploy.status !== 0) {
console.error('\n✘ wrangler 部署失敗。');
process.exit(deploy.status ?? 1);
}
// ── ③ 線上閘 ─────────────────────────────────────────────────────────
console.log('\n── 回頭驗線上(組態+世代)──');
const report = await verifyTarget(name, { wait: true });
printReport([report]);
if (!report.ok) {
console.error('\n✘ 推上去了,但線上跑的 ≠ 我們手上這一份。**本次部署視為失敗**。');
console.error(' wrangler 說成功不代表對外網址就對——這正是要被擋掉的那個病。)');
process.exit(1);
}
writeState(name, {
generation: gate.generation,
apiBase: t.apiBase,
profile: t.profile,
urls: t.verifyUrls,
verifiedAt: new Date().toISOString(),
});
console.log('\n✅ 部署完成,且線上實測=宣告值+當代世代。已記入 .deploy-state.json。');
-269
View File
@@ -1,269 +0,0 @@
/**
* targets.mjs 部署目標的唯一讀取點deploy.mjs verify-live.mjs 共用
*
* 存在的理由宣告值deploy.targets.json只准被解讀一次
* 部署時印在終端機的值寫進產物的值事後驗線上的值若各自去讀各自算
* 三者就會漂移2026-08-08 那場事故的形狀正是印的是 A推的是 B
* 這支把一個目標展開成期望的產物長相定死成一個函式三邊共用同一個答案
*
* 🔴 2026-08-08 第二層leo已經發生過一次這個錯誤把舊版界面上到 prod
* 你要確定不可再犯組態對 世代對
* 一個網址可以 apiBaseprofile 全部正確卻對外展示一套早就被淘汰的介面
* 而所有只驗組態的檢查都說它綠故本檔另外定義世代指紋見下半段
* 線上這一份是不是當代的變成一個可機械比對的值
*/
import { createHash } from 'node:crypto';
import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
export const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
export const PUBLIC_DIR = join(ROOT, 'public');
export function loadTargets() {
const raw = JSON.parse(readFileSync(join(ROOT, 'deploy.targets.json'), 'utf8'));
const profiles = raw._profiles;
if (!profiles) throw new Error('deploy.targets.json 缺 _profilesprofile → views/home 對照)');
const names = Object.keys(raw).filter((k) => !k.startsWith('_'));
const active = names.filter((n) => !raw[n].frozen);
return { raw, profiles, names, active };
}
export function resolveTarget(name) {
const { raw, profiles, names } = loadTargets();
const t = raw[name];
if (!t) {
const err = new Error(`未知的部署目標:"${name}"。可用:${names.join(' / ')}`);
err.usage = true;
throw err;
}
// 凍結目標:連讀都不准碰(frozen.reason 說明是誰、何時、為什麼)。
// 這不是「壞掉所以跳過」,是「這個帳號的資源不歸我們動」——工具自己守,不靠人記得。
if (t.frozen) return { name, ...t, frozen: t.frozen, views: profiles[t.profile]?.views, home: profiles[t.profile]?.home };
const p = profiles[t.profile];
if (!p) {
throw new Error(
`目標 ${name} 的 profile="${t.profile}" 在 _profiles 裡沒有定義(可用:${Object.keys(profiles).join(' / ')})。` +
'\n宣告了一個沒人知道怎麼落地的 profile ⇒ 拒絕部署,不要猜。',
);
}
if (!t.apiBase) throw new Error(`目標 ${name} 沒有 apiBase——空值會讓前端安靜地連不上,拒絕部署。`);
if (!t.accountId) throw new Error(`目標 ${name} 沒有 accountId——不指定帳號可能部到別人的站上,拒絕部署。`);
if (!Array.isArray(t.verifyUrls) || t.verifyUrls.length === 0) {
throw new Error(`目標 ${name} 沒有 verifyUrls——沒有對外網址就無法驗「站上跑的=宣告的」,拒絕部署。`);
}
return { name, ...t, views: p.views, home: p.home };
}
/** 這個目標「應該長成什麼樣」——產物閘與線上閘都比對這一份。 */
export function expected(t) {
return {
configJs: configJsFor(t),
apiBase: t.apiBase,
viewsLine: ` var VIEWS = ${JSON.stringify(t.views)};`,
homeLine: ` var HOME = ${JSON.stringify(t.home)};`,
};
}
export function configJsFor(t) {
return (
'// 由 console-ui/scripts/deploy.mjs 於部署時依 deploy.targets.json 產生——請勿手改,也不進 git。\n' +
`// 目標:${t.name}${t.description}\n` +
`window.ARCRUN_CONFIG = { apiBase: ${JSON.stringify(t.apiBase)} };\n`
);
}
/** 從 config.js 的文字裡取出 apiBase(線上/產物共用同一個解析法)。 */
export function parseApiBase(text) {
const m = text.match(/apiBase\s*:\s*"([^"]*)"/);
return m ? m[1] : null;
}
// ─────────────────────────────────────────────────────────────────────────────
// 世代指紋(2026-08-08 第二層)
//
// 問題:verify-live 原本只驗組態(apiBase / VIEWS / HOME)。實測當天三個對外網址
// 這三項全綠,但線上跑的是 2026-07-22 那一代的 portal82,911 bytes、
// 金色 serif「Arcrun」品牌、Songti 12 處),repo 是 343,969 bytes 的
// 「arc >> run」新代——**組態全對、介面整整落後半個月,機械檢查一片綠**。
//
// 判準:「線上這一份,是不是我們手上這一份?」不加解釋、不留模糊地帶——
// 逐一抓下線上資產、遮掉「本來就該隨部署目標不同」的那幾行,其餘按位元組比對。
//
// 為什麼是位元組而不是「找幾個關鍵字」:
// 關鍵字清單要人維護,而人只會在「這次剛好想到」時更新它。舊世代之所以能無聲上線,
// 正是因為沒有人記得去更新那張清單。位元組比對不需要任何人記得任何事:
// repo 改了一個字,指紋就不同,線上沒跟上就是 ❌。
//
// 誠實的 trade-offmindset §7,不假裝完美):
// ① 只要 repo 動過而還沒部署,這個檢查就會說「線上落後」——那是**正確的**,
// 因為那時線上確實不是當代的。它會吵,但吵的是真的。
// ② 若哪天 CF 邊緣開始改寫 HTMLRocket Loader 之類),會出現假 ❌。
// 2026-08-08 實測 mira.uncle6.me 與 pages.dev 回傳位元組完全相同(sha 一致),
// 證明目前沒有改寫。真出現時它會大聲壞掉、有人來查——
// **假 ❌ 的代價遠低於假 ✅**(假 ✅ 就是這次事故本身)。
// ─────────────────────────────────────────────────────────────────────────────
/** 納入世代指紋的資產:filepublic/ 底下的路徑,urlPath=線上要抓的位址。 */
export const GENERATION_ASSETS = [
{ file: 'index.html', urlPath: '/' },
{ file: 'portal/index.html', urlPath: '/portal/' },
{ file: 'console/index.html', urlPath: '/console/' },
{ file: 'favicon.svg', urlPath: '/favicon.svg' },
];
/**
* 本來就該隨部署目標不同的行比世代時遮掉否則個人版與企業版永遠指紋不同
* 遮的只有這兩行其餘全部按原樣比對
* config.js 整支不納入世代它是純產物 apiBase 那一項單獨驗
*/
const TARGET_DEPENDENT_LINES = [
{ file: 'console/index.html', re: /^[ \t]*var VIEWS = .*$/m, tag: '«VIEWS:由部署目標決定»' },
{ file: 'console/index.html', re: /^[ \t]*var HOME = .*$/m, tag: '«HOME:由部署目標決定»' },
];
/** 遮掉目標相依的行。抓不到就原樣回傳(線上是舊世代時本來就可能沒有那幾行 → 該判 ❌)。 */
export function maskTargetValues(file, bytes) {
const rules = TARGET_DEPENDENT_LINES.filter((r) => r.file === file);
if (!rules.length) return bytes;
let text = Buffer.from(bytes).toString('utf8');
for (const r of rules) text = text.replace(r.re, r.tag);
return Buffer.from(text, 'utf8');
}
export function sha256(bytes) {
return createHash('sha256').update(bytes).digest('hex');
}
/**
* 檔名 位元組抓不到給 null算出世代指紋
* @param {Array<{file:string, bytes:Buffer|null}>} entries
*/
export function fingerprintOf(entries) {
const assets = {};
const lines = [];
for (const { file, bytes } of entries) {
if (bytes == null) {
assets[file] = { sha: null, size: null, missing: true };
lines.push(`${file}\tMISSING`);
continue;
}
const masked = maskTargetValues(file, bytes);
const sha = sha256(masked);
assets[file] = { sha, size: Buffer.from(bytes).length, missing: false };
lines.push(`${file}\t${sha}`);
}
return { assets, digest: sha256(Buffer.from(lines.join('\n'), 'utf8')) };
}
/** repo(或某個產物目錄)現在這一代長什麼樣。這就是「當代」的定義。 */
export function generationOfDir(dir = PUBLIC_DIR) {
return fingerprintOf(
GENERATION_ASSETS.map(({ file }) => {
let bytes = null;
try {
bytes = readFileSync(join(dir, file));
} catch {
bytes = null;
}
return { file, bytes };
}),
);
}
// ─────────────────────────────────────────────────────────────────────────────
// 產物:把宣告值真的寫進去(e730b3f 標的 WIP,本次收掉)
// ─────────────────────────────────────────────────────────────────────────────
/**
* 依目標把 public/ 展開成要推上去的那一份
* 🔴 覆寫沒命中就中止宣告了卻沒寫進產物正是這串事故的根
*/
export function buildArtifact(t, outDir) {
rmSync(outDir, { recursive: true, force: true });
mkdirSync(outDir, { recursive: true });
cpSync(PUBLIC_DIR, outDir, { recursive: true });
const exp = expected(t);
// ① config.js:產物,不是原始碼(public/ 裡不留)
writeFileSync(join(outDir, 'config.js'), exp.configJs, 'utf8');
// ② console 的 VIEWS/HOMEpublic/ 裡那兩行只是本機 preview 的預設值
const consolePath = join(outDir, 'console', 'index.html');
let html = readFileSync(consolePath, 'utf8');
for (const [re, line, what] of [
[/^[ \t]*var VIEWS = .*$/m, exp.viewsLine, 'VIEWS'],
[/^[ \t]*var HOME = .*$/m, exp.homeLine, 'HOME'],
]) {
if (!re.test(html)) {
throw new Error(
`產物覆寫沒命中:console/index.html 找不到 ${what} 那一行 ⇒ 中止部署。\n` +
'(前端改版把那行換了寫法時會發生。宣告值寫不進去就不准推——這正是 2026-08-08 事故的形狀。)',
);
}
html = html.replace(re, line);
}
writeFileSync(consolePath, html, 'utf8');
return outDir;
}
/**
* 產物閘推之前回頭讀真的要被推上去的那些檔案確認宣告值
* 不看 deploy.mjs 自己印了什麼只看磁碟上那份
*/
export function assertArtifact(t, outDir) {
const exp = expected(t);
const problems = [];
const cfg = readFileSync(join(outDir, 'config.js'), 'utf8');
const gotApiBase = parseApiBase(cfg);
if (gotApiBase !== t.apiBase) problems.push(`config.js 的 apiBase:宣告 ${t.apiBase},產物 ${gotApiBase}`);
const html = readFileSync(join(outDir, 'console', 'index.html'), 'utf8');
const gotViews = html.match(/^[ \t]*var VIEWS = .*$/m)?.[0];
const gotHome = html.match(/^[ \t]*var HOME = .*$/m)?.[0];
if (gotViews !== exp.viewsLine) problems.push(`console VIEWS:宣告 ${exp.viewsLine.trim()},產物 ${gotViews?.trim()}`);
if (gotHome !== exp.homeLine) problems.push(`console HOME:宣告 ${exp.homeLine.trim()},產物 ${gotHome?.trim()}`);
// 世代閘(產物側):注入不得改動世代相關位元組
const src = generationOfDir(PUBLIC_DIR);
const art = generationOfDir(outDir);
if (src.digest !== art.digest) {
problems.push(`產物世代指紋 ${art.digest.slice(0, 12)} ≠ public/ 的 ${src.digest.slice(0, 12)}(注入改到了不該改的位元組)`);
}
// 世代閘(內容側,沿用 t160 的文字指紋——擋「整份 public 被換成舊代」)
//
// 🔴 只看「使用者看得到的內容」,比對前先剝掉 HTML 註解。
// 2026-08-08 實撞:原版直接對全文比對「登記新庫」,而 66f1b5908-03)在 portal 裡
// 加了一則**說明「已經把登記新庫拿掉了」的註解** ⇒ 這道閘從那天起每次都誤判,
// `npm run deploy:personal` 連續五天推不出去、而錯誤訊息說的是「你的 UI 是舊代」。
// ⇒ 手工維護的關鍵字清單會腐爛,這就是實例;世代的主判準因此改用位元組指紋,
// 這道文字閘只留來擋「整份 public 被換成舊代」,且必須剝註解才不會自傷。
const portalRaw = readFileSync(join(outDir, 'portal', 'index.html'), 'utf8');
const portal = portalRaw.replace(/<!--[\s\S]*?-->/g, '');
if (!portal.includes('不需要人工新增') || portal.includes('登記新庫')) {
problems.push('portal/index.html 不是現行世代(可見內容缺「不需要人工新增」或仍有「登記新庫」)');
}
return { ok: problems.length === 0, problems, generation: art.digest };
}
/** 部署狀態記錄檔(只在「線上實測通過」之後才寫,見 deploy.mjs)。 */
export const STATE_FILE = join(ROOT, '.deploy-state.json');
export function readState() {
try {
return JSON.parse(readFileSync(STATE_FILE, 'utf8'));
} catch {
return {};
}
}
export function writeState(name, record) {
const state = readState();
state[name] = record;
writeFileSync(STATE_FILE, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
}
-220
View File
@@ -1,220 +0,0 @@
/**
* verify-live.mjs 線上網址現在真的在跑的那一份我們手上這一份
*
* 用法
* node scripts/verify-live.mjs 驗全部服役中目標的全部對外網址
* node scripts/verify-live.mjs personal 只驗某個目標
* node scripts/verify-live.mjs --wait 容忍 CF Pages 生效延遲重試
* node scripts/verify-live.mjs --url <網址> 只對某個網址驗世代不需要是宣告目標
* npm run verify
*
* 兩層缺一不可
* 組態層apiBaseprofile views/home deploy.targets.json 宣告值
* 世代層線上資產的位元組指紋 repo public/ 的指紋
*
* 為什麼要第二層2026-08-08leo已經發生過一次這個錯誤把舊版界面上到 prod
* 你要確定不可再犯當天實測三個對外網址第一層**三項全過**
* 而它們跑的是 07-22 那一代的 portal82,911 bytes金色 serif 舊品牌
* repo 343,969 bytes 的新品牌世代
* **組態可以完全正確同時展示一套早就被淘汰的介面而機械檢查一片綠**
* 第二層就是為了讓這個狀態不可能無聲存在
*
* 🔴 一律帶 no-cache快取害人誤判過curl|grep 不算驗前端 config.jsVIEWSHOME
* 與世代指紋都是**純文字資產比對**抓原始碼比對是這幾項的正確驗法
* 頁面真的能用另外走瀏覽器實載
* 🔴 frozen 目標 deploy.targets.json連抓都不抓不是我們的帳號不碰
*/
import {
GENERATION_ASSETS,
fingerprintOf,
generationOfDir,
loadTargets,
parseApiBase,
readState,
resolveTarget,
} from './targets.mjs';
const NOCACHE = { 'Cache-Control': 'no-cache', Pragma: 'no-cache' };
async function get(url) {
const res = await fetch(`${url}${url.includes('?') ? '&' : '?'}_nc=${Date.now()}`, {
headers: NOCACHE,
cache: 'no-store',
redirect: 'follow',
});
const buf = Buffer.from(await res.arrayBuffer());
return { status: res.status, bytes: buf, text: buf.toString('utf8') };
}
/** 抓線上的世代資產,算指紋。抓不到的當 MISSING(照樣算,缺檔本來就是另一代)。 */
async function liveGeneration(base) {
const entries = [];
const detail = {};
for (const { file, urlPath } of GENERATION_ASSETS) {
try {
const r = await get(`${base.replace(/\/$/, '')}${urlPath}`);
const ok = r.status === 200;
entries.push({ file, bytes: ok ? r.bytes : null });
detail[file] = { status: r.status, text: ok ? r.text : null };
} catch (e) {
entries.push({ file, bytes: null });
detail[file] = { status: `連線失敗:${e.message}`, text: null };
}
}
return { ...fingerprintOf(entries), detail };
}
/** 驗一個網址。t 給 null=只驗世代(ad-hoc 模式)。 */
export async function verifyUrl(t, url, want) {
const checks = [];
const base = url.replace(/\/$/, '');
const live = await liveGeneration(base);
// ── 世代層 ──────────────────────────────────────────────
const genOk = live.digest === want.digest;
const diffs = Object.entries(want.assets)
.filter(([f, a]) => live.assets[f]?.sha !== a.sha)
.map(([f, a]) => {
const l = live.assets[f] ?? {};
const st = live.detail[f]?.status;
return `${f}repo ${a.size ?? '缺'} bytes / 線上 ${l.missing ? `抓不到(${st}` : `${l.size} bytes`}`;
});
checks.push({
name: '世代',
ok: genOk,
want: `${want.digest.slice(0, 12)}repo public/`,
got: genOk
? `${live.digest.slice(0, 12)}`
: `${live.digest.slice(0, 12)}\n 不同的資產:\n ${diffs.join('\n ')}`,
});
if (!t) return { url, ok: genOk, checks };
// ── 組態層 ──────────────────────────────────────────────
try {
const cfg = await get(`${base}/config.js`);
const got = cfg.status === 200 ? parseApiBase(cfg.text) : `HTTP ${cfg.status}`;
checks.push({ name: 'apiBase', ok: got === t.apiBase, want: t.apiBase, got: got ?? '(config.js 裡找不到 apiBase)' });
} catch (e) {
checks.push({ name: 'apiBase', ok: false, want: t.apiBase, got: `連線失敗:${e.message}` });
}
const con = live.detail['console/index.html'];
const conText = con?.text;
const views = conText?.match(/var VIEWS = (\[[^\]]*\]);/);
const home = conText?.match(/var HOME = "([^"]*)";/);
const gotViews = conText ? (views ? views[1] : '(找不到 VIEWS)') : `HTTP ${con?.status}`;
const gotHome = conText ? (home ? home[1] : '(找不到 HOME)') : `HTTP ${con?.status}`;
checks.push({
name: `profile(${t.profile}).views`,
ok: gotViews === JSON.stringify(t.views),
want: JSON.stringify(t.views),
got: gotViews,
});
checks.push({ name: `profile(${t.profile}).home`, ok: gotHome === t.home, want: t.home, got: gotHome });
return { url, ok: checks.every((c) => c.ok), checks };
}
export async function verifyTarget(name, { wait = false } = {}) {
const t = resolveTarget(name);
if (t.frozen) return { name, target: t, skipped: true, ok: true, results: [] };
const want = generationOfDir();
const attempts = wait ? 8 : 1;
let results = [];
for (let i = 1; i <= attempts; i++) {
results = [];
for (const url of t.verifyUrls) results.push(await verifyUrl(t, url, want));
if (results.every((r) => r.ok) || i === attempts) break;
process.stdout.write(` … 尚未生效,5s 後重試(${i}/${attempts - 1}\n`);
await new Promise((r) => setTimeout(r, 5000));
}
return { name, target: t, ok: results.every((r) => r.ok), results };
}
export function printReport(reports) {
for (const r of reports) {
console.log(`\n${r.name}${r.target.description}`);
if (r.skipped) {
console.log(` ⏸️ 已凍結,不抓不驗:${r.target.frozen}`);
continue;
}
console.log(` 宣告:profile=${r.target.profile} apiBase=${r.target.apiBase}`);
for (const u of r.results) {
console.log(` ${u.ok ? '✅' : '❌'} ${u.url}`);
for (const c of u.checks) {
if (c.ok) console.log(`${c.name} = ${c.got}`);
else console.log(`${c.name}\n 我們手上:${c.want}\n 線上跑的:${c.got}`);
}
}
}
}
export async function verifyAll(names, opts) {
const reports = [];
for (const n of names) reports.push(await verifyTarget(n, opts));
return reports;
}
const isCli = process.argv[1] && import.meta.url === `file://${process.argv[1]}`;
if (isCli) {
const args = process.argv.slice(2);
const wait = args.includes('--wait');
const urlIdx = args.indexOf('--url');
if (args.includes('--offline-lag')) {
// 不連網,只問一句:「我手上這一代,有沒有真的送出去過?」
// 給 Stop hook 用(每回合都跑,所以不准連網、不准慢)。
// 唯一的事實來源是 .deploy-state.json,而它**只在線上實測通過後**才被寫(見 deploy.mjs
// ⇒ 它說綠就是真的有人驗過線上,不是「我跑過部署指令」。
const here = generationOfDir().digest;
const state = readState();
const stale = [];
for (const n of loadTargets().active) {
const s = state[n];
if (!s) stale.push(`${n}:沒有任何一次通過線上實測的部署紀錄(線上是哪一代,現在沒人知道)`);
else if (s.generation !== here) {
stale.push(`${n}:最後一次驗過的是 ${s.generation.slice(0, 12)}${s.verifiedAt.slice(0, 10)}),現在手上是 ${here.slice(0, 12)}`);
}
}
if (stale.length) {
console.log(stale.join('\n'));
process.exit(1);
}
process.exit(0);
}
if (urlIdx !== -1) {
// ad-hoc:只問「這個網址上跑的是不是當代的」——不需要它是宣告過的目標。
const url = args[urlIdx + 1];
if (!url) {
console.error('用法:node scripts/verify-live.mjs --url <網址>');
process.exit(2);
}
const want = generationOfDir();
const r = await verifyUrl(null, url, want);
console.log(`\n【世代檢查】${url}`);
for (const c of r.checks) {
if (c.ok) console.log(`${c.name} = ${c.got}`);
else console.log(`${c.name}\n 我們手上:${c.want}\n 線上跑的:${c.got}`);
}
if (!r.ok) {
console.error('\n❌ 這個網址上跑的不是當代的前端——它展示的是一套已經被淘汰的介面。');
process.exit(1);
}
console.log('\n✅ 這個網址上跑的=我們手上這一份。');
process.exit(0);
}
const picked = args.filter((a) => !a.startsWith('--'));
const names = picked.length ? picked : loadTargets().names;
const reports = await verifyAll(names, { wait });
printReport(reports);
const bad = reports.filter((r) => !r.ok);
if (bad.length) {
console.error(`\n${bad.length} 個目標與宣告/當代不符:${bad.map((b) => b.name).join('、')}`);
console.error(' (線上實際在跑的 ≠ 我們手上這一份——這正是要被擋掉的那個病)');
process.exit(1);
}
console.log('\n✅ 所有服役中目標:線上組態=宣告值,線上世代=repo 當代。');
}
-3
View File
@@ -1,3 +0,0 @@
name = "arcrun-console-ui"
pages_build_output_dir = "public"
compatibility_date = "2026-07-21"
+62 -37
View File
@@ -6,88 +6,113 @@
*
* (rule 02 §2.2):
* - **** credential / template / JWT
* - auth primitive WASM ( host function `secret_get` )
* - auth primitive WASM ( host function `crypto_decrypt` )
* - recipe primitive Worker+HTTP fetch
*
* `auth_static_key` + `auth_service_account` + `auth_oauth2`,
* Phase 4 `auth_mtls`mTLS handshake Worker runtime
*
* 執行時機:graph-executor runner , ctx :
* 1. dispatcher return enriched ctx
* 2. ctx (T10 injectCredentials fallback )
* 1. dispatcher( return enriched ctx)
* 2. fallback `injectCredentials`(Phase 1.9 )
*/
import type { Bindings } from '../types';
import { resolveAuthRecipe, resolveRecipe } from '../routes/recipes';
import { wasmWorkerUrl } from '../lib/component-loader';
import { createArcrunHostFunctions } from '../lib/wasi-shim';
import { getCredentialSecretRefs, touchLastUsed } from '../routes/credentials';
// ── credential-store 遷移 T6/T7(方案 AD19 D38 圍牆修復(2026-08-07───────────
// ── credential-store 遷移 T6/T7(方案 AD19────────────────────────────────
//
// 密文值住 cypher-executor 自己的 per-script secretsT5 寫入)。解密發生在獨立的
// auth_static_key / auth_service_account worker 上,它們讀不到 cypher 的 secrets。
// 故 cypher 這一層先取這個租戶的 credential 目錄(name → secret_ref→ 用 secret_get(ref)
// (即 env[ref]T4)取明文 → 塞進送給 auth WASM 的 payload 新欄位 `resolved_secrets`。
// WASM 收到優先用它,沒有才 fallback 舊 KV + crypto_decrypt(那個 fallback 即 T7 雙讀)。
// 故 cypher 這一層先查 D1 拿 secret_ref → 用 secret_get(ref)(即 env[ref]T4)取明文
// → 塞進送給 auth WASM 的 payload 新欄位 `resolved_secrets`。WASM 收到優先用它,沒有
// 才 fallback 舊 KV + crypto_decrypt(那個 fallback 即 T7 雙讀)。
//
// D38leo 2026-06-14 立、2026-08-07 擴大):目錄不再直連 D1,改走 KBDB HTTP API
// `credentials.ts` 的 `getCredentialSecretRefs`,內建 60 秒租戶級快取——這是熱路徑,
// 每次 workflow 執行都會呼叫,映射「幾乎不變」故快取後多數命中零網路呼叫,效能不因改走
// API 而變差,見 credentials.ts 檔頭「效能」段的實測數字)。
//
// 嚴格邊界(rule 02 §2.2):本檔只做「查目錄拿 ref → secret_get 取值 → 當字串塞 payload」。
// 嚴格邊界(rule 02 §2.2):本檔只做「查 D1 ref → secret_get 取值 → 當字串塞 payload」。
// **不解密、不展開模板、不組 JWT**——secret_get 的實作(env[ref])在 wasi-shim host function
// 內,解密/注入邏輯仍全在 WASM 零件。
/** D1 credentials 目錄一列(只取本檔需要的欄位)。 */
interface CredentialRefRow {
name: string;
secret_ref: string;
}
/**
* credential namecypher per-script secrets
*
* KBDB credential api_key + name `secret_ref`
* `secret_get(ref)`host function = env[ref]
* D1 `credentials`api_key + name `secret_ref` `secret_get(ref)`
* host function = env[ref]
*
* ref secret_get name map ref
* D1 ref secret_get name map ref
* secret_get null ** name **
* WASM key fallback KV T7 WASM
*
* name last_used_at§2.5 last_used touchLastUsed
* fire-and-forget
* name D1 `last_used_at`§2.5 last_used
*
* KBDB / credential map fallback
* D1 / migration / CREDENTIALS_DB map fallback
* throw
*/
/** credential name → 明文值對照(獨立型別別名,避免函式簽章直接內嵌逗號分隔泛型)。 */
type ResolvedSecretMap = Record<string, string>;
export async function resolveSecretsFromNewHome(
env: Bindings,
apiKey: string,
names: string[],
): Promise<ResolvedSecretMap> {
const resolved: ResolvedSecretMap = {};
): Promise<Record<string, string>> {
const resolved: Record<string, string> = {};
if (names.length === 0) return resolved;
// 1. 拿這個租戶的 credential 目錄(name → secret_ref,快取層見 credentials.ts
const refs = await getCredentialSecretRefs(env, apiKey);
if (Object.keys(refs).length === 0) return resolved; // 目錄空 / KBDB 不可達 → 整組走 fallback
const db = env.CREDENTIALS_DB;
if (!db) return resolved; // 未綁 D1 → 整組走 fallback
// 1. 查 D1 拿每個 name 的 secret_ref
let rows: CredentialRefRow[];
try {
const placeholders = names.map(() => '?').join(', ');
const result = await db
.prepare(
`SELECT name, secret_ref FROM credentials
WHERE api_key = ? AND name IN (${placeholders})`,
)
.bind(apiKey, ...names)
.all<CredentialRefRow>();
rows = result.results ?? [];
} catch {
// D1 未建表 / query 失敗 → 過渡期整組走 fallback(雙讀),不假綠
return resolved;
}
if (rows.length === 0) return resolved;
// 2. 用 secret_ref 從新家取值(host function secret_get = env[ref]
const secretGet = createArcrunHostFunctions(env, apiKey).secret_get;
if (!secretGet) return resolved; // host function 未就緒 → 走 fallback
const resolvedNames: string[] = [];
for (const name of names) {
const ref = refs[name];
if (!ref) continue; // 目錄沒這個 name → 缺席,走 fallback
const value = await secretGet(ref);
for (const row of rows) {
const value = await secretGet(row.secret_ref);
// null(新家沒這把值 / 非 CRED_ 前綴被拒)→ 不放進 map,讓 WASM fallback 舊 KV
if (value === null) continue;
resolved[name] = value;
resolvedNames.push(name);
resolved[row.name] = value;
resolvedNames.push(row.name);
}
// 3. 順手更新 last_used_at(只更新真的從新家取到值的 namefire-and-forget,非關鍵路徑
if (resolvedNames.length > 0) touchLastUsed(env, apiKey, resolvedNames);
// 3. 順手更新 last_used_at(只更新真的從新家取到值的 name)
if (resolvedNames.length > 0) {
try {
const now = Math.floor(Date.now() / 1000);
const placeholders = resolvedNames.map(() => '?').join(', ');
await db
.prepare(
`UPDATE credentials SET last_used_at = ?
WHERE api_key = ? AND name IN (${placeholders})`,
)
.bind(now, apiKey, ...resolvedNames)
.run();
} catch {
// last_used 更新失敗不影響注入主流程(治理面欄位,非關鍵路徑)
}
}
return resolved;
}
@@ -225,7 +250,7 @@ function replaceCredentialRefs(value: unknown, resolved: Record<string, string>)
*
* rule 02 §2.2**** {{credential.X}} names
* auth_static_key WASM `resolve_credentials` actionWASM kv_get + crypto_decrypt
*
* ENCRYPTION_KEY
*
* - {{credential.}} WASM
* - / credential throw
@@ -0,0 +1,235 @@
/**
* Credential Injector
*
*
* 1. auth recipeauth_recipe:{componentId} in RECIPES KV
* auth recipe static_key, service_account
* flat injection
*
* Auth Recipe
* - static_key inject.header/query/body {{secret.KEY}}
* - service_accountJWT signing token exchange {{runtime.access_token}}
* - _auth_headers / _auth_query / _auth_body
*
*
* - RECIPES KV credentials_required recipe
* - BUILTIN_CREDENTIALS_MAP
* - inject_as context
*/
import type { Bindings } from '../types';
import { resolveRecipe, resolveAuthRecipe } from '../routes/recipes';
import type { AuthRecipeDefinition } from '../routes/recipes';
export interface CredentialRequirement {
key: string; // CREDENTIALS_KV 的 credential 名稱(如 gmail_token
inject_as: string; // 注入到 input 的欄位名稱(如 access_token
}
/** 內建 API recipe 的 credentials_required(對應 component-loader 的 BUILTIN_API_RECIPES*/
const BUILTIN_CREDENTIALS_MAP: Record<string, CredentialRequirement[]> = {
gmail: [{ key: 'gmail_token', inject_as: 'access_token' }],
google_sheets: [{ key: 'google_oauth', inject_as: 'access_token' }],
telegram: [{ key: 'telegram_bot_token', inject_as: 'bot_token' }],
line_notify: [{ key: 'line_token', inject_as: 'token' }],
};
// ── AES-GCM 解密 ──────────────────────────────────────────────────────────────
async function decryptCredential(encryptedJson: string, encryptionKey: string): Promise<string> {
const { encrypted, iv } = JSON.parse(encryptedJson) as { encrypted: string; iv: string };
const keyBytes = hexToUint8Array(encryptionKey);
const cryptoKey = await crypto.subtle.importKey(
'raw', keyBytes, { name: 'AES-GCM' }, false, ['decrypt'],
);
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: base64ToUint8Array(iv) },
cryptoKey,
base64ToUint8Array(encrypted),
);
return new TextDecoder().decode(decrypted);
}
function hexToUint8Array(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);
return bytes;
}
function base64ToUint8Array(b64: string): Uint8Array {
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}
// ── 解密所有 required_secrets → { key: decryptedValue } ──────────────────────
async function decryptSecrets(
recipe: AuthRecipeDefinition,
apiKey: string,
env: Bindings,
): Promise<Record<string, string>> {
const result: Record<string, string> = {};
for (const req of recipe.required_secrets) {
if (req.optional) continue;
const kvKey = `${apiKey}:cred:${req.key}`;
const record = await env.CREDENTIALS_KV.get(kvKey);
if (!record) {
throw new Error(
`缺少 credential${req.key}${req.label}\n` +
`修復步驟:\n` +
` 1. 在 credentials.yaml 加入 ${req.key}: "your-value"\n` +
` 2. 執行:acr creds push`,
);
}
result[req.key] = await decryptCredential(record, env.ENCRYPTION_KEY);
}
return result;
}
// ── Template 展開:{{secret.KEY}} 和 {{runtime.KEY}} ─────────────────────────
function interpolateTemplate(
template: string,
secrets: Record<string, string>,
runtime: Record<string, string>,
): string {
return template.replace(/\{\{(secret|runtime)\.(\w+)\}\}/g, (_, ns, key) => {
if (ns === 'secret') return secrets[key] ?? '';
if (ns === 'runtime') return runtime[key] ?? '';
return '';
});
}
function interpolateRecord(
record: Record<string, string>,
secrets: Record<string, string>,
runtime: Record<string, string>,
): Record<string, string> {
const result: Record<string, string> = {};
for (const [k, v] of Object.entries(record)) {
result[k] = interpolateTemplate(v, secrets, runtime);
}
return result;
}
// ── Auth Recipe 注入(新路徑)────────────────────────────────────────────────
async function injectFromAuthRecipe(
recipe: AuthRecipeDefinition,
input: Record<string, unknown>,
env: Bindings,
apiKey: string,
): Promise<Record<string, unknown>> {
// 解密所有 required_secrets
const secrets = await decryptSecrets(recipe, apiKey, env);
// runtime tokenservice_account 路徑已改走 auth-dispatcher → auth_service_account WASM;
// 這條 TS fallback 只處理 static_key (runtime 為空即可),service_account 永遠不會走到這裡
const runtime: Record<string, string> = {};
if (recipe.primitive === 'service_account') {
throw new Error(
`service_account primitive 應由 auth-dispatcher → auth_service_account WASM 處理,` +
`不應進到 credential-injector TS fallback (service=${recipe.service})`,
);
}
// 展開 inject 模板
const authHeaders = recipe.inject.header
? interpolateRecord(recipe.inject.header, secrets, runtime)
: {};
const authQuery = recipe.inject.query
? interpolateRecord(recipe.inject.query, secrets, runtime)
: {};
const authBody = recipe.inject.body
? interpolateRecord(recipe.inject.body, secrets, runtime)
: {};
return {
...input,
_auth_headers: authHeaders,
_auth_query: authQuery,
_auth_body: authBody,
};
}
// ── 舊有路徑:flat injection(向後相容)──────────────────────────────────────
async function loadCredentialsRequired(
componentId: string,
env: Bindings,
): Promise<CredentialRequirement[]> {
const recipe = await resolveRecipe(componentId, env.RECIPES);
if (recipe?.credentials_required?.length) {
return recipe.credentials_required;
}
return BUILTIN_CREDENTIALS_MAP[componentId] ?? [];
}
// ── 主入口 ────────────────────────────────────────────────────────────────────
/**
* credential
*
* @param componentId - canonical_id hash
* @param input - merged context
* @param env - Cloudflare Worker Bindings
* @param apiKey - API Keyak_前綴 KV namespace
*/
export async function injectCredentials(
componentId: string,
input: Record<string, unknown>,
env: Bindings,
apiKey?: string,
): Promise<Record<string, unknown>> {
// 沒有 api_key → local 模式,略過
if (!apiKey) return input;
// ── 新路徑:auth recipe ──
const authRecipe = await resolveAuthRecipe(componentId, env.RECIPES);
if (authRecipe) {
return injectFromAuthRecipe(authRecipe, input, env, apiKey);
}
// ── 舊路徑:flat injection(向後相容)──
const required = await loadCredentialsRequired(componentId, env);
if (required.length === 0) return input;
const enriched = { ...input };
for (const cred of required) {
const kvKey = `${apiKey}:cred:${cred.key}`;
const record = await env.CREDENTIALS_KV.get(kvKey);
if (!record) {
throw new Error(
`缺少 credential${cred.key}\n` +
`修復步驟:\n` +
` 1. 在 credentials.yaml 中加入 ${cred.key}: "your-token"\n` +
` 2. 執行:acr creds push`,
);
}
try {
const decrypted = await decryptCredential(record, env.ENCRYPTION_KEY);
enriched[cred.inject_as] = decrypted;
} catch (e) {
throw new Error(
`credential "${cred.key}" 解密失敗:${e instanceof Error ? e.message : String(e)}\n` +
`修復步驟:重新執行 acr creds push。`,
);
}
}
return enriched;
}
+29 -21
View File
@@ -3,33 +3,24 @@ import { ExecutionError, WorkflowPaused } from '../types';
import { GraphExecutor } from '../graph-executor';
import { graphSchema } from '../lib/schemas';
import { createComponentLoader } from '../lib/component-loader';
import { recordComponentStats } from './execution-evaluator';
import { writeEvaluation, updateComponentStats } from './execution-evaluator';
import { parseTriplets } from './triplet-parser';
import { searchNodes, type SearchMode, type SearchTarget } from './search-nodes';
import { searchNodes } from './search-nodes';
import { buildExecutionGraph } from './graph-builder';
export async function handleCypherSearch(
triplets: unknown[],
env: Bindings,
mode: SearchMode = 'discover',
target?: SearchTarget,
): Promise<{ nodes: Record<string, unknown>; cypher: unknown; missing: string[] }> {
const parsed = parseTriplets(triplets);
if (!parsed) {
throw new Error('無法解析任何節點');
}
// 2026-07-30:查 registry 判真實存在(workflow-discovery)。
// `missing` 以前寫死 [],等於告訴 AI「什麼都有」——那是「腹語術」的入口。
//
// t15807-31 迴歸修復,leo:「這裡只是複製一些工作流的 data 過去,沒有要在這裡驗證」):
// 誠實化只屬於 **discover**AI 問「有沒有」);**compile**(部署/推送的複製路徑)
// 純編圖零查詢——那本來就是既有設計(workflows.json=打包期預編的搬運),
// 5cadc60 起誠實化漏進複製路徑=迴歸(冷實例 8 節點 25.7s、安裝器 timeout 炸)。
const { nodeResults, missingNodes } = await searchNodes(parsed, undefined, env, mode, target);
const { nodeResults } = searchNodes(parsed);
const graph = buildExecutionGraph(parsed, nodeResults, 'cypher-search-result', 'Cypher Search Result');
return { nodes: nodeResults, cypher: { nodes: graph.nodes, edges: graph.edges }, missing: missingNodes };
return { nodes: nodeResults, cypher: { nodes: graph.nodes, edges: graph.edges }, missing: [] };
}
export async function handleCypherExecute(
@@ -59,9 +50,7 @@ export async function handleCypherExecute(
throw new Error('無法解析任何節點');
}
// t158:執行路徑=compile(零 discovery round-trip)——存在性由 component-loader
// 在載入該節點時決定(原本的權威),查詢層不重複驗。
const { nodeResults } = await searchNodes(parsed, config, env, 'compile');
const { nodeResults } = searchNodes(parsed, config);
const graph = buildExecutionGraph(parsed, nodeResults, graphId, graphName, config);
const parseResult = graphSchema.safeParse(graph);
@@ -77,8 +66,18 @@ export async function handleCypherExecute(
const result = await executor.execute(parseResult.data as ExecutionGraph, context ?? {}, env.EXEC_CONTEXT);
const duration_ms = Date.now() - start;
// 非同步回寫每顆零件的執行統計(design.md「執行統計設計」;fire-and-forget 不阻擋回應
waitUntil(recordComponentStats(env, graph.nodes, result.trace));
// 非同步記錄統計(Phase 7 補充 analytics,目前為 no-op
const componentId = graph.nodes.find(n => n.componentId)?.componentId ?? graphId;
const runId = `${graphId}-${Date.now()}`;
waitUntil(writeEvaluation(env, {
run_id: runId,
workflow_id: graphId,
component_id: componentId,
verdict: 'success',
duration_ms,
evaluated_at: Date.now(),
}));
waitUntil(updateComponentStats(env, componentId, 'success', duration_ms));
return { success: true, data: result.data, trace: result.trace, duration_ms, graph };
} catch (err) {
@@ -100,10 +99,19 @@ export async function handleCypherExecute(
}
const errMsg = err instanceof Error ? err.message : String(err);
// 失敗路徑同樣回寫每顆零件統計:ExecutionError 帶完整 trace(失敗節點有 error、
// 之前成功的節點照記成功);非 ExecutionError 無 trace 可歸因 → 不記(誠實:不瞎猜)。
const componentId = graph.nodes.find(n => n.componentId)?.componentId ?? graphId;
const runId = `${graphId}-${Date.now()}`;
waitUntil(writeEvaluation(env, {
run_id: runId,
workflow_id: graphId,
component_id: componentId,
verdict: 'failed',
duration_ms,
error_message: errMsg.slice(0, 200),
evaluated_at: Date.now(),
}));
waitUntil(updateComponentStats(env, componentId, 'failed', duration_ms));
if (err instanceof ExecutionError) {
waitUntil(recordComponentStats(env, graph.nodes, err.trace));
const traceFormatted = err.trace.map(s => ({
node: s.nodeId,
status: s.error ? 'failed' : 'success',
@@ -1,96 +1,36 @@
/**
* Execution Analytics
* Execution Analytics
*
* SDD: system-dev/docs/3-specs/arcrun-core-mvp/design.md
* cypher-handlers / webhook-handlers ****
* fire-and-forget POST registry `/analytics/record`
* waitUntil 仿 recordRecipeStats / recordTelemetry
*
* traceper-node
* - trace step `error` runner throw
* - output `success === false` makeHttpRunner 2xx throw
* -
* FOREACH trace
* Phase 1 MVPstub
* Phase 7 fire-and-forget POST registry.arcrun.dev/analytics/record
*/
import type { GraphNode, TraceStep } from '../types';
import { wasmWorkerUrl } from '../lib/component-loader';
import type { Bindings } from '../types';
/** 本模組需要的環境子集(傳整份 Bindings 也相容,仿 SearchNodesEnv 慣例)。 */
export type AnalyticsEnv = {
WORKER_SUBDOMAIN?: string;
/** registry 位置覆蓋(可選;本地 wrangler dev / self-hosted 用)。未設 → wasmWorkerUrl('registry', WORKER_SUBDOMAIN)。 */
REGISTRY_BASE_URL?: string;
};
export interface ComponentVerdict {
export interface EvaluationRecord {
run_id: string;
workflow_id: string;
component_id: string;
success: boolean;
verdict: 'success' | 'failed' | 'timeout';
duration_ms: number;
error_message?: string;
evaluated_at: number;
}
/** 從執行 trace 導出每顆零件的成敗(只算 type=Component 且有 componentId 的節點)。 */
export function componentVerdictsFromTrace(
nodes: GraphNode[],
trace: TraceStep[],
): ComponentVerdict[] {
const componentByNodeId = new Map<string, string>();
for (const n of nodes) {
if (n.type === 'Component' && n.componentId) componentByNodeId.set(n.id, n.componentId);
}
const verdicts: ComponentVerdict[] = [];
for (const step of trace) {
const componentId = componentByNodeId.get(step.nodeId);
if (!componentId) continue;
const out = step.output;
const outputSaysFailed =
typeof out === 'object' && out !== null && !Array.isArray(out) &&
(out as Record<string, unknown>).success === false;
verdicts.push({
component_id: componentId,
success: !step.error && !outputSaysFailed,
duration_ms: Math.max(0, Number(step.duration_ms) || 0),
});
}
return verdicts;
}
/**
* registrydesign.mdAnalytics Record
* throw waitUntil
*/
export async function recordComponentStats(
env: AnalyticsEnv,
nodes: GraphNode[],
trace: TraceStep[],
/** 記錄執行結果(MVPno-opPhase 7 補充 analytics*/
export async function writeEvaluation(
_env: Bindings,
_record: EvaluationRecord,
): Promise<void> {
try {
const base = (
env.REGISTRY_BASE_URL ??
(env.WORKER_SUBDOMAIN ? wasmWorkerUrl('registry', env.WORKER_SUBDOMAIN) : undefined)
)?.replace(/\/$/, '');
if (!base) return;
const verdicts = componentVerdictsFromTrace(nodes, trace);
if (verdicts.length === 0) return;
await Promise.all(
verdicts.map(v =>
fetch(`${base}/analytics/record`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
canonical_id: v.component_id,
success: v.success,
duration_ms: v.duration_ms,
}),
}).catch(() => undefined), // 統計失敗不影響執行
),
);
} catch {
// fire-and-forget:不拋錯,不影響主流程
}
// Phase 7: POST to registry.arcrun.dev/analytics/record
}
/** 更新零件統計(MVPno-opPhase 7 補充)*/
export async function updateComponentStats(
_env: Bindings,
_componentId: string,
_verdict: 'success' | 'failed' | 'timeout',
_durationMs: number,
): Promise<void> {
// Phase 7: update ANALYTICS_KV via registry worker
}
+25 -55
View File
@@ -1,56 +1,24 @@
/**
* Execution Logger KBDBfire-and-forget
* Execution Logger ANALYTICS_KVfire-and-forget
*
* KV 2026-08-07 ANALYTICS_KVWorkers KV
* key = stats:{workflowId}:{timestamp}
* Evan 690 KV write 1,000/ 1,070 write
*
* KBDB leo 2026-06-14KBDBAPI-as-Wall SQL KBDB HTTP API
* D1 SQL** D1** fire-and-forget POST
* `{KBDB_BASE_URL}/execution-log/record`/ recordRecipeStats
* webhook-handlers.ts/ kbdb/src/actions/execution-log.ts
*
* leo
* D1entries rows written 100,000/ KV 100
* n8n Execution /workflow/verdict/
* duration//() KBDB
*
* A2 KBDB execution-log.tsD1
* KBDB mode='skip'/'log_failure_only'****
* KBDB fire-and-forget throwworkflow
* workflow ANALYTICS_KVkey = stats:{workflowId}
* Phase 7 POST registry.arcrun.dev/analytics/record
*/
import type { Bindings, GraphNode } from '../types';
import { kbdbBase } from '../routes/kbdb-proxy';
export interface ExecutionVerdict {
workflow_id: string;
component_ids: string[];
verdict: 'success' | 'failed';
duration_ms: number;
message: string;
target?: string;
recorded_at: string;
}
/**
* trigger context page_name / path
* key/
*/
function extractTarget(input?: Record<string, unknown>): string | undefined {
if (!input) return undefined;
const raw = input.page_name ?? input.path;
if (raw === undefined || raw === null) return undefined;
return typeof raw === 'string' ? raw : JSON.stringify(raw);
}
/**
* KBDBfire-and-forget
* c.executionCtx.waitUntil()
*
* @param nodes component_ids
* 使
* @param input trigger context page_name / path target
*
* @param apiKey /execute
* ANALYTICS_KVfire-and-forget
* c.executionCtx.waitUntil()
*/
export async function writeExecutionVerdict(
env: Bindings,
@@ -59,25 +27,27 @@ export async function writeExecutionVerdict(
verdict: 'success' | 'failed',
durationMs: number,
message: string,
input?: Record<string, unknown>,
apiKey?: string,
): Promise<void> {
void nodes; // 少記:不再從節點算 component_ids,保留參數只為呼叫端相容
try {
const { base, headers } = kbdbBase(env);
await fetch(`${base}/execution-log/record`, {
method: 'POST',
headers,
body: JSON.stringify({
workflow_id: workflowId,
owner_id: apiKey ?? null,
verdict,
duration_ms: Math.max(0, Math.round(durationMs)),
message: message ?? '',
target: extractTarget(input) ?? null,
}),
const componentIds = nodes
.filter(n => n.type === 'Component' && n.componentId)
.map(n => n.componentId!);
const record: ExecutionVerdict = {
workflow_id: workflowId,
component_ids: componentIds,
verdict,
duration_ms: durationMs,
message,
recorded_at: new Date().toISOString(),
};
// ANALYTICS_KV key = stats:{workflowId}:{timestamp}(避免覆蓋)
const key = `stats:${workflowId}:${Date.now()}`;
await env.ANALYTICS_KV.put(key, JSON.stringify(record), {
expirationTtl: 60 * 60 * 24 * 90, // 保留 90 天
});
} catch {
// fire-and-forget任何錯誤(含 KBDB 端額度打滿、網路失敗)都吞掉、不影響主流程
// fire-and-forget不拋錯,不影響主流程
}
}
+1 -17
View File
@@ -43,28 +43,12 @@ export function buildExecutionGraph(
iterator = foreachMatch[1];
label = '對每個'; // 改回標準 label 走 SEMANTIC_EDGE_MAP
}
// 「ON_BRANCH(標籤)」抽 branch:意圖語法表達具名分支(SDD workflow-discovery 3.11
// 例:'my_switch >> ON_BRANCH(branch_active) >> 處理啟用' → type=ON_BRANCH, branch='branch_active'
// 沒有這段的話,帶括號的 label 會落到 toEdgeType 的預設值 PIPE ⇒ 分支靜默失效
// (即「教了語法但引擎不收」——比沒做更糟,故與 skill 文件同批補上)
let branch: string | undefined;
const branchMatch = label.match(/^(?:ON_BRANCH|分支)\s*[(]\s*([\w-]+)\s*[)]$/i);
if (branchMatch) {
branch = branchMatch[1];
label = 'ON_BRANCH';
}
const edge: {
from: string; to: string; type: ReturnType<typeof toEdgeType>;
iterator?: string; branch?: string;
} = {
const edge: { from: string; to: string; type: ReturnType<typeof toEdgeType>; iterator?: string } = {
from: e.from.toLowerCase().replace(/\s+/g, '-'),
to: e.to.toLowerCase().replace(/\s+/g, '-'),
type: toEdgeType(label),
};
if (iterator) edge.iterator = iterator;
if (branch) edge.branch = branch;
return edge;
});
+17 -721
View File
@@ -1,744 +1,40 @@
import type { ParsedTriplets, NodeRole } from './triplet-parser';
import { resolveNodeRole, isVirtualIoName } from './triplet-parser';
import { wasmWorkerUrl, RUNTIME_NATIVE_COMPONENT_IDS } from '../lib/component-loader';
import { resolveRecipe } from '../routes/recipes';
import type { RecipeDefinition } from '../routes/recipes';
import { branchHintFor } from '../lib/branch-hints';
import type { BranchHint } from '../lib/branch-hints';
/**
* `not_found` `missing`
* `system-dev/docs/3-specs/arcrun-usable/verify.sh` 01 grep `not_found`
*/
/** `unchecked`compile 模式的誠實標記:沒查、不知道有沒有(≠found 的假信號)。 */
/** `resolved`=意圖節點被媒合替換成真實零件/recipe(步驟 4;≠字面 exact 的 found)。 */
export type NodeStatus = 'found' | 'not_found' | 'unknown' | 'unchecked' | 'resolved';
/**
* /recipe CP 4workflow-discovery 3.x
* CP AI payload telegram
* `http_request`recipe `telegram_send`
*/
export type NodeSubstitution = {
/** 原始意圖節點名(替換前)。 */
from: string;
/**
* component
* recipe `http_request`recipe http_request
*/
componentId: string;
/** recipe 替換時的 canonical_id——workflow config 寫 `component: <此值>` 即可直接用。 */
recipe?: string;
/** 為什麼這樣換(簡單可解釋規則的命中說明,不接 LLM)。 */
reason: string;
};
/**
* leo 07-31 recipe
* `component` registry`recipe` recipe
* `workflow` workflow route /workflows/search
*/
export type SearchTarget = 'component' | 'recipe';
export type NodeInfo = {
status: NodeStatus;
componentId?: string;
type: NodeRole;
/**
* found registrycomponentrecipe recipe
* cypher-executor registry builtin
* Arcrun#88component-loader.ts RUNTIME_NATIVE_COMPONENT_IDS
*/
source?: 'component' | 'recipe' | 'builtin';
/** 零件契約(found 時附上,讓 AI 知道怎麼填 payload)。 */
input_schema?: unknown;
/** 成功率(found 時附上,讓「被測過幾次」看得見)。 */
success_rate?: number;
stability?: string;
/** recipe found 時附上(AI 看得懂這個 recipe 在打哪個 API)。 */
description?: string;
endpoint?: string;
/**
* recipe payload3.12 branch_hint
* recipe endpoint payload 退 code
*/
payload_hint?: {
/** 這個 recipe 期望的 body 形狀(body_template 的欄位骨架,值是 {{var}} 佔位) */
body_template?: unknown;
/** 回應正規化規則存在時,說明取值路徑等 */
response_map?: unknown;
/** 一行說明:怎麼用這個 recipe */
usage: string;
};
/**
* not_found task 3.7 registryrecipe
* AI `suggestion`verify.sh 03
*/
suggestion?: string;
/** not_found 時的相近零件候選(自然語言節點名 → 既有零件的媒合)。 */
similar_components?: string[];
/** not_found 時的相近 recipe 候選。 */
similar_recipes?: string[];
/** resolved 時的替換明細(步驟 4:意圖節點 → 真實零件/recipe)。 */
substitution?: NodeSubstitution;
/**
* 3.11
* if_controlswitchtry_catch
* n8n AI input_schema
* code
*/
branch_hint?: BranchHint;
};
import { resolveNodeRole } from './triplet-parser';
export type SearchResult = {
nodeResults: Record<string, NodeInfo>;
nodeResults: Record<string, { status: 'found' | 'missing'; componentId?: string; type: NodeRole }>;
missingNodes: string[];
};
/**
* t158leo 07-31 調
* data
*
*
* - `compile`**** registry recipe missing
* // workflow
* - `discover``/cypher/search` AI
* not_found
* ID
*
*
* component-loader Service Binding / KV / URL
*
*
* 1. Input/Output componentId =
* 2. config[nodeName].component 使 config componentId
* 3. componentId = component-loader
*/
export type SearchMode = 'discover' | 'compile';
/** searchNodes 需要的環境子集(cypher-handlers 傳整份 Bindings 進來也相容)。 */
export type SearchNodesEnv = {
WORKER_SUBDOMAIN?: string;
/**
* registry wasmWorkerUrl('registry', WORKER_SUBDOMAIN)
* KBDB_GRAPH_URL wrangler dev / self-hosted registry
*/
REGISTRY_BASE_URL?: string;
/** recipe 庫(本 worker 自己的 KV;task 3.6 兩庫都查的第二庫)。 */
RECIPES?: KVNamespace;
};
/**
* recipe ****
*
* 2026-07-30 registryworkflow-discovery task 3.x
* 2026-07-31 recipe task 3.63.7
*
* `status: 'found'``missingNodes`
* 西xyz found
*
* leo 2026-07-30
* AI found
* / `code` JS
* workflow 2 8 code if×61
* 1000 AI payload
*
* 調leo 2026-07-31 調****
* registryrecipe
* API recipeskill `write_recipe`
* 稿 PRskill `add_new_wasm_component`
*
* registry `'unknown'` `'not_found'`
* AI code
*/
export async function searchNodes(
export function searchNodes(
parsed: ParsedTriplets,
config?: Record<string, Record<string, unknown>>,
env?: SearchNodesEnv,
mode: SearchMode = 'discover',
target?: SearchTarget,
): Promise<SearchResult> {
const nodeResults: Record<string, NodeInfo> = {};
const missingNodes: string[] = [];
// ── compile:純編圖,零外部查詢(t158,部署≠發現)─────────────────────────
if (mode === 'compile') {
for (const nodeName of parsed.nodeNames) {
const role = resolveNodeRole(nodeName, parsed);
if ((role === 'Input' || role === 'Output') && isVirtualIoName(nodeName)) {
nodeResults[nodeName] = { status: 'found', componentId: nodeName.toLowerCase(), type: role };
continue;
}
const configComponent = config?.[nodeName]?.component as string | undefined;
// unchecked=誠實「沒查」;存在性由 component-loader 在執行時決定
nodeResults[nodeName] = {
status: configComponent ? 'found' : 'unchecked',
componentId: configComponent ?? nodeName,
type: role,
};
}
return { nodeResults, missingNodes };
}
const sub = env?.WORKER_SUBDOMAIN;
const registryBase = env?.REGISTRY_BASE_URL ?? (sub ? wasmWorkerUrl('registry', sub) : undefined);
// target 限庫(leo 07-31):component=只查零件 registryrecipe=只查 recipe 庫。
// 不給=混搜兩庫(既有行為)。
const wantComponents = target !== 'recipe';
const wantRecipes = target !== 'component';
// ── discover 批次化(t158):兩庫各抓**一次**,之後全在記憶體內比對。────────
// 病史(07-31 stage 實測):舊版對每個 missing 節點各打「1 次逐顆查+最多 9 次
// 相似搜尋+一輪 recipe KV 掃描」⇒ 冷實例 8 節點 /cypher/search 25.7s
// 安裝器 15s timeout 必炸。批次化後每 request 固定 1 次 catalog1 次 recipe 清單。
// 步驟 4 的意圖替換也在**同一份清單**上做——不加任何新 round-trip。
const catalog = !wantComponents
? { status: 'ok' as const, entries: [] } // target=reciperegistry 不參與,不因此回 unknown
: registryBase ? await fetchCatalog(registryBase) : { status: 'unreachable' as const, entries: [] };
const recipes = wantRecipes && env?.RECIPES ? await listAllRecipes(env.RECIPES) : [];
const byId = new Map<string, CatalogFullRecord>();
for (const e of catalog.entries) {
const prev = byId.get(e.canonical_id);
if (!prev || (e.score ?? 0) > (prev.score ?? 0)) byId.set(e.canonical_id, e);
for (const a of e.aliases ?? []) if (!byId.has(a)) byId.set(a, e);
}
): SearchResult {
const nodeResults: Record<string, { status: 'found' | 'missing'; componentId?: string; type: NodeRole }> = {};
for (const nodeName of parsed.nodeNames) {
const role = resolveNodeRole(nodeName, parsed);
// 只有**字面上的虛擬 IO 名**input/trigger/…/output/done)才免查——
// 位置上是頭節點但名字是真零件(`aes_encrypt >> … >> code` 的頭,role 也是 Input
// 仍要照常查兩庫,否則缺件被角色掩蓋、又回到「假 found」。
if ((role === 'Input' || role === 'Output') && isVirtualIoName(nodeName)) {
if (role === 'Input' || role === 'Output') {
nodeResults[nodeName] = { status: 'found', componentId: nodeName.toLowerCase(), type: role };
continue;
}
const configComponent = config?.[nodeName]?.component as string | undefined;
const componentId = configComponent ?? nodeName;
// config 明確給了 component(多半是安裝器代入的 worker URL 或既有 workflow
// → 不判 not_found。這條路徑的存在性由 component-loader 在執行時決定(原行為)。
if (configComponent) {
nodeResults[nodeName] = { status: 'found', componentId, type: role };
continue;
}
// ── 執行期原生零件(Arcrun#88):查 registry 之前先比對 ──────────────────
// component-loader.ts 的 RUNTIME_NATIVE_COMPONENT_IDStrigger_workflow
// BUILTIN_COMPONENTSLOGIC_BINDING_MAPWASM_HTTP_RUNNER_IDS 的聯集——
// 這些零件 cypher-executor 自己就能 resolve,從不查 registry,執行期保證解析得動。
// 病史:registry 是空的/未部署新版 `/catalog` 端點時,這批零件(if_control
// http_requestswitch…)會被下面「兩庫都查過沒有」誤判成 not_found——
// 而 leo 08-11 實測探測工作流證明它們跑得動。命中即 found,不受 registry 健康狀態影響。
// target=recipe(使用者明確只要查 recipe 庫)不適用——這些從來不是 recipe。
if (wantComponents && RUNTIME_NATIVE_COMPONENT_IDS.has(componentId)) {
nodeResults[nodeName] = {
status: 'found',
componentId,
type: role,
source: 'builtin',
branch_hint: branchHintFor(componentId),
};
continue;
}
// registry 完全查不通(未部署/網路失敗)⇒ 誠實回 unknown。
// **不能誤判 not_found**——那會讓 AI 以為零件不存在而重寫 code,正是要避免的事。
// 舊 registry 沒有 /catalog 端點(no_endpoint)→ 退回逐顆查(相容路徑)。
if (catalog.status === 'unreachable') {
nodeResults[nodeName] = { status: 'unknown', componentId, type: role };
continue;
}
if (catalog.status === 'no_endpoint') {
const legacy = await legacyPerNodeLookup(registryBase!, componentId, nodeName, role, env, recipes);
nodeResults[nodeName] = legacy.info;
if (legacy.missing) missingNodes.push(nodeName);
continue;
}
// ── 第一庫:零件 catalog(記憶體)────────────────────────────────────────
const hit = byId.get(componentId);
if (hit) {
nodeResults[nodeName] = {
status: 'found',
componentId,
type: role,
source: 'component',
input_schema: hit.input_schema,
success_rate: typeof hit.success_rate === 'number' ? hit.success_rate : undefined,
stability: typeof hit.stability === 'string' ? hit.stability : undefined,
branch_hint: branchHintFor(componentId),
};
continue;
}
// ── 第二庫:recipe 清單(記憶體;canonical_id 精確比對)──────────────────
const recipe = recipes.find(r => r.canonical_id === componentId);
if (recipe) {
nodeResults[nodeName] = {
status: 'found',
componentId: recipe.canonical_id,
type: role,
source: 'recipe',
description: recipe.description,
endpoint: recipe.endpoint,
payload_hint: buildPayloadHint(recipe),
};
continue;
}
// ── 步驟 4:意圖節點 → 真實零件/recipe 替換(同一份清單、全記憶體)────────
// 字面 exact 兩庫都落空的自然語言節點(例「傳到 telegram」「判斷有沒有新資料」),
// 先試保守的替換規則;換得到=resolved(回應直接可組 workflow),換不到才 not_found。
const substituted = trySubstitution(nodeName, catalog.entries, recipes);
if (substituted) {
nodeResults[nodeName] = { ...substituted, type: role };
continue;
}
// ── 兩庫都沒有 ⇒ not_found + 分型指路(task 3.7)+ 相近候選(全記憶體)──
const similarComponents = similarFromCatalog(catalog.entries, nodeName);
const similarRecipes = similarFromRecipes(recipes, nodeName);
nodeResults[nodeName] = {
status: 'not_found',
componentId,
type: role,
suggestion: buildSuggestion(componentId),
...(similarComponents.length > 0 ? { similar_components: similarComponents } : {}),
...(similarRecipes.length > 0 ? { similar_recipes: similarRecipes } : {}),
};
missingNodes.push(nodeName);
nodeResults[nodeName] = { status: 'found', componentId, type: role };
}
return { nodeResults, missingNodes };
}
// ── t158 批次化 helpers ────────────────────────────────────────────────────────
type CatalogFullRecord = {
canonical_id: string;
display_name?: string;
description?: string;
aliases?: string[];
tags?: string[];
score?: number;
input_schema?: unknown;
success_rate?: number;
stability?: string;
};
type CatalogFetch = { status: 'ok' | 'no_endpoint' | 'unreachable'; entries: CatalogFullRecord[] };
/** 一次抓 registry 全目錄。404=舊版 registry 沒這端點 → 呼叫端退回逐顆查。 */
async function fetchCatalog(registryBase: string): Promise<CatalogFetch> {
try {
const res = await fetch(`${registryBase}/components/catalog`, { signal: AbortSignal.timeout(10000) });
if (res.status === 404) return { status: 'no_endpoint', entries: [] };
if (!res.ok) return { status: 'unreachable', entries: [] };
const body = (await res.json()) as { data?: { components?: CatalogFullRecord[] } };
return { status: 'ok', entries: body.data?.components ?? [] };
} catch {
return { status: 'unreachable', entries: [] };
}
}
/** recipe recipe exact
* export target=recipe actions/target-search.ts */
export async function listAllRecipes(kv: KVNamespace): Promise<RecipeDefinition[]> {
try {
const list = await kv.list({ prefix: 'recipe:' });
return (await Promise.all(
list.keys.map(k => kv.get(k.name, 'json') as Promise<RecipeDefinition | null>),
)).filter(Boolean) as RecipeDefinition[];
} catch {
return [];
}
}
/** 相似零件(記憶體版):全名 substring 優先,否則斷詞計數 top3——判準與舊 HTTP 版一致。 */
function similarFromCatalog(entries: CatalogFullRecord[], nodeName: string): string[] {
const searchableOf = (e: CatalogFullRecord) =>
[e.canonical_id, e.display_name ?? '', e.description ?? '', ...(e.aliases ?? []), ...(e.tags ?? [])]
.join(' ').toLowerCase();
const full = nodeName.toLowerCase();
const direct = entries.filter(e => searchableOf(e).includes(full)).map(e => e.canonical_id);
if (direct.length > 0) return [...new Set(direct)].slice(0, 3);
const tokens = extractTokens(nodeName);
if (tokens.length === 0) return [];
const count = new Map<string, number>();
for (const e of entries) {
const hay = searchableOf(e);
const hits = tokens.filter(t => hay.includes(t)).length;
if (hits > 0) count.set(e.canonical_id, Math.max(count.get(e.canonical_id) ?? 0, hits));
}
return [...count.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3).map(([id]) => id);
}
/** 相似 recipe(記憶體版;判準沿用 searchSimilarRecipes)。 */
function similarFromRecipes(recipes: RecipeDefinition[], nodeName: string): string[] {
const tokens = [nodeName.toLowerCase(), ...extractTokens(nodeName)];
const seen = new Set<string>();
const matched: string[] = [];
for (const r of recipes) {
if (seen.has(r.canonical_id)) continue;
const hay = `${r.canonical_id} ${r.display_name ?? ''} ${r.description ?? ''}`.toLowerCase();
if (tokens.some(t => hay.includes(t))) {
seen.add(r.canonical_id);
matched.push(r.canonical_id);
}
}
return matched.slice(0, 3);
}
/** 舊 registry(無 /catalog 端點)的相容路徑:維持逐顆查語義。 */
async function legacyPerNodeLookup(
registryBase: string,
componentId: string,
nodeName: string,
role: NodeRole,
env: SearchNodesEnv | undefined,
recipes: RecipeDefinition[],
): Promise<{ info: NodeInfo; missing: boolean }> {
const q = await fetchComponent(registryBase, componentId);
if (!q.ok) return { info: { status: 'unknown', componentId, type: role }, missing: false };
if (q.entry) {
return {
info: {
status: 'found', componentId, type: role, source: 'component',
input_schema: q.entry.input_schema, success_rate: q.entry.success_rate, stability: q.entry.stability,
branch_hint: branchHintFor(componentId),
},
missing: false,
};
}
const recipe = recipes.find(r => r.canonical_id === componentId)
?? (env?.RECIPES ? await resolveRecipe(componentId, env.RECIPES) : null);
if (recipe) {
return {
info: {
status: 'found', componentId: recipe.canonical_id, type: role, source: 'recipe',
description: recipe.description, endpoint: recipe.endpoint,
payload_hint: buildPayloadHint(recipe),
},
missing: false,
};
}
const similarComponents = await searchSimilarComponents(registryBase, nodeName);
const similarRecipes = similarFromRecipes(recipes, nodeName);
return {
info: {
status: 'not_found', componentId, type: role, suggestion: buildSuggestion(componentId),
...(similarComponents.length > 0 ? { similar_components: similarComponents } : {}),
...(similarRecipes.length > 0 ? { similar_recipes: similarRecipes } : {}),
},
missing: true,
};
}
// ── 步驟 4:意圖節點 → 真實零件/recipe 替換 ────────────────────────────────────
//
// 目的(CP arcrun-usable 步驟 4):AI 只要填 payload——系統把「傳到 telegram」翻成
// `http_request`recipe `telegram_send`。媒合在「一次抓好的兩庫清單」記憶體內做,
// 零新增 round-trip;規則沿用 task 3.7 的服務詞判型+既有斷詞媒合(extractTokens),
// 刻意簡單可解釋、不接 LLM。
//
// 兩條規則(保守——換錯比不換更糟,寧可 not_found+候選讓 AI 自己選):
// A) 服務詞規則(recipe 路):節點名含 SERVICE_HINTS 服務詞 → 名字裡**全部**服務詞
// 都命中同一個 recipe、且該 recipe **唯一**才替換。
// 例「傳到 telegram」:服務詞 [telegram] → 唯一命中 telegram_send ⇒ 換。
// 反例「google_slides_create」:服務詞 [google, slides] → google_sheets_* 只中
// google 不中 slides ⇒ 不換(照 3.7 指去寫 recipe)。
// 有服務詞的節點**不落入規則 B**——外部服務就該是 recipe,不硬配零件
// (否則「google_slides」會被 display_name 含 Google 的零件誤吃)。
// B) 強欄位規則(零件路):斷詞後只算**強欄位**canonical_iddisplay_namealiases
// 命中為主:分數=強命中×10+弱命中(descriptiontags)×1
// 需「至少一個強命中」且「分數唯一最高」才替換。
// 例「判斷有沒有新資料」:2-gram「判斷」命中 if_control display_name「條件判斷」
// (強 10 分),try_catch 只在 description 中「判斷」(弱 1 分)⇒ 唯一最高 ⇒ 換。
// 反例「aes_encrypt」:無任何強命中 ⇒ 不換(照 3.7 指去投零件 PR)。
type SubstitutionHit = Pick<
NodeInfo,
'status' | 'componentId' | 'source' | 'substitution' |
'input_schema' | 'success_rate' | 'stability' | 'description' | 'endpoint' | 'branch_hint'
>;
function trySubstitution(
nodeName: string,
catalogEntries: CatalogFullRecord[],
recipes: RecipeDefinition[],
): SubstitutionHit | null {
const lower = nodeName.toLowerCase();
const serviceHits = SERVICE_HINTS.filter(w => lower.includes(w));
// 規則 A:服務詞 → recipe(全部服務詞命中+唯一)
if (serviceHits.length > 0) {
const matched = new Map<string, RecipeDefinition>();
for (const r of recipes) {
const hay = `${r.canonical_id} ${r.display_name ?? ''} ${r.description ?? ''}`.toLowerCase();
if (serviceHits.every(h => hay.includes(h))) matched.set(r.canonical_id, r);
}
if (matched.size !== 1) return null; // 0=真缺件走 not_found;≥2=歧義,候選留給 similar_recipes
const recipe = [...matched.values()][0];
return {
status: 'resolved',
componentId: recipe.canonical_id,
source: 'recipe',
description: recipe.description,
endpoint: recipe.endpoint,
substitution: {
from: nodeName,
componentId: 'http_request', // recipehttp_request+參數模板的具名封裝
recipe: recipe.canonical_id,
reason:
`服務詞「${serviceHits.join('、')}」唯一命中 recipe「${recipe.canonical_id}」;` +
`workflow config 寫 component: ${recipe.canonical_id}(底層零件=http_request),只需填 payload`,
},
};
}
// 規則 B:強欄位斷詞媒合 → 零件(至少一強命中+分數唯一最高)
const tokens = extractTokens(nodeName);
if (tokens.length === 0) return null;
type Scored = { entry: CatalogFullRecord; score: number; strongHits: string[] };
const byCanonical = new Map<string, Scored>();
for (const e of catalogEntries) {
const strongHay = [e.canonical_id, e.display_name ?? '', ...(e.aliases ?? [])].join(' ').toLowerCase();
const weakHay = [e.description ?? '', ...(e.tags ?? [])].join(' ').toLowerCase();
const strongHits = tokens.filter(t => strongHay.includes(t));
const weakCount = tokens.filter(t => weakHay.includes(t)).length;
const score = strongHits.length * 10 + weakCount;
if (score === 0) continue;
const prev = byCanonical.get(e.canonical_id);
if (!prev || score > prev.score) byCanonical.set(e.canonical_id, { entry: e, score, strongHits });
}
const ranked = [...byCanonical.values()].sort((a, b) => b.score - a.score);
const top = ranked[0];
if (!top || top.strongHits.length === 0) return null; // 沒有強命中=證據不足
if (ranked[1] && ranked[1].score >= top.score) return null; // 同分歧義=不硬猜
return {
status: 'resolved',
componentId: top.entry.canonical_id,
source: 'component',
input_schema: top.entry.input_schema,
success_rate: typeof top.entry.success_rate === 'number' ? top.entry.success_rate : undefined,
stability: typeof top.entry.stability === 'string' ? top.entry.stability : undefined,
// 替換成分岔零件時(例「判斷有沒有新資料」→ if_control)一併附分支用法,
// 否則 AI 換到零件卻不知道怎麼接兩條路,仍會退回寫 code。
branch_hint: branchHintFor(top.entry.canonical_id),
substitution: {
from: nodeName,
componentId: top.entry.canonical_id,
reason:
`斷詞「${top.strongHits.join('、')}」命中零件「${top.entry.canonical_id}` +
`${top.entry.display_name ?? ''})強欄位且分數唯一最高;只需照 input_schema 填 payload`,
},
};
}
// ── 缺件分型(task 3.7)────────────────────────────────────────────────────────
//
// 分型判準(刻意用簡單可解釋的規則,不接 LLM——查詢端點要快、要可預測):
// 1) 名字含**外部服務詞**googletelegramslack…)→「外部 API 樣貌」
// → recipe 路:recipe 是 http_request+參數模板的具名封裝,用戶自己就能寫,不用改平台。
// 2) 否則名字含**計算原語詞**encrypthashencode…)→「計算原語樣貌」
// → 零件路:純計算得進 WASM 沙箱跑,要走 GitHub PR 投稿(人 merge=人類閘門,mindset §4)。
// 3) 都不含 → 判不出型,誠實說判不出,兩條路都給(不硬猜——猜錯會把人指去錯的路)。
// 判斷順序:服務詞優先於計算詞——「google_sheets_parse」雖含 parse,本質仍是打外部 API。
const SERVICE_HINTS = [
'google', 'gmail', 'sheets', 'slides', 'gdocs', 'drive', 'calendar', 'youtube',
'slack', 'telegram', 'discord', 'line', 'whatsapp', 'twilio',
'notion', 'airtable', 'trello', 'jira', 'asana', 'linear',
'github', 'gitea', 'gitlab', 'bitbucket',
'stripe', 'paypal', 'shopify', 'hubspot', 'salesforce',
'openai', 'anthropic', 'claude', 'gemini', 'groq',
'twitter', 'facebook', 'instagram', 'linkedin', 'dropbox', 'zoom',
'sendgrid', 'mailgun', 'kbdb',
];
const COMPUTE_HINTS = [
'encrypt', 'decrypt', 'cipher', 'aes', 'rsa', 'sha', 'md5', 'hmac', 'hash',
'sign', 'verify', 'encode', 'decode', 'base64', 'hex',
'compress', 'decompress', 'zip', 'gzip',
'uuid', 'random', 'regex', 'math', 'calc',
'sort', 'dedup', 'diff', 'template', 'render', 'convert', 'transform',
'parse', 'format', 'csv', 'xml',
];
function buildSuggestion(componentId: string): string {
const lower = componentId.toLowerCase();
const serviceHit = SERVICE_HINTS.find(w => lower.includes(w));
const computeHit = COMPUTE_HINTS.find(w => lower.includes(w));
if (serviceHit) {
return (
`兩庫都查過,零件 registry 與 recipe 庫皆無「${componentId}」。` +
`名字含服務詞「${serviceHit}」=外部 API 樣貌 → 沒有此 recipe,可自己寫:` +
`寫法看 skill「write_recipe」(arcrun_get_skill('write_recipe')),` +
`寫好用 acr recipe push 或 POST /recipes 裝上即可用,不用改平台。`
);
}
if (computeHit) {
return (
`兩庫都查過,零件 registry 與 recipe 庫皆無「${componentId}」。` +
`名字含計算詞「${computeHit}」=計算原語樣貌 → 沒有此零件,可投稿 PR 新增 WASM component` +
`做法看 skill「add_new_wasm_component」(arcrun_get_skill('add_new_wasm_component'))。`
);
}
return (
`兩庫都查過,零件 registry 與 recipe 庫皆無「${componentId}」,且名字判不出型。` +
`缺外部 API → 自己寫 recipeskill「write_recipe」);` +
`缺計算能力 → 投稿零件 PRskill「add_new_wasm_component」,component 進 WASM 沙箱)。`
);
}
/**
* recipe payload3.12
* branch_hint recipen8n endpoint payload
* AI 退 workflow code
*/
export function buildPayloadHint(recipe: RecipeDefinition): NodeInfo['payload_hint'] {
const parts: string[] = [];
if (recipe.body_template) {
parts.push('payload 已收在 recipe 的 body_template 裡,你只要把 {{變數}} 對應的值放進節點 context');
} else if (recipe.body) {
parts.push('payload 形狀見 body 欄位({{變數}} 由節點 context 填)');
} else {
parts.push('未定義 body_template:節點 context 會整包當 body 送出(_ 開頭的內部欄位會被剔除)');
}
if (recipe.response_map) {
parts.push('回應已正規化:執行結果除了原始 data,另附 text(取值路徑等規則寫在 recipe 裡,換源不必改 workflow');
} else {
parts.push('未定義 response_map:回應原樣放在 data,取值要自己指路徑');
}
if (recipe.auth === 'binding') {
parts.push(`認證=binding(免金鑰,用平台內建 ${recipe.binding_name ?? 'AI'}`);
} else if (recipe.auth_service) {
parts.push(`認證走 auth recipe「${recipe.auth_service}」(金鑰由系統在執行前注入,你不必也不該填)`);
}
return {
body_template: recipe.body_template,
response_map: recipe.response_map,
usage: parts.join('') + '。',
};
}
// ── registry 查詢 ─────────────────────────────────────────────────────────────
type CatalogEntry = {
input_schema?: unknown;
success_rate?: number;
stability?: string;
};
/**
* registry
*
* registry ****
* `GET /components` 404 `GET /components/<id>`
* CP2-B /components 404
* <10且有 5s timeout可接受
*
* `ok:false` registry
* `unknown` recipe
*/
async function fetchComponent(
registryBase: string,
id: string,
): Promise<{ ok: boolean; entry?: CatalogEntry }> {
try {
const res = await fetch(`${registryBase}/components/${encodeURIComponent(id)}`, {
signal: AbortSignal.timeout(5000),
});
if (res.status === 404) return { ok: true }; // registry 活著,但沒這顆
if (!res.ok) return { ok: false };
const body = (await res.json()) as { success?: boolean; data?: Record<string, unknown> };
if (body.success === false) return { ok: true }; // 同上:回「零件不存在」
const d = body.data ?? (body as unknown as Record<string, unknown>);
return {
ok: true,
entry: {
input_schema: d.input_schema,
success_rate: typeof d.success_rate === 'number' ? d.success_rate : undefined,
stability: typeof d.stability === 'string' ? d.stability : undefined,
},
};
} catch {
return { ok: false };
}
}
// ── 相近候選(自然語言節點名 → 既有零件/recipe 的媒合)──────────────────────────
//
// 節點名常是自然語言(例「判斷有沒有新資料」)。leo:「AI 不用知道零件存在」——
// 所以 not_found 時要主動給相近候選,讓 AI 看回覆就知道「其實有 if_control 可用」。
// 做法:先拿全名打 registry `/components/search`;沒中再斷詞重試——
// ASCII 取 3 字以上的詞、中日韓取 2-gramregistry search 是子字串比對,整句中文必落空,
// 2-gram 才撈得到「判斷」→ if_controldisplay_name「條件判斷」)這種命中)。
function extractTokens(name: string): string[] {
const tokens: string[] = [];
const ascii = name.toLowerCase().match(/[a-z0-9]{3,}/g) ?? [];
tokens.push(...ascii);
const cjkRuns = name.match(/[一-鿿]+/g) ?? [];
for (const run of cjkRuns) {
for (let i = 0; i + 2 <= run.length; i++) tokens.push(run.slice(i, i + 2));
}
return [...new Set(tokens)].slice(0, 8); // 上限 8 個 token,避免對 registry 掃太多輪
}
async function searchRegistryIds(registryBase: string, q: string): Promise<string[]> {
try {
const res = await fetch(`${registryBase}/components/search?q=${encodeURIComponent(q)}`, {
signal: AbortSignal.timeout(5000),
});
if (!res.ok) return [];
const body = (await res.json()) as { data?: { results?: Array<{ canonical_id?: string }> } };
return (body.data?.results ?? []).map(r => r.canonical_id).filter((s): s is string => !!s);
} catch {
return [];
}
}
async function searchSimilarComponents(registryBase: string, nodeName: string): Promise<string[]> {
// 1) 全名直接搜
const direct = await searchRegistryIds(registryBase, nodeName);
if (direct.length > 0) return direct.slice(0, 3);
// 2) 斷詞搜,依命中次數排序
const tokens = extractTokens(nodeName);
if (tokens.length === 0) return [];
const hits = await Promise.all(tokens.map(t => searchRegistryIds(registryBase, t)));
const count = new Map<string, number>();
for (const ids of hits) {
for (const id of ids) count.set(id, (count.get(id) ?? 0) + 1);
}
return [...count.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3).map(([id]) => id);
}
/** recipe 庫的相近候選:KV 全列(本部署 recipe 數量小)後子字串比對。 */
async function searchSimilarRecipes(kv: KVNamespace, nodeName: string): Promise<string[]> {
try {
const list = await kv.list({ prefix: 'recipe:' });
const all = (await Promise.all(
list.keys.map(k => kv.get(k.name, 'json') as Promise<RecipeDefinition | null>),
)).filter(Boolean) as RecipeDefinition[];
const tokens = [nodeName.toLowerCase(), ...extractTokens(nodeName)];
const seen = new Set<string>();
const matched: string[] = [];
for (const r of all) {
if (seen.has(r.canonical_id)) continue;
const hay = `${r.canonical_id} ${r.display_name ?? ''} ${r.description ?? ''}`.toLowerCase();
if (tokens.some(t => hay.includes(t))) {
seen.add(r.canonical_id);
matched.push(r.canonical_id);
}
}
return matched.slice(0, 3);
} catch {
return [];
}
return { nodeResults, missingNodes: [] };
}

Some files were not shown because too many files have changed in this diff Show More