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
298 changed files with 8864 additions and 21333 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 closurerule 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(要連哪個服務才填哪個;可之後再加)────────────────────────
-20
View File
@@ -48,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
+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
*/
+47 -22
View File
@@ -11,6 +11,7 @@ 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';
@@ -18,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}: `));
@@ -102,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);
@@ -230,16 +248,15 @@ 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 部署)...'));
@@ -305,20 +322,28 @@ async function initSelfHosted(
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[] };
+3 -7
View File
@@ -84,13 +84,9 @@ export async function cmdUpdate(opts: { force?: boolean } = {}): Promise<void> {
// 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,
};
const result = await downloadAndDeploy(ctx, 'main', { force: opts.force });
-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);
}
}
+40
View File
@@ -164,3 +164,43 @@ export class CfAccountClient {
return result.uuid;
}
}
/** 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),
});
}
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 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 }> = [];
+22 -65
View File
@@ -129,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
@@ -163,27 +170,8 @@ 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;
@@ -336,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}`);
}
}
@@ -436,9 +395,7 @@ 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=1024 / metric=cosine bge-m3
* `**dimensions=1024**/metric``*` `/` block comment TS1127
* `kbdb/src/embed.ts` `DEFAULT_EMBED_MODEL` upsert CF
* 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> {
@@ -448,8 +405,8 @@ async function ensureVectorizeIndex(ctx: DeployContext): Promise<void> {
headers: { Authorization: `Bearer ${ctx.apiToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
name: KBDB_VECTORIZE_INDEX,
config: { dimensions: 1024, metric: 'cosine' },
description: 'arcrun KBDB embed module — bge-m3 1024d (issue #7 / #59)',
config: { dimensions: 768, metric: 'cosine' },
description: 'arcrun KBDB optional embed module (issue #7)',
}),
signal: AbortSignal.timeout(60_000),
});
-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 -698
View File
@@ -1,721 +1,40 @@
import type { ParsedTriplets, NodeRole } from './triplet-parser';
import { resolveNodeRole, isVirtualIoName } from './triplet-parser';
import { wasmWorkerUrl } 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 時標來源庫:零件 registrycomponent)或 recipe 庫(recipe)。 */
source?: 'component' | 'recipe';
/** 零件契約(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;
}
// 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: [] };
}
@@ -1,113 +0,0 @@
/**
* target-search POST /cypher/search t159
*
* leo 07-31search search
* recipe
*
* discover `target`componentrecipeworkflow`query`
* - target=component registry GET /components/searchMCP arcrun_search_components
* - target=recipe RECIPES KV discover **** listAllRecipes
* /public-recipesMCP arcrun_recipe_search
* - target=workflow lib/workflow-search.tsGET /workflows/searchMCP arcrun_search_workflows
*
* API target ****
* flag pull
*/
import { wasmWorkerUrl } from '../lib/component-loader';
import { fetchTenantWorkflowSearch } from '../lib/workflow-search';
import { listAllRecipes, buildPayloadHint, type SearchNodesEnv } from './search-nodes';
import { branchHintFor } from '../lib/branch-hints';
export type TargetQueryEnv = SearchNodesEnv & {
KBDB_BASE_URL?: string;
KBDB_INTERNAL_TOKEN?: string;
};
export type TargetQueryResult =
| { ok: true; body: Record<string, unknown> }
| { ok: false; status: 400 | 401 | 502; error: string };
export async function searchByTarget(
target: 'component' | 'recipe' | 'workflow',
query: string,
env: TargetQueryEnv,
apiKey?: string,
): Promise<TargetQueryResult> {
if (target === 'component') {
const sub = env.WORKER_SUBDOMAIN;
const registryBase = env.REGISTRY_BASE_URL ?? (sub ? wasmWorkerUrl('registry', sub) : undefined);
if (!registryBase) return { ok: false, status: 502, error: 'registry 位置未設定(WORKER_SUBDOMAINREGISTRY_BASE_URL 皆缺)' };
try {
const res = await fetch(
`${registryBase}/components/search?q=${encodeURIComponent(query)}`,
{ signal: AbortSignal.timeout(10000) },
);
if (!res.ok) return { ok: false, status: 502, error: `registry 搜尋失敗(HTTP ${res.status}` };
const body = (await res.json()) as { data?: { results?: unknown[]; count?: number } };
// 3.11:逐顆查零件(n8n 式「自己一顆一顆填」)時,會分岔的零件要自我說明分支用法。
// leo 08-01:「它可以一一查詢自己手工填寫每個零件,就像在 n8n 那樣」——
// 這條路徑若只回 input_schemaAI 拿到 if_controlswitch 仍不知道兩條路怎麼接 ⇒ 回頭寫 code。
const results = (body.data?.results ?? []).map(r => {
if (!r || typeof r !== 'object') return r;
const rec = r as Record<string, unknown>;
const hint = branchHintFor(typeof rec.canonical_id === 'string' ? rec.canonical_id : undefined);
return hint ? { ...rec, branch_hint: hint } : rec;
});
return {
ok: true,
body: {
target,
query,
results,
count: body.data?.count ?? 0,
},
};
} catch (e) {
return { ok: false, status: 502, error: `registry 查不通:${e instanceof Error ? e.message : String(e)}` };
}
}
if (target === 'recipe') {
if (!env.RECIPES) return { ok: false, status: 502, error: 'RECIPES KV 未綁定' };
const all = await listAllRecipes(env.RECIPES);
const q = query.toLowerCase();
// 與 discover 混搜同一份庫(私庫=workflow 實際引用得到的);子字串比對、canonical 去重
const seen = new Set<string>();
const results: Array<{
canonical_id: string; display_name?: string; description?: string; endpoint: string;
payload_hint?: unknown;
}> = [];
for (const r of all) {
if (seen.has(r.canonical_id)) continue;
const hay = `${r.canonical_id} ${r.display_name ?? ''} ${r.description ?? ''}`.toLowerCase();
if (!hay.includes(q)) continue;
seen.add(r.canonical_id);
results.push({
canonical_id: r.canonical_id,
display_name: r.display_name,
description: r.description,
endpoint: r.endpoint,
// 3.12:逐顆查 recipe 時也要說得出「payload 怎麼填、回應怎麼取值」
payload_hint: buildPayloadHint(r),
});
}
return {
ok: true,
body: {
target,
query,
results,
count: results.length,
note: '搜的是本部署私庫(workflow 可直接 component: <canonical_id> 引用)。公庫(多作者市場)走 MCP arcrun_recipe_searchGET /public-recipes。',
},
};
}
// target === 'workflow':租戶隔離,必帶 API key(同 GET /workflows/search 的既有契約)
if (!apiKey) return { ok: false, status: 401, error: 'target=workflow 需要 X-Arcrun-API-Key headerworkflow 搜尋限本租戶)' };
const res = await fetchTenantWorkflowSearch(env, apiKey, query);
if (!res.ok) return { ok: false, status: 502, error: `workflow 搜尋失敗(KBDB HTTP ${res.status}` };
const body = (await res.json()) as Record<string, unknown>;
return { ok: true, body: { target, query, ...body } };
}
@@ -105,17 +105,6 @@ export function parseTriplets(rawTriplets: unknown[]): ParsedTriplets | null {
const INPUT_NAMES = new Set(['input', 'trigger', 'webhook', 'start']);
const OUTPUT_NAMES = new Set(['output', 'result', 'end', 'done']);
/**
* IO input/output
* searchNodes ** IO **
* `aes_encrypt >> ON_SUCCESS >> code`
* foundtask 3.7
*/
export function isVirtualIoName(name: string): boolean {
const lower = name.toLowerCase();
return INPUT_NAMES.has(lower) || OUTPUT_NAMES.has(lower);
}
/** type
*
*
@@ -14,7 +14,7 @@ export async function resolveWebhookGraph(
const parsed = parseTriplets(body.triplets as unknown[]);
if (!parsed) return { resolvedGraph: {}, error: '無法解析 triplets' };
const { nodeResults } = await searchNodes(parsed);
const { nodeResults } = searchNodes(parsed);
const graphId = `webhook-${Date.now()}`;
const graphName = description || `Webhook ${new Date().toISOString()}`;
@@ -4,8 +4,6 @@ import { GraphExecutor } from '../graph-executor';
import { graphSchema } from '../lib/schemas';
import { createComponentLoader } from '../lib/component-loader';
import { recordTelemetry } from '../lib/telemetry';
import { recordComponentStats } from './execution-evaluator';
import type { GraphNode, TraceStep } from '../types';
/**
* kbdb-base §7.1+§7.5.h recipe / KBDB
@@ -98,17 +96,6 @@ export async function executeWebhookGraph(
// kbdb-base §7.1:整體成功 → 用到的 recipe 各記成功一次。
recordRecipeStats(env, executor.usedRecipeKeys, true, Date.now(), ctx);
// arcrun-core-mvp「執行統計設計」:對用到的每顆零件回寫執行結果(fire-and-forget)。
{
const statsPromise = recordComponentStats(
env,
(parsed.data as ExecutionGraph).nodes as GraphNode[],
result.trace as TraceStep[],
);
if (ctx?.waitUntil) ctx.waitUntil(statsPromise);
else void statsPromise;
}
return { success: true, data: result.data, duration_ms };
} catch (err) {
const duration_ms = Date.now() - start;
@@ -130,18 +117,6 @@ export async function executeWebhookGraph(
recordRecipeStats(env, executor.usedRecipeKeys, false, Date.now(), ctx);
}
// 零件統計失敗路徑:ExecutionError 帶完整 trace(失敗節點有 error、先前成功節點照記成功);
// paused 非失敗不記;非 ExecutionError 無 trace 可歸因 → 不記。
if (!isPaused && err instanceof ExecutionError) {
const statsPromise = recordComponentStats(
env,
(parsed.data as ExecutionGraph).nodes as GraphNode[],
err.trace,
);
if (ctx?.waitUntil) ctx.waitUntil(statsPromise);
else void statsPromise;
}
if (err instanceof ExecutionError) {
const traceFormatted = err.trace.map(s => ({
node: s.nodeId,
+17 -93
View File
@@ -1,6 +1,7 @@
// arcrun 圖遍歷引擎 — 支援完整 Cypher 語意關係
import type { ExecutionGraph, GraphNode, TraceStep, ComponentRunner, KVContextStore, EdgeType, Bindings } from './types';
import { kvSetNodeOutput, kvGetNodeOutput, ExecutionError, WorkflowPaused } from './types';
import { injectCredentials } from './actions/credential-injector';
import { tryAuthDispatch, resolveCredentialRefs } from './actions/auth-dispatcher';
import { expandPromptRecipe } from './lib/recipe-expander';
import { resolveRecipe } from './routes/recipes';
@@ -245,8 +246,8 @@ export class GraphExecutor {
};
// 用戶面 {{credential.NAME}} 展開(design §8):偵測 node.data 裡用戶寫的
// {{credential.X}} → 交 auth_static_key WASM resolve_credentials 取值回填。
// 取值在 WASMrule 02 §2.2),此處只偵測+回填,不碰任何秘密值
// {{credential.X}} → 交 auth_static_key WASM resolve_credentials 解密回填。
// 解密在 WASMrule 02 §2.2),此處只偵測+回填,不碰 ENCRYPTION_KEY
if (this.env && this.apiKey) {
mergedContext = await resolveCredentialRefs(mergedContext, this.env, this.apiKey);
}
@@ -282,13 +283,19 @@ export class GraphExecutor {
}
}
// Credential 注入:在 WASM 執行前自動注入 credentials_required 中宣告的 token
// 走 auth dispatcherauth primitive WASM Worker via HTTP)——值住 CF Workers
// Secrets,由 WASM 內 secret_get 取用。
if (this.env && this.apiKey) {
const dispatched = await tryAuthDispatch(node.componentId, mergedContext, this.env, this.apiKey);
if (dispatched) {
mergedContext = dispatched;
// Credential 注入:在 WASM 執行前自動注入 credentials_required 中宣告的 token
if (this.env) {
// 先試 auth dispatcher(新路徑,走 auth primitive WASM Worker via HTTP
// 命中才 return;否則 fallback 到舊 injectCredentialsPhase 1.9 會刪除)
if (this.apiKey) {
const dispatched = await tryAuthDispatch(node.componentId, mergedContext, this.env, this.apiKey);
if (dispatched) {
mergedContext = dispatched;
} else {
mergedContext = await injectCredentials(node.componentId, mergedContext, this.env, this.apiKey);
}
} else {
mergedContext = await injectCredentials(node.componentId, mergedContext, this.env, this.apiKey);
}
}
@@ -348,15 +355,7 @@ export class GraphExecutor {
// BUILD-006:將節點 output 存入 KVkey = {run_id}:node:{node_id}
// 這讓下游節點可以透過 KV 讀取上游的具名 output,解決同名欄位衝突
//
// P8 短板齊平(2026-08-09,任務層小改記 portal-auth/tasks.md):只在「下游真的會讀」
// 時才寫。全 codebase 唯一的讀點是 PIPE 邊處理(本檔下方 kvGetNodeOutput 呼叫處)——
// 沒有 PIPE 出邊的節點,這筆寫入沒有任何讀者,卻每個節點(含 FOREACH 每一圈)
// 都燒一次 KV write。實測 rag_ingest_card 一張卡燒 15 次(4 固定節點+5 blocks
// 6 triplets),把免費層 KV 1,000 write/日壓成約 66 檔/日的最短板——全是白燒。
// 有 PIPE 出邊(含「完成後」與未知語意詞的預設)的節點行為完全不變。
if (kvStore && result !== null && result !== undefined
&& graph.edges.some((e) => e.from === node.id && (e.type as EdgeType) === 'PIPE')) {
if (kvStore && result !== null && result !== undefined) {
await kvSetNodeOutput(kvStore, node.id, result);
}
@@ -486,37 +485,6 @@ export class GraphExecutor {
break;
}
// ── 條件邊(SDD workflow-discovery 3.11 / CP arcrun-usable 步驟 5 缺口①)──
// 為什麼要有:`if_control` 回 {result, branch} 卻沒有邊讀得懂它,
// AI 照規矩用了零件仍得寫 code 判斷走哪條 ⇒「全變成 code」的根(Arcrun#5)。
// 讀法對齊零件 output_schema:優先 data.branchif_control/switch 的正式形狀),
// 相容 top-level branch / result 布林。讀不出分支=不走(誠實,不亂挑一條)。
case 'ON_TRUE': {
if (readBranch(result) === 'true') {
const mergedCtx = propagateCtx(context, result, node.id);
result = await this.executeNode(nextNode, graph, mergedCtx, visited, trace, fanIn, kvStore);
}
break;
}
case 'ON_FALSE': {
if (readBranch(result) === 'false') {
const mergedCtx = propagateCtx(context, result, node.id);
result = await this.executeNode(nextNode, graph, mergedCtx, visited, trace, fanIn, kvStore);
}
break;
}
case 'ON_BRANCH': {
// switch 具名分支:邊上的 branch 要跟上游 output 的 branch 字面相等才走
const actual = readBranch(result);
if (edge.branch !== undefined && actual !== undefined && actual === edge.branch) {
const mergedCtx = propagateCtx(context, result, node.id);
result = await this.executeNode(nextNode, graph, mergedCtx, visited, trace, fanIn, kvStore);
}
break;
}
case 'FOREACH': {
const iteratorKey = edge.iterator ?? 'item';
// 找 iterable 順序:先看上游 output (result),沒有再看完整 context (含上游 chain 累積的 fields)
@@ -539,26 +507,6 @@ export class GraphExecutor {
iterResults.push(itemResult);
}
// t117: FOREACH 全部項目 success===false → 不再靜默,拋出含 status code 的錯誤
if (iterResults.length > 0) {
const failures = iterResults.filter(
r => r !== null && typeof r === 'object' && (r as Record<string, unknown>).success === false
);
if (failures.length === iterResults.length) {
const first = failures[0] as Record<string, unknown>;
const errParts: string[] = [];
if (first.error) errParts.push(String(first.error));
if (typeof first.status === 'number') errParts.push(`HTTP ${first.status}`);
const bodyData = first.data as { body?: string } | null | undefined;
if (bodyData && typeof bodyData.body === 'string' && bodyData.body) {
errParts.push(bodyData.body.slice(0, 200));
}
throw new Error(
`FOREACH 所有 ${iterResults.length} 項目均失敗(首項:${errParts.join('') || '未知錯誤'}`
);
}
}
result = { ...(result as Record<string, unknown>), results: iterResults };
break;
}
@@ -690,30 +638,6 @@ function getNestedValue(ctx: unknown, path: string): unknown {
return cur;
}
/**
* output SDD workflow-discovery 3.11
*
* contract output_schema
* 1. `data.branch` if_control / switch {success, data:{result, branch}}
* 2. `branch` propagateCtx spread top-level
* 3. `data.result` branch
* 4. `result` top-level
* undefined
*/
function readBranch(result: unknown): string | undefined {
if (!result || typeof result !== 'object') return undefined;
const r = result as Record<string, unknown>;
const data = (r.data && typeof r.data === 'object') ? r.data as Record<string, unknown> : undefined;
const named = data?.branch ?? r.branch;
if (typeof named === 'string') return named;
const bool = data?.result ?? r.result;
if (typeof bool === 'boolean') return bool ? 'true' : 'false';
return undefined;
}
/** 判斷節點執行結果是否為失敗:success === false 或含有 error key */
function isFailure(result: unknown): boolean {
if (!result || typeof result !== 'object') return false;
+8 -43
View File
@@ -12,6 +12,7 @@ import { docsRouter } from './routes/docs';
import { webhooksRouter } from './routes/webhooks';
import { webhooksCrudRouter } from './routes/webhooks-crud';
import { webhooksListRouter } from './routes/webhooks-list';
import { registerRouter } from './routes/register';
import { recipesRouter } from './routes/recipes';
import { credentialsRouter } from './routes/credentials';
import { webhooksNamedRouter } from './routes/webhooks-named';
@@ -20,58 +21,19 @@ import { resumeRouter } from './routes/resume';
import { executionsRouter } from './routes/executions';
import { initSeedRouter } from './routes/init-seed';
import { kbdbProxyRouter } from './routes/kbdb-proxy';
import { consoleRouter } from './routes/console';
import { consoleAuthRouter } from './routes/console-auth';
import { consoleDashboardRouter } from './routes/console-dashboard';
import { portalRouter } from './routes/portal';
import { portalDataRouter } from './routes/portal-data';
import { portalUiRouter } from './routes/portal-ui';
const app = new Hono<{ Bindings: Bindings }>();
// 全域 CORS(允許 arcrun.dev landing page 帶 credentials 存取)
//
// 2026-07-21 cypher-ui-splitconsole/portal UI 搬到 Cloudflare Pages 後,前端與本 API
// **不再同源**,故 UI 站的 origin 必須進白名單,否則所有 fetch 會被瀏覽器擋。
// 允許來源=上面兩個 landing + UI_ORIGINSwrangler.toml [vars] 逗號分隔,實例自填
// 自己的 Pages 網域,如 https://arcrun-console-ui.pages.dev)。
// 刻意不用萬用字元:credentials:true 與 `*` 在 CORS 規格上互斥,且 Authorization/
// X-Arcrun-API-Key 是憑證等級標頭,開全域等於誰都能代打。
const STATIC_ORIGINS = ['https://arcrun.dev', 'https://www.arcrun.dev'];
app.use('*', cors({
origin: (origin, c) => {
// ⚠️ 非瀏覽器請求(CLIcurlMCP)沒有 Origin 標頭 → origin 是空字串/undefined。
// 此時必須原樣放行,不能回 null——回 null 會讓 Hono cors 中介層在後續處理拋錯,
// 表現為所有 CLI 部署一律 5002026-07-21 實撞:acr push 全掛,對照組亦然)。
if (!origin) return origin;
let extra: string[] = [];
try {
extra = String((c.env as Record<string, unknown>).UI_ORIGINS || '')
.split(',').map((s: string) => s.trim()).filter(Boolean);
} catch { /* UI_ORIGINS 未設定=只用靜態白名單 */ }
// 🔴 2026-08-08 事故根因修復:**同一台實例的 portal 一律自動放行,不再依賴注入**。
//
// 那天發生什麼:leo 的 youlin 實例 portal 整個不能用——先是畫面頂端紅字
// 「設定檔沒載入(config.js)」(UI worker 缺 WORKER_SUBDOMAIN),修好之後**登入仍然失敗**。
// 瀏覽器 console 實證:
// Access to fetch at '…/portal/login' … blocked by CORS policy:
// No 'Access-Control-Allow-Origin' header is present
// 真因=這台的 `UI_ORIGINS` 沒被設。
//
// 兩次同一個病:**這些變數只有安裝器那條路會注入,任何人手動 `wrangler deploy` 就會漏掉——
// 而漏掉時系統看起來完全正常**(worker 上線、HTTP 200、版本號還是對的),
// 只有真人點下去才會發現。leo:「這麼危險的問題已經發生 2 次,不可以再有一次。」
//
// ⇒ 治法不是「記得要注入」,是**讓它不需要被注入**:
// portal 與本 worker 是同一個 workers.dev 子網域下的兄弟,位址推導得出來。
// **少一個必須注入的變數,就少一個會被漏掉的東西。**
// `UI_ORIGINS` 仍然有效(自訂網域/額外前端還是靠它),只是不再是「登得進去」的前提。
const sub = String((c.env as Record<string, unknown>).WORKER_SUBDOMAIN || '').trim();
const sibling = sub ? [`https://arcrun-rag-ui.${sub}.workers.dev`] : [];
return [...STATIC_ORIGINS, ...sibling, ...extra].includes(origin) ? origin : null;
},
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
origin: ['https://arcrun.dev', 'https://www.arcrun.dev'],
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization', 'X-Arcrun-API-Key'],
credentials: true,
}));
@@ -86,6 +48,7 @@ app.route('/', webhooksRouter);
app.route('/', webhooksNamedRouter); // 必須在 webhooksCrudRouter 前(避免 /webhooks/:token 攔截 /webhooks/named
app.route('/', webhooksCrudRouter);
app.route('/', webhooksListRouter);
app.route('/', registerRouter);
app.route('/', recipesRouter);
app.route('/', credentialsRouter);
app.route('/', authRouter);
@@ -93,10 +56,12 @@ app.route('/', resumeRouter);
app.route('/', executionsRouter); // LI SDD M2.1: /executions/* + /workflows/:name/executions
app.route('/', initSeedRouter); // 薄殼原則:seed recipe 是 API 行為(rule 07,壓測 §4.1
app.route('/', kbdbProxyRouter); // kbdb-base 9.5KBDB 資料層 proxy(讓 CLI 透過 cypher 達 KBDB,純轉發)
app.route('/', consoleRouter); // Arcrun#3:搜尋/控制台頁 v0(單檔 HTML+原生 JS,薄殼)
app.route('/', consoleAuthRouter); // Arcrun#3 發現②:console 專用簡單 email+password 登入(單一管理員帳密,非多租戶)
app.route('/', consoleDashboardRouter); // T-cockpit ②:駕駛艙 dashboard(聚合 KBDB dash_* entries,無需登入唯讀)
app.route('/', portalRouter); // portal-auth P2#24/#25):RAG Portal 多人授權——用戶模型+認證 API
app.route('/', portalDataRouter); // portal-auth P3/portal/data/* server-side enforceowner_idlibrary 注入,安全核心)
app.route('/', portalUiRouter); // portal-auth P3GET /portal 單檔 HTML 殼(登入/搜尋/設定;獨立於 console)
// Worker 導出(fetch + scheduled
// scheduled handler 對應 wrangler.toml [triggers].crons,每分鐘 tick
@@ -22,21 +22,13 @@
* KBDB seed
*/
import type { ResponseMap } from './recipe-payload';
export interface ApiRecipeSeed {
canonical_id: string;
display_name: string;
description?: string;
/** HTTP recipe=要打的網址;`auth: 'binding'` 型=要呼叫的資源名(如 Workers AI 的模型 id)。 */
endpoint: string;
method: string;
auth_service?: string;
// ── payload/回應/binding 三層(3.12):全選填,既有種子不帶=行為完全不變 ──
body_template?: Record<string, unknown>;
response_map?: ResponseMap;
auth?: 'static_key' | 'service_account' | 'oauth2' | 'binding';
binding_name?: string;
}
export const API_RECIPE_SEEDS: ApiRecipeSeed[] = [
@@ -128,47 +120,4 @@ export const API_RECIPE_SEEDS: ApiRecipeSeed[] = [
method: 'POST',
auth_service: 'line_notify',
},
// ── LLM 對話(binding=免金鑰,3.12 第四型認證的第一個真實案例)──
//
// 為什麼進種子(而非寫在某個產品的安裝器裡):「裝好之後預設有哪些 recipe」是平台能力,
// 與本檔其餘種子同理由(見檔頭)。裝完 /init/seed 就有 ⇒ **用戶不填任何金鑰就能問答**。
//
// 換模型/換供應商=**改這一筆 recipe**endpoint + body_template + response_map),
// workflow 的 ask_llm 節點不動——這正是「換源=換 recipe 不是換引擎」。
//
// 選型實測(2026-08-03,在 1.4.4 實例上跑真實長度的 RAG prompt,每個模型連跑 2 次):
// @cf/meta/llama-4-scout-17b-16e-instruct 23732173 ms ✅ 答案最完整、引用正確
// @cf/meta/llama-3.3-70b-instruct-fp8-fast 32612147 ms ✅ 可用但波動較大
// @cf/mistralai/mistral-small-3.1-24b-instruct 35603631 ms
// @cf/qwen/qwen2.5-coder-32b-instruct 35723353 ms
// @cf/openai/gpt-oss-120b 19712295 ms ❌ 回應形狀不同,response 取不到文字
// @cf/google/gemma-3-12b-it ❌ 5018 This account is not allowed to access this model
// 對照舊路徑(Gemini `gemma-4-31b-it`):同型提問 **16.87 s**,且吐整段英文思考草稿
// ⇒ 選 llama-4-scout:**快 7 倍以上,且不需要淨化思考草稿**。
{
canonical_id: 'workers_ai_chat',
display_name: 'Workers AI 對話(免金鑰)',
description:
'Cloudflare Workers AI 文字生成,走 env.AI binding ⇒ 不需要任何 API 金鑰。'
+ 'ctx 帶 prompt,回應正規化成 text(含【答】標記與前綴淨化)。'
+ '換模型=改本 recipe 的 endpointworkflow 不動。',
endpoint: '@cf/meta/llama-4-scout-17b-16e-instruct',
method: 'POST',
auth: 'binding',
binding_name: 'AI',
body_template: {
messages: [{ role: 'user', content: '{{prompt}}' }],
max_tokens: 1024,
temperature: 0.2,
},
response_map: {
// Workers AI chat 回應:{ response: "…" }(另有 OpenAI 相容的 choices,取 response 最穩)
text_path: 'response',
// 提示詞要求答案以【答】開頭;模型偶爾會在前面多帶一行 ⇒ 取最後一個標記之後
answer_marker: '【答】',
// 前綴組合順序不定,循環剝殼(規則見 recipe-payload.ts sanitize
strip_prefixes: ['*', '-', '•', '>', '#', '"', '「', '【答】', 'Answer:', 'Draft:'],
},
},
];
-83
View File
@@ -1,83 +0,0 @@
/**
* SDD workflow-discovery 3.11 / CP arcrun-usable 5
*
* leo 08-01
* leo**
* n8n ** code
* `if_control` {status, componentId, input_schema, success_rate}
* `input_schema` {condition, input}** AI**
* n8n AI if_control code
*
* leo
* **AI ** skill
*
* output_schema `data.branch: string`
* ON_BRANCHON_TRUE/ON_FALSE
*/
export type BranchHint = {
/** 這顆零件會輸出哪個欄位當分支標籤 */
branch_field: string;
/** 可能的分支標籤(switch 是動態的,故標明由 cases 決定) */
branches: string[] | string;
/** 接下游要用哪些邊型 */
edge_types: string[];
/** 一行說明:這顆零件之後怎麼分岔 */
usage: string;
/** 可直接照抄的最小範例(意圖語法+對應的邊) */
example: string;
};
/**
* key = canonical_id
* branch_hint
*/
const BRANCH_HINTS: Record<string, BranchHint> = {
if_control: {
branch_field: 'data.branch',
branches: ['true', 'false'],
edge_types: ['ON_TRUE', 'ON_FALSE'],
usage:
'這顆算完會輸出 data.branch"true""false")。下游接兩條邊:ON_TRUE 接條件成立要做的事,' +
'ON_FALSE 接不成立要做的事。**不需要自己寫 code 判斷走哪條**——引擎依 branch 自動選路。',
example:
'判斷有沒有新資料 >> ON_TRUE >> 傳到 telegram\n' +
'判斷有沒有新資料 >> ON_FALSE >> 結束\n' +
'(中文語意詞亦可:「成立時」=ON_TRUE、「否則」=ON_FALSE',
},
switch: {
branch_field: 'data.branch',
branches: '由 input_schema.cases[].branch 與 default_branch 決定(N 路,非固定清單)',
edge_types: ['ON_BRANCH'],
usage:
'這顆依 value 比對 cases,輸出 data.branch=命中那個 case 的 branch 名(都沒中則是 default_branch)。' +
'下游**每條路各接一條 ON_BRANCH 邊,並在邊上標 branch 等於你在 cases 裡取的名字**。' +
'default_branch 不需要特別的邊型,照樣用 ON_BRANCH 標它的名字即可。',
example:
'{"cases":[{"match":"active","branch":"branch_active"}],"default_branch":"branch_default"}\n' +
'edges: [\n' +
' {"from":"my_switch","to":"處理啟用","type":"ON_BRANCH","branch":"branch_active"},\n' +
' {"from":"my_switch","to":"處理其他","type":"ON_BRANCH","branch":"branch_default"}\n' +
']',
},
try_catch: {
branch_field: 'data.branch',
branches: ['try', 'catch'],
edge_types: ['ON_BRANCH'],
usage:
'這顆看上游 error 是否非空,輸出 data.branch"try"=沒錯/"catch"=有錯)。' +
'下游接兩條 ON_BRANCH 邊,branch 分別標 "try" 與 "catch"。' +
'**錯誤處理不需要寫 code**——把要補救的節點接在 catch 那條邊後面即可。',
example:
'edges: [\n' +
' {"from":"my_try_catch","to":"正常流程","type":"ON_BRANCH","branch":"try"},\n' +
' {"from":"my_try_catch","to":"補救流程","type":"ON_BRANCH","branch":"catch"}\n' +
']',
},
};
/** 取某零件的分支用法說明;不分岔的零件回 undefined(回應不加噪音)。 */
export function branchHintFor(componentId: string | undefined): BranchHint | undefined {
if (!componentId) return undefined;
return BRANCH_HINTS[componentId.toLowerCase()];
}
+5 -83
View File
@@ -20,7 +20,6 @@ import { isComponentHash, isRecipeHash } from './hash';
import { resolveRecipe, resolveAuthRecipe } from '../routes/recipes';
import type { AuthRecipeDefinition } from '../routes/recipes';
import type { Bindings, ComponentRunner, ServiceBinding } from '../types';
import { renderBodyTemplate, applyResponseMap } from './recipe-payload';
/**
* WASM HTTP runnercanonical_id Worker URL
@@ -121,7 +120,7 @@ export function createComponentLoader(env: Bindings) {
// 4. rec_hash → 查 RECIPES KV idx → recipe 執行
if (isRecipeHash(componentId)) {
const recipe = await resolveRecipe(componentId, env.RECIPES);
if (recipe) return pickRecipeRunner(recipe, env);
if (recipe) return makeRecipeRunner(recipe);
throw new Error(`找不到 recipe hash "${componentId}",請確認已透過 acr push 上傳`);
}
@@ -135,7 +134,7 @@ export function createComponentLoader(env: Bindings) {
// 6. KV recipe(動態,用戶 push 的)
const kvRecipe = await resolveRecipe(componentId, env.RECIPES);
if (kvRecipe) return pickRecipeRunner(kvRecipe, env);
if (kvRecipe) return makeRecipeRunner(kvRecipe);
// 7. WASM HTTP runner:auth primitive / API 零件 → 獨立 Worker URL
// 白名單見 WASM_HTTP_RUNNER_IDShttp_request、5 個待降級 API 零件、4 個 auth primitive)。
@@ -272,73 +271,6 @@ function makeLogicRunner(canonicalId: string, env: Bindings): ComponentRunner |
return makeHttpRunner(wasmWorkerUrl(canonicalId, env.WORKER_SUBDOMAIN));
}
/**
* recipe runner 3.12auth='binding' binding
* HTTP auth recipe
*/
function pickRecipeRunner(
recipe: import('../routes/recipes').RecipeDefinition,
env: Bindings,
): ComponentRunner {
return recipe.auth === 'binding'
? makeBindingRecipeRunner(recipe, env)
: makeRecipeRunner(recipe);
}
/**
* auth='binding' recipe runner3.12 HTTP
* bindingenv.AIVECTORIZE leo
*
* recipe HTTP APIendpoint+method+auth_service
* Cloudflare binding HTTP ** recipe **
* Workers AI env.AIVECTORIZEBROWSERQUEUE
*/
function makeBindingRecipeRunner(
recipe: import('../routes/recipes').RecipeDefinition,
env: Bindings,
): ComponentRunner {
return async (ctx: unknown) => {
const ctxObj = (ctx && typeof ctx === 'object') ? ctx as Record<string, unknown> : {};
const name = recipe.binding_name ?? 'AI';
const binding = (env as unknown as Record<string, unknown>)[name];
if (!binding) {
return {
success: false,
error:
`recipe "${recipe.canonical_id}" 宣告 auth: binding、binding_name: "${name}"` +
`但這個部署沒有綁定 ${name}。請在 wrangler.toml 補上該 binding 後重新部署。`,
};
}
// endpoint 在 binding 型當作「要呼叫的資源名」(例 Workers AI 的模型 id
const target = recipe.endpoint;
const payload = renderBodyTemplate(recipe.body_template ?? recipe.body, ctxObj)
?? Object.fromEntries(Object.entries(ctxObj).filter(([k]) => !k.startsWith('_')));
try {
const runner = binding as { run?: (model: string, input: unknown) => Promise<unknown> };
if (typeof runner.run !== 'function') {
return {
success: false,
error: `binding "${name}" 沒有 run() 方法,目前 binding 型只支援 run(model, input) 形狀(如 env.AI)。`,
};
}
const data = await runner.run(target, payload);
if (recipe.response_map) {
const normalized = applyResponseMap(data, recipe.response_map);
return { success: true, data, text: normalized.text };
}
return { success: true, data };
} catch (e) {
return {
success: false,
error: `binding "${name}" 呼叫失敗(${target}):${e instanceof Error ? e.message : String(e)}`,
};
}
};
}
function makeRecipeRunner(recipe: import('../routes/recipes').RecipeDefinition): ComponentRunner {
return async (ctx: unknown) => {
const ctxObj = (ctx && typeof ctx === 'object') ? ctx as Record<string, unknown> : {};
@@ -361,12 +293,9 @@ function makeRecipeRunner(recipe: import('../routes/recipes').RecipeDefinition):
headers[k] = interpolate(v);
}
// body優先 body_template(③ payload 層,3.12——支援巢狀/dot path/保留型別),
// 其次既有 recipe.body(淺層 {{key}},舊 recipe 照舊),最後才拿 ctx 當 body。
// body把 recipe.body 裡的 {{key}} 都換掉
let bodyStr: string | undefined;
if (recipe.body_template) {
bodyStr = JSON.stringify(renderBodyTemplate(recipe.body_template, ctxObj));
} else if (recipe.body) {
if (recipe.body) {
bodyStr = interpolate(JSON.stringify(recipe.body));
} else if (method !== 'GET') {
// 沒指定 body template → 用 ctx 當 body,但剔除 _ 前綴的內部欄位
@@ -384,20 +313,13 @@ function makeRecipeRunner(recipe: import('../routes/recipes').RecipeDefinition):
});
const data = await readBodyOnce(res);
// ③ 回應正規化(3.12):未設 response_map ⇒ 原樣回傳(既有 recipe 零行為變化)。
// 設了 ⇒ 額外附 `text`(各家形狀差異收在 recipe 裡,換源不必改 workflow)。
if (recipe.response_map) {
const normalized = applyResponseMap(data, recipe.response_map);
return { success: res.ok, status: res.status, data, text: normalized.text };
}
return { success: res.ok, status: res.status, data };
};
}
// ── Auth Recipe Runner ────────────────────────────────────────────────────────
//
// auth-dispatcher 已先將認證資訊注入為 _auth_headers / _auth_query / _auth_body。
// credential-injector 已先將認證資訊注入為 _auth_headers / _auth_query / _auth_body。
// 這裡只需要讀取這些欄位,合併進 fetch,再清除 _auth_* 不傳給下游。
function makeAuthRecipeRunner(recipe: AuthRecipeDefinition): ComponentRunner {
@@ -308,7 +308,7 @@ export function extractCompletedDays(text: string): string[] {
}
/** /
*
* T10 ENCRYPTION_KEY
* caller */
function trimUnbalancedParen(s: string): string {
let depth = 0;
-12
View File
@@ -5,8 +5,6 @@ export const VALID_EDGE_TYPES = new Set([
'PIPE', 'IF', 'FOREACH', 'CONTINUE',
// 新增:執行語意
'IS_A', 'ON_SUCCESS', 'ON_FAIL',
// 新增:條件語意(SDD workflow-discovery 3.11)—— 讀上游 if_control/switch 的 branch
'ON_TRUE', 'ON_FALSE', 'ON_BRANCH',
// 新增:觸發語意
'ON_CLICK', 'CALLS_SUBFLOW',
// 新增:結構語意(記錄圖結構,不執行)
@@ -30,19 +28,9 @@ export const SEMANTIC_EDGE_MAP: Record<string, EdgeType> = {
'失敗時': 'ON_FAIL',
'對每個': 'FOREACH',
'條件滿足時': 'IF',
// 條件分支語意(SDD workflow-discovery 3.11):讓意圖工作流寫得出兩條路
'成立時': 'ON_TRUE',
'為真時': 'ON_TRUE',
'不成立時': 'ON_FALSE',
'為假時': 'ON_FALSE',
'否則': 'ON_FALSE',
// 英文別名
'SUCCESS': 'ON_SUCCESS',
'FAIL': 'ON_FAIL',
'TRUE': 'ON_TRUE',
'FALSE': 'ON_FALSE',
'ELSE': 'ON_FALSE',
'BRANCH': 'ON_BRANCH',
'CLICK': 'ON_CLICK',
'SUBFLOW': 'CALLS_SUBFLOW',
};

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