Compare commits
116 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c4cee35adb | |||
| ae81d22775 | |||
| 9740794050 | |||
| 453dec60b1 | |||
| bfc98fe41a | |||
| be9b92eb28 | |||
| 19c82df05f | |||
| 23d36b311a | |||
| ebd4bf5d97 | |||
| 889b70b8f9 | |||
| 21be6d19f7 | |||
| 6846d6ddae | |||
| 8eb10049b8 | |||
| 831cb62d2e | |||
| 894d9abeb1 | |||
| 3447efc94e | |||
| 4ca23c256a | |||
| aa6b899276 | |||
| 466e56bc2d | |||
| 07cc7f51b5 | |||
| 42cb1d7aa9 | |||
| e730b3f831 | |||
| 84471659af | |||
| 93b1140bf1 | |||
| 962d863ef7 | |||
| 7dbd4f59e7 | |||
| d779a11958 | |||
| c7a0b317cb | |||
| 046ceba29c | |||
| ac4fb56c91 | |||
| 1e2ef6806a | |||
| 5388f40c03 | |||
| 83aa1f6bb2 | |||
| 9344562258 | |||
| 7ba78552a4 | |||
| 60688c3108 | |||
| 36a5630c63 | |||
| 05b215c9f7 | |||
| 0ff369f818 | |||
| fe0ee8296a | |||
| b9b5d98d75 | |||
| 3d21bf0725 | |||
| 5783420fcf | |||
| 1eefce952b | |||
| 6c5706ba7d | |||
| 5d78806806 | |||
| 1b6b775c0e | |||
| 66f1b59f7f | |||
| eee3f93815 | |||
| f3af5d3631 | |||
| 47c6aaea03 | |||
| 5b983c47b8 | |||
| 36cdc8fba6 | |||
| 0d49989c19 | |||
| c735b911a0 | |||
| eefbed98b6 | |||
| a9a47b7c37 | |||
| e688d805ea | |||
| c9feb15a37 | |||
| 5f5c0a89e2 | |||
| 323ccc8475 | |||
| 9f777ff43e | |||
| 46afea83c2 | |||
| 341bcb13c8 | |||
| 832f270f52 | |||
| 5c5109cd45 | |||
| e744ad1f96 | |||
| cae0d7be3b | |||
| 062757f1b1 | |||
| 9c9aff0046 | |||
| d48f83ae6f | |||
| ea1c0571c1 | |||
| 7e631a890a | |||
| 7e87a3336b | |||
| 1794072179 | |||
| 747dc3f843 | |||
| c56863a7e0 | |||
| fc6c98cd58 | |||
| 0a426c4e16 | |||
| b8ca98cb49 | |||
| cda1c7b261 | |||
| fac7d6c9bb | |||
| 0686d39fa1 | |||
| 156cdcbe7b | |||
| 5cadc60e36 | |||
| e28e19069f | |||
| cdca296044 | |||
| e8bd518efa | |||
| 159f0b07dc | |||
| 9d38d580f2 | |||
| 0860e84d22 | |||
| 6d4980d3d7 | |||
| ccb86481ae | |||
| eb9f2db513 | |||
| 429b2d965f | |||
| f6728974ea | |||
| 2ff36962be | |||
| e36cd2d990 | |||
| ba92d10a3f | |||
| a909072dc1 | |||
| 11e772496f | |||
| 53c6334fd9 | |||
| d7fab7c6aa | |||
| 139d4c5ed1 | |||
| e7fe83a872 | |||
| 8d19d0b2d7 | |||
| a8d246ebe9 | |||
| 11ffd42899 | |||
| 24f989d818 | |||
| 9bbd25fdfc | |||
| 76b89be6d7 | |||
| beb0653e15 | |||
| 646e689b65 | |||
| dd78b770a9 | |||
| 5491409003 | |||
| 0617dab70a |
@@ -32,6 +32,33 @@ 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
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"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
@@ -1,898 +0,0 @@
|
||||
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
|
||||
@@ -1,8 +0,0 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
sharp: true
|
||||
workerd: true
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
- sharp
|
||||
- workerd
|
||||
@@ -1,81 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["@cloudflare/workers-types"],
|
||||
"strict": true,
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
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"
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"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
@@ -1,898 +0,0 @@
|
||||
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
|
||||
@@ -1,8 +0,0 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
sharp: true
|
||||
workerd: true
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
- sharp
|
||||
- workerd
|
||||
@@ -1,83 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["@cloudflare/workers-types"],
|
||||
"strict": true,
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
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"
|
||||
+16
@@ -52,3 +52,19 @@ 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
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
**讓 AI 用的工作流軟體(目前只支援 Claude Code)**
|
||||
|
||||
> 想先看用它做出來的產品?**[Arcrun RAG](https://git.uncle6.me/Leo/arcrun-rag)** —— 企業知識庫(丟檔案自動長出可查詢、可問答的知識庫),有[線上 demo](https://rag-demo.arcrun.dev/portal) 可直接玩。
|
||||
> 想先看用它做出來的產品?**[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 及時間,但絕大部分是重複內容,例如登入認證、存取資料庫等。
|
||||
|
||||
@@ -310,7 +313,7 @@ acr update self-hosted:拉新版零件/引擎並重新
|
||||
acr update --force 強制重部所有 worker(忽略未變動跳過快取)
|
||||
```
|
||||
|
||||
> 給 AI 操盤手:開始前讀 `.claude/rules/06-mindset.md`(或 arcrun-mindset Skill)——它說明 arcrun 的世界觀(工作流是 default、零件稀有且不自製、一切外部 API 皆 recipe),讓你一開始就走在正路上。
|
||||
> 給 AI 操盤手:開始前讀 [`llms.txt`](llms.txt)——它說明 arcrun 的世界觀(工作流是 default、零件稀有且不自製、一切外部 API 皆 recipe),讓你一開始就走在正路上。跑過 `acr install-harness` 之後,同一套規範會裝進你的專案(arcrun-mindset Skill)。
|
||||
|
||||
---
|
||||
|
||||
@@ -333,7 +336,7 @@ MIT
|
||||
|
||||
arcrun 的核心架構、WASM 零件、CLI 工具鏈與這份文件,由以下貢獻者共同打造:
|
||||
|
||||
- **[@richblack](https://github.com/richblack)** — 創始人,產品設計與架構決策
|
||||
- **[@youlinhsieh](https://github.com/youlinhsieh)** — 創始人,產品設計與架構決策
|
||||
- **Claude(Anthropic)** — 實作夥伴:零件開發、executor 架構、CLI 實作與程式碼審查
|
||||
|
||||
歡迎加入:[CONTRIBUTING.md](CONTRIBUTING.md)
|
||||
想投稿零件?看 [CONTRIBUTING-components.md](CONTRIBUTING-components.md)。
|
||||
|
||||
+49
-25
@@ -1,41 +1,65 @@
|
||||
<!-- arcrun-harness:start -->
|
||||
## 這個專案用 arcrun 做自動化
|
||||
## 這個專案用 Arcrun 做自動化
|
||||
|
||||
當需要「串服務 / 排程 / 打 API / 資料處理自動化」時,用 arcrun 工作流,**不要自己寫 Python/Node 一次性腳本**。arcrun 工作流是純文字、可複用、跑在使用者自己的 Cloudflare。
|
||||
當需要「串服務/排程/打 API/資料處理自動化」時,用 Arcrun 工作流,**不要自己寫 Python/Node 一次性腳本**。
|
||||
Arcrun 工作流是純文字、可複用、跑在使用者自己的 Cloudflare。
|
||||
|
||||
arcrun 只有三種東西,先分清楚就不會做歪:
|
||||
⚠️ **不要上網搜 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 只有三種東西,先分清楚就不會做歪
|
||||
|
||||
| 東西 | 是什麼 | 你能做的 |
|
||||
|---|---|---|
|
||||
| **工作流(workflow)** | 把零件/recipe 串起來的純文字流程 | **預設就寫這個**,自由寫 |
|
||||
| **recipe** | 打「一個固定外部 API」的設定(http_request + endpoint/header/body 模板) | 自由寫、**而且該投稿**(見下) |
|
||||
| **零件(component)** | WASM 程式(流程控制 / 資料處理 / http_request / auth),固定一小套 | **你不自製**,由維護者管,走 GitHub PR |
|
||||
| **工作流(workflow)** | 把零件/recipe 串起來的純文字流程 | **預設就寫這個**,自由寫 |
|
||||
| **recipe** | 打「一個固定外部 API」的設定(endpoint/header/body 模板) | 自由寫、**而且該投稿** |
|
||||
| **零件(component)** | WASM 程式(流程控制/資料處理/`http_request`/auth),固定一小套 | **你不自製**,走 PR |
|
||||
|
||||
> **一句話判準**:打一個固定外部 endpoint → 寫 **recipe**;流程控制 / 資料處理 / 通用 HTTP → 用既有**零件**;其他 → 寫**工作流**串起來。
|
||||
> **一句話判準**:打一個固定外部 endpoint → 寫 **recipe**;流程控制/資料處理/通用 HTTP → 用既有**零件**;其他 → 寫**工作流**串起來。
|
||||
>
|
||||
> ⚠️ 常見錯誤:把 `telegram_send`/`gmail_send`/`kbdb_get` 當**零件**寫。它們是 **recipe**。
|
||||
|
||||
### 三個最常踩的坑(讀懂這三條,就不會像前人誤解四輪)
|
||||
### 查詢回 `not_found` 時:兩條路,都不准改寫成 code
|
||||
|
||||
**① recipe 是公共資產,發現缺的就自己做一個投稿,不必問人。**
|
||||
`acr recipe push` = 把 recipe **投稿到公共庫**,不是存私人腳本。公開/私有只是 recipe 的一個屬性(同一身份可有公私版本並存),不是兩條不同的路。
|
||||
→ 所以「想打某個 API 但沒有現成 recipe」時,**正解是自己寫一個 recipe 並 push 上去**(會 inject credential、push 時幫你檢查打不打得通)。這是被鼓勵的,別誤以為要自用、不上傳。
|
||||
查詢會誠實回報(兩庫都查過才點名):
|
||||
|
||||
**② 缺一個能力 → 去補 API(cypher endpoint),不准用 recipe / 多步工作流拼裝出來。**
|
||||
判準口訣:**「這段邏輯換一個介面(CLI→MCP)要不要重寫?要重寫 → 它是『能力』,該長在 API。」**
|
||||
- ❌ 缺 `upsert` → 在工作流裡拼「先查、沒有再建」、或寫個 recipe 假裝補上。
|
||||
- ✅ 缺 `upsert` → 去 API 加一個 `upsert` endpoint,CLI/MCP/recipe 都呼叫它。
|
||||
recipe 只負責「打一個固定外部 API」這件單純事;它不是用來補 arcrun 自己缺的能力的。缺能力就回報 / 補在 API,不要繞。
|
||||
| status | 意思 | 你該做什麼 |
|
||||
|---|---|---|
|
||||
| `found` / `resolved` | 有現成的可用 | **只填 payload** |
|
||||
| `not_found` | 零件庫與 recipe 庫都沒有 | 照回應的 `suggestion` 走(見下兩條路),並看 `similar_components`/`similar_recipes` 有沒有能直接用的 |
|
||||
| `unknown` | 查不到 registry(未部署/網路失敗) | **不代表不存在**,別據此改寫成 code |
|
||||
|
||||
**③ 已經有自製零件(例如 mira 的那幾個)→ 讓它退場,別再加新的。**
|
||||
你不該自製零件;既有的自製零件要往這三條退場:
|
||||
- `claude_api` 之類「工作流回頭叫 LLM」→ **刪掉**,需要 AI 判斷時是**你(操盤的 CC)自己做**,再叫工作流做確定性的下一步。arcrun 是 AI 用的工具,不是工具回頭用 AI。
|
||||
- `kbdb_*` 之類資料存取 → 改走已備好的 **`acr kbdb` 薄殼 / `kbdb_*` MCP 工具**(template + record 模型),不要當零件。
|
||||
- 純粹打某個固定外部 API 的假零件 → **改寫成 recipe** 投稿(見①)。
|
||||
- **缺外部 API** → **自己寫一個 recipe** 並 `acr recipe push`(幾行 YAML,不用部署 Worker、不用寫程式)。
|
||||
recipe 是公共資產,發現缺的就補一個投稿,不必問人。
|
||||
- **缺計算能力**(加解密/壓縮這類純運算) → 投稿**零件 PR**(要人類確認,罕見)。
|
||||
|
||||
🔴 **查不到就改寫成 `code` 節點 =「腹語術」**(表面用 Arcrun、實際全寫 JS)。
|
||||
`code` 只用於**局部整形**(例:剝掉 LLM 回應的雜訊、切段落),不用來取代零件與流程控制。
|
||||
> 實錄:每一個寫進 `code` 的 `if` 都是沒被測過的新 bug;零件的價值是「被測過 1000 次」,寫進 code 就歸零。
|
||||
|
||||
### 其餘鐵律
|
||||
|
||||
- **先查能力再動手**:`acr parts`(看可用零件)、`acr auth-recipe list`(看支援的認證服務)、`acr kbdb`(資料存取)。
|
||||
- **暴露資料要人類同意**:部署對外 webhook / push recipe 會讓東西可被外部呼叫 → 停下來讓使用者明示同意,不替他決定公開。
|
||||
- **誠實**:沒打通就誠實說(缺 credential 標「未驗收:缺 X」),不假裝成功;完成以 HTTP 2xx / trace 為證,不口頭宣布。
|
||||
- **先查能力再動手**:`acr search <關鍵字>`(一次掃零件/recipe/auth-recipe/workflow)、
|
||||
`acr parts`(零件)、`acr recipe list`(recipe)、`acr auth-recipe list`(支援的認證)。
|
||||
- **需要 AI 判斷時你自己做**,不要讓工作流回頭呼叫 LLM。Arcrun 是 AI 用的工具,不是工具回頭用 AI。
|
||||
- **金鑰只拿名字**:定義裡只寫 `{{credential.<名字>}}`,真身絕不寫進 workflow/recipe 檔案。
|
||||
- **暴露資料要人類同意**:`acr push`/`acr recipe push` 會讓東西可被外部呼叫 → 停下來讓使用者明示同意,不替他決定公開。
|
||||
- **誠實**:沒打通就誠實說(缺 credential 標「未驗收:缺 X」),不假裝成功;完成以 HTTP 2xx/trace 為證,不口頭宣布。
|
||||
|
||||
開始前讀 **arcrun-mindset** Skill(世界觀)。使用者技術細節交給你,CLI 操作你來做。
|
||||
開始前讀 **arcrun-mindset** Skill(意圖語法+範本+世界觀)。使用者技術細節交給你,CLI 操作你來做。
|
||||
<!-- arcrun-harness:end -->
|
||||
|
||||
@@ -1,26 +1,60 @@
|
||||
# 用 arcrun 完成這個自動化需求
|
||||
# 用 Arcrun 完成這個自動化需求
|
||||
|
||||
使用者想做一個自動化。你的任務:用 arcrun 做出來,全程不要讓使用者自己寫程式。
|
||||
使用者想做一個自動化。你的任務:用 Arcrun 做出來,全程不要讓使用者自己寫程式。
|
||||
|
||||
⚠️ **不要上網搜 Arcrun 文件**(網路上沒有)。先讀 **arcrun-mindset** Skill,再用 `acr` 指令查現況。
|
||||
|
||||
## 鐵則
|
||||
- **用 arcrun 工作流 / recipe,絕不自己寫 Python/Node 腳本。** 使用者選 arcrun 就是不想要一次性腳本。
|
||||
- 打外部 API → 寫 recipe(`acr recipe push`),不自刻 HTTP client。
|
||||
- 不自製零件(WASM)—— 零件由 arcrun 維護。你能用的是現有零件 + recipe + 工作流。
|
||||
- 需要 AI 判斷時你自己做,不要讓工作流回頭呼叫 LLM。
|
||||
- **用 Arcrun 工作流/recipe,絕不自己寫 Python/Node 腳本。** 使用者選 Arcrun 就是不想要一次性腳本。
|
||||
- **打外部 API → 寫 recipe**(`acr recipe push`),不自刻 HTTP client。缺 recipe 就自己補一個,不必問人。
|
||||
- **不自製零件(WASM)**——零件由 Arcrun 維護。你能用的是現有零件 + recipe + 工作流。
|
||||
- **需要 AI 判斷時你自己做**,不要讓工作流回頭呼叫 LLM。
|
||||
- 🔴 **查不到零件就改寫成 `code` 節點 = 腹語術**,禁止。缺 API 寫 recipe、缺能力投稿零件。
|
||||
|
||||
## 步驟
|
||||
1. 先讀 **arcrun-mindset** Skill(世界觀 + 資源去哪取)。
|
||||
2. 跑 `acr parts` 看零件、`acr auth-recipe list` 看支援的認證。**先查再動手。**
|
||||
3. 把使用者需求拆成工作流(哪些零件、什麼順序、什麼條件),寫成 `.yaml`。
|
||||
4. 需要 credential(API key / token)→ 用 `acr auth-recipe scaffold <service>` 看要哪些,
|
||||
明確告訴使用者去哪取得、怎麼 `acr creds push`。
|
||||
5. `acr validate` 通過後 `acr push` 部署,告訴使用者 webhook URL / 怎麼 `acr run`。
|
||||
6. 完成給客觀證據(HTTP 2xx / trace),不要只說「做好了」。
|
||||
|
||||
## 遇到要暴露資料(對外 webhook)
|
||||
### 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 <關鍵字>` 一次掃零件/recipe/auth-recipe/workflow;
|
||||
或把意圖串丟 `/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 投稿)
|
||||
停下來,明確告訴使用者「這會讓 X 可被外部呼叫」,要他同意。不要替他決定公開。
|
||||
非互動環境下把完整指令印給使用者自己貼上跑。
|
||||
|
||||
## 還沒設定好 arcrun?
|
||||
## 還沒設定好 Arcrun?
|
||||
若 `acr` 指令不存在或還沒 `acr init`:先帶使用者完成前置設定
|
||||
(裝 CLI → 拿 Cloudflare 帳號的兩串憑證 → `acr init --self-hosted`)。
|
||||
拿 Cloudflare 憑證時用白話照抄式引導,不要對使用者講 KV / Worker / R2 等術語。
|
||||
|
||||
@@ -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>\`。或使用者先在對話明示同意後親自於終端機執行。不要替使用者決定公開。"
|
||||
"交人類在終端機執行(真 TTY 會自動放行)。可把指令完整複製給使用者貼上自己跑:\`acr push <你的 workflow.yaml>\`。或使用者先在對話明示同意後親自於終端機執行。不要替使用者決定公開。(部署前的正路見 arcrun-mindset Skill:先 \`acr validate\`)"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
@@ -76,15 +76,29 @@ 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,串服務/自動化不要自刻一次性腳本。" \
|
||||
"先跑 \`acr parts\` 看有哪些零件,把需求寫成 workflow.yaml 用 \`acr run\`。若這確實不是自動化(例如跑測試/別的工具),忽略本提醒。"
|
||||
remind "偵測到用 python/node 跑腳本。這專案用 Arcrun,串服務/自動化不要自刻一次性腳本。" \
|
||||
"讀 arcrun-mindset Skill,先把需求寫成「意圖」串(\`input >> ON_SUCCESS >> <下一步>\`,邊只有 ON_SUCCESS 與「對每個 X」),再用 \`acr search <關鍵字>\` 查哪些零件/recipe 存在,最後才寫 workflow.yaml → \`acr validate\` → \`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 push\` 把這個 API 包成 recipe,workflow 裡用 component 引用它。見 arcrun-mindset Skill。"
|
||||
remind "偵測到自己打外部 API。Arcrun 裡「打固定 endpoint」應寫成 recipe,不自刻 HTTP 呼叫。" \
|
||||
"先 \`acr recipe search <服務名>\` 看有沒有現成的;沒有就自己寫幾行 YAML(canonical_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
|
||||
fi
|
||||
|
||||
exit 0
|
||||
|
||||
@@ -10,6 +10,16 @@
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Write|Edit|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": ".claude/hooks/arcrun-guard.sh",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,78 +1,285 @@
|
||||
---
|
||||
name: arcrun-mindset
|
||||
description: >-
|
||||
arcrun 的世界觀 — 用 arcrun 開發自動化時的預設心態 + 資源去哪取。當你(AI 操盤手)要在
|
||||
arcrun 上做任何事(串服務、處理資料、認證、把東西開放給人用)前讀這個。它讓你做出「方向對」
|
||||
的選擇、知道資源在哪,避免技術上能跑但架構上錯、或自己重刻 arcrun 已有的東西。
|
||||
在 Arcrun 上做任何事時使用(用戶說「幫我用 Arcrun 做 X」「用 arcrun 寫一個工作流」「把 X 自動化」)。
|
||||
Arcrun 是跑在 Cloudflare 上的工作流引擎——你用 `>>` 寫「意圖」,系統告訴你有哪些現成零件與 recipe,
|
||||
你只填 payload,不必自己寫程式。**不要上網搜 Arcrun 文件**(網路上沒有),也不要自己猜 YAML 格式:
|
||||
先讀本 skill,再用 `acr` 指令(或 MCP 工具)查現成零件。
|
||||
涵蓋:意圖工作流語法、四份實跑過的範本、零件 vs recipe 的分別、缺件的兩條路、已知的坑。
|
||||
---
|
||||
|
||||
# arcrun mindset(給 AI 操盤手)
|
||||
# Arcrun:怎麼寫意圖工作流
|
||||
|
||||
你在 arcrun 上幫使用者開發自動化。arcrun 很簡單,簡單到你常會把它想複雜、或退回自己熟悉的
|
||||
Python/Node 自刻。這份幫你在岔路上選對方向,並告訴你資源在哪。
|
||||
> **你已經配備 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)。** 工作流 = 一張紙,寫「用哪些零件、什麼順序、什麼條件」。
|
||||
你大部分時間在**寫紙、改紙**,不是在造新零件、也不是自己寫腳本。
|
||||
|
||||
**Arcrun 只有三種東西,先分清楚就不會做歪:**
|
||||
|
||||
| 東西 | 是什麼 | 你能做的 |
|
||||
|---|---|---|
|
||||
| **工作流(workflow)** | 把零件/recipe 串起來的純文字流程 | **預設就寫這個**,自由寫 |
|
||||
| **recipe** | 打「一個固定外部 API」的設定(endpoint/header/body 模板) | 自由寫、**而且該投稿**(缺就自己補) |
|
||||
| **零件(component)** | WASM 程式(流程控制/資料處理/`http_request`/auth),固定一小套 | **你不自製**,走 PR 由維護者管 |
|
||||
|
||||
> **一句話判準**:打一個固定外部 endpoint → 寫 **recipe**;流程控制/資料處理/通用 HTTP → 用既有**零件**;其他 → 寫**工作流**串起來。
|
||||
|
||||
---
|
||||
|
||||
## 1. 工作流是 default,不要退回自己寫 Python
|
||||
<!-- 以下正文由 registry/skills/write_intent_workflow.md 於建置期複製而來(單一真相源)。
|
||||
不要直接編輯本段——改 registry 那份,然後跑 `npm run build:harness`。 -->
|
||||
|
||||
使用者選 arcrun,就是不要「每次重刻、跑完即丟」的腳本。所以你的預設順序:
|
||||
## 1. 意圖工作流的語法
|
||||
|
||||
1. **先想能不能用工作流做**(串現有零件 / recipe + 流程控制)。99% 可以。
|
||||
2. 要打的服務有 HTTP API、但沒有對應 recipe → **寫一個 recipe**(http_request + 固定設定 YAML,不用部署、不用審核)。
|
||||
3. **只有**封閉純邏輯(流程控制 / 資料處理)、現有零件不夠、且值得全 arcrun 重用 → 才考慮零件(而零件走 PR,不是你現在做)。
|
||||
一串「誰接誰」,每行一個關係:
|
||||
|
||||
> 典型走歪:「我先用 Python 測一下」。停。使用者要的是 arcrun 工作流。先 `acr parts` 看有什麼,用工作流串。
|
||||
```
|
||||
<節點A> >> <邊> >> <節點B>
|
||||
```
|
||||
|
||||
## 2. 資源去哪取(不要自己重造 arcrun 已有的)
|
||||
- **節點**=一個步驟。用你想得到的名字(中文可以),**不必是真實零件名**
|
||||
- **邊**=什麼情況下往下走
|
||||
|
||||
## 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 → 寫 recipe(skill `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 已有的)
|
||||
|
||||
| 你想知道 | 跑這個 |
|
||||
|---|---|
|
||||
| 有哪些零件可用 | `acr parts` |
|
||||
| 某零件的設定範本 | `acr parts scaffold <name>` |
|
||||
| 有哪些 recipe | `acr recipe list`/`acr recipe search <關鍵字>` |
|
||||
| 支援哪些服務的認證 | `acr auth-recipe list` |
|
||||
| 某服務認證要哪些 credential + 範例 | `acr auth-recipe scaffold <service>` |
|
||||
| 已上傳的 recipe | `acr recipe list` |
|
||||
| 某服務認證要哪些 credential + 範例 | `acr auth-recipe scaffold <service>` |
|
||||
| **一次掃全部**(零件/recipe/auth-recipe/workflow) | `acr search <關鍵字>` |
|
||||
| 已部署的 workflow | `acr list` |
|
||||
| 某次執行為什麼失敗 | `acr logs <workflow>` |
|
||||
| 工作流語法、指令 | `acr --help` |
|
||||
|
||||
**先查再動手**——arcrun 多半已經有你要的零件 / recipe / 認證,不要自刻。
|
||||
**先查再動手**——Arcrun 多半已經有你要的零件/recipe/認證,不要自刻。
|
||||
|
||||
## 3. arcrun 是你(AI)用的工具,不是工具回頭呼叫 AI
|
||||
## 10. 做出來以後:驗證 → 部署
|
||||
|
||||
需要智慧判斷 / 自然語言轉換時,**你自己做**,再呼叫工作流執行確定性的下一步。
|
||||
**不要在工作流中間放零件回頭呼叫 LLM**。arcrun 的大腦就是操盤的你。
|
||||
```bash
|
||||
acr validate <workflow>.yaml # 先驗,別直接部署
|
||||
acr push <workflow>.yaml # 部署(暴露動作,見 §12)
|
||||
acr run <workflow> # 觸發一次,看實際結果
|
||||
acr logs <workflow> # 看執行紀錄/失敗原因
|
||||
```
|
||||
|
||||
## 4. arcrun 不替你做授權判斷
|
||||
需要 credential(API key/token)時:`acr auth-recipe scaffold <service>` 看要哪些,
|
||||
明確告訴使用者去哪取得、怎麼 `acr creds push`。
|
||||
🔑 **金鑰只拿名字**:workflow/recipe 裡只寫 `{{credential.<名字>}}`,
|
||||
**真身絕不寫進定義檔**(執行前才由系統回填)。
|
||||
|
||||
API 打不打得通由發 key 的服務決定。401/403 是對方服務在行使授權,**不是 arcrun 的 bug、不是你做錯**。
|
||||
不要在 arcrun 裡建「允許/禁止某 endpoint」的二次授權清單。
|
||||
## 11. Arcrun 是你(AI)用的工具,不是工具回頭呼叫 AI
|
||||
|
||||
## 5. 把東西開放給別人用 = 要使用者明示同意
|
||||
需要智慧判斷/自然語言轉換時,**你自己做**,再呼叫工作流執行確定性的下一步。
|
||||
**不要在工作流中間放零件回頭呼叫 LLM**——Arcrun 的大腦就是操盤的你。
|
||||
(唯一例外:`ask_llm` 這種「內容生成本身就是流程的一步」,見範本 B。)
|
||||
|
||||
部署對外 webhook、push recipe 會讓資料/能力**可被外部呼叫**(暴露面):
|
||||
## 12. 把東西開放給別人用 = 要使用者明示同意
|
||||
|
||||
`acr push`(部署 workflow)與 `acr recipe push`(投稿 recipe)會讓資料/能力**可被外部呼叫**:
|
||||
- 停下來,明確告訴使用者「這會讓 X 可被外部呼叫」,要他同意。**不替他決定公開。**
|
||||
- 非互動環境(你直跑)遇到 → 停,要人類確認,絕不自己塞 confirm 假裝同意。
|
||||
- arcrun 可提供保護(要求呼叫者帶 key / 限流)——提醒使用者。
|
||||
- 非互動環境(你直跑)遇到 → 停,把完整指令印給使用者自己貼上跑,絕不自己塞 confirm 假裝同意。
|
||||
- Arcrun 可提供保護(要求呼叫者帶 key/限流)——提醒使用者。
|
||||
|
||||
## 6. 誠實(最重要)
|
||||
## 13. Arcrun 不替你做授權判斷
|
||||
|
||||
API 打不打得通由發 key 的服務決定。401/403 是對方服務在行使授權,**不是 Arcrun 的 bug、不是你做錯**。
|
||||
不要在 Arcrun 裡建「允許/禁止某 endpoint」的二次授權清單。
|
||||
|
||||
## 14. 誠實(最重要)
|
||||
|
||||
- **不假綠**:沒打通就誠實說。缺 credential 打不到 2xx → 標「未驗收:缺 X」,不 mock 充綠燈。
|
||||
- **不假裝防偽 / 不代替人類確認**有風險的動作(暴露資料)。
|
||||
- **完成 = 客觀證據**(HTTP 2xx + trace),不是口頭「做好了」。
|
||||
- **不假裝防偽/不代替人類確認**有風險的動作(暴露資料)。
|
||||
- **完成 = 客觀證據**(HTTP 2xx + trace),不是口頭「做好了」。
|
||||
|
||||
---
|
||||
|
||||
## 怎麼用這份 mindset
|
||||
## 動手前的自檢清單
|
||||
|
||||
每次準備動手,先過一遍:
|
||||
1. 這能用工作流 / recipe 做嗎?(多半能 → 別自己寫 Python、別造零件)
|
||||
2. 我查過 `acr parts` / `acr auth-recipe` 了嗎?(arcrun 可能已有)
|
||||
3. 我是不是讓工作流回頭呼叫 AI?(是 → 改成我自己做)
|
||||
4. 這動作會把資料開放給別人嗎?(會 → 要使用者明示同意)
|
||||
5. 我有沒有假裝(假綠 / 假防偽 / 代替人類確認)?(有 → 停,誠實標明)
|
||||
1. 我把意圖寫成 `>>` 串了嗎?(還是直接跳去寫 YAML/寫程式)
|
||||
2. 我查過 `acr search` / `acr parts` / `acr recipe list` 了嗎?
|
||||
3. 查詢回 `not_found` 時,我走的是 recipe/零件 PR 兩條路,**還是偷偷改寫成 `code`**?(後者=腹語術)
|
||||
4. 我是不是讓工作流回頭呼叫 AI 做判斷?(是 → 改成我自己做)
|
||||
5. 這動作會把資料開放給別人嗎?(會 → 要使用者明示同意)
|
||||
6. 我有沒有假裝(假綠/假防偽/代替人類確認)?(有 → 停,誠實標明)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
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」的設定(endpoint/header/body 模板) | 自由寫、**而且該投稿**(缺就自己補) |
|
||||
| **零件(component)** | WASM 程式(流程控制/資料處理/`http_request`/auth),固定一小套 | **你不自製**,走 PR 由維護者管 |
|
||||
|
||||
> **一句話判準**:打一個固定外部 endpoint → 寫 **recipe**;流程控制/資料處理/通用 HTTP → 用既有**零件**;其他 → 寫**工作流**串起來。
|
||||
|
||||
---
|
||||
@@ -0,0 +1,67 @@
|
||||
|
||||
---
|
||||
|
||||
## 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>` |
|
||||
| **一次掃全部**(零件/recipe/auth-recipe/workflow) | `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> # 看執行紀錄/失敗原因
|
||||
```
|
||||
|
||||
需要 credential(API key/token)時:`acr auth-recipe scaffold <service>` 看要哪些,
|
||||
明確告訴使用者去哪取得、怎麼 `acr creds push`。
|
||||
🔑 **金鑰只拿名字**:workflow/recipe 裡只寫 `{{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. 我有沒有假裝(假綠/假防偽/代替人類確認)?(有 → 停,誠實標明)
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "arcrun",
|
||||
"version": "1.3.13",
|
||||
"version": "1.3.14",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "arcrun",
|
||||
"version": "1.3.13",
|
||||
"version": "1.3.14",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "^5.3.0",
|
||||
|
||||
+4
-2
@@ -8,7 +8,9 @@
|
||||
"main": "./dist/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"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",
|
||||
"dev": "tsc --watch",
|
||||
"test": "node --test \"tests/**/*.test.ts\"",
|
||||
"prepublishOnly": "npm run build && chmod +x dist/index.js"
|
||||
@@ -42,6 +44,6 @@
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/uncle6me-web/Arcrun.git"
|
||||
"url": "git+https://github.com/youlinhsieh/Arcrun.git"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/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 專屬(frontmatter/CLI 入口/三種東西的分型)
|
||||
* 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`);
|
||||
@@ -0,0 +1,136 @@
|
||||
#!/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');
|
||||
@@ -230,15 +230,16 @@ async function initSelfHosted(
|
||||
console.log(chalk.yellow(` ⚠ 查 subdomain 失敗(${e instanceof Error ? e.message : e}),稍後可手動補`));
|
||||
}
|
||||
|
||||
// 3.5 語義查詢開關(issue #7 / T2.4):問用戶要不要開(預設關,free-tier 友善)。
|
||||
// 開 → deploy 建 CF Vectorize index + 注入 binding。關 → base 維持 LIKE keyword,零花費。
|
||||
// 之後想開:跟 CC 說「幫我開語義查詢」或設 kbdb_embed:true + acr update(不必重 init)。
|
||||
// 3.5 語義查詢(issue #7 / T2.4):**預設開**(2026-08-09 翻轉,leo:「語義搜尋已經
|
||||
// 確定是一安裝就提供的功能」——預設關會產出一批「看起來裝好了、其實少一條腿」的
|
||||
// 實例,之後畫面上還被誤說成「沒開通」)。顯式回答 n 才關(極端省額度者自選)。
|
||||
// 開 → deploy 建 CF Vectorize index + 注入 binding。關 → base 維持 LIKE keyword。
|
||||
const embedAns = (await prompt(
|
||||
rl,
|
||||
'要開語義查詢嗎?(KBDB 加 AI 向量搜尋;用 CF Vectorize,可能多花費;預設關,之後可隨時開) [y/N]',
|
||||
'要開語義查詢嗎?(內建功能,建議保持開啟;用 CF Vectorize,有免費額度) [Y/n]',
|
||||
)).trim().toLowerCase();
|
||||
const kbdbEmbed = embedAns === 'y' || embedAns === 'yes';
|
||||
if (kbdbEmbed) console.log(chalk.gray(' → 已選開語義查詢:部署時會建 Vectorize index。'));
|
||||
const kbdbEmbed = !(embedAns === 'n' || embedAns === 'no');
|
||||
if (!kbdbEmbed) console.log(chalk.yellow(' → 已選關語義查詢:這台實例將只有關鍵字搜尋(之後可設 kbdb_embed:true + acr update 補開)。'));
|
||||
|
||||
// 4. 下載 repo 部署物(含預編譯 wasm)+ 注入 KV id + wrangler deploy 全部 Worker
|
||||
console.log(chalk.gray('\n → 下載部署物 + 部署 Worker(從 GitHub 拉預編譯 wasm,用你的 CF token 部署)...'));
|
||||
|
||||
@@ -110,11 +110,18 @@ 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);
|
||||
|
||||
@@ -55,10 +55,12 @@ 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 }),
|
||||
body: JSON.stringify({ triplets: workflow.flow, mode: 'compile' }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -68,10 +70,8 @@ export async function cmdPush(filePath: string): Promise<void> {
|
||||
}
|
||||
|
||||
const data = await res.json() as { cypher: { nodes: unknown[]; edges: unknown[] }; missing: string[] };
|
||||
if (data.missing?.length > 0) {
|
||||
searchSpinner.fail(chalk.red(`以下零件不存在:${data.missing.join(', ')}\n執行 acr parts 查看可用零件。`));
|
||||
process.exit(1);
|
||||
}
|
||||
// t158:push 不看 missing(compile 模式亦恆空)——存在性由執行時 component-loader 決定;
|
||||
// 要「先問有沒有」用 acr validate/MCP 查詢(discover 路徑)。
|
||||
|
||||
// 附上 id / name,並將 workflow.config 套入節點(componentId + data)
|
||||
const rawGraph = data.cypher as { nodes: Array<{ id: string; componentId?: string; data?: Record<string, unknown> }>; edges: unknown[] };
|
||||
|
||||
@@ -84,9 +84,13 @@ 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:true → 部署建 Vectorize index + 注入 binding。
|
||||
// 這也是「CC 幫開」的落地路徑:CC 寫 kbdb_embed:true 進 config → acr update redeploy 即生效。
|
||||
kbdbEmbed: config.kbdb_embed === true,
|
||||
// 語義查詢(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,
|
||||
};
|
||||
|
||||
const result = await downloadAndDeploy(ctx, 'main', { force: opts.force });
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* acr workflow export <name> / acr workflow import <file> — workflow 可攜原語(t158)。
|
||||
*
|
||||
* leo 07-31 定調:「你要做的就是一個叫 export,另一個是 import,打包好的幾個工作流
|
||||
* 準備好直接 import 就好了。現在如果我要把我做的工作流分享給同事,我要怎麼 export?
|
||||
* 他要如何 import?是缺了功能用 search 來湊嗎?在從前就是寫成幾個 yaml 丟過去
|
||||
* 讓新的送進 KBDB 不是嗎?」
|
||||
*
|
||||
* - export:GET /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 打包期預編 graph,pushWorkflow 直接 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_key/NAMESPACE,請先 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);
|
||||
}
|
||||
}
|
||||
@@ -28,10 +28,12 @@ export interface ArcrunConfig {
|
||||
mcp_url?: string;
|
||||
multi_tenant?: boolean;
|
||||
// 語義查詢開關(issue #7 / SDD T2.4,self-hosted 從零做)。
|
||||
// true → deploy 時建 CF Vectorize index 並注入 kbdb worker 的 [[vectorize]]+[ai] binding;
|
||||
// 🔴 2026-08-09 預設翻轉(leo:「語義搜尋已經確定是一安裝就提供的功能」):
|
||||
// 未設 → **視同開**(init/update 皆以 `!== false` 判斷)。只有顯式 false 才關。
|
||||
// true/未設 → deploy 時建 CF Vectorize index 並注入 kbdb worker 的 [[vectorize]]+[ai] binding;
|
||||
// kbdb embed 模組啟用(寫入時對標記 embed 的 entry embed、search 支援 mode=semantic)。
|
||||
// 未設/false → base 維持 LIKE keyword(free-tier 友善,不建 index、不花費)。
|
||||
// 開法:設 kbdb_embed:true → redeploy(acr update)。「CC 幫開」=CC 寫此欄 true + 跑 acr update。
|
||||
// false → base 維持 LIKE keyword(顯式選擇才有這個狀態;缺欄位不再等於關——
|
||||
// 舊語意會讓 acr update 把正常實例的 binding 靜默剝掉,畫面再謊稱「沒開通」)。
|
||||
kbdb_embed?: boolean;
|
||||
// 暴露 consent 閘已移除(leo 2026-06-29,Arcrun#13)。此欄位保留只為向後相容舊 config.yaml
|
||||
// (讀到不報錯,不再寫入/檢查)。
|
||||
|
||||
+65
-15
@@ -163,8 +163,27 @@ export interface DeployContext {
|
||||
kbdbEmbed?: boolean;
|
||||
}
|
||||
|
||||
/** Vectorize index 名(kbdb embed 模組用)。bge-base-en-v1.5 = 768 維、cosine。 */
|
||||
export const KBDB_VECTORIZE_INDEX = 'arcrun-kbdb-embed';
|
||||
/**
|
||||
* 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.1410、959ms)。
|
||||
* leo 08-05:「換 embed model 當然要合併,當然要換 vectorize,原本的根本不能用」。
|
||||
*
|
||||
* **換模型必須換 index,且必須換「名字」**:
|
||||
* ① 維度 768→1024,舊 index 收不進新向量
|
||||
* ② 就算維度相同也不能沿用——不同模型的向量混在同一 index,比對出來是垃圾;
|
||||
* 而 #58(Vectorize vector delete 未接)代表舊向量刪不掉
|
||||
* ⇒ **開新名字的 index 反而順手繞開 #58**,且新舊並存可回滾。
|
||||
*
|
||||
* ⚠️ 這個常數同時被 `ensureVectorizeMetadataIndexes()` 使用(deploy.ts:426)
|
||||
* ⇒ t36 的四個 metadata index(owner_id/entry_type/source/library,Arcrun#11 根因修復)
|
||||
* 會自動建在新 index 上,**不會因為改名而遺失**(已查證,非假設)。
|
||||
*
|
||||
* 既有實例遷移:部署後 `POST /embed/backfill {"reindex":true}` 重嵌到 remaining=0,
|
||||
* 確認語意查詢正常後,舊的 `arcrun-kbdb-embed` 可自行刪除。
|
||||
*/
|
||||
export const KBDB_VECTORIZE_INDEX = 'arcrun-kbdb-embed-m3';
|
||||
|
||||
export interface DeployResult {
|
||||
implemented: boolean;
|
||||
@@ -317,20 +336,49 @@ export async function downloadAndDeploy(
|
||||
failures.push(`D1 migration: 部署物缺 kbdb/migrations/0001_base.sql(${migPath})`);
|
||||
}
|
||||
|
||||
// 3.6 credentials 目錄表(api_key/name/service/sensitivity/secret_ref/created_at/last_used_at)。
|
||||
// 現行 credential 規範見 .claude/rules/01-tech-stack.md「Credential 儲存規範」。
|
||||
// 同一顆 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)) {
|
||||
// 3.6 credential template seed(D38 圍牆修復,總管交辦,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.ts),D19「擁有目錄不擁有內容物」不變。
|
||||
const credTplMigPath = join(root, 'kbdb', 'migrations', '0005_credential_template.sql');
|
||||
if (existsSync(credTplMigPath)) {
|
||||
try {
|
||||
await applyD1Migration(ctx, readFileSync(credMigPath, 'utf8'));
|
||||
await applyD1Migration(ctx, readFileSync(credTplMigPath, 'utf8'));
|
||||
} catch (e) {
|
||||
failures.push(`D1 migration 0002_credentials (${ctx.d1DatabaseId}): ${e instanceof Error ? e.message : String(e)}`);
|
||||
failures.push(`D1 migration 0005_credential_template (${ctx.d1DatabaseId}): ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
} else {
|
||||
failures.push(`D1 migration: 部署物缺 kbdb/migrations/0002_credentials.sql(${credMigPath})`);
|
||||
failures.push(`D1 migration: 部署物缺 kbdb/migrations/0005_credential_template.sql(${credTplMigPath})`);
|
||||
}
|
||||
|
||||
// 3.6b 退役舊 credentials 表(D38,2026-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 seed(KV 額度事故修復,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})`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,7 +436,9 @@ async function applyD1Migration(ctx: DeployContext, sql: string): Promise<void>
|
||||
|
||||
/**
|
||||
* 確保 KBDB embed 用的 Vectorize index 存在(issue #7 / T2.4)。
|
||||
* REST `POST /accounts/{id}/vectorize/v2/indexes`(dimensions=768/metric=cosine,對齊 bge-base-en-v1.5)。
|
||||
* 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 拒絕。
|
||||
* 冪等:已存在(CF 回「already exists」類錯)視為成功,不報錯。用 init 已驗的 apiToken+accountId。
|
||||
*/
|
||||
async function ensureVectorizeIndex(ctx: DeployContext): Promise<void> {
|
||||
@@ -398,8 +448,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: 768, metric: 'cosine' },
|
||||
description: 'arcrun KBDB optional embed module (issue #7)',
|
||||
config: { dimensions: 1024, metric: 'cosine' },
|
||||
description: 'arcrun KBDB embed module — bge-m3 1024d (issue #7 / #59)',
|
||||
}),
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
|
||||
@@ -1,22 +1,68 @@
|
||||
{
|
||||
"_readme": [
|
||||
"部署目標定義檔(leo 2026-07-22 立)。一個目標=一組『帳號+profile+apiBase+專案名』。",
|
||||
"部署目標定義檔(leo 2026-07-22 立)。一個目標=一組『帳號+profile+apiBase+專案名+對外網址』。",
|
||||
"",
|
||||
"為什麼要這個檔:5a16484 把 UI 搬 CF Pages 後,這些值從 worker 環境變數變成 build 期參數。",
|
||||
"誰部署誰要記得帶 → 帶漏了就退回預設,而預設值對兩邊都不對。今天實際踩到的:",
|
||||
"為什麼要這個檔: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",
|
||||
" 烤進產物。但 t160(e744ad1)為了清世代債把 build.mjs 整支刪掉、改成直接託管 public/,",
|
||||
" **沒有人把『把宣告值寫進產物』這件事接手過去** ⇒ deploy.mjs 照樣在終端機印",
|
||||
" 『profile:full / 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,",
|
||||
" 你要確定不可再犯」):**組態對 ≠ 世代對**。",
|
||||
" 當天實測:三個對外網址的 apiBase/views/home **三項全過**,",
|
||||
" 但它們跑的是 07-22 那一代的 portal(82,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、知識庫權限不可用 → 加在對應目標的欄位裡,別再散進部署指令。",
|
||||
"",
|
||||
"用法:npm run deploy:personal / npm run deploy:enterprise"
|
||||
"🧊 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",
|
||||
@@ -24,6 +70,7 @@
|
||||
"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
|
||||
@@ -31,12 +78,14 @@
|
||||
},
|
||||
|
||||
"enterprise": {
|
||||
"description": "企業版 demo 站。rag-demo.arcrun.dev → uncle6 帳號 cypher。",
|
||||
"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
|
||||
|
||||
@@ -2,12 +2,11 @@
|
||||
"name": "arcrun-console-ui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Arcrun Console / Portal 靜態前端(Cloudflare Pages)——從 cypher-executor 搬出的 UI 層",
|
||||
"description": "Arcrun Console / Portal 靜態前端——public/ 是唯一世代真身(t160:舊 src/+build 已 git rm);部署時由 deploy.mjs 依 deploy.targets.json 產出 .staging/<目標> 再推",
|
||||
"scripts": {
|
||||
"build": "node scripts/build.mjs",
|
||||
"deploy": "node scripts/deploy.mjs",
|
||||
"deploy:personal": "node scripts/deploy.mjs personal",
|
||||
"deploy:enterprise": "node scripts/deploy.mjs enterprise",
|
||||
"preview": "npm run build && npx serve public"
|
||||
"verify": "node scripts/verify-live.mjs",
|
||||
"preview": "node scripts/deploy.mjs personal --dry-run && npx serve .staging/personal"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.8 KiB |
@@ -1,2 +0,0 @@
|
||||
// Arcrun UI runtime 組態——改這一行就能切 API 目標,不必重新 build。
|
||||
window.ARCRUN_CONFIG = { apiBase: "https://cypher.arcrun.dev" };
|
||||
@@ -92,7 +92,12 @@
|
||||
.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>window.ARCRUN_API_BASE = (window.ARCRUN_CONFIG && window.ARCRUN_CONFIG.apiBase) || "https://cypher.arcrun.dev";</script>
|
||||
<script>
|
||||
// 2026-08-01(arcrun-rag#10 同族):拔掉寫死中央位址的 fallback。
|
||||
// apiBase 由 worker 動態產生的 /config.js 注入;缺它就讓它明顯壞掉,
|
||||
// **不要靜默把請求(可能含金鑰)送去中央實例**。
|
||||
window.ARCRUN_API_BASE = (window.ARCRUN_CONFIG && window.ARCRUN_CONFIG.apiBase) || "";
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
|
||||
@@ -220,7 +220,12 @@
|
||||
.kvline { display: flex; justify-content: space-between; gap: 12px; font-size: 15px; margin: 5px 0; }
|
||||
</style>
|
||||
<script src="/config.js"></script>
|
||||
<script>window.ARCRUN_API_BASE = (window.ARCRUN_CONFIG && window.ARCRUN_CONFIG.apiBase) || "https://cypher.arcrun.dev";</script>
|
||||
<script>
|
||||
// 2026-08-01(arcrun-rag#10 同族):拔掉寫死中央位址的 fallback。
|
||||
// apiBase 由 worker 動態產生的 /config.js 注入;缺它就讓它明顯壞掉,
|
||||
// **不要靜默把請求(可能含金鑰)送去中央實例**。
|
||||
window.ARCRUN_API_BASE = (window.ARCRUN_CONFIG && window.ARCRUN_CONFIG.apiBase) || "";
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -412,16 +417,14 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div style="display:flex;align-items:center;gap:14px">
|
||||
<div style="min-width:0">
|
||||
<div style="font-size:17px;font-weight:600">語意搜尋(vectorize)</div>
|
||||
<div id="st-vec" style="margin-top:4px;font-size:14px;line-height:1.65;color:rgba(var(--ink-rgb),.55)">狀態偵測中…</div>
|
||||
</div>
|
||||
<div class="switch" id="st-vec-switch" title="此開關不能遠端改,只如實顯示狀態"><i></i></div>
|
||||
</div>
|
||||
<div style="margin-top:12px;padding:12px 14px;border-radius:10px;background:rgba(var(--amber-rgb),.07);border:1px dashed rgba(var(--amber-rgb),.35);font-size:14px;line-height:1.7;color:rgba(var(--ink-rgb),.65)">
|
||||
誠實提示:開關真相=部署端 <code style="font-size:12.5px">~/.arcrun/config.yaml</code> 的 <code style="font-size:12.5px">kbdb_embed: true</code> + <code style="font-size:12.5px">acr update</code> 重部署(#32 教訓:wrangler 直推改的形態 config 要同步)。Console 只如實顯示狀態,不假裝能遠端開啟。目前狀態:<b style="color:var(--amber)" id="st-vec-state">偵測中…</b>
|
||||
</div>
|
||||
<!-- t36(leo 2026-07-25 定案:語意搜尋預設開啟、不做開關):
|
||||
這裡原本是一個「長得像開關、其實不能按」的唯讀狀態燈(title 自承「不能遠端改」),
|
||||
旁邊還教用戶去部署端改 config.yaml 跑 CLI——對一鍵安裝進來的用戶那是天書,
|
||||
而且安裝器現在裝機時就把語意索引開好了,那段指示已經不成立。
|
||||
改成單純的狀態列:能用就說能用,不能用就說原因,不擺一個按不動的開關讓人誤會。 -->
|
||||
<div style="font-size:17px;font-weight:600">語意搜尋</div>
|
||||
<div id="st-vec" style="margin-top:4px;font-size:14px;line-height:1.65;color:rgba(var(--ink-rgb),.55)">狀態偵測中…</div>
|
||||
<div id="st-vec-hint" style="margin-top:12px;padding:12px 14px;border-radius:10px;background:rgba(var(--amber-rgb),.07);border:1px dashed rgba(var(--amber-rgb),.35);font-size:14px;line-height:1.7;color:rgba(var(--ink-rgb),.65);display:none"></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div style="font-size:17px;font-weight:600">MCP token 有效期(TTL)</div>
|
||||
@@ -442,6 +445,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div style="font-size:17px;font-weight:600">Portal 帳號密碼救援</div>
|
||||
<div style="margin-top:4px;font-size:14px;line-height:1.65;color:rgba(var(--ink-rgb),.55)">忘記某個 Portal(RAG 搜尋頁)帳號的密碼,包含你自己那組管理員帳號——不需要先登進 Portal。輸入該帳號的 Email,會產生一組新密碼,只顯示這一次,請立刻抄下並拿去 Portal 登入頁使用。</div>
|
||||
<div style="margin-top:14px;display:flex;flex-direction:column;gap:10px">
|
||||
<input type="email" id="st-portal-recover-email" class="txt" placeholder="Portal 帳號 Email">
|
||||
<button class="btn" id="st-portal-recover-btn">產生新密碼</button>
|
||||
<div id="st-portal-recover-status" style="font-size:14px;min-height:1.2em"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div style="font-size:17px;font-weight:600;margin-bottom:12px">系統資訊</div>
|
||||
<div id="st-info"><div class="muted">載入中…</div></div>
|
||||
@@ -859,7 +871,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
}
|
||||
var libs = x.d.libraries || [];
|
||||
if (!libs.length) {
|
||||
lmHonest('還沒有藏書地圖', '還沒有任何庫跑過重算——對 KBDB 呼 <code style="font-size:12.5px">POST /map/recompute?library=庫名</code> backfill 後,這裡會出現全館導覽。<br>不影響下方搜尋,可直接搜全庫。');
|
||||
lmHonest('還沒有藏書地圖', '這個租戶目前沒有任何三元組資料(地圖是查詢時即時核對重算的,不是要人手動 backfill——資料一進來下次載入就會出現)。<br>不影響下方搜尋,可直接搜全庫。');
|
||||
return;
|
||||
}
|
||||
LM.libs = libs; LM.details = {};
|
||||
@@ -967,7 +979,8 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
if (!x.ok) { $('se-count').innerHTML = '<span class="err">' + esc(x.d.error || ('查詢失敗(HTTP ' + x.status + ')')) + '</span>'; return; }
|
||||
var d = x.d;
|
||||
if (S.semantic && d.mode === 'keyword') {
|
||||
$('se-banner').innerHTML = '<div class="honest" style="margin-top:18px"><div class="h">語意搜尋尚未啟用</div><div class="b">語意搜尋用「意思」找資料,不是字面比對。<br>' + esc(d.capability_hint || '部署端尚未開啟 Vectorize——不會假裝有語意結果,以下是關鍵字結果。') + '</div></div>';
|
||||
// 2026-08-09 leo:語意搜尋是安裝即提供的功能,降級=故障,不說「尚未啟用」。
|
||||
$('se-banner').innerHTML = '<div class="honest" style="margin-top:18px"><div class="h">語意搜尋目前故障</div><div class="b">' + esc(d.capability_hint || '語意搜尋目前故障(實例缺 Vectorize/AI 設定),以下先給關鍵字結果,不假裝是語意結果。') + '<br>維運資訊:' + esc(d.admin_hint || '(此版本後端未回報細節)') + '</div></div>';
|
||||
}
|
||||
var entries = d.entries || [];
|
||||
$('se-count').textContent = '命中 ' + entries.length + ' 筆・模式 ' + (d.mode || 'keyword') +
|
||||
@@ -1451,16 +1464,28 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
fetch(API_BASE + '/kbdb/search?q=mira&mode=semantic', { headers: apiHeaders() })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) {
|
||||
// t36:狀態照實顯示(live 探測 mode,不是讀設定值)。啟用時不再顯示任何操作指示——
|
||||
// 沒有東西要用戶操作;未啟用才給一句人話與下一步。
|
||||
// 2026-08-09 leo:語意搜尋是安裝即提供的功能——探測到降級=這台實例壞了,
|
||||
// 照實標「故障」,不說「尚未啟用」(那會把 bug 說成沒提供的功能)。
|
||||
var on = d.mode === 'semantic';
|
||||
$('st-vec-switch').classList.toggle('on', on);
|
||||
$('st-vec').textContent = on
|
||||
? '已啟用・語意搜尋可用(搜尋頁切「語意」)'
|
||||
: '未啟用・' + (d.capability_hint || '部署端尚未開啟 Vectorize(kbdb_embed)');
|
||||
$('st-vec-state').textContent = on ? '已啟用(live 探測 mode=semantic)' : '未啟用(live 探測降級 keyword)';
|
||||
? '● 正常——搜尋頁切到「語意」就能用意思找資料。'
|
||||
: '○ 故障——語意搜尋是內建功能,這台實例現在少了它(系統端問題,不是操作問題)。';
|
||||
var hint = $('st-vec-hint');
|
||||
if (on) {
|
||||
hint.style.display = 'none';
|
||||
} else {
|
||||
hint.style.display = '';
|
||||
hint.innerHTML = '修復方式:重新跑一次安裝流程(用原本的 Cloudflare 帳號),會把缺的語意索引設定補回來;已建好的資料不會重來。'
|
||||
+ (d.admin_hint ? '<br>維運資訊:' + esc(d.admin_hint) : '');
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
$('st-vec').textContent = '狀態偵測失敗(KBDB 不可達)';
|
||||
$('st-vec-state').textContent = '偵測失敗';
|
||||
$('st-vec').textContent = '狀態偵測失敗(知識庫服務目前連不上)';
|
||||
var hint = $('st-vec-hint');
|
||||
hint.style.display = '';
|
||||
hint.textContent = '這通常是暫時的,稍後重新整理這一頁再看。';
|
||||
});
|
||||
// MCP token TTL(誠實佔位:只顯示目前生效值,不假裝能改)
|
||||
fetch(API_BASE + '/console/settings-data')
|
||||
@@ -1510,9 +1535,30 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
})
|
||||
.catch(function (e) { st.innerHTML = '<span class="err">請求失敗:' + esc(friendlyErr(e)) + '</span>'; });
|
||||
});
|
||||
$('st-vec-switch').addEventListener('click', function () {
|
||||
toast('此開關不能遠端改——部署端 config.yaml 開 kbdb_embed 後 acr update 重部署');
|
||||
// arcrun-rag#25:portal admin 密碼救援——只吃 console owner session(S.token,本頁登入用的
|
||||
// 那把),不吃 portal session,所以就算忘記 portal 密碼、進不去 portal 也走得通。
|
||||
$('st-portal-recover-btn').addEventListener('click', function () {
|
||||
var email = $('st-portal-recover-email').value.trim();
|
||||
var st = $('st-portal-recover-status');
|
||||
if (!email) { st.innerHTML = '<span class="err">請輸入 Email</span>'; return; }
|
||||
st.textContent = '處理中…';
|
||||
fetch(API_BASE + '/portal/admin/recover-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + S.token },
|
||||
body: JSON.stringify({ email: email })
|
||||
})
|
||||
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
|
||||
.then(function (x) {
|
||||
if (!x.ok) { st.innerHTML = '<span class="err">' + esc(x.d.error || '失敗') + '</span>'; return; }
|
||||
st.innerHTML = '<span class="ok">新密碼:<code style="font-size:15px;user-select:all">' + esc(x.d.password) + '</code>(只顯示這一次,請立刻抄下)</span>';
|
||||
$('st-portal-recover-email').value = '';
|
||||
toast('新密碼已產生,請立刻抄下');
|
||||
})
|
||||
.catch(function (e) { st.innerHTML = '<span class="err">請求失敗:' + esc(friendlyErr(e)) + '</span>'; });
|
||||
});
|
||||
// t36:原本這裡綁在那顆假開關上(點了只會 toast 一段 CLI 指示)。開關已移除,
|
||||
// 這個 handler 也必須一起拿掉——留著會讓 $('st-vec-switch') 回 null、addEventListener
|
||||
// 當場拋錯,把後面所有綁定(含登出)一起打斷。
|
||||
$('st-logout').addEventListener('click', function () {
|
||||
var t = S.token;
|
||||
if (t) fetch(API_BASE + '/console/logout', { method: 'POST', headers: { Authorization: 'Bearer ' + t } }).catch(function () {});
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
@@ -7,8 +7,11 @@
|
||||
根目錄直接導向搜尋 Portal。
|
||||
|
||||
為什麼不做「選擇介面」的導覽頁(2026-07-21 leo 實際撞到):
|
||||
這個網域(rag-demo.arcrun.dev)是給**客戶測試**用的入口,
|
||||
客戶測試指南寫的就是「一個網址、一組帳密」——多一層選擇=多一個困惑點,
|
||||
這份 UI 部署出去的網址是給**使用者**的入口(個人站 mira.uncle6.me,
|
||||
以及自架用戶自己的網址),進站就是要能用——多一層選擇=多一個困惑點,
|
||||
(2026-08-08 更正:原註解寫「這個網域=rag-demo.arcrun.dev 是客戶測試入口」,
|
||||
那是 uncle6 帳號那個已廢的 demo 站,leo 已定案不再拿它當範例;
|
||||
註解留著會把下一個人導向錯的環境,故改寫。理由本身仍然成立。)
|
||||
而且會讓客戶看到 Admin Console 這個維運介面(不該對客戶露出)。
|
||||
|
||||
維運者要進 console 直接打 /console/ 即可。
|
||||
|
||||
+811
-110
File diff suppressed because one or more lines are too long
@@ -0,0 +1,56 @@
|
||||
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-05:Mac 一律給 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);
|
||||
@@ -0,0 +1,46 @@
|
||||
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);
|
||||
@@ -1,236 +0,0 @@
|
||||
/**
|
||||
* console-ui build — 把 cypher-executor 的三支 UI renderer 在「建置時」跑一次,
|
||||
* 產出純靜態 HTML 到 public/,交給 Cloudflare Pages 託管。
|
||||
*
|
||||
* 為什麼這樣做(cypher-ui-split 第一刀):
|
||||
* 原本 console/portal/dashboard 的 HTML 由 cypher-executor Worker 在「每次請求時」
|
||||
* 用 template literal 組出來 → 5,240 行 UI 字串永遠躺在 Worker bundle 裡(748KB),
|
||||
* 連 /health 這種什麼都不做的請求都要付 5-7ms CPU(免費層上限 10ms)。
|
||||
* UI 是靜態的(單檔 HTML+原生 JS、零外部資源),本來就該待在 Pages。
|
||||
*
|
||||
* 保持原特性(leo 反覆強調簡化):
|
||||
* - 零打包工具、零 npm 依賴:本檔只用 node 內建 fs/path,正則抽出 renderer 的
|
||||
* template literal 後求值。不引入 esbuild/vite/rollup。
|
||||
* - 產出仍是「單檔 HTML+原生 JS hash routing、零外部資源」。
|
||||
*
|
||||
* 唯一的行為差異=API base:
|
||||
* 原本 UI 與 API 同源,fetch 全用相對路徑('/kbdb/search')。搬上 Pages 後跨網域,
|
||||
* 故注入 window.ARCRUN_API_BASE,並把 fetch 的相對路徑改成 API_BASE + path。
|
||||
* 見下方 rewriteFetchPaths()。
|
||||
*/
|
||||
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(HERE, '..');
|
||||
const SRC = join(ROOT, '..', 'cypher-executor', 'src');
|
||||
const OUT = join(ROOT, 'public');
|
||||
|
||||
// ── 建置期組態(原本是 Worker 的 env var,現在是建置參數)────────────────
|
||||
// Pages 是靜態站,沒有 per-request env;品牌/profile 這類「一個部署一個值」的
|
||||
// 設定改在建置時決定(要換值=重跑 build 再部署,符合靜態站模型)。
|
||||
// 具名部署目標(deploy.targets.json):一個目標=帳號+profile+apiBase 綁在一起。
|
||||
// 帶 DEPLOY_TARGET=personal|enterprise 就套用該組值;個別環境變數仍可覆蓋(除錯用)。
|
||||
// 立此檔的原因見 deploy.targets.json 的 _readme——散在部署指令裡的參數帶漏過三次。
|
||||
const TARGET_NAME = process.env.DEPLOY_TARGET || '';
|
||||
let TARGET = {};
|
||||
if (TARGET_NAME) {
|
||||
const targets = JSON.parse(readFileSync(join(ROOT, 'deploy.targets.json'), 'utf8'));
|
||||
TARGET = targets[TARGET_NAME];
|
||||
if (!TARGET) {
|
||||
const names = Object.keys(targets).filter((k) => !k.startsWith('_'));
|
||||
throw new Error(`未知的 DEPLOY_TARGET:"${TARGET_NAME}"。可用:${names.join(' / ')}`);
|
||||
}
|
||||
console.log(`部署目標:${TARGET_NAME} — ${TARGET.description}`);
|
||||
}
|
||||
|
||||
const CFG = {
|
||||
brand: process.env.CONSOLE_BRAND || TARGET.brand || 'Arcrun',
|
||||
profile: process.env.CONSOLE_PROFILE || TARGET.profile || 'full',
|
||||
registryBase: process.env.REGISTRY_BASE || 'https://registry.arcrun.dev',
|
||||
sourceWebBase: process.env.PORTAL_SOURCE_WEB_BASE || '',
|
||||
// API base 走 runtime 注入(見 public/config.js),這裡只放預設值
|
||||
apiBase: process.env.ARCRUN_API_BASE || TARGET.apiBase || '',
|
||||
};
|
||||
|
||||
/**
|
||||
* 讀 TS 原始碼並取出整個 renderer 函式的**函式主體**(不只 template literal)。
|
||||
*
|
||||
* 取整個 body 而非只取反引號區塊,是因為 renderer 在 return 之前會先算區域變數
|
||||
* (如 console.ts 的 rag/views/home 由 profile 推導)。只搬模板=把那段推導邏輯
|
||||
* 複製一份到本檔=雙份真相會漂移。連 body 一起求值 → 推導邏輯永遠只有一份。
|
||||
*/
|
||||
/**
|
||||
* renderer 原始檔的位置:本專案 `console-ui/src/` 優先,找不到才回退 cypher-executor。
|
||||
*
|
||||
* 為什麼要這層(2026-07-22 修):`5a16484` 把 UI 搬出 cypher-executor 時,
|
||||
* **刪了 console.ts / portal-ui.ts 卻只搬走 build 產物(HTML),原始檔沒跟著搬**
|
||||
* → build.mjs 讀不到來源,`npm run build` 從那天起就 ENOENT 死掉,
|
||||
* 線上 HTML 是刪檔前烤好的、之後再也無法重建(profile 改了也不會生效)。
|
||||
* 現已從 git 撈回放進 console-ui/src/——UI 原始碼跟著 UI 專案走,才是那一刀的原意。
|
||||
* console-dashboard.ts 仍在 cypher-executor(它同時含 API),故保留回退路徑。
|
||||
*/
|
||||
function resolveSource(file) {
|
||||
const local = join(ROOT, 'src', file.replace(/^routes\//, ''));
|
||||
if (existsSync(local)) return local;
|
||||
return join(SRC, file);
|
||||
}
|
||||
|
||||
function extractRendererBody(file, fnName) {
|
||||
const code = readFileSync(resolveSource(file), 'utf8');
|
||||
const start = code.indexOf(`function ${fnName}(`);
|
||||
if (start < 0) throw new Error(`找不到 ${fnName} in ${file}`);
|
||||
const braceStart = code.indexOf('{', code.indexOf(')', start));
|
||||
if (braceStart < 0) throw new Error(`${fnName} 找不到函式主體`);
|
||||
// 掃到配對的收尾大括號;需略過字串/template literal/註解裡的括號
|
||||
let i = braceStart + 1;
|
||||
let depth = 1;
|
||||
let mode = null; // null | "'" | '"' | '`' | 'line' | 'block'
|
||||
let tplDepth = 0;
|
||||
while (i < code.length && depth > 0) {
|
||||
const ch = code[i];
|
||||
const nx = code[i + 1];
|
||||
if (mode === null) {
|
||||
if (ch === '\\') { i += 2; continue; }
|
||||
if (ch === '/' && nx === '/') { mode = 'line'; i += 2; continue; }
|
||||
if (ch === '/' && nx === '*') { mode = 'block'; i += 2; continue; }
|
||||
if (ch === "'" || ch === '"') { mode = ch; i++; continue; }
|
||||
if (ch === '`') { mode = '`'; tplDepth = 0; i++; continue; }
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') depth--;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (mode === 'line') { if (ch === '\n') mode = null; i++; continue; }
|
||||
if (mode === 'block') { if (ch === '*' && nx === '/') { mode = null; i += 2; continue; } i++; continue; }
|
||||
if (ch === '\\') { i += 2; continue; }
|
||||
if (mode === '`') {
|
||||
// template literal 內的 ${ … } 是真程式碼,其中的引號/括號要照常計數才不會誤判收尾
|
||||
if (ch === '$' && nx === '{') { tplDepth++; i += 2; continue; }
|
||||
if (ch === '}' && tplDepth > 0) { tplDepth--; i++; continue; }
|
||||
if (ch === '`' && tplDepth === 0) { mode = null; i++; continue; }
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === mode) mode = null;
|
||||
i++;
|
||||
}
|
||||
// 去掉 TS 的型別註記(本 body 只有 `const x: T =` 這種簡單形態)
|
||||
return code.slice(braceStart + 1, i - 1).replace(/\bconst\s+(\w+):\s*[\w<>[\]|]+\s*=/g, 'const $1 =');
|
||||
}
|
||||
|
||||
/** 取出 lib/taipei-time.ts 匯出的 TAIPEI_CLIENT_JS 字串常數(UI 內嵌的客戶端時間工具)。 */
|
||||
function extractTaipeiClientJs() {
|
||||
const code = readFileSync(join(SRC, 'lib', 'taipei-time.ts'), 'utf8');
|
||||
// 形態=字串陣列 .join('\n')(見 lib/taipei-time.ts),直接求值該陣列表達式
|
||||
const m = code.match(/export const TAIPEI_CLIENT_JS\s*=\s*(\[[\s\S]*?\]\.join\('\\n'\));/);
|
||||
if (!m) throw new Error('找不到 TAIPEI_CLIENT_JS');
|
||||
return new Function(`return ${m[1]};`)();
|
||||
}
|
||||
|
||||
/**
|
||||
* 求值 renderer 函式主體。用 new Function 而非 eval——只餵建置期組態,
|
||||
* 輸入是本 repo 自己的原始碼(非使用者輸入),無注入面。
|
||||
*/
|
||||
function render(body, vars) {
|
||||
const names = Object.keys(vars);
|
||||
const fn = new Function(...names, body);
|
||||
return fn(...names.map((n) => vars[n]));
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 UI 內原生 JS 的相對路徑 fetch 改成打 API base。
|
||||
*
|
||||
* 只改 `fetch('/...` 與 `fetch("/...`(開頭是單斜線=同源絕對路徑)這一種形態,
|
||||
* 其餘(fetch(url, …) 這類變數形式)另由各檔的 url 組法在下面單獨處理。
|
||||
*/
|
||||
function rewriteFetchPaths(html, file) {
|
||||
// ① fetch('/xxx → fetch(API_BASE + '/xxx
|
||||
let out = html.replace(/fetch\((['"])\/(?!\/)/g, 'fetch(API_BASE + $1/');
|
||||
// ② 變數式 fetch(url, ...):url 由上方 var url = '/kbdb/search?...' 組成 →
|
||||
// 把這類「以單斜線開頭的路徑字面值指派」也補上 API_BASE
|
||||
out = out.replace(/(\bvar\s+url\s*=\s*)(['"])\/(?!\/)/g, '$1API_BASE + $2/');
|
||||
// ③ portal 的 adminApi(method, path, body):path 由呼叫端傳字面值進來,①②
|
||||
// 都掃不到(8 個呼叫點)。在 helper 內部補前綴=一處修好全部,不必改 8 個呼叫點。
|
||||
out = out.replace(
|
||||
/(function adminApi\(method, path, body\) \{)/,
|
||||
'$1\n path = API_BASE + path;'
|
||||
);
|
||||
|
||||
// 防呆:搬完後不該再有「直接 fetch 同源相對路徑」的殘留。掃到就讓建置失敗,
|
||||
// 免得漏網的呼叫點在 Pages 上打到 Pages 自己(404)才被發現。
|
||||
// 註:adminApi 的呼叫端仍是相對路徑字面值——那是對的,前綴由 helper 內部(③)加。
|
||||
const unprefixed = [...out.matchAll(/fetch\((['"])\/(?!\/)[^'"]*/g)].map((m) => m[0]);
|
||||
if (unprefixed.length) {
|
||||
throw new Error(
|
||||
`${file}:有 ${unprefixed.length} 個相對路徑 fetch 沒被改寫成 API_BASE:\n ` +
|
||||
[...new Set(unprefixed)].join('\n ')
|
||||
);
|
||||
}
|
||||
// adminApi 形態存在時,必須確認 helper 已被加上前綴(否則 8 個呼叫點全會打錯家)
|
||||
if (/function adminApi\(method, path, body\)/.test(out) && !/path = API_BASE \+ path;/.test(out)) {
|
||||
throw new Error(`${file}:偵測到 adminApi helper 但前綴注入失敗`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 在頁面 <head> 注入 config.js(runtime 決定 API base),並定義 API_BASE 供內嵌 JS 用。 */
|
||||
function injectApiBase(html) {
|
||||
const snippet = `<script src="/config.js"></script>
|
||||
<script>window.ARCRUN_API_BASE = (window.ARCRUN_CONFIG && window.ARCRUN_CONFIG.apiBase) || ${JSON.stringify(CFG.apiBase)};</script>`;
|
||||
const withCfg = html.replace('</head>', `${snippet}\n</head>`);
|
||||
// 內嵌的 IIFE 裡宣告 API_BASE(各頁的主 <script> 都是 (function(){ … })() 形態)
|
||||
return withCfg.replace(
|
||||
/<script>\s*\(function\s*\(\)\s*\{/,
|
||||
'<script>\n(function () {\n var API_BASE = window.ARCRUN_API_BASE || \'\';'
|
||||
);
|
||||
}
|
||||
|
||||
function build(name, file, fnName, vars) {
|
||||
const body = extractRendererBody(file, fnName);
|
||||
let html = render(body, vars);
|
||||
html = rewriteFetchPaths(html, name);
|
||||
html = injectApiBase(html);
|
||||
const dest = join(OUT, name);
|
||||
mkdirSync(dirname(dest), { recursive: true });
|
||||
writeFileSync(dest, html, 'utf8');
|
||||
console.log(` ${name.padEnd(24)} ${(Buffer.byteLength(html) / 1024).toFixed(1)} KB`);
|
||||
}
|
||||
|
||||
const TAIPEI_CLIENT_JS = extractTaipeiClientJs();
|
||||
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
console.log('console-ui build →', OUT);
|
||||
|
||||
// /console — Admin Console 完整版(console.ts renderConsoleHtml)
|
||||
build('console/index.html', 'routes/console.ts', 'renderConsoleHtml', {
|
||||
registryBase: CFG.registryBase,
|
||||
brand: CFG.brand,
|
||||
profile: CFG.profile,
|
||||
TAIPEI_CLIENT_JS,
|
||||
});
|
||||
|
||||
// /portal — RAG Portal(portal-ui.ts renderPortalHtml)
|
||||
build('portal/index.html', 'routes/portal-ui.ts', 'renderPortalHtml', {
|
||||
brand: CFG.brand,
|
||||
sourceWebBase: CFG.sourceWebBase,
|
||||
TAIPEI_CLIENT_JS,
|
||||
});
|
||||
|
||||
// /console/dashboard — 駕駛艙(console-dashboard.ts renderDashboardHtml)
|
||||
build('console/dashboard/index.html', 'routes/console-dashboard.ts', 'renderDashboardHtml', {
|
||||
brand: CFG.brand,
|
||||
TAIPEI_CLIENT_JS,
|
||||
});
|
||||
|
||||
// config.js:部署後可直接改這一檔切 API 目標,不必重 build
|
||||
writeFileSync(
|
||||
join(OUT, 'config.js'),
|
||||
`// Arcrun UI runtime 組態——改這一行就能切 API 目標,不必重新 build。
|
||||
window.ARCRUN_CONFIG = { apiBase: ${JSON.stringify(CFG.apiBase)} };
|
||||
`,
|
||||
'utf8'
|
||||
);
|
||||
console.log(' config.js');
|
||||
console.log('done.');
|
||||
@@ -1,51 +1,108 @@
|
||||
/**
|
||||
* deploy.mjs — 依具名目標部署 console-ui 到 Cloudflare Pages
|
||||
*
|
||||
* 用法:npm run deploy:personal / npm run deploy:enterprise
|
||||
* 用法:npm run deploy:personal
|
||||
* npm run deploy:personal -- --dry-run (只產出並驗產物,不推)
|
||||
*
|
||||
* 為什麼不直接用 `wrangler pages deploy`(2026-07-22 leo 立,實際踩到才補):
|
||||
* **兩個帳號都有名為 arcrun-console-ui 的 Pages 專案**
|
||||
* · leo21c → arcrun-console-ui.pages.dev(個人版 console)
|
||||
* · uncle6 → 綁 rag-demo.arcrun.dev(企業版 demo 站)
|
||||
* wrangler 若 OAuth 登入在 uncle6,`--project-name arcrun-console-ui` 會部到 demo 站上。
|
||||
* wrangler 若 OAuth 登入在別的帳號,`--project-name arcrun-console-ui` 會部到別人的站上。
|
||||
* 本腳本強制帶目標的 accountId,並在部署前印出目標,避免部錯帳號。
|
||||
*
|
||||
* 同時把 profile/apiBase 綁進目標(deploy.targets.json),不再靠部署者記得帶環境變數——
|
||||
* 帶漏過三次:demo 站漏 profile=rag 顯示成個人版、兩站漏 apiBase 導致登入 405。
|
||||
* 帶漏過三次:漏 profile 顯示成錯的版本、漏 apiBase 導致登入 405。
|
||||
*
|
||||
* 🔴 三道閘,全部**讀磁碟上真的要被推的那份**,不看本腳本自己印了什麼
|
||||
* (2026-08-08 事故的形狀正是「印的是 A、推的是 B」):
|
||||
* ① 產物閘 :宣告值有沒有真的寫進產物(apiBase / VIEWS / HOME)
|
||||
* ② 世代閘 :產物是不是當代(指紋+t160 的文字指紋)
|
||||
* ③ 線上閘 :推完回頭抓線上,組態+世代都要對上,否則本次部署算失敗
|
||||
* 三閘都過才寫 .deploy-state.json(那份紀錄是「經過線上實測」的意思,不是「我跑過指令」)。
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { join } from 'node:path';
|
||||
import { ROOT, assertArtifact, buildArtifact, loadTargets, resolveTarget, writeState } from './targets.mjs';
|
||||
import { printReport, verifyTarget } from './verify-live.mjs';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const targets = JSON.parse(readFileSync(join(ROOT, 'deploy.targets.json'), 'utf8'));
|
||||
const names = Object.keys(targets).filter((k) => !k.startsWith('_'));
|
||||
const args = process.argv.slice(2);
|
||||
const dryRun = args.includes('--dry-run');
|
||||
const name = args.find((a) => !a.startsWith('--'));
|
||||
|
||||
const name = process.argv[2];
|
||||
if (!name || !targets[name]) {
|
||||
console.error(`用法:npm run deploy:<target>\n可用目標:${names.join(' / ')}`);
|
||||
if (name) console.error(`(收到未知目標:"${name}")`);
|
||||
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);
|
||||
}
|
||||
const t = targets[name];
|
||||
|
||||
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}\n`);
|
||||
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 };
|
||||
|
||||
const build = spawnSync('node', [join(ROOT, 'scripts', 'build.mjs')], { stdio: 'inherit', env });
|
||||
if (build.status !== 0) process.exit(build.status ?? 1);
|
||||
|
||||
// --commit-dirty:本地部署常有未提交變更,不因此中斷
|
||||
const deploy = spawnSync(
|
||||
'npx',
|
||||
['wrangler', 'pages', 'deploy', 'public', '--project-name', t.projectName, '--commit-dirty=true'],
|
||||
['wrangler', 'pages', 'deploy', outDir, '--project-name', t.projectName, '--commit-dirty=true'],
|
||||
{ stdio: 'inherit', cwd: ROOT, env },
|
||||
);
|
||||
process.exit(deploy.status ?? 1);
|
||||
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。');
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* targets.mjs — 部署目標的唯一讀取點(deploy.mjs 與 verify-live.mjs 共用)。
|
||||
*
|
||||
* 存在的理由:宣告值(deploy.targets.json)只准被解讀一次。
|
||||
* 「部署時印在終端機的值」「寫進產物的值」「事後驗線上的值」若各自去讀、各自算,
|
||||
* 三者就會漂移——2026-08-08 那場事故的形狀正是「印的是 A、推的是 B」。
|
||||
* 這支把「一個目標展開成期望的產物長相」定死成一個函式,三邊共用同一個答案。
|
||||
*
|
||||
* 🔴 2026-08-08 第二層(leo:「已經發生過一次這個錯誤,把舊版界面上到 prod,
|
||||
* 你要確定不可再犯」):組態對 ≠ 世代對。
|
||||
* 一個網址可以 apiBase/profile 全部正確,卻對外展示一套早就被淘汰的介面,
|
||||
* 而所有只驗組態的檢查都說它綠。故本檔另外定義「世代指紋」(見下半段):
|
||||
* 把「線上這一份是不是當代的」變成一個可機械比對的值。
|
||||
*/
|
||||
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 缺 _profiles(profile → 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 那一代的 portal(82,911 bytes、
|
||||
// 金色 serif「Arcrun」品牌、Songti 12 處),repo 是 343,969 bytes 的
|
||||
// 「arc >> run」新代——**組態全對、介面整整落後半個月,機械檢查一片綠**。
|
||||
//
|
||||
// 判準:「線上這一份,是不是我們手上這一份?」不加解釋、不留模糊地帶——
|
||||
// 逐一抓下線上資產、遮掉「本來就該隨部署目標不同」的那幾行,其餘按位元組比對。
|
||||
//
|
||||
// 為什麼是位元組而不是「找幾個關鍵字」:
|
||||
// 關鍵字清單要人維護,而人只會在「這次剛好想到」時更新它。舊世代之所以能無聲上線,
|
||||
// 正是因為沒有人記得去更新那張清單。位元組比對不需要任何人記得任何事:
|
||||
// repo 改了一個字,指紋就不同,線上沒跟上就是 ❌。
|
||||
//
|
||||
// 誠實的 trade-off(mindset §7,不假裝完美):
|
||||
// ① 只要 repo 動過而還沒部署,這個檢查就會說「線上落後」——那是**正確的**,
|
||||
// 因為那時線上確實不是當代的。它會吵,但吵的是真的。
|
||||
// ② 若哪天 CF 邊緣開始改寫 HTML(Rocket Loader 之類),會出現假 ❌。
|
||||
// 2026-08-08 實測 mira.uncle6.me 與 pages.dev 回傳位元組完全相同(sha 一致),
|
||||
// 證明目前沒有改寫。真出現時它會大聲壞掉、有人來查——
|
||||
// **假 ❌ 的代價遠低於假 ✅**(假 ✅ 就是這次事故本身)。
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 納入世代指紋的資產:file=public/ 底下的路徑,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/HOME:public/ 裡那兩行只是本機 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 實撞:原版直接對全文比對「登記新庫」,而 66f1b59(08-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');
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* 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
|
||||
*
|
||||
* 兩層,缺一不可:
|
||||
* ① 組態層:apiBase/profile 的 views/home = deploy.targets.json 宣告值
|
||||
* ② 世代層:線上資產的位元組指紋 = repo public/ 的指紋
|
||||
*
|
||||
* 為什麼要第二層(2026-08-08,leo:「已經發生過一次這個錯誤,把舊版界面上到 prod,
|
||||
* 你要確定不可再犯」):當天實測三個對外網址,第一層**三項全過**,
|
||||
* 而它們跑的是 07-22 那一代的 portal(82,911 bytes、金色 serif 舊品牌),
|
||||
* repo 是 343,969 bytes 的新品牌世代。
|
||||
* ⇒ **組態可以完全正確,同時展示一套早就被淘汰的介面,而機械檢查一片綠。**
|
||||
* 第二層就是為了讓這個狀態不可能無聲存在。
|
||||
*
|
||||
* 🔴 一律帶 no-cache(快取害人誤判過)。curl|grep 不算驗前端,但 config.js/VIEWS/HOME
|
||||
* 與世代指紋都是**純文字資產比對**,抓原始碼比對是這幾項的正確驗法;
|
||||
* 「頁面真的能用」另外走瀏覽器實載。
|
||||
* 🔴 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 當代。');
|
||||
}
|
||||
@@ -1,822 +0,0 @@
|
||||
/**
|
||||
* arcrun console 駕駛艙 dashboard(T-cockpit ②,Arcrun#3 console 系,2026-07-04 總管派工;
|
||||
* 2026-07-07 fix/console-dashboard-live-data:stale 資料整修,總管交辦)
|
||||
*
|
||||
* 端點皆「無需登入」(唯讀、不吐機敏值——只回聚合後的狀態燈/任務標題/計數):
|
||||
* - GET /console/dashboard-data:聚合 JSON。
|
||||
* - GET /console/dashboard:單檔 HTML(同 console.ts 薄殼風格),每 60 秒自動刷新。
|
||||
* - GET /console/kb-scale-data:精耕層規模(wiki 卡/三元組/已嵌入;2026-07-07 leo 裁
|
||||
* 「遺產庫不用顯示」後 console 頭部統計改讀這裡)。
|
||||
* - GET /console/settings-data:設定頁誠實系統值(MCP token TTL 佔位)。
|
||||
*
|
||||
* ── 2026-07-07 二修(fix/console-truth-audit):「今日完成/今日路線」接 sprint 任務板 ──
|
||||
* leo 拍板(「今天做了這麼多事……其實就是我們到底完成了多少事」):dash_task 同 dash_wait
|
||||
* 病(沒活管線),真相源=sprint 檔「## 任務板」勾選。比照「等你的事」#36 模式:同一輪
|
||||
* Gitea fetch(90s 快取共用)解析任務板,「今日完成」只認「完成(今天台北日)」標記,
|
||||
* dash_task 降 fallback;板檔今天沒 commit → 頁面誠實標「今日任務板未更新(最後 N 小時前)」。
|
||||
*
|
||||
* ── 2026-07-07 整修:每個區塊都讀「live 一手資料」,讀不到就誠實標示,不擺 stale 殘骸 ──
|
||||
*
|
||||
* 資料源診斷(leo 抱怨「等你的事錯了好幾天」的根因):
|
||||
* - dash_wait(等你的事舊資料源)最後寫入 2026-07-04,**沒有活的維護管線**——等leo清單#11
|
||||
* 已於 07-05 銷案(誤判),dashboard 卻繼續掛著它。真相源其實是 InkStoneCo sprint 檔的
|
||||
* 「## 等 leo 清單」表格(progress-guard routine 每日核實維護)。
|
||||
* - dash_task 的 scope:"today" 沒有日期——07-04 的「今日路線」到 07-07 還被當今天的。
|
||||
* - dash_beat 是唯一有活管線的 dash_*(progress-guard/cloud-worker/watchdog 每日寫入)。
|
||||
*
|
||||
* 整修後的資料源:
|
||||
* 等你的事 → 首選 Gitea sprint 檔等leo清單(需 GITEA_BASE_URL var + GITEA_TOKEN secret;
|
||||
* 進程內 fetch Gitea API,非 GitHub、無 D20 疑慮);讀不到 → fallback dash_wait
|
||||
* 但必標 age + stale 警示;連 dash_wait 都沒有 → 誠實顯示「管線未接」。
|
||||
* 今日路線 → dash_task,但以台北日曆日判 is_today;非今日寫入=降級顯示「最後路線(N 天前)」,
|
||||
* 不假裝是今天的。今日無寫入時明講管線缺口(sprint 任務板→dashboard 無自動投影)。
|
||||
* 系統狀況 → live 健康信號:KBDB /health、/embed/backfill/status(enabled:false 誠實顯示)、
|
||||
* kbdb-graph-plugin /triplets/stats、workflow 總數(KBDB entry_type=workflow)。
|
||||
* 總庫規模 → KBDB entries 總數/wiki_card 數/triplets 數,全部 live API 一手拉。
|
||||
*
|
||||
* 燈號判定(寫死在端點,頁面只渲染):
|
||||
* red = 「今日寫入」的任務有 blocked,或最新心跳距今 > 240 分(台北 09:00-22:00 窗內判定),
|
||||
* 或 KBDB /health 打不通。stale 殘任務**不再**觸發燈號(07-04 的 blocked 不該讓 07-07 亮紅)。
|
||||
* yellow = 無 red 條件,但今日任務有非標準 status(late/behind 等落後標記)。
|
||||
* green = 其餘。
|
||||
*
|
||||
* 薄殼定位:聚合端點(能力長在 API 一次,rule 07 正例)——頁面零業務邏輯;判定純函式抽在
|
||||
* lib/console-dashboard-model.ts(可單測)。讀 KBDB 走 HTTP(kbdbBase 慣例),不新增 binding。
|
||||
*/
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { kbdbBase, graphBase } from './kbdb-proxy';
|
||||
import { validateConsoleSession } from './console-auth';
|
||||
import {
|
||||
type KbdbEntry,
|
||||
type WaitingItem,
|
||||
type WaitingModel,
|
||||
type CachedWaitingEnvelope,
|
||||
type SprintBoardTask,
|
||||
type SprintSnapshot,
|
||||
GITEA_WAITING_CACHE_TTL_SECONDS,
|
||||
parseCreatedAtMs,
|
||||
parseJsonContent,
|
||||
agoMinutes,
|
||||
buildRouteModel,
|
||||
buildSprintRouteModel,
|
||||
buildWaitingFallback,
|
||||
parseSprintTaskBoard,
|
||||
parseSprintWaitingTable,
|
||||
pickLatestSprintFiles,
|
||||
reviveWaitingAges,
|
||||
sortWaitingItems,
|
||||
taipeiDayKey,
|
||||
} from '../lib/console-dashboard-model';
|
||||
import { applyTriageCheck, buildTriageModel, type TriageCheckAction } from '../lib/console-triage-model';
|
||||
import { TAIPEI_CLIENT_JS } from '../lib/taipei-time';
|
||||
|
||||
export const consoleDashboardRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
const STALE_MINUTES = 240;
|
||||
const JUDGE_START_HOUR = 9; // 台北時間,含
|
||||
const JUDGE_END_HOUR = 22; // 台北時間,不含
|
||||
const STANDARD_TASK_STATUS = new Set(['done', 'doing', 'todo', 'blocked']);
|
||||
|
||||
async function fetchEntries(env: Bindings, tenant: string, entryType: string, limit: number): Promise<KbdbEntry[]> {
|
||||
const { base, headers } = kbdbBase(env);
|
||||
const params = new URLSearchParams({ owner_id: tenant, entry_type: entryType, limit: String(limit) });
|
||||
try {
|
||||
const res = await fetch(`${base}/entries?${params.toString()}`, { headers });
|
||||
if (!res.ok) return [];
|
||||
const data = (await res.json()) as { entries?: KbdbEntry[] };
|
||||
return data.entries ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** 泛用 GET JSON(失敗回 null,caller 誠實顯示「讀不到」,不編數字)。 */
|
||||
async function fetchJson<T>(url: string, headers?: Record<string, string>): Promise<T | null> {
|
||||
try {
|
||||
const res = await fetch(url, headers ? { headers } : undefined);
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** KBDB entries 符合條件的總數(limit=1 只拿 total 欄,不搬資料)。null = 讀不到。 */
|
||||
async function fetchEntryTotal(env: Bindings, filters: Record<string, string>): Promise<number | null> {
|
||||
const { base, headers } = kbdbBase(env);
|
||||
const params = new URLSearchParams({ ...filters, limit: '1' });
|
||||
const data = await fetchJson<{ total?: unknown }>(`${base}/entries?${params.toString()}`, headers);
|
||||
return data && typeof data.total === 'number' ? data.total : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* sprint 檔活資料源(同一輪 fetch 兩個產物,leo 2026-07-07 拍板加「今日完成」):
|
||||
* - 「等你的事」=「## 等 leo 清單」表格(progress-guard 每日維護)。
|
||||
* - 「今日完成/今日路線」=「## 任務板」checkbox(今天勾的才算今日完成)。
|
||||
* 需 GITEA_BASE_URL(var)+ GITEA_TOKEN(secret,建議唯讀 scope)。請求序:
|
||||
* 列目錄挑最新兩個 sprint-*.md(換 sprint 後前一檔常還有未銷案項/未收項,例:07b 開了、
|
||||
* 🔴 mira 憑證外洩與 [🔄] T-cockpit 仍掛 07a)→ 各抓 raw、兩個 parser 吃同一份文字 →
|
||||
* 最新檔的最後 commit 時間當「維護於」。等leo清單全解析失敗回 null → caller fallback
|
||||
* dash_wait / dash_task(標 age),不硬湊。
|
||||
*/
|
||||
async function fetchGiteaSprint(env: Bindings, nowMs: number): Promise<SprintSnapshot | null> {
|
||||
const base = (env.GITEA_BASE_URL ?? '').replace(/\/$/, '');
|
||||
const token = env.GITEA_TOKEN;
|
||||
if (!base || !token) return null;
|
||||
const repo = env.GITEA_SPRINT_REPO ?? 'Leo/InkStoneCo';
|
||||
const dir = env.GITEA_SPRINT_DIR ?? 'system-dev/docs/3-specs/autonomy-dispatch';
|
||||
const headers = { Authorization: `token ${token}` };
|
||||
try {
|
||||
const files = await fetchJson<{ name: string }[]>(`${base}/api/v1/repos/${repo}/contents/${encodeURI(dir)}`, headers);
|
||||
if (!files) return null;
|
||||
const sprints = pickLatestSprintFiles(files.map((f) => f.name));
|
||||
if (!sprints.length) return null;
|
||||
const parsed = await Promise.all(
|
||||
sprints.map(async (name) => {
|
||||
const rawRes = await fetch(`${base}/api/v1/repos/${repo}/raw/${encodeURI(`${dir}/${name}`)}`, { headers });
|
||||
if (!rawRes.ok) return null;
|
||||
const text = await rawRes.text();
|
||||
return { waiting: parseSprintWaitingTable(text, name), board: parseSprintTaskBoard(text, name) };
|
||||
}),
|
||||
);
|
||||
const readFiles = sprints.filter((_, i) => parsed[i]?.waiting != null);
|
||||
const merged = parsed.map((p) => p?.waiting).filter((p): p is WaitingItem[] => p != null).flat();
|
||||
if (!readFiles.length) return null; // 等leo清單全部解析失敗=誠實 fallback
|
||||
// 任務板:新→舊合併(現役 sprint 的板先列);兩檔都沒有可解析的板 → null(fallback dash_task)
|
||||
const boardMerged = parsed.map((p) => p?.board).filter((b): b is SprintBoardTask[] => b != null).flat();
|
||||
// 清單上次維護時間 = 現役 sprint 檔最後 commit(progress-guard 每日 commit,>48h 沒動才算 stale)
|
||||
let ago = -1;
|
||||
const commits = await fetchJson<{ commit?: { committer?: { date?: string } } }[]>(
|
||||
`${base}/api/v1/repos/${repo}/commits?path=${encodeURIComponent(`${dir}/${readFiles[0]}`)}&limit=1&stat=false&verification=false&files=false`,
|
||||
headers,
|
||||
);
|
||||
const date = commits?.[0]?.commit?.committer?.date;
|
||||
if (date) {
|
||||
const ms = Date.parse(date);
|
||||
if (!Number.isNaN(ms)) ago = agoMinutes(nowMs, ms);
|
||||
}
|
||||
return {
|
||||
waiting: {
|
||||
items: sortWaitingItems(merged),
|
||||
source: 'gitea_sprint',
|
||||
updated_ago_minutes: ago,
|
||||
stale: ago >= 0 && ago > 48 * 60,
|
||||
sprint_files: readFiles,
|
||||
},
|
||||
board: boardMerged.length ? boardMerged : null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export type GiteaSprintFetcher = (env: Bindings, nowMs: number) => Promise<SprintSnapshot | null>;
|
||||
|
||||
/**
|
||||
* fetchGiteaSprint 的快取層(總管 #36 審查要求):CF Cache API(caches.default)、
|
||||
* TTL 90s(GITEA_WAITING_CACHE_TTL_SECONDS)。前端 60 秒刷新下,Gitea 從
|
||||
* 「每分鐘 3-4 個 API call」降到「≤1 輪/90s」;快取是查詢面的讀優化,不是輪詢。
|
||||
*
|
||||
* - key:合成 URL(Cache API 要求合法 URL;host 用不會真的被打的保留名),帶
|
||||
* base/repo/dir 參數——設定變了自然 miss,不會吐到別的 Gitea 的殘資料。
|
||||
* - hit 回放時用 reviveWaitingAges 把「維護於 N 分鐘前」隨牆鐘補算(存的是 fetch
|
||||
* 當下的 ago,直接回放會讓時間停走)。任務板存原始 completed_days,「今天完成幾件」
|
||||
* 由請求當下算——跨台北午夜的快取不會把昨天的完成冒領成今天。
|
||||
* - **失敗不快取**:negative cache 會把一時網路抖動放大成 90 秒盲區,caller 該
|
||||
* 當場 fallback dash_wait / dash_task。
|
||||
* - cache.put 走 waitUntil(不阻塞回應);fetcher 參數可注入=單測不用真打網路。
|
||||
* - 回傳多帶 cache:'hit'|'miss',吐進 waiting_meta 當快取生效的客觀證據(curl 兩次
|
||||
* 第二次該是 hit)。
|
||||
*/
|
||||
export async function cachedGiteaSprint(
|
||||
env: Bindings,
|
||||
nowMs: number,
|
||||
waitUntil: (p: Promise<unknown>) => void,
|
||||
fetcher: GiteaSprintFetcher = fetchGiteaSprint,
|
||||
): Promise<(SprintSnapshot & { cache: 'hit' | 'miss' }) | null> {
|
||||
if (!env.GITEA_BASE_URL || !env.GITEA_TOKEN) return null;
|
||||
const repo = env.GITEA_SPRINT_REPO ?? 'Leo/InkStoneCo';
|
||||
const dir = env.GITEA_SPRINT_DIR ?? 'system-dev/docs/3-specs/autonomy-dispatch';
|
||||
const cacheKey = new Request(
|
||||
`https://console-dashboard.arcrun.internal/gitea-waiting?${new URLSearchParams({ base: env.GITEA_BASE_URL, repo, dir }).toString()}`,
|
||||
);
|
||||
const cache = caches.default;
|
||||
try {
|
||||
const hit = await cache.match(cacheKey);
|
||||
if (hit) {
|
||||
const envelope = (await hit.json()) as CachedWaitingEnvelope;
|
||||
return {
|
||||
waiting: reviveWaitingAges(envelope.snapshot.waiting, envelope.fetched_at_ms, nowMs),
|
||||
board: envelope.snapshot.board,
|
||||
cache: 'hit',
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
/* cache 故障不致命,走 miss 路徑 */
|
||||
}
|
||||
const fresh = await fetcher(env, nowMs);
|
||||
if (!fresh) return null; // 失敗不快取,caller 誠實 fallback
|
||||
const envelope: CachedWaitingEnvelope = { snapshot: fresh, fetched_at_ms: nowMs };
|
||||
try {
|
||||
waitUntil(
|
||||
cache.put(
|
||||
cacheKey,
|
||||
new Response(JSON.stringify(envelope), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': `public, max-age=${GITEA_WAITING_CACHE_TTL_SECONDS}`,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
/* put 失敗只是少了快取,不影響本次回應 */
|
||||
}
|
||||
return { ...fresh, cache: 'miss' };
|
||||
}
|
||||
|
||||
// GET /console/dashboard-data — 聚合 JSON(無需登入;唯讀、不含機敏值)
|
||||
consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
|
||||
const tenant = c.env.CONSOLE_TENANT || 'leo';
|
||||
const now = Date.now();
|
||||
const { base: kbdbUrl, headers: kbdbHeaders } = kbdbBase(c.env);
|
||||
const graphUrl = graphBase(c.env);
|
||||
|
||||
const [
|
||||
beatEntries,
|
||||
taskEntries,
|
||||
waitEntries,
|
||||
inboxEntries,
|
||||
giteaSprint,
|
||||
kbdbHealth,
|
||||
embedStatus,
|
||||
graphStats,
|
||||
entriesTotal,
|
||||
wikiCardTotal,
|
||||
workflowTotal,
|
||||
] = await Promise.all([
|
||||
fetchEntries(c.env, tenant, 'dash_beat', 100),
|
||||
fetchEntries(c.env, tenant, 'dash_task', 200),
|
||||
fetchEntries(c.env, tenant, 'dash_wait', 100),
|
||||
fetchEntries(c.env, tenant, 'inbox', 200),
|
||||
cachedGiteaSprint(c.env, now, (p) => c.executionCtx.waitUntil(p)),
|
||||
fetchJson<{ ok?: boolean }>(`${kbdbUrl}/health`, kbdbHeaders),
|
||||
fetchJson<{ enabled?: boolean; pending?: number; embedded?: number }>(`${kbdbUrl}/embed/backfill/status`, kbdbHeaders),
|
||||
fetchJson<{ total?: number; recent?: { today?: number; this_week?: number } }>(`${graphUrl}/triplets/stats`),
|
||||
// owner_id 一律鎖本租戶:原本不帶 owner 會混到別租戶(實測 459,137 vs leo 的 458,732)
|
||||
fetchEntryTotal(c.env, { owner_id: tenant }),
|
||||
fetchEntryTotal(c.env, { entry_type: 'wiki_card', owner_id: tenant }),
|
||||
fetchEntryTotal(c.env, { entry_type: 'workflow', owner_id: tenant }),
|
||||
]);
|
||||
|
||||
// dash_beat:每 actor 最新一筆(list 已 created_at DESC → first-seen 即最新)。唯一有活管線的 dash_*。
|
||||
const beats: { actor: string; event: string; note: string; at: string | number; ago_minutes: number }[] = [];
|
||||
const seenActors = new Set<string>();
|
||||
for (const e of beatEntries) {
|
||||
const j = parseJsonContent(e);
|
||||
const actor = typeof j?.actor === 'string' ? j.actor : null;
|
||||
if (!actor || seenActors.has(actor)) continue;
|
||||
seenActors.add(actor);
|
||||
const ms = parseCreatedAtMs(e.created_at);
|
||||
beats.push({
|
||||
actor,
|
||||
event: typeof j?.event === 'string' ? (j.event as string) : '',
|
||||
note: typeof j?.note === 'string' ? (j.note as string) : '',
|
||||
at: e.created_at,
|
||||
ago_minutes: agoMinutes(now, ms),
|
||||
});
|
||||
}
|
||||
const lastBeat = beats.filter((b) => b.ago_minutes >= 0).sort((a, b) => a.ago_minutes - b.ago_minutes)[0] ?? null;
|
||||
|
||||
// 等你的事:Gitea sprint 等leo清單優先(走 90s 快取);讀不到 fallback dash_wait(帶 age + stale)
|
||||
let waiting: WaitingModel;
|
||||
let waitingCache: 'hit' | 'miss' | null = null;
|
||||
if (giteaSprint) {
|
||||
waiting = giteaSprint.waiting;
|
||||
waitingCache = giteaSprint.cache;
|
||||
} else {
|
||||
waiting = buildWaitingFallback(waitEntries, now);
|
||||
if (waiting.source === 'kbdb_dash_wait' && !(c.env.GITEA_BASE_URL && c.env.GITEA_TOKEN)) {
|
||||
waiting.note = 'Gitea sprint 清單未接(缺 GITEA_TOKEN secret)——以下是 dash_wait 殘資料';
|
||||
} else if (waiting.source === 'kbdb_dash_wait') {
|
||||
waiting.note = 'Gitea sprint 清單讀取失敗——以下是 dash_wait 殘資料';
|
||||
}
|
||||
}
|
||||
|
||||
// 今日完成/今日路線:sprint 任務板優先(leo 2026-07-07 拍板——「到底完成了多少事」的
|
||||
// 真相源=progress-guard/cloud-worker 每日勾選的板,dash_task 沒活管線降 fallback)。
|
||||
// 板的「今日完成」只認「完成(今天台北日)」標記;板檔今天沒 commit 過 → 誠實標示。
|
||||
const sprintRoute = giteaSprint?.board ? buildSprintRouteModel(giteaSprint.board, now) : null;
|
||||
const route = buildRouteModel(taskEntries, now); // fallback + 燈號仍吃 dash_task 今日寫入
|
||||
const boardAgo = giteaSprint ? giteaSprint.waiting.updated_ago_minutes : -1;
|
||||
const boardUpdatedToday = boardAgo >= 0 && taipeiDayKey(now - boardAgo * 60000) === taipeiDayKey(now);
|
||||
|
||||
// inbox:未處理計數(status !== 'done';沒標 status 視為未處理)
|
||||
const inboxNew = inboxEntries.reduce((n, e) => {
|
||||
const j = parseJsonContent(e);
|
||||
return j && j.status !== 'done' ? n + 1 : n;
|
||||
}, 0);
|
||||
|
||||
// 燈號:只吃「今日寫入」的任務 + 心跳 + KBDB 健康(stale 殘任務不再觸發燈號)
|
||||
const todayWrites = route.tasks.filter((t) => t.is_today_write);
|
||||
const hasBlocked = todayWrites.some((t) => t.status === 'blocked');
|
||||
const hasLagMark = todayWrites.some((t) => !STANDARD_TASK_STATUS.has(t.status));
|
||||
const taipeiHour = new Date(now + 8 * 3600 * 1000).getUTCHours();
|
||||
const inJudgeWindow = taipeiHour >= JUDGE_START_HOUR && taipeiHour < JUDGE_END_HOUR;
|
||||
const beatStale = lastBeat === null || lastBeat.ago_minutes > STALE_MINUTES;
|
||||
const kbdbOk = kbdbHealth?.ok === true;
|
||||
const light: 'green' | 'yellow' | 'red' =
|
||||
hasBlocked || (inJudgeWindow && beatStale) || !kbdbOk ? 'red' : hasLagMark ? 'yellow' : 'green';
|
||||
const lightReason = !kbdbOk
|
||||
? 'KBDB 基本盤 /health 打不通'
|
||||
: hasBlocked
|
||||
? '今日任務有 blocked'
|
||||
: inJudgeWindow && beatStale
|
||||
? `心跳超過 ${STALE_MINUTES} 分鐘`
|
||||
: hasLagMark
|
||||
? '今日任務有落後標記'
|
||||
: '';
|
||||
|
||||
return c.json({
|
||||
light,
|
||||
light_reason: lightReason,
|
||||
last_beat: lastBeat ? { actor: lastBeat.actor, ago_minutes: lastBeat.ago_minutes, event: lastBeat.event, note: lastBeat.note } : null,
|
||||
beats,
|
||||
// 路線:sprint 任務板優先(tasks 欄位形狀與 dash_task 版相容——title/status/scope);
|
||||
// 板上開著的項 is_today_write=false(燈號沿 #36 原則只吃 dash_task 今日寫入+心跳+KBDB,
|
||||
// 板上掛了幾天的 [!] 不會天天亮紅燈——那是「等裁決」不是「今天卡住」)
|
||||
tasks: sprintRoute
|
||||
? sprintRoute.tasks.map((t, i) => ({
|
||||
title: t.title,
|
||||
status: t.status,
|
||||
order: i,
|
||||
scope: 'today' as const,
|
||||
age_minutes: boardAgo,
|
||||
is_today_write: t.status === 'done', // done 項必然是「今天完成」的(模型已濾)
|
||||
sprint: t.sprint ?? null,
|
||||
}))
|
||||
: route.tasks.map((t) => ({
|
||||
title: t.title,
|
||||
status: t.status,
|
||||
order: t.order,
|
||||
scope: t.scope,
|
||||
age_minutes: t.age_minutes,
|
||||
is_today_write: t.is_today_write,
|
||||
sprint: null,
|
||||
})),
|
||||
route_meta: sprintRoute
|
||||
? {
|
||||
source: 'gitea_sprint_board',
|
||||
// is_today=板檔今天(台北)有 commit 過;false → 頁面誠實標「今日任務板未更新」
|
||||
is_today: boardUpdatedToday,
|
||||
updated_ago_minutes: boardAgo,
|
||||
sprint_files: waiting.sprint_files ?? null,
|
||||
}
|
||||
: {
|
||||
source: 'kbdb_dash_task',
|
||||
is_today: route.is_today,
|
||||
updated_ago_minutes: route.updated_ago_minutes,
|
||||
sprint_files: null,
|
||||
},
|
||||
today_done: sprintRoute ? sprintRoute.today_done : route.today_done,
|
||||
today_total: sprintRoute ? sprintRoute.today_total : route.today_total,
|
||||
done_today_titles: sprintRoute ? sprintRoute.done_today_titles : null,
|
||||
waiting: waiting.items,
|
||||
waiting_meta: {
|
||||
source: waiting.source,
|
||||
updated_ago_minutes: waiting.updated_ago_minutes,
|
||||
stale: waiting.stale,
|
||||
sprint_files: waiting.sprint_files ?? null,
|
||||
note: waiting.note ?? null,
|
||||
// Gitea 快取層狀態(hit/miss;fallback 路徑為 null)——快取生效的客觀證據
|
||||
cache: waitingCache,
|
||||
},
|
||||
inbox_new: inboxNew,
|
||||
system: {
|
||||
kbdb_ok: kbdbHealth ? kbdbHealth.ok === true : false,
|
||||
embed: embedStatus
|
||||
? { enabled: embedStatus.enabled === true, embedded: embedStatus.embedded ?? null, pending: embedStatus.pending ?? null }
|
||||
: null,
|
||||
graph: graphStats ? { ok: true, triplets: graphStats.total ?? null } : { ok: false, triplets: null },
|
||||
workflow_total: workflowTotal,
|
||||
},
|
||||
kb: {
|
||||
entries_total: entriesTotal,
|
||||
wiki_card_total: wikiCardTotal,
|
||||
triplets_total: graphStats?.total ?? null,
|
||||
},
|
||||
generated_at: new Date(now).toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
// GET /console/kb-scale-data — 總庫「精耕層」規模(leo 2026-07-07 裁:45.8 萬 14-E 搬遷
|
||||
// blocks 已 deprecated 之後要刪,頭部統計**不再拿遺產數字撐場面**,只顯示真的新的)。
|
||||
// 免登入(純聚合計數、無內容原文,同 dashboard-data 標準)。3 個 subrequest,全是
|
||||
// limit=1(只拿 total 欄)或現成 stats 聚合端點——不逐筆掃庫,不撞子請求上限。
|
||||
// 搜尋功能本身仍可搜全庫(資料不藏),只是規模感不再引用遺產總數。
|
||||
consoleDashboardRouter.get('/console/kb-scale-data', async (c) => {
|
||||
const tenant = c.env.CONSOLE_TENANT || 'leo';
|
||||
const { base, headers } = kbdbBase(c.env);
|
||||
const graphUrl = graphBase(c.env);
|
||||
const now = Date.now();
|
||||
const [wikiCards, graphStats, embedStatus] = await Promise.all([
|
||||
// limit=1 順手拿最新一筆 created_at(list 為 created_at DESC)=「最近寫入時間」
|
||||
fetchJson<{ total?: number; entries?: { created_at?: string | number }[] }>(
|
||||
`${base}/entries?${new URLSearchParams({ owner_id: tenant, entry_type: 'wiki_card', limit: '1' }).toString()}`,
|
||||
headers,
|
||||
),
|
||||
fetchJson<{ total?: number }>(`${graphUrl}/triplets/stats`),
|
||||
fetchJson<{ enabled?: boolean; embedded?: number; pending?: number }>(`${base}/embed/backfill/status`, headers),
|
||||
]);
|
||||
const latestMs = parseCreatedAtMs(wikiCards?.entries?.[0]?.created_at ?? null);
|
||||
// 讀不到的欄位誠實回 null(頁面顯示「讀不到」),不編數字
|
||||
return c.json({
|
||||
wiki_card_total: typeof wikiCards?.total === 'number' ? wikiCards.total : null,
|
||||
wiki_card_latest_ago_minutes: latestMs === null ? -1 : agoMinutes(now, latestMs),
|
||||
triplets_total: typeof graphStats?.total === 'number' ? graphStats.total : null,
|
||||
embedded: embedStatus?.embedded ?? null,
|
||||
embed_enabled: embedStatus ? embedStatus.enabled === true : null,
|
||||
generated_at: new Date(now).toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
// GET /console/settings-data — 設定頁的誠實系統值(目前只有 MCP token TTL 佔位區塊用)。
|
||||
// TTL 真相住在 mcp worker 部署端 env `MCP_TOKEN_TTL`(mcp/src/types.ts,預設 2592000=30 天);
|
||||
// cypher 讀的是自己這份同名 var(deploy 時兩處要一致,#32 形態 config 同步教訓)——
|
||||
// source 欄位如實標 env/default,頁面不假裝這是能遠端改的設定。
|
||||
consoleDashboardRouter.get('/console/settings-data', (c) => {
|
||||
const raw = c.env.MCP_TOKEN_TTL;
|
||||
const parsed = raw ? parseInt(raw, 10) : NaN;
|
||||
const fromEnv = Number.isFinite(parsed) && parsed > 0;
|
||||
return c.json({
|
||||
mcp_token_ttl_seconds: fromEnv ? parsed : 2592000,
|
||||
mcp_token_ttl_source: fromEnv ? 'env' : 'default',
|
||||
});
|
||||
});
|
||||
|
||||
// GET /console/triage-data — 分流台資料(Mira Console 頁 7,Arcrun#9 收件夾改裝;原
|
||||
// /console/inbox-data 的後繼——唯一消費者是 console 頁本身,一起改裝,不留死端點)。
|
||||
// **需 console session**(Bearer):dashboard-data 只吐計數可免登入;這裡吐待辦/訊息原文屬機敏,鎖登入。
|
||||
// 資料源二合一(kb-ingest SDD R7):entry_type=todo(Logseq 萃取,Arcrun#8 ingest 線)+
|
||||
// entry_type=inbox(Telegram)。契約解析/三欄分流/計數=純函式 lib/console-triage-model.ts。
|
||||
consoleDashboardRouter.get('/console/triage-data', async (c) => {
|
||||
const ok = await validateConsoleSession(c.env, c.req.header('authorization'));
|
||||
if (!ok) return c.json({ error: '需要登入(console session)' }, 401);
|
||||
|
||||
const tenant = c.env.CONSOLE_TENANT || 'leo';
|
||||
const [todoEntries, inboxEntries] = await Promise.all([
|
||||
fetchEntries(c.env, tenant, 'todo', 500),
|
||||
fetchEntries(c.env, tenant, 'inbox', 200),
|
||||
]);
|
||||
const model = buildTriageModel(todoEntries, inboxEntries);
|
||||
return c.json({ ...model, generated_at: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// POST /console/triage-check — 分流台勾掉/還原(leo 2026-07-08 拍板;body: {entry_id, action?})。
|
||||
// 為什麼開這個小端點而不讓瀏覽器直打 KBDB:瀏覽器沒有 KBDB_INTERNAL_TOKEN(token 只能在
|
||||
// server 側,同 kbdb-graph proxy 理由),且 console session ≠ X-Arcrun-API-Key。沿用
|
||||
// triage-data 同款 session 驗證,server 端做 KBDB PATCH(kbdbBase 慣例)。
|
||||
//
|
||||
// PATCH content 需**整串回寫**(KBDB updateEntry 是欄位級覆蓋,content 給什麼存什麼)——
|
||||
// 先 GET 原 entry、只動 status/checked_* 欄再回寫,防蓋掉 text/marker/owner_tier 等別的欄位。
|
||||
// 改寫邏輯=lib/console-triage-model.ts applyTriageCheck(純函式,vitest 驗證)。
|
||||
//
|
||||
// ── 雙向銷案語意(死循環防呆,與 applyTriageCheck 註解同一套規約,萃取端會配合)──
|
||||
// console 勾掉=終局(checked_via:"console"):即使 Logseq 原文還是 TODO,萃取端也絕不
|
||||
// 復活它;Logseq 改 DONE 的由萃取端 PATCH status:done(checked_via:"logseq")。
|
||||
// console 只需忠實顯示非 done 項;還原=status 回 new + 移除 checked_via/checked_at。
|
||||
consoleDashboardRouter.post('/console/triage-check', async (c) => {
|
||||
const ok = await validateConsoleSession(c.env, c.req.header('authorization'));
|
||||
if (!ok) return c.json({ error: '需要登入(console session)' }, 401);
|
||||
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const entryId = typeof body?.entry_id === 'string' ? body.entry_id.trim() : '';
|
||||
if (!entryId) return c.json({ error: 'entry_id 必填' }, 400);
|
||||
const action: TriageCheckAction = body?.action === 'restore' ? 'restore' : 'check';
|
||||
|
||||
const tenant = c.env.CONSOLE_TENANT || 'leo';
|
||||
const { base, headers } = kbdbBase(c.env);
|
||||
|
||||
// 先 GET 原 entry(整串回寫的前提),順便守兩道邊界:
|
||||
// 1. owner_id 必須=console 固定租戶(session 只代表 leo 這個租戶,不能改到別人的資料);
|
||||
// 2. entry_type 限分流台的兩個來源 todo/inbox(這端點不是泛用 entry 改寫器)。
|
||||
const got = await fetchJson<{ entry?: { owner_id?: string; entry_type?: string; content?: string | null } }>(
|
||||
`${base}/entries/${encodeURIComponent(entryId)}`,
|
||||
headers,
|
||||
);
|
||||
const entry = got?.entry;
|
||||
if (!entry) return c.json({ error: '找不到這筆待辦(可能已被刪除)' }, 404);
|
||||
if (entry.owner_id !== tenant) return c.json({ error: '找不到這筆待辦(可能已被刪除)' }, 404); // 不洩漏他租戶存在性
|
||||
if (entry.entry_type !== 'todo' && entry.entry_type !== 'inbox') {
|
||||
return c.json({ error: '只有分流台項目(todo/inbox)能在這裡勾掉' }, 400);
|
||||
}
|
||||
|
||||
const newContent = applyTriageCheck(entry.content, action, new Date().toISOString());
|
||||
const res = await fetch(`${base}/entries/${encodeURIComponent(entryId)}`, {
|
||||
method: 'PATCH',
|
||||
headers,
|
||||
body: JSON.stringify({ content: newContent }),
|
||||
});
|
||||
if (!res.ok) return c.json({ error: `KBDB 回寫失敗(HTTP ${res.status})` }, 502);
|
||||
return c.json({ success: true, entry_id: entryId, action, status: action === 'restore' ? 'new' : 'done' });
|
||||
});
|
||||
|
||||
function renderDashboardHtml(brand: string): string {
|
||||
return `<!doctype html>
|
||||
<html lang="zh-Hant">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${brand} 駕駛艙</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>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="pagehead">
|
||||
<div class="title serif">${brand}<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 () {
|
||||
// 台北時間 helper(lib/taipei-time.ts 注入,與 server 判定同一套——顯示不隨看的裝置時區漂移)
|
||||
${TAIPEI_CLIENT_JS}
|
||||
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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('/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>
|
||||
`;
|
||||
}
|
||||
|
||||
// GET /console/dashboard — 駕駛艙頁(無需登入;純渲染 dashboard-data,無互動、無說明文字)
|
||||
// 品牌字樣(Arcrun#21):引擎預設 Arcrun,實例可用 CONSOLE_BRAND 覆蓋(如 "Arcrun RAG")
|
||||
// CONSOLE_PROFILE=rag(console-profile-trim):駕駛艙不屬企業版頁面 → 302 回 /console。
|
||||
// 選 302 不選 404:舊書籤/外鏈直接落回產品頁,不給死路(只裁 UI 頁面,資料端點行為不動)。
|
||||
consoleDashboardRouter.get('/console/dashboard', (c) => {
|
||||
if ((c.env.CONSOLE_PROFILE || 'full') === 'rag') return c.redirect('/console', 302);
|
||||
return c.html(renderDashboardHtml(c.env.CONSOLE_BRAND || 'Arcrun'));
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -21,98 +21,73 @@ 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(方案 A,D19)────────────────────────────────
|
||||
// ── credential-store 遷移 T6/T7(方案 A,D19)+ D38 圍牆修復(2026-08-07)───────────
|
||||
//
|
||||
// 密文值住 cypher-executor 自己的 per-script secrets(T5 寫入)。解密發生在獨立的
|
||||
// auth_static_key / auth_service_account worker 上,它們讀不到 cypher 的 secrets。
|
||||
// 故 cypher 這一層先查 D1 拿 secret_ref → 用 secret_get(ref)(即 env[ref],T4)取明文
|
||||
// → 塞進送給 auth WASM 的 payload 新欄位 `resolved_secrets`。WASM 收到優先用它,沒有
|
||||
// 才 fallback 舊 KV + crypto_decrypt(那個 fallback 即 T7 雙讀)。
|
||||
// 故 cypher 這一層先取這個租戶的 credential 目錄(name → secret_ref)→ 用 secret_get(ref)
|
||||
// (即 env[ref],T4)取明文 → 塞進送給 auth WASM 的 payload 新欄位 `resolved_secrets`。
|
||||
// WASM 收到優先用它,沒有才 fallback 舊 KV + crypto_decrypt(那個 fallback 即 T7 雙讀)。
|
||||
//
|
||||
// 嚴格邊界(rule 02 §2.2):本檔只做「查 D1 ref → secret_get 取值 → 當字串塞 payload」。
|
||||
// D38(leo 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」。
|
||||
// **不解密、不展開模板、不組 JWT**——secret_get 的實作(env[ref])在 wasi-shim host function
|
||||
// 內,解密/注入邏輯仍全在 WASM 零件。
|
||||
|
||||
/** D1 credentials 目錄一列(只取本檔需要的欄位)。 */
|
||||
interface CredentialRefRow {
|
||||
name: string;
|
||||
secret_ref: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 對一組 credential name,從新家(cypher per-script secrets)取明文。
|
||||
*
|
||||
* 流程:查 D1 `credentials`(api_key + name)拿 `secret_ref` → 用 `secret_get(ref)`
|
||||
* (host function,實作 = env[ref])取值。
|
||||
* 流程:查 KBDB credential 目錄(api_key + name,快取命中零網路呼叫)拿 `secret_ref`
|
||||
* → 用 `secret_get(ref)`(host function,實作 = env[ref])取值。
|
||||
*
|
||||
* ⚠️ 只把「D1 有 ref 且 secret_get 真的取到值」的 name 放進回傳 map。查不到 ref、
|
||||
* ⚠️ 只把「目錄有 ref 且 secret_get 真的取到值」的 name 放進回傳 map。查不到 ref、
|
||||
* 或 secret_get 回 null(新家還沒這把值)→ **該 name 缺席**(不是放空字串!),
|
||||
* 讓 WASM 對這把 key 走 fallback 舊 KV 路徑(T7 雙讀)。放空字串會讓 WASM 誤判命中用空值。
|
||||
*
|
||||
* 取到值的 name 順手更新 D1 `last_used_at`(§2.5 治理面 last_used)。
|
||||
* 取到值的 name 順手更新 last_used_at(§2.5 治理面 last_used,見 touchLastUsed——
|
||||
* fire-and-forget、非同步、不阻塞本函式回傳,失敗吞掉)。
|
||||
*
|
||||
* D1 未建表 / migration 未跑 / CREDENTIALS_DB 未綁 → 回空 map(整組走 fallback),
|
||||
* KBDB 不可達 / 這個租戶還沒有任何 credential → 回空 map(整組走 fallback),
|
||||
* 不 throw——遷移過渡期(雙讀)本就允許「新家還沒資料」。
|
||||
*/
|
||||
/** credential name → 明文值對照(獨立型別別名,避免函式簽章直接內嵌逗號分隔泛型)。 */
|
||||
type ResolvedSecretMap = Record<string, string>;
|
||||
|
||||
export async function resolveSecretsFromNewHome(
|
||||
env: Bindings,
|
||||
apiKey: string,
|
||||
names: string[],
|
||||
): Promise<Record<string, string>> {
|
||||
const resolved: Record<string, string> = {};
|
||||
): Promise<ResolvedSecretMap> {
|
||||
const resolved: ResolvedSecretMap = {};
|
||||
if (names.length === 0) return resolved;
|
||||
|
||||
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;
|
||||
// 1. 拿這個租戶的 credential 目錄(name → secret_ref,快取層見 credentials.ts)
|
||||
const refs = await getCredentialSecretRefs(env, apiKey);
|
||||
if (Object.keys(refs).length === 0) return resolved; // 目錄空 / KBDB 不可達 → 整組走 fallback
|
||||
|
||||
// 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 row of rows) {
|
||||
const value = await secretGet(row.secret_ref);
|
||||
for (const name of names) {
|
||||
const ref = refs[name];
|
||||
if (!ref) continue; // 目錄沒這個 name → 缺席,走 fallback
|
||||
const value = await secretGet(ref);
|
||||
// null(新家沒這把值 / 非 CRED_ 前綴被拒)→ 不放進 map,讓 WASM fallback 舊 KV
|
||||
if (value === null) continue;
|
||||
resolved[row.name] = value;
|
||||
resolvedNames.push(row.name);
|
||||
resolved[name] = value;
|
||||
resolvedNames.push(name);
|
||||
}
|
||||
|
||||
// 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 更新失敗不影響注入主流程(治理面欄位,非關鍵路徑)
|
||||
}
|
||||
}
|
||||
// 3. 順手更新 last_used_at(只更新真的從新家取到值的 name;fire-and-forget,非關鍵路徑)
|
||||
if (resolvedNames.length > 0) touchLastUsed(env, apiKey, resolvedNames);
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
@@ -3,24 +3,33 @@ import { ExecutionError, WorkflowPaused } from '../types';
|
||||
import { GraphExecutor } from '../graph-executor';
|
||||
import { graphSchema } from '../lib/schemas';
|
||||
import { createComponentLoader } from '../lib/component-loader';
|
||||
import { writeEvaluation, updateComponentStats } from './execution-evaluator';
|
||||
import { recordComponentStats } from './execution-evaluator';
|
||||
import { parseTriplets } from './triplet-parser';
|
||||
import { searchNodes } from './search-nodes';
|
||||
import { searchNodes, type SearchMode, type SearchTarget } 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('無法解析任何節點');
|
||||
}
|
||||
|
||||
const { nodeResults } = searchNodes(parsed);
|
||||
// 2026-07-30:查 registry 判真實存在(workflow-discovery)。
|
||||
// `missing` 以前寫死 [],等於告訴 AI「什麼都有」——那是「腹語術」的入口。
|
||||
//
|
||||
// t158(07-31 迴歸修復,leo:「這裡只是複製一些工作流的 data 過去,沒有要在這裡驗證」):
|
||||
// 誠實化只屬於 **discover**(AI 問「有沒有」);**compile**(部署/推送的複製路徑)
|
||||
// 純編圖零查詢——那本來就是既有設計(workflows.json=打包期預編的搬運),
|
||||
// 5cadc60 起誠實化漏進複製路徑=迴歸(冷實例 8 節點 25.7s、安裝器 timeout 炸)。
|
||||
const { nodeResults, missingNodes } = await searchNodes(parsed, undefined, env, mode, target);
|
||||
|
||||
const graph = buildExecutionGraph(parsed, nodeResults, 'cypher-search-result', 'Cypher Search Result');
|
||||
return { nodes: nodeResults, cypher: { nodes: graph.nodes, edges: graph.edges }, missing: [] };
|
||||
return { nodes: nodeResults, cypher: { nodes: graph.nodes, edges: graph.edges }, missing: missingNodes };
|
||||
}
|
||||
|
||||
export async function handleCypherExecute(
|
||||
@@ -50,7 +59,9 @@ export async function handleCypherExecute(
|
||||
throw new Error('無法解析任何節點');
|
||||
}
|
||||
|
||||
const { nodeResults } = searchNodes(parsed, config);
|
||||
// t158:執行路徑=compile(零 discovery round-trip)——存在性由 component-loader
|
||||
// 在載入該節點時決定(原本的權威),查詢層不重複驗。
|
||||
const { nodeResults } = await searchNodes(parsed, config, env, 'compile');
|
||||
|
||||
const graph = buildExecutionGraph(parsed, nodeResults, graphId, graphName, config);
|
||||
const parseResult = graphSchema.safeParse(graph);
|
||||
@@ -66,18 +77,8 @@ export async function handleCypherExecute(
|
||||
const result = await executor.execute(parseResult.data as ExecutionGraph, context ?? {}, env.EXEC_CONTEXT);
|
||||
const duration_ms = Date.now() - start;
|
||||
|
||||
// 非同步記錄統計(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));
|
||||
// 非同步回寫每顆零件的執行統計(design.md「執行統計設計」;fire-and-forget 不阻擋回應)
|
||||
waitUntil(recordComponentStats(env, graph.nodes, result.trace));
|
||||
|
||||
return { success: true, data: result.data, trace: result.trace, duration_ms, graph };
|
||||
} catch (err) {
|
||||
@@ -99,19 +100,10 @@ export async function handleCypherExecute(
|
||||
}
|
||||
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
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));
|
||||
// 失敗路徑同樣回寫每顆零件統計:ExecutionError 帶完整 trace(失敗節點有 error、
|
||||
// 之前成功的節點照記成功);非 ExecutionError 無 trace 可歸因 → 不記(誠實:不瞎猜)。
|
||||
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,36 +1,96 @@
|
||||
/**
|
||||
* Execution Analytics — 零件執行後的統計記錄
|
||||
* Execution Analytics — 零件執行後的統計回寫
|
||||
*
|
||||
* Phase 1 MVP:stub(不寫入任何外部服務)
|
||||
* Phase 7 補充:fire-and-forget POST 至 registry.arcrun.dev/analytics/record
|
||||
* 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 既有慣例)。
|
||||
*
|
||||
* 每顆零件的成敗判定來源=執行 trace(per-node):
|
||||
* - trace step 有 `error` → 失敗(runner throw)
|
||||
* - output 是物件且 `success === false` → 失敗(makeHttpRunner 對非 2xx 不 throw,回這種)
|
||||
* - 其餘 → 成功
|
||||
* FOREACH 重複執行同一節點 → trace 有幾筆就記幾次(每次真實執行都算一次樣本)。
|
||||
*/
|
||||
|
||||
import type { Bindings } from '../types';
|
||||
import type { GraphNode, TraceStep } from '../types';
|
||||
import { wasmWorkerUrl } from '../lib/component-loader';
|
||||
|
||||
export interface EvaluationRecord {
|
||||
run_id: string;
|
||||
workflow_id: string;
|
||||
/** 本模組需要的環境子集(傳整份 Bindings 也相容,仿 SearchNodesEnv 慣例)。 */
|
||||
export type AnalyticsEnv = {
|
||||
WORKER_SUBDOMAIN?: string;
|
||||
/** registry 位置覆蓋(可選;本地 wrangler dev / self-hosted 用)。未設 → wasmWorkerUrl('registry', WORKER_SUBDOMAIN)。 */
|
||||
REGISTRY_BASE_URL?: string;
|
||||
};
|
||||
|
||||
export interface ComponentVerdict {
|
||||
component_id: string;
|
||||
verdict: 'success' | 'failed' | 'timeout';
|
||||
success: boolean;
|
||||
duration_ms: number;
|
||||
error_message?: string;
|
||||
evaluated_at: number;
|
||||
}
|
||||
|
||||
/** 記錄執行結果(MVP:no-op,Phase 7 補充 analytics)*/
|
||||
export async function writeEvaluation(
|
||||
_env: Bindings,
|
||||
_record: EvaluationRecord,
|
||||
): Promise<void> {
|
||||
// Phase 7: POST to registry.arcrun.dev/analytics/record
|
||||
/** 從執行 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;
|
||||
}
|
||||
|
||||
/** 更新零件統計(MVP:no-op,Phase 7 補充)*/
|
||||
export async function updateComponentStats(
|
||||
_env: Bindings,
|
||||
_componentId: string,
|
||||
_verdict: 'success' | 'failed' | 'timeout',
|
||||
_durationMs: number,
|
||||
/**
|
||||
* 對本次執行用到的每顆零件回寫統計到 registry(design.md「Analytics Record」)。
|
||||
* 永不 throw;呼叫端用 waitUntil 包,不阻擋主流程。
|
||||
*/
|
||||
export async function recordComponentStats(
|
||||
env: AnalyticsEnv,
|
||||
nodes: GraphNode[],
|
||||
trace: TraceStep[],
|
||||
): Promise<void> {
|
||||
// Phase 7: update ANALYTICS_KV via registry worker
|
||||
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:不拋錯,不影響主流程
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,56 @@
|
||||
/**
|
||||
* Execution Logger — 執行結果寫入 ANALYTICS_KV(fire-and-forget)
|
||||
* Execution Logger — 執行結果寫入 KBDB(fire-and-forget)
|
||||
*
|
||||
* 設計:每次 workflow 執行後,將統計數據寫入 ANALYTICS_KV(key = stats:{workflowId})。
|
||||
* Phase 7 可升級為 POST 至 registry.arcrun.dev/analytics/record。
|
||||
* KV 額度事故修復(總管交辦,2026-08-07):舊版寫 ANALYTICS_KV(Workers KV),
|
||||
* key = stats:{workflowId}:{timestamp}(註解寫「避免覆蓋」)⇒ 只增不減、永不覆蓋。
|
||||
* 封測者 Evan 處理約 690 個檔案,KV 免費層 write 上限 1,000/日被打爆(實測 1,070 write)。
|
||||
*
|
||||
* KBDB 鐵律(leo 2026-06-14):KBDB=API-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 兩條判準:
|
||||
* ① 執行紀錄是稽核資料 → 搬去 D1(entries 表,rows written 100,000/日,額度是 KV 的 100 倍)。
|
||||
* ② 不是 n8n、不靠 Execution 計費 → 少記:不留每節點輸入輸出,只留時間/workflow/verdict/
|
||||
* duration/錯誤訊息/(可得的)目標;成功記最少,失敗多記一點(截斷長度不對稱,見 KBDB 端)。
|
||||
*
|
||||
* A2 自我降級(門檻與降級邏輯全在 KBDB 端,見 execution-log.ts):D1 額度仍與知識卡共用,
|
||||
* 超過門檻 KBDB 會回報 mode='skip'/'log_failure_only',但**這件事對呼叫端透明**——
|
||||
* 本函式不管 KBDB 決定寫或不寫,一律 fire-and-forget、永不 throw,workflow 執行不受影響。
|
||||
*/
|
||||
|
||||
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;
|
||||
recorded_at: string;
|
||||
target?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 寫入執行結果至 ANALYTICS_KV(fire-and-forget,不阻擋主流程)
|
||||
* 由 c.executionCtx.waitUntil() 包裹呼叫
|
||||
* 從觸發時的 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 寫入執行結果至 KBDB(fire-and-forget,不阻擋主流程)。
|
||||
* 由 c.executionCtx.waitUntil() 包裹呼叫。
|
||||
*
|
||||
* @param nodes 保留參數相容既有呼叫端簽名(原本用來算 component_ids);「不記每節點」
|
||||
* 是本次修復的明確要求(少記),此參數現不使用。
|
||||
* @param input 觸發時的 trigger context(可選)——只用來抓 page_name / path 當 target,
|
||||
* 不整包送出(少記:不留每節點輸入輸出,這裡也不例外)。
|
||||
* @param apiKey 觸發者的租戶(可選,/execute 舊路徑無租戶概念)。
|
||||
*/
|
||||
export async function writeExecutionVerdict(
|
||||
env: Bindings,
|
||||
@@ -27,27 +59,25 @@ 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 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 天
|
||||
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,
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
// fire-and-forget:不拋錯,不影響主流程
|
||||
// fire-and-forget:任何錯誤(含 KBDB 端額度打滿、網路失敗)都吞掉、不影響主流程
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,12 +43,28 @@ export function buildExecutionGraph(
|
||||
iterator = foreachMatch[1];
|
||||
label = '對每個'; // 改回標準 label 走 SEMANTIC_EDGE_MAP
|
||||
}
|
||||
const edge: { from: string; to: string; type: ReturnType<typeof toEdgeType>; iterator?: string } = {
|
||||
|
||||
// 「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;
|
||||
} = {
|
||||
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;
|
||||
});
|
||||
|
||||
|
||||
@@ -1,40 +1,721 @@
|
||||
import type { ParsedTriplets, NodeRole } from './triplet-parser';
|
||||
import { resolveNodeRole } 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 步驟 4,workflow-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 時標來源庫:零件 registry(component)或 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 的 payload/回應用法自我說明(3.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):兩庫(零件 registry+recipe 庫)都查過才點名,
|
||||
* 並告訴 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_control/switch/try_catch)。
|
||||
* 存在的理由=走 n8n 式「逐顆查、自己組圖」的 AI,光看 input_schema 不知道
|
||||
* 「判斷完之後兩條路怎麼接」⇒ 會回頭寫 code。判準:只看這一顆的回應就知道怎麼接下一步。
|
||||
*/
|
||||
branch_hint?: BranchHint;
|
||||
};
|
||||
|
||||
export type SearchResult = {
|
||||
nodeResults: Record<string, { status: 'found' | 'missing'; componentId?: string; type: NodeRole }>;
|
||||
nodeResults: Record<string, NodeInfo>;
|
||||
missingNodes: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* 對所有節點進行解析,確認每個節點對應的零件 ID。
|
||||
*
|
||||
* 注意:此步驟只做靜態解析,不做遠端查找。
|
||||
* 零件是否真的存在由 component-loader 在執行時決定(Service Binding / KV / URL)。
|
||||
*
|
||||
* 優先序:
|
||||
* 1. Input/Output 角色:自動標記,componentId = 小寫節點名稱
|
||||
* 2. config[nodeName].component 已指定:使用 config 提供的 componentId
|
||||
* 3. 其他:componentId = 節點名稱(交給 component-loader 在執行時解析)
|
||||
* t158(leo 07-31 定調「部署≠發現」):
|
||||
* 「這裡只是複製一些工作流的 data 過去,沒有要在這裡驗證,難怪這麼慢。
|
||||
* 就算是我自己寫了錯的工作流,也可以跑跑看,如果錯誤就修改,
|
||||
* 沒有說有錯誤還要一個個驗證這回事。」
|
||||
* - `compile`=純編圖:**零外部查詢**(不打 registry、不掃 recipe、不算相似度、不擋 missing)。
|
||||
* 部署/推送/執行路徑用——寫錯的 workflow 照樣部署,錯在執行時現形。
|
||||
* - `discover`=誠實查詢(預設,`/cypher/search` 的既有契約):AI 問「有沒有」時用,
|
||||
* not_found+分型指路+相似候選全保留。
|
||||
*/
|
||||
export function searchNodes(
|
||||
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 改為會查 registry(workflow-discovery task 3.x);
|
||||
* 2026-07-31 再加 recipe 庫查詢+缺件分型指路(task 3.6/3.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 二次定調):**回覆的重點是「缺哪些」不是「有哪些」**——
|
||||
* 有的照常編圖不必報告;缺的要兩庫(零件 registry+recipe 庫)都搜過後點名+給正確指示:
|
||||
* 缺外部 API → 自己寫 recipe(skill `write_recipe`);
|
||||
* 缺計算原語 → 投稿零件 PR(skill `add_new_wasm_component`)。
|
||||
*
|
||||
* 誠實限制:查不到 registry(未部署/網路失敗)時回 `'unknown'` 而不是 `'not_found'`——
|
||||
* 不能因為查詢失敗就宣告零件不存在(那會讓 AI 誤判而重寫 code,正是要避免的事)。
|
||||
*/
|
||||
export async function searchNodes(
|
||||
parsed: ParsedTriplets,
|
||||
config?: Record<string, Record<string, unknown>>,
|
||||
): SearchResult {
|
||||
const nodeResults: Record<string, { status: 'found' | 'missing'; componentId?: string; type: NodeRole }> = {};
|
||||
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=只查零件 registry;recipe=只查 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 次 catalog+1 次 recipe 清單。
|
||||
// 步驟 4 的意圖替換也在**同一份清單**上做——不加任何新 round-trip。
|
||||
const catalog = !wantComponents
|
||||
? { status: 'ok' as const, entries: [] } // target=recipe:registry 不參與,不因此回 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);
|
||||
}
|
||||
|
||||
for (const nodeName of parsed.nodeNames) {
|
||||
const role = resolveNodeRole(nodeName, parsed);
|
||||
|
||||
if (role === 'Input' || role === 'Output') {
|
||||
// 只有**字面上的虛擬 IO 名**(input/trigger/…/output/done)才免查——
|
||||
// 位置上是頭節點但名字是真零件(`aes_encrypt >> … >> code` 的頭,role 也是 Input)
|
||||
// 仍要照常查兩庫,否則缺件被角色掩蓋、又回到「假 found」。
|
||||
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;
|
||||
const componentId = configComponent ?? nodeName;
|
||||
nodeResults[nodeName] = { status: 'found', componentId, type: role };
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
return { nodeResults, missingNodes: [] };
|
||||
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_id/display_name/aliases)
|
||||
// 命中為主:分數=強命中×10+弱命中(description/tags)×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', // recipe=http_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) 名字含**外部服務詞**(google/telegram/slack…)→「外部 API 樣貌」
|
||||
// → recipe 路:recipe 是 http_request+參數模板的具名封裝,用戶自己就能寫,不用改平台。
|
||||
// 2) 否則名字含**計算原語詞**(encrypt/hash/encode…)→「計算原語樣貌」
|
||||
// → 零件路:純計算得進 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 → 自己寫 recipe(skill「write_recipe」);` +
|
||||
`缺計算能力 → 投稿零件 PR(skill「add_new_wasm_component」,component 進 WASM 沙箱)。`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* recipe 的 payload/回應用法自我說明(3.12)。
|
||||
* 動機同 branch_hint:逐顆查 recipe(n8n 式)時,光看 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-gram(registry search 是子字串比對,整句中文必落空,
|
||||
// 2-gram 才撈得到「判斷」→ if_control(display_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 [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* target-search — POST /cypher/search 的「指定搜尋對象」名字搜尋(t159)
|
||||
*
|
||||
* leo 07-31:「search 節點名稱和 search 工作流名稱是同一個?一個死了另一個不能動?
|
||||
* 難道我不能指定要搜尋工作流或節點或 recipe 嗎?」
|
||||
*
|
||||
* ⇒ discover 入口加 `target`(component/recipe/workflow)+`query`:
|
||||
* - target=component → 轉發 registry GET /components/search(MCP arcrun_search_components 同一條路)
|
||||
* - target=recipe → 掃私庫 RECIPES KV(與 discover 混搜的第二庫**同一份讀法** listAllRecipes);
|
||||
* 公庫(多作者市場)另有 /public-recipes=MCP arcrun_recipe_search,回應註明
|
||||
* - target=workflow → lib/workflow-search.ts(GET /workflows/search=MCP 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_SUBDOMAIN/REGISTRY_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_schema,AI 拿到 if_control/switch 仍不知道兩條路怎麼接 ⇒ 回頭寫 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_search/GET /public-recipes。',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// target === 'workflow':租戶隔離,必帶 API key(同 GET /workflows/search 的既有契約)
|
||||
if (!apiKey) return { ok: false, status: 401, error: 'target=workflow 需要 X-Arcrun-API-Key header(workflow 搜尋限本租戶)' };
|
||||
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,6 +105,17 @@ 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` 的頭)
|
||||
* 仍要查兩庫,否則缺件被角色掩蓋、又回到「假 found」(task 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 } = searchNodes(parsed);
|
||||
const { nodeResults } = await searchNodes(parsed);
|
||||
|
||||
const graphId = `webhook-${Date.now()}`;
|
||||
const graphName = description || `Webhook ${new Date().toISOString()}`;
|
||||
|
||||
@@ -4,6 +4,8 @@ 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 市場星數。
|
||||
@@ -96,6 +98,17 @@ 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;
|
||||
@@ -117,6 +130,18 @@ 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,
|
||||
|
||||
@@ -348,7 +348,15 @@ export class GraphExecutor {
|
||||
|
||||
// BUILD-006:將節點 output 存入 KV(key = {run_id}:node:{node_id})
|
||||
// 這讓下游節點可以透過 KV 讀取上游的具名 output,解決同名欄位衝突
|
||||
if (kvStore && result !== null && result !== undefined) {
|
||||
//
|
||||
// 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')) {
|
||||
await kvSetNodeOutput(kvStore, node.id, result);
|
||||
}
|
||||
|
||||
@@ -478,6 +486,37 @@ 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.branch(if_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)
|
||||
@@ -500,6 +539,26 @@ 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;
|
||||
}
|
||||
@@ -631,6 +690,30 @@ 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;
|
||||
|
||||
@@ -39,11 +39,37 @@ const STATIC_ORIGINS = ['https://arcrun.dev', 'https://www.arcrun.dev'];
|
||||
|
||||
app.use('*', cors({
|
||||
origin: (origin, c) => {
|
||||
const extra = (c.env.UI_ORIGINS || '')
|
||||
.split(',')
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean);
|
||||
return [...STATIC_ORIGINS, ...extra].includes(origin) ? origin : null;
|
||||
// ⚠️ 非瀏覽器請求(CLI/curl/MCP)沒有 Origin 標頭 → origin 是空字串/undefined。
|
||||
// 此時必須原樣放行,不能回 null——回 null 會讓 Hono cors 中介層在後續處理拋錯,
|
||||
// 表現為所有 CLI 部署一律 500(2026-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'],
|
||||
allowHeaders: ['Content-Type', 'Authorization', 'X-Arcrun-API-Key'],
|
||||
|
||||
@@ -22,13 +22,21 @@
|
||||
* 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[] = [
|
||||
@@ -120,4 +128,47 @@ 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 2373/2173 ms ✅ 答案最完整、引用正確
|
||||
// @cf/meta/llama-3.3-70b-instruct-fp8-fast 3261/2147 ms ✅ 可用但波動較大
|
||||
// @cf/mistralai/mistral-small-3.1-24b-instruct 3560/3631 ms
|
||||
// @cf/qwen/qwen2.5-coder-32b-instruct 3572/3353 ms
|
||||
// @cf/openai/gpt-oss-120b 1971/2295 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 的 endpoint,workflow 不動。',
|
||||
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:'],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 分支用法自我說明(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_BRANCH),ON_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()];
|
||||
}
|
||||
@@ -20,6 +20,7 @@ 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 runner:canonical_id → 對應獨立 Worker URL。
|
||||
@@ -120,7 +121,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 makeRecipeRunner(recipe);
|
||||
if (recipe) return pickRecipeRunner(recipe, env);
|
||||
throw new Error(`找不到 recipe hash "${componentId}",請確認已透過 acr push 上傳`);
|
||||
}
|
||||
|
||||
@@ -134,7 +135,7 @@ export function createComponentLoader(env: Bindings) {
|
||||
|
||||
// 6. KV recipe(動態,用戶 push 的)
|
||||
const kvRecipe = await resolveRecipe(componentId, env.RECIPES);
|
||||
if (kvRecipe) return makeRecipeRunner(kvRecipe);
|
||||
if (kvRecipe) return pickRecipeRunner(kvRecipe, env);
|
||||
|
||||
// 7. WASM HTTP runner:auth primitive / API 零件 → 獨立 Worker URL
|
||||
// 白名單見 WASM_HTTP_RUNNER_IDS(http_request、5 個待降級 API 零件、4 個 auth primitive)。
|
||||
@@ -271,6 +272,73 @@ function makeLogicRunner(canonicalId: string, env: Bindings): ComponentRunner |
|
||||
return makeHttpRunner(wasmWorkerUrl(canonicalId, env.WORKER_SUBDOMAIN));
|
||||
}
|
||||
|
||||
/**
|
||||
* recipe → runner 的分派(3.12):auth='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 runner(3.12 第四型認證):不打外部 HTTP、不需要任何金鑰,
|
||||
* 直接用平台 binding(env.AI/VECTORIZE/…)⇒ leo 要的「開機就可用」。
|
||||
*
|
||||
* 為什麼要開這型:recipe 的舊抽象=「打一個外部 HTTP API」(endpoint+method+auth_service),
|
||||
* 而 Cloudflare 的 binding 呼叫不是 HTTP ⇒ **整類能力被排除在 recipe 之外**。
|
||||
* 開這一型不是為 Workers AI 開特例,是一次打開 env.AI/VECTORIZE/BROWSER/QUEUE 整排。
|
||||
*/
|
||||
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> : {};
|
||||
@@ -293,9 +361,12 @@ function makeRecipeRunner(recipe: import('../routes/recipes').RecipeDefinition):
|
||||
headers[k] = interpolate(v);
|
||||
}
|
||||
|
||||
// body:把 recipe.body 裡的 {{key}} 都換掉
|
||||
// body:優先 body_template(③ payload 層,3.12——支援巢狀/dot path/保留型別),
|
||||
// 其次既有 recipe.body(淺層 {{key}},舊 recipe 照舊),最後才拿 ctx 當 body。
|
||||
let bodyStr: string | undefined;
|
||||
if (recipe.body) {
|
||||
if (recipe.body_template) {
|
||||
bodyStr = JSON.stringify(renderBodyTemplate(recipe.body_template, ctxObj));
|
||||
} else if (recipe.body) {
|
||||
bodyStr = interpolate(JSON.stringify(recipe.body));
|
||||
} else if (method !== 'GET') {
|
||||
// 沒指定 body template → 用 ctx 當 body,但剔除 _ 前綴的內部欄位
|
||||
@@ -313,6 +384,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 };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ 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',
|
||||
// 新增:結構語意(記錄圖結構,不執行)
|
||||
@@ -28,9 +30,19 @@ 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',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* 認證儲存(D61:認證與資料分離)— 門鎖不住在知識資料庫裡
|
||||
*
|
||||
* leo 2026-08-10 下令(ADR D61 / Leo/arcrun-rag#55):
|
||||
* 「登入認證資料要分離⋯⋯**就算只有我一個人存在單獨的 json 檔也好**,
|
||||
* 它不能被改資料庫的連結導致無法登入。」
|
||||
*
|
||||
* 不變量(整份檔案只為這一句存在):
|
||||
* **登入所需要的一切,不得存放在任何「會被安裝/遷移重新指向」的地方。**
|
||||
*
|
||||
* 為什麼家選在 CF Workers per-script Secrets(判斷過程留著,方便日後推翻):
|
||||
* - D1 / KV / R2 / Vectorize 全靠 **binding** 指過去,安裝器每次都會重新指一次
|
||||
* ⇒ 換家=換鎖。所以「搬到另一顆資料庫」根本不解問題。
|
||||
* - Workers Secret **掛在 script 本身**,與 bindings 是兩套資源:
|
||||
* `wrangler deploy` 帶新 bindings 重部不會洗掉它(journeys/gemini-key-lost-on-reinstall.md
|
||||
* 在 stage 完整重裝 24/24 顆 worker 後 secret 仍在;installer worker.js:1148 亦有同款實證)。
|
||||
* - 它是**自足**的:讀出來就是完整的一份 JSON,裡面沒有任何「再去某顆 D1/KV 查一次」的指標。
|
||||
* 自足是重點——只要還要回頭查一次,就又被綁回去了。
|
||||
* - 不開新 D1(P9:leo 2026-08-07「你建一顆新的 D1,以後就會偷偷溜去那裡建表」)。
|
||||
* - 不牴觸 D38「KBDB 三張核心表永不加新的」:本檔是把東西**搬出去**,KBDB 表數不增不減。
|
||||
*
|
||||
* 容量(2026-08-10 查官方 developers.cloudflare.com/workers/platform/limits/,不是憑記憶):
|
||||
* - 每個變數(secret + text 合計)上限 **5 KB**
|
||||
* - 每顆 worker 變數數量上限 **64(Free)/ 128(Paid)**,與 CRED_* 共用同一份額度
|
||||
* ⇒ 故採「單一 store + 溢位分片」:`ARCRUN_AUTH_STORE`、`ARCRUN_AUTH_STORE_1`、`_2`…
|
||||
* 一份 ~4.5 KB 大約裝得下 12–15 個帳號;超過就自動長出下一片。
|
||||
* 這是刻意的取捨:**不**做「一個帳號一顆 secret」,因為那會用同一份 64 格的額度去跟
|
||||
* workflow credential 搶位子,且沒有任何實例接近這個量級。
|
||||
*
|
||||
* 寫入路徑:CF Workers Scripts secrets 管理 API(唯寫,讀不回值)。
|
||||
* 與 routes/credentials.ts 走**同一支** putWorkerSecret/deleteWorkerSecret,不另造第二套
|
||||
* (D36 教訓:AI 天生偏向新增一種做法而非沿用既有的,兩套並存必然漂移)。
|
||||
*
|
||||
* 讀取路徑:`env` 直接讀——**零網路呼叫**。這正是它比 KBDB 可靠的原因:
|
||||
* 登入不再依賴任何外部系統活著。
|
||||
*
|
||||
* ⚠️ 傳播延遲(誠實限制,mindset §7):更新 secret 會產生 worker 的新版本,
|
||||
* **既有 isolate 讀到的仍是舊 env**,要等新版本鋪開。故本檔帶一層 per-isolate 的
|
||||
* write-through overlay(AUTH_OVERLAY_TTL_MS),讓「剛改完密碼立刻登入」在同一顆 isolate 上
|
||||
* 立即生效;跨 isolate 仍可能有數十秒的落差,這是平台特性,不假裝沒有。
|
||||
*/
|
||||
import type { Bindings } from '../types';
|
||||
import { putWorkerSecret, deleteWorkerSecret } from '../routes/credentials';
|
||||
|
||||
/** 主分片名;溢位分片為 `${AUTH_STORE_PREFIX}_1`、`_2`… */
|
||||
export const AUTH_STORE_PREFIX = 'ARCRUN_AUTH_STORE';
|
||||
/** 單片安全上限(官方 5 KB,留 ~10% 給 JSON 結構與 UTF-8 膨脹)。 */
|
||||
const SHARD_MAX_BYTES = 4600;
|
||||
/** 剛寫完的資料在本 isolate 內優先採信多久(跨 isolate 傳播用)。 */
|
||||
const AUTH_OVERLAY_TTL_MS = 180_000;
|
||||
/**
|
||||
* 「剛寫完」加速器的 KV key 與存活時間。
|
||||
*
|
||||
* 🔴 為什麼需要它(2026-08-10 stage 演練**實測撞到**,不是預防性設計):
|
||||
* 更新 secret 會產生 worker 新版本,**既有 isolate 讀到的還是舊 env**。實測「建好帳號 →
|
||||
* 立刻登入」有 **15 秒以上**登不進去,而且那幾次失敗**會被算進 5 次鎖定**
|
||||
* ⇒ 安裝精靈「建立帳號 → 馬上登入」會把人鎖在門外 15 分鐘。**這正是本案要根治的病的變種。**
|
||||
*
|
||||
* 🔑 它**不是**認證的家,只是「新版本還沒鋪開時的臨時快遞」:
|
||||
* - 讀取順序永遠是 **secret 優先**;secret 裡查不到/密碼對不上,才回頭問加速器一次
|
||||
* - KV 被重裝指到新的空的 → 加速器空 → 退回 secret ⇒ **D61 的不變量不受影響**
|
||||
* - 短 TTL:密碼雜湊不長期躺在 KV 裡(舊設計是永久躺著,這比舊的嚴格)
|
||||
*/
|
||||
const ACCEL_KEY = 'auth_store_recent';
|
||||
const ACCEL_TTL_SECONDS = 600;
|
||||
/** store 內 user id 前綴——呼叫端據此分辨「這筆住新家還是舊家(KBDB)」。 */
|
||||
export const AUTH_ID_PREFIX = 'auth:';
|
||||
|
||||
export interface AuthUserRecord {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name: string;
|
||||
status: string;
|
||||
role: string;
|
||||
libraries: string[];
|
||||
password_hash: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** console 管理員那一組(原本住 SESSIONS_KV `console:credentials`,重裝就跟著蒸發)。 */
|
||||
export interface AuthConsoleRecord {
|
||||
email: string;
|
||||
salt: string;
|
||||
hash: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AuthStoreData {
|
||||
version: number;
|
||||
console: AuthConsoleRecord | null;
|
||||
users: AuthUserRecord[];
|
||||
}
|
||||
|
||||
interface ShardPayload {
|
||||
v: number;
|
||||
console?: AuthConsoleRecord | null;
|
||||
users?: AuthUserRecord[];
|
||||
}
|
||||
|
||||
/** 寫入路徑未就緒(缺 CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID,或 CF API 回錯)。 */
|
||||
export class AuthStoreWriteError extends Error {}
|
||||
|
||||
// ── per-isolate overlay(見檔頭「傳播延遲」)─────────────────────────────────────
|
||||
let overlay: AuthStoreData | null = null;
|
||||
let overlayAt = 0;
|
||||
|
||||
function emptyStore(): AuthStoreData {
|
||||
return { version: 1, console: null, users: [] };
|
||||
}
|
||||
|
||||
function shardNames(env: Bindings): string[] {
|
||||
const bag = env as unknown as Record<string, unknown>;
|
||||
return Object.keys(bag)
|
||||
.filter((k) => k === AUTH_STORE_PREFIX || /^ARCRUN_AUTH_STORE_\d+$/.test(k))
|
||||
.filter((k) => typeof bag[k] === 'string' && (bag[k] as string).length > 0)
|
||||
.sort((a, b) => shardIndex(a) - shardIndex(b));
|
||||
}
|
||||
|
||||
function shardIndex(name: string): number {
|
||||
if (name === AUTH_STORE_PREFIX) return 0;
|
||||
return Number.parseInt(name.slice(AUTH_STORE_PREFIX.length + 1), 10) || 0;
|
||||
}
|
||||
|
||||
function shardNameOf(index: number): string {
|
||||
return index === 0 ? AUTH_STORE_PREFIX : `${AUTH_STORE_PREFIX}_${index}`;
|
||||
}
|
||||
|
||||
/** 這台實例的 env 裡有沒有認證儲存(不論裡面有沒有帳號)。 */
|
||||
export function authStorePresent(env: Bindings): boolean {
|
||||
return shardNames(env).length > 0 || (overlay !== null && Date.now() - overlayAt < AUTH_OVERLAY_TTL_MS);
|
||||
}
|
||||
|
||||
/** 寫入路徑是否就緒——缺就誠實回報「不能改密碼」,不假綠。 */
|
||||
export function authStoreWritable(env: Bindings): boolean {
|
||||
return Boolean(env.CF_SECRETS_API_TOKEN && env.CF_ACCOUNT_ID);
|
||||
}
|
||||
|
||||
/**
|
||||
* 讀出完整認證資料。**同步、零網路呼叫**——這就是分離的意義:
|
||||
* 登入不依賴 KBDB / D1 / KV 任何一個活著。
|
||||
* 壞掉的分片(JSON parse 失敗)誠實跳過,不讓一片損毀鎖死整台實例。
|
||||
*/
|
||||
export function readAuthStore(env: Bindings): AuthStoreData {
|
||||
if (overlay && Date.now() - overlayAt < AUTH_OVERLAY_TTL_MS) return overlay;
|
||||
|
||||
const bag = env as unknown as Record<string, unknown>;
|
||||
const out = emptyStore();
|
||||
for (const name of shardNames(env)) {
|
||||
let parsed: ShardPayload | null = null;
|
||||
try {
|
||||
parsed = JSON.parse(bag[name] as string) as ShardPayload;
|
||||
} catch {
|
||||
continue; // 損毀的分片跳過(其餘帳號仍登得進去)
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object') continue;
|
||||
if (parsed.console && !out.console) out.console = parsed.console;
|
||||
if (Array.isArray(parsed.users)) {
|
||||
for (const u of parsed.users) {
|
||||
if (u && typeof u.email === 'string' && typeof u.id === 'string') out.users.push(u);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 找一筆帳號(email 比對,大小寫不敏感)。 */
|
||||
export function findAuthUserByEmail(env: Bindings, email: string): AuthUserRecord | null {
|
||||
const needle = email.trim().toLowerCase();
|
||||
return readAuthStore(env).users.find((u) => u.email.toLowerCase() === needle) ?? null;
|
||||
}
|
||||
|
||||
export function findAuthUserById(env: Bindings, id: string): AuthUserRecord | null {
|
||||
return readAuthStore(env).users.find((u) => u.id === id) ?? null;
|
||||
}
|
||||
|
||||
/** 判斷一個 record_id 是不是住新家(呼叫端據此決定打 store 還是打 KBDB)。 */
|
||||
export function isAuthStoreId(recordId: string): boolean {
|
||||
return recordId.startsWith(AUTH_ID_PREFIX);
|
||||
}
|
||||
|
||||
export function newAuthUserId(): string {
|
||||
const arr = new Uint8Array(12);
|
||||
crypto.getRandomValues(arr);
|
||||
return AUTH_ID_PREFIX + Array.from(arr).map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 把整份認證資料切片後寫回 Workers Secrets。
|
||||
* 分片規則:console 一定放第 0 片;users 依序塞,塞不下就開下一片。
|
||||
* 多出來的舊分片會被刪掉(避免「刪了帳號卻還留在舊分片裡復活」)。
|
||||
*/
|
||||
export async function writeAuthStore(env: Bindings, data: AuthStoreData): Promise<void> {
|
||||
if (!authStoreWritable(env)) {
|
||||
throw new AuthStoreWriteError(
|
||||
'這台實例還不能寫入認證儲存(缺 CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID)。' +
|
||||
'認證分離需要這兩項才寫得進 Workers Secrets——請重新執行安裝/更新讓它就緒。',
|
||||
);
|
||||
}
|
||||
|
||||
const shards: string[] = [];
|
||||
let current: ShardPayload = { v: 1, console: data.console ?? null, users: [] };
|
||||
for (const u of data.users) {
|
||||
const trial: ShardPayload = { ...current, users: [...(current.users ?? []), u] };
|
||||
const size = new TextEncoder().encode(JSON.stringify(trial)).length;
|
||||
if (size > SHARD_MAX_BYTES && (current.users ?? []).length > 0) {
|
||||
shards.push(JSON.stringify(current));
|
||||
current = { v: 1, users: [u] };
|
||||
} else {
|
||||
current = trial;
|
||||
}
|
||||
}
|
||||
shards.push(JSON.stringify(current));
|
||||
|
||||
// 單筆帳號本身就超過一片=真的塞不下,誠實擋下(不靜默丟資料)
|
||||
for (const s of shards) {
|
||||
if (new TextEncoder().encode(s).length > 5000) {
|
||||
throw new AuthStoreWriteError('單筆認證資料超過 Cloudflare 變數 5 KB 上限,無法寫入。');
|
||||
}
|
||||
}
|
||||
|
||||
const existing = shardNames(env);
|
||||
for (let i = 0; i < shards.length; i++) {
|
||||
await putWorkerSecret(env, shardNameOf(i), shards[i]);
|
||||
}
|
||||
for (const name of existing) {
|
||||
if (shardIndex(name) >= shards.length) await deleteWorkerSecret(env, name);
|
||||
}
|
||||
|
||||
overlay = { version: 1, console: data.console ?? null, users: [...data.users] };
|
||||
overlayAt = Date.now();
|
||||
|
||||
// 加速器(非真相源,見 ACCEL_KEY 註解):讓別的 isolate 在新版本鋪開前也讀得到剛寫的東西。
|
||||
// 寫失敗完全不影響正確性——最多就是回到「等 secret 傳播」的狀態,故吞掉例外。
|
||||
try {
|
||||
await env.SESSIONS_KV.put(
|
||||
ACCEL_KEY,
|
||||
JSON.stringify({ written_at: Date.now(), data: overlay }),
|
||||
{ expirationTtl: ACCEL_TTL_SECONDS },
|
||||
);
|
||||
} catch {
|
||||
/* 加速器是加分項,不是必要條件 */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 「secret 裡查不到/密碼對不上」時再問一次加速器(見 ACCEL_KEY)。
|
||||
* 命中就把它放進本 isolate 的 overlay,呼叫端重跑一次同樣的查找即可。
|
||||
* 回傳是否真的拿到比較新的資料(沒有就不必重跑)。
|
||||
*/
|
||||
export async function hydrateFromAccelerator(env: Bindings): Promise<boolean> {
|
||||
let raw: string | null = null;
|
||||
try {
|
||||
raw = await env.SESSIONS_KV.get(ACCEL_KEY);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!raw) return false;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { written_at?: number; data?: AuthStoreData };
|
||||
if (!parsed?.data || !Array.isArray(parsed.data.users)) return false;
|
||||
if (overlay && overlayAt >= (parsed.written_at ?? 0)) return false; // 本地的更新
|
||||
overlay = { version: 1, console: parsed.data.console ?? null, users: parsed.data.users };
|
||||
overlayAt = parsed.written_at ?? Date.now();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 讀出來 → 改 → 寫回去(同一支,避免各處自己拼 read/modify/write)。 */
|
||||
export async function mutateAuthStore(
|
||||
env: Bindings,
|
||||
fn: (data: AuthStoreData) => void | Promise<void>,
|
||||
): Promise<AuthStoreData> {
|
||||
const data = readAuthStore(env);
|
||||
const next: AuthStoreData = { version: 1, console: data.console, users: [...data.users] };
|
||||
await fn(next);
|
||||
await writeAuthStore(env, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
/** 診斷用(/health、/console/auth-status、daemon diagnostics 共用同一份判讀)。 */
|
||||
export function authStoreStatus(env: Bindings): {
|
||||
present: boolean;
|
||||
writable: boolean;
|
||||
users: number;
|
||||
console_configured: boolean;
|
||||
shards: number;
|
||||
} {
|
||||
const data = readAuthStore(env);
|
||||
return {
|
||||
present: authStorePresent(env),
|
||||
writable: authStoreWritable(env),
|
||||
users: data.users.length,
|
||||
console_configured: Boolean(data.console),
|
||||
shards: shardNames(env).length,
|
||||
};
|
||||
}
|
||||
@@ -37,4 +37,21 @@ export const PORTAL_TEMPLATE_SEEDS: PortalTemplateSeed[] = [
|
||||
slots: ['name', 'display_name', 'description', 'status', 'graph_source'],
|
||||
created_by: 'system',
|
||||
},
|
||||
{
|
||||
// t130:rag_ingest_card.post_triplet 寫 POST /records {template:'triplet'}。
|
||||
// 新實例若無此 template 回 400「template not found: triplet」→ 三元組全滅。
|
||||
// slots 來源:kbdb_list_templates 核實(2026-07-19,library-map.test.ts PROD_TRIPLET_SLOTS)
|
||||
// + library(library-map.ts M1 預案:recompute 歸庫用,ensurePortalTemplates 若缺則 PATCH 補入)。
|
||||
name: 'triplet',
|
||||
description: 'KBDB 知識圖譜三元組(kbdb-graph-plugin 寫入;portal 讀此 template 建鄰接圖)',
|
||||
slots: [
|
||||
'subject', 'predicate', 'object',
|
||||
'source_block_id', 'confidence', 'clusters_json',
|
||||
'bridge_score', 'subject_entity_type', 'object_entity_type',
|
||||
'status', 'superseded_by',
|
||||
'source_uri', 'content_hash', 'source_anchor', 'predicate_embed',
|
||||
'library',
|
||||
],
|
||||
created_by: 'system',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* recipe 的 payload 與回應處理層(SDD workflow-discovery 3.12 / CP arcrun-usable 步驟 5 缺口②)
|
||||
*
|
||||
* 為什麼存在(leo 的三層模型,第③層過去是空的):
|
||||
* ① 零件(http_request) ② auth recipe(auth_service) ③ **payload recipe** ← 這層
|
||||
* 舊 schema 存不住 body 與「回應怎麼取值」⇒ 帶 body 的 API 只能把整包寫進 workflow code,
|
||||
* 回應解析(rag_chat 的 finalize,2786 字元)綁死 Gemini 格式 ⇒ 換源必壞。
|
||||
* 有了這層:**換 LLM 供應商=換 recipe,不必動 workflow**。
|
||||
*
|
||||
* 相容鐵律:三個欄位全為選填。既有 recipe(沒有這些欄位)行為**完全不變**——
|
||||
* renderBodyTemplate(undefined,…) 回 undefined、applyResponseMap(body, undefined) 原樣回傳。
|
||||
*/
|
||||
|
||||
/** 回應正規化規則(隨 recipe 走,故換源=換 recipe) */
|
||||
export type ResponseMap = {
|
||||
/**
|
||||
* 取值路徑(dot path,支援陣列索引)。
|
||||
* 例:Gemini `candidates.0.content.parts.0.text`/Claude `content.0.text`/
|
||||
* Workers AI `response`。
|
||||
* 搭配 thinking_model 時可指向 parts 陣列本身。
|
||||
*/
|
||||
text_path?: string;
|
||||
/**
|
||||
* 思考型模型(如 gemma):parts 內會混入 `thought: true` 的思考過程,
|
||||
* 要剔除後取最後一個非 thought 的 part。
|
||||
*/
|
||||
thinking_model?: boolean;
|
||||
/** 淨化:要剝掉的前綴(實撞過「Draft:」「*」「Answer:」,且組合順序不定) */
|
||||
strip_prefixes?: string[];
|
||||
/** 答案標記:出現時只取其後的內容(實撞:模型會把草稿吐在標記前) */
|
||||
answer_marker?: string;
|
||||
};
|
||||
|
||||
/** 從物件用 dot path 取值:'a.0.b' → obj.a[0].b */
|
||||
function getPath(obj: unknown, path: string): unknown {
|
||||
let cur: unknown = obj;
|
||||
for (const part of path.split('.')) {
|
||||
if (cur === null || cur === undefined) return undefined;
|
||||
if (typeof cur !== 'object') return undefined;
|
||||
cur = (cur as Record<string, unknown>)[part];
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
// ── ③-a body_template:payload 收回 recipe ───────────────────────────────────
|
||||
|
||||
/**
|
||||
* 把 body_template 內所有 `{{var}}` 用 ctx 填掉(遞迴進巢狀 object / array)。
|
||||
*
|
||||
* 與 graph-executor 的 interpolateData 同一套語義(刻意一致,避免兩種插值行為):
|
||||
* - 整個字串就是單一 `{{x}}` → 回**原型別**(陣列/物件/數字不被 stringify)
|
||||
* - 混合文字 → 拼成字串
|
||||
* - 取不到 → **保留原樣** `{{x}}`(看得見才好 debug,不靜默吞掉)
|
||||
*/
|
||||
export function renderBodyTemplate(
|
||||
template: unknown,
|
||||
ctx: Record<string, unknown>,
|
||||
): unknown {
|
||||
if (template === undefined || template === null) return undefined;
|
||||
return renderValue(template, ctx);
|
||||
}
|
||||
|
||||
function renderValue(v: unknown, ctx: Record<string, unknown>): unknown {
|
||||
if (typeof v === 'string') return renderString(v, ctx);
|
||||
if (Array.isArray(v)) return v.map(item => renderValue(item, ctx));
|
||||
if (v !== null && typeof v === 'object') {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
|
||||
out[k] = renderValue(val, ctx);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function renderString(s: string, ctx: Record<string, unknown>): unknown {
|
||||
const single = s.match(/^\s*\{\{([\w.]+)\}\}\s*$/);
|
||||
if (single) {
|
||||
const val = getPath(ctx, single[1]);
|
||||
return val === undefined ? s : val;
|
||||
}
|
||||
return s.replace(/\{\{([\w.]+)\}\}/g, (_, key: string) => {
|
||||
const val = getPath(ctx, key);
|
||||
if (val === undefined) return `{{${key}}}`;
|
||||
return typeof val === 'string' ? val : JSON.stringify(val);
|
||||
});
|
||||
}
|
||||
|
||||
// ── ③-b response_map:回應正規化 ─────────────────────────────────────────────
|
||||
|
||||
export type NormalizedResponse = {
|
||||
/** 正規化後的純文字(沒有 response_map 或取不到時 undefined——誠實,不編造) */
|
||||
text?: string;
|
||||
/** 原始回應永遠保留(除錯與向後相容都靠它) */
|
||||
raw: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* 依 response_map 把各家 API 的回應正規化成 `{ text }`。
|
||||
* 沒給 map ⇒ 原樣回傳(既有 recipe 零行為變化)。
|
||||
*/
|
||||
export function applyResponseMap(body: unknown, map?: ResponseMap): NormalizedResponse {
|
||||
if (!map) return { raw: body };
|
||||
|
||||
let picked: unknown = map.text_path ? getPath(body, map.text_path) : body;
|
||||
|
||||
// 思考型模型:picked 是 parts 陣列 → 剔除 thought=true,取最後一個
|
||||
if (map.thinking_model && Array.isArray(picked)) {
|
||||
const real = picked.filter(
|
||||
p => !(p && typeof p === 'object' && (p as Record<string, unknown>).thought === true),
|
||||
);
|
||||
const last = real[real.length - 1];
|
||||
picked = (last && typeof last === 'object')
|
||||
? (last as Record<string, unknown>).text
|
||||
: last;
|
||||
}
|
||||
|
||||
if (typeof picked !== 'string') return { text: undefined, raw: body };
|
||||
|
||||
return { text: sanitize(picked, map), raw: body };
|
||||
}
|
||||
|
||||
/**
|
||||
* 淨化(知識是實撞出來的,非預想):
|
||||
* 1. 有 answer_marker → 只取標記**最後一次**出現之後的內容
|
||||
* (實撞:模型的自檢清單內文也會提到標記,用 lastIndexOf 才撈得到真的那個)
|
||||
* 2. 前綴組合順序不定(「* 【答】」「Draft: 【答】」「Answer: * 【答】」三型都撞過)
|
||||
* ⇒ **循環**剝殼,單趟剝不乾淨
|
||||
*/
|
||||
function sanitize(input: string, map: ResponseMap): string {
|
||||
let s = input.trim();
|
||||
|
||||
if (map.answer_marker) {
|
||||
const idx = s.lastIndexOf(map.answer_marker);
|
||||
if (idx >= 0) s = s.slice(idx + map.answer_marker.length);
|
||||
}
|
||||
|
||||
const prefixes = map.strip_prefixes ?? [];
|
||||
if (prefixes.length > 0) {
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
s = s.trimStart();
|
||||
for (const p of prefixes) {
|
||||
if (p && s.startsWith(p)) {
|
||||
s = s.slice(p.length);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return s.trim();
|
||||
}
|
||||
@@ -14,9 +14,10 @@ export const graphSchema = z.object({
|
||||
edges: z.array(z.object({
|
||||
from: z.string(),
|
||||
to: z.string(),
|
||||
type: z.enum(['PIPE', 'IF', 'FOREACH', 'CONTINUE', 'IS_A', 'ON_SUCCESS', 'ON_FAIL', 'ON_CLICK', 'CALLS_SUBFLOW', 'CONTAINS', 'HAS_STYLE', 'HAS_BEHAVIOR']),
|
||||
type: z.enum(['PIPE', 'IF', 'FOREACH', 'CONTINUE', 'IS_A', 'ON_SUCCESS', 'ON_FAIL', 'ON_TRUE', 'ON_FALSE', 'ON_BRANCH', 'ON_CLICK', 'CALLS_SUBFLOW', 'CONTAINS', 'HAS_STYLE', 'HAS_BEHAVIOR']),
|
||||
condition: z.string().optional(),
|
||||
iterator: z.string().optional(),
|
||||
branch: z.string().optional(), // ON_BRANCH 的具名分支(SDD workflow-discovery 3.11)
|
||||
})),
|
||||
});
|
||||
|
||||
|
||||
@@ -353,8 +353,15 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti
|
||||
const result = await hostFunctions!.http_request!(url, method, headers, body);
|
||||
// await 後重新拿 memory.buffer(grow 會產生新的 ArrayBuffer)
|
||||
return writeOut(memory.buffer, outPtr, outLenPtr, new TextEncoder().encode(result));
|
||||
} catch {
|
||||
return 1;
|
||||
} catch (e) {
|
||||
// t117: 寫錯誤 envelope 到 WASM 輸出(main.go 讀 error key → success:false + 詳情);
|
||||
// 取代只 return 1(WASM 寫無資訊的 "HTTP request failed")。
|
||||
// writeOut 失敗(memory 壞)才 fallback return 1。
|
||||
const errDetail = e instanceof Error ? e.message : String(e);
|
||||
const errEnv = new TextEncoder().encode(
|
||||
JSON.stringify({ error: `fetch failed: ${errDetail}`, status: 0, body: '' })
|
||||
);
|
||||
return writeOut(memory.buffer, outPtr, outLenPtr, errEnv);
|
||||
}
|
||||
})
|
||||
: () => 1,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* workflow-search — 本租戶 workflow 名字搜尋的**唯一一條路**
|
||||
*
|
||||
* 既有機制(workflow-discovery 3.1):轉發 KBDB /entries/search
|
||||
* (entry_type=workflow + owner_id=apiKey 租戶隔離;優先 semantic,KBDB 未開
|
||||
* Vectorize 自動降級 keyword + capability_hint)。
|
||||
*
|
||||
* 為什麼抽成共用(leo 07-31:「search 節點名稱和 search 工作流名稱是同一個?
|
||||
* 難道我不能指定要搜尋工作流或節點或 recipe 嗎?」+「外部 API 只有一條一致的路」鐵律):
|
||||
* - GET /workflows/search(MCP arcrun_search_workflows 走的路)
|
||||
* - POST /cypher/search { target: "workflow", query }(discover 入口指定搜尋對象)
|
||||
* 兩個入口共用本函式 ⇒ 行為必然一致,改一處兩邊同步。
|
||||
*
|
||||
* 已知缺口(如實透傳,不掩蓋):workflow_metadata 無 description slot——
|
||||
* 無 description 的 workflow 沒有 search entry、搜不到;補救走
|
||||
* POST /workflows/backfill-search-entries(有 description 的補 entry、沒有的誠實列出)。
|
||||
*
|
||||
* flag 安全:主動 pull,無輪詢/排程。
|
||||
*/
|
||||
|
||||
export type WorkflowSearchEnv = {
|
||||
KBDB_BASE_URL?: string;
|
||||
KBDB_INTERNAL_TOKEN?: string;
|
||||
};
|
||||
|
||||
export type WorkflowSearchMode = 'semantic' | 'keyword';
|
||||
|
||||
/**
|
||||
* 打 KBDB /entries/search(本租戶、entry_type=workflow)。
|
||||
* 回原始 Response——GET /workflows/search 直接 stream 透傳(既有行為,一字不改);
|
||||
* target=workflow 的呼叫端自行 json() 解析。
|
||||
*/
|
||||
export async function fetchTenantWorkflowSearch(
|
||||
env: WorkflowSearchEnv,
|
||||
apiKey: string,
|
||||
q: string,
|
||||
mode: WorkflowSearchMode = 'semantic',
|
||||
): Promise<Response> {
|
||||
const base = (env.KBDB_BASE_URL ?? 'https://arcrun-kbdb.uncle6-me.workers.dev').replace(/\/$/, '');
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${env.KBDB_INTERNAL_TOKEN}`;
|
||||
const params = new URLSearchParams({
|
||||
q,
|
||||
owner_id: apiKey, // 租戶隔離(只搜本租戶的 workflow)
|
||||
entry_type: 'workflow', // base 通用 filter(Q4),只回 workflow entry
|
||||
mode,
|
||||
});
|
||||
return fetch(`${base}/entries/search?${params.toString()}`, { headers });
|
||||
}
|
||||
@@ -22,6 +22,19 @@
|
||||
*/
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
// D61(ADR D61 / Leo/arcrun-rag#55):這組管理員帳密原本住 SESSIONS_KV(`console:credentials`,
|
||||
// 而且沒有 TTL)——KV 是靠 binding 指過去的,重裝會被指到**新建的空 KV** ⇒ 帳密憑空消失。
|
||||
// 這是「KV=暫存、非長期真相源」第三次被違反,而這一次違反的是大門的鎖。
|
||||
// 現改存進認證儲存(Workers Secrets,不靠 binding);舊 KV 只保留為回退讀路徑,
|
||||
// 讀到就順手搬過去(見 loadCredentials)。
|
||||
import {
|
||||
AuthStoreWriteError,
|
||||
authStoreStatus,
|
||||
hydrateFromAccelerator,
|
||||
mutateAuthStore,
|
||||
readAuthStore,
|
||||
type AuthConsoleRecord,
|
||||
} from '../lib/portal-auth-store';
|
||||
|
||||
export const consoleAuthRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
@@ -70,16 +83,72 @@ function tenantOf(c: { env: Bindings }): string {
|
||||
return c.env.CONSOLE_TENANT || 'leo';
|
||||
}
|
||||
|
||||
// ── D61:帳密的家 ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 讀出 console 管理員帳密。**新家(Workers Secrets)優先**;沒有才回退舊家(KV),
|
||||
* 且一旦從舊家讀到就順手搬過去(best-effort,搬不動不影響本次登入)。
|
||||
*/
|
||||
async function loadCredentials(env: Bindings): Promise<{ creds: StoredCredentials | null; source: 'secrets' | 'legacy-kv' | 'none' }> {
|
||||
let fromStore = readAuthStore(env).console;
|
||||
if (!fromStore && (await hydrateFromAccelerator(env))) {
|
||||
// 剛設定完帳密、secret 的新版本還沒鋪到這顆 isolate(實測有 15 秒以上的窗口)
|
||||
// → 先問一次加速器,免得「剛設好就說你沒設過」。細節見 lib 的 ACCEL_KEY 註解。
|
||||
fromStore = readAuthStore(env).console;
|
||||
}
|
||||
if (fromStore) return { creds: fromStore, source: 'secrets' };
|
||||
|
||||
const raw = await env.SESSIONS_KV.get(CREDS_KEY);
|
||||
if (!raw) return { creds: null, source: 'none' };
|
||||
let legacy: StoredCredentials | null = null;
|
||||
try {
|
||||
legacy = JSON.parse(raw) as StoredCredentials;
|
||||
} catch {
|
||||
return { creds: null, source: 'none' };
|
||||
}
|
||||
try {
|
||||
await mutateAuthStore(env, (data) => {
|
||||
if (!data.console) data.console = legacy as AuthConsoleRecord;
|
||||
});
|
||||
} catch {
|
||||
/* 搬不動就照舊用 KV 這份(狀態看 /health 的 auth_store) */
|
||||
}
|
||||
return { creds: legacy, source: 'legacy-kv' };
|
||||
}
|
||||
|
||||
/** 寫入 console 管理員帳密——**只寫新家**,不再寫 KV(寫回去等於把病種回土裡)。 */
|
||||
async function saveCredentials(env: Bindings, record: StoredCredentials): Promise<void> {
|
||||
await mutateAuthStore(env, (data) => {
|
||||
data.console = record;
|
||||
});
|
||||
}
|
||||
|
||||
// GET /console/auth-status — 前端用來決定顯示「首次設定」還是「登入」表單。不洩漏 email。
|
||||
consoleAuthRouter.get('/console/auth-status', async (c) => {
|
||||
const existing = await c.env.SESSIONS_KV.get(CREDS_KEY);
|
||||
return c.json({ configured: !!existing });
|
||||
const { creds, source } = await loadCredentials(c.env);
|
||||
// D61:多回一個 auth_store 區塊——「認證住在哪、寫不寫得進去」要在實例自己這一側看得出來,
|
||||
// 不是等用戶登不進去才發現(#10「寧可明顯失敗,不要靜默錯置」)。
|
||||
return c.json({ configured: !!creds, credentials_source: source, auth_store: authStoreStatus(c.env) });
|
||||
});
|
||||
|
||||
// POST /console/setup — 首次設定帳密(body: {email, password})。已設定過 → 409(不可覆蓋,防外人搶注)。
|
||||
consoleAuthRouter.post('/console/setup', async (c) => {
|
||||
const existing = await c.env.SESSIONS_KV.get(CREDS_KEY);
|
||||
if (existing) return c.json({ error: '已設定過帳密,請改用登入;要換帳密請用 /console/setup/reset(需舊密碼)' }, 409);
|
||||
const { creds: existing } = await loadCredentials(c.env);
|
||||
if (existing) {
|
||||
// D61 明顯失敗:舊版只說「已設定過」,**沒說剛才填的那組密碼被整個丟掉了**——
|
||||
// 用戶(含安裝精靈裡的 leo)以為自己剛設好了新密碼,其實從頭到尾沒有被採用過。
|
||||
return c.json(
|
||||
{
|
||||
error:
|
||||
'這台實例已經有管理員帳密了,**你剛才輸入的密碼沒有被採用**,目前的密碼仍是當初設定的那一組。' +
|
||||
'要用舊密碼登入,或用 /console/setup/reset(需要舊密碼)換一組。',
|
||||
code: 'already_configured',
|
||||
password_applied: false,
|
||||
reset_path: '/console/setup/reset',
|
||||
},
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const email = (body?.email ?? '').trim();
|
||||
@@ -90,7 +159,13 @@ consoleAuthRouter.post('/console/setup', async (c) => {
|
||||
const salt = randomHex(16);
|
||||
const hash = await hashPassword(password, salt);
|
||||
const record: StoredCredentials = { email: email.toLowerCase(), salt, hash, created_at: new Date().toISOString() };
|
||||
await c.env.SESSIONS_KV.put(CREDS_KEY, JSON.stringify(record));
|
||||
try {
|
||||
await saveCredentials(c.env, record);
|
||||
} catch (e) {
|
||||
// 寫不進去就誠實回報(不假綠:舊版寫 KV 幾乎不會失敗,於是沒人處理過這條路)
|
||||
const msg = e instanceof AuthStoreWriteError ? e.message : String(e);
|
||||
return c.json({ error: `帳密沒有存起來:${msg}`, code: 'auth_store_not_writable' }, 502);
|
||||
}
|
||||
|
||||
const token = randomHex(32);
|
||||
await c.env.SESSIONS_KV.put(`${SESSION_PREFIX}${token}`, JSON.stringify({ created_at: Date.now() }), {
|
||||
@@ -101,9 +176,8 @@ consoleAuthRouter.post('/console/setup', async (c) => {
|
||||
|
||||
// POST /console/setup/reset — 換帳密(body: {current_password, email, password})。需驗舊密碼,防外人重設。
|
||||
consoleAuthRouter.post('/console/setup/reset', async (c) => {
|
||||
const raw = await c.env.SESSIONS_KV.get(CREDS_KEY);
|
||||
if (!raw) return c.json({ error: '尚未設定過,請用 /console/setup' }, 400);
|
||||
const existing = JSON.parse(raw) as StoredCredentials;
|
||||
const { creds: existing } = await loadCredentials(c.env);
|
||||
if (!existing) return c.json({ error: '尚未設定過,請用 /console/setup' }, 400);
|
||||
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const currentPassword = body?.current_password ?? '';
|
||||
@@ -118,23 +192,48 @@ consoleAuthRouter.post('/console/setup/reset', async (c) => {
|
||||
const salt = randomHex(16);
|
||||
const hash = await hashPassword(password, salt);
|
||||
const record: StoredCredentials = { email: email.toLowerCase(), salt, hash, created_at: existing.created_at };
|
||||
await c.env.SESSIONS_KV.put(CREDS_KEY, JSON.stringify(record));
|
||||
try {
|
||||
await saveCredentials(c.env, record);
|
||||
} catch (e) {
|
||||
const msg = e instanceof AuthStoreWriteError ? e.message : String(e);
|
||||
return c.json({ error: `新帳密沒有存起來:${msg}`, code: 'auth_store_not_writable' }, 502);
|
||||
}
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
// POST /console/login — body: {email, password}。成功 → session token(localStorage 存這個,不存密碼)。
|
||||
consoleAuthRouter.post('/console/login', async (c) => {
|
||||
const raw = await c.env.SESSIONS_KV.get(CREDS_KEY);
|
||||
if (!raw) return c.json({ error: '尚未設定帳密,請先完成首次設定' }, 400);
|
||||
const existing = JSON.parse(raw) as StoredCredentials;
|
||||
const { creds: existing } = await loadCredentials(c.env);
|
||||
if (!existing) {
|
||||
// D61 明顯失敗:這是「這台實例讀不到認證資料」,不是「你帳密打錯」
|
||||
return c.json(
|
||||
{
|
||||
error: '這台實例還沒有管理員帳密(或讀不到)——不是密碼錯。請先完成首次設定。',
|
||||
code: 'auth_store_empty',
|
||||
auth_store: authStoreStatus(c.env),
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const email = (body?.email ?? '').trim().toLowerCase();
|
||||
const password = body?.password ?? '';
|
||||
if (!email || !password) return c.json({ error: 'email 與 password 必填' }, 400);
|
||||
|
||||
const hash = await hashPassword(password, existing.salt);
|
||||
if (email !== existing.email || hash !== existing.hash) {
|
||||
let creds = existing;
|
||||
let hash = await hashPassword(password, creds.salt);
|
||||
if (email !== creds.email || hash !== creds.hash) {
|
||||
// D61:剛改完帳密、secret 新版本還沒鋪開的窗口 → 問一次加速器再判失敗
|
||||
if (await hydrateFromAccelerator(c.env)) {
|
||||
const again = (await loadCredentials(c.env)).creds;
|
||||
if (again) {
|
||||
creds = again;
|
||||
hash = await hashPassword(password, creds.salt);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (email !== creds.email || hash !== creds.hash) {
|
||||
return c.json({ error: 'email 或密碼錯誤' }, 401);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,24 +7,41 @@
|
||||
* 寫入(POST 建立 / PUT 覆寫):
|
||||
* 1. 密文值 PUT 進 CF Workers per-script Secrets(掛在本 worker 上,管理 API 唯寫,
|
||||
* arcrun 自己也讀不回值——D19「不持有內容物」)。
|
||||
* 2. D1 `credentials` 表只寫「目錄」(api_key/name/service/sensitivity/secret_ref/
|
||||
* created_at),**不含密文**。
|
||||
* 2. 目錄(api_key/name/service/sensitivity/secret_ref/created_at/last_used_at,
|
||||
* **不含密文**)走 KBDB HTTP API 寫,不再直連任何 D1。
|
||||
* 不再寫 KV / 不再寫明文密文到 D1。
|
||||
*
|
||||
* 傳輸格式:client **不做** AES-GCM 加密,明文值經 TLS 送到 cypher,cypher 短暫在記憶體
|
||||
* 經手明文(不落地、不持久、不持金鑰)後直接 PUT 進 Workers Secrets。(此為 2026-07-03
|
||||
* 定案並已落地的做法,取代更早的 `{name, encrypted, iv}` 格式;rule 01 已同步。)
|
||||
*
|
||||
* D38 圍牆修復(總管交辦,2026-08-07;leo「任何東西禁止用 SQL 語句存取資料,一律 API」):
|
||||
* 目錄舊家是 KBDB 裡多開的一張獨立 credentials 表(0002_credentials.sql,違規),現改走
|
||||
* KBDB 三張核心表——entries 表一列(entry_type='credential',page_name=name 當冪等鍵,
|
||||
* owner_id=api_key 隔離租戶,其餘欄位打包進 metadata_json),template 定義見
|
||||
* kbdb/migrations/0005_credential_template.sql,舊表資料遷移+拆表見 0006。連法比照既有
|
||||
* execution-logger.ts / portal.ts 慣例:kbdbBase(env) 組 base+headers,直接 fetch KBDB
|
||||
* HTTP API,不經自己的 /kbdb/* proxy route(那支是給 CLI 用的,server 端直連 base 更省一跳)。
|
||||
*
|
||||
* 效能(D38 評估要求「帶數字」,見 system-dev/wiki/decisions-summary.md D38 段):
|
||||
* 熱路徑(auth-dispatcher.ts resolveSecretsFromNewHome,每次 workflow 執行都會查一次)原本
|
||||
* 直連 D1、零快取;改走 HTTP 後若一樣「每次查一次」延遲只會變差(多一趟公網往返)。這份
|
||||
* name→secret_ref 映射「幾乎不變」(D38 評估原話),故本檔加一個租戶級記憶體快取
|
||||
* (dirCache,per-isolate,TTL 60 秒),寫入(POST/PUT/DELETE)時主動失效,讓熱路徑多數
|
||||
* 命中零網路呼叫。見下方 getCredentialDirectory / invalidateCredentialCache。
|
||||
*
|
||||
* 治理端點:
|
||||
* - `GET /credentials`:改讀 D1(與 `/credentials/catalog` 共用同一份 query,同時保留
|
||||
* `/catalog` 別名,Console 既有呼叫不受影響)。
|
||||
* - `DELETE /credentials/:name`:先查 D1 拿 secret_ref → 有則刪 Workers Secret + D1 row;
|
||||
* 沒有(credential 從未回填過,只存在舊 KV)→ fallback 刪舊 KV key,避免刪不掉的孤兒資料。
|
||||
* - `GET /credentials`:改讀 KBDB entries(與 `/credentials/catalog` 共用同一份查詢,同時
|
||||
* 保留 `/catalog` 別名,Console 既有呼叫不受影響)。
|
||||
* - `DELETE /credentials/:name`:先查 KBDB 拿 secret_ref → 有則刪 Workers Secret + entries
|
||||
* row;沒有(credential 從未回填過,只存在舊 KV)→ fallback 刪舊 KV key,避免刪不掉的
|
||||
* 孤兒資料。
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { sha256Prefix } from '../lib/hash';
|
||||
import { kbdbBase } from './kbdb-proxy';
|
||||
|
||||
export const credentialsRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
@@ -61,7 +78,7 @@ export async function storeCredential(
|
||||
): Promise<void> {
|
||||
const secretRef = await deriveSecretRef(apiKey, name);
|
||||
await putWorkerSecret(env, secretRef, value);
|
||||
await upsertCredentialRow(env.CREDENTIALS_DB, apiKey, name, service, 'standard', secretRef);
|
||||
await upsertCredentialEntry(env, apiKey, name, service, 'standard', secretRef);
|
||||
}
|
||||
|
||||
function validateName(name: unknown): name is string {
|
||||
@@ -76,7 +93,7 @@ function validSensitivity(s: unknown): s is 'standard' | 'high' {
|
||||
* 呼叫 CF Workers Scripts secrets 管理 API,把明文值存進本 worker 的 per-script secret。
|
||||
* 唯寫:這支 API 不回傳任何既有 secret 的值,只能 create/update/delete/list 名字(D19 對齊)。
|
||||
*/
|
||||
async function putWorkerSecret(env: Bindings, secretRef: string, value: string): Promise<void> {
|
||||
export async function putWorkerSecret(env: Bindings, secretRef: string, value: string): Promise<void> {
|
||||
if (!env.CF_SECRETS_API_TOKEN || !env.CF_ACCOUNT_ID) {
|
||||
throw new Error(
|
||||
'此 worker 缺 CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID 設定,寫入路徑未就緒(見 ' +
|
||||
@@ -105,7 +122,7 @@ async function putWorkerSecret(env: Bindings, secretRef: string, value: string):
|
||||
* 呼叫 CF Workers Scripts secrets 管理 API 刪除一個 per-script secret(T9 治理端點用)。
|
||||
* 404(本來就不存在)視為成功(冪等刪除,呼叫端可能已被清過)。
|
||||
*/
|
||||
async function deleteWorkerSecret(env: Bindings, secretRef: string): Promise<void> {
|
||||
export async function deleteWorkerSecret(env: Bindings, secretRef: string): Promise<void> {
|
||||
if (!env.CF_SECRETS_API_TOKEN || !env.CF_ACCOUNT_ID) {
|
||||
throw new Error('此 worker 缺 CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID 設定,刪除路徑未就緒');
|
||||
}
|
||||
@@ -124,32 +141,197 @@ async function deleteWorkerSecret(env: Bindings, secretRef: string): Promise<voi
|
||||
}
|
||||
}
|
||||
|
||||
// ── KBDB 目錄存取(D38:零 SQL,一律走 entries HTTP API)──────────────────────────
|
||||
|
||||
const CREDENTIAL_ENTRY_TYPE = 'credential';
|
||||
|
||||
/** entries 表回來的一列(本檔只取用得到的欄位,避免耦合 KBDB 內部型別)。 */
|
||||
interface KbdbEntryRow {
|
||||
id: string;
|
||||
page_name: string | null;
|
||||
owner_id: string | null;
|
||||
metadata_json: string | null;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
interface CredentialMeta {
|
||||
service: string | null;
|
||||
sensitivity: 'standard' | 'high';
|
||||
secret_ref: string;
|
||||
last_used_at: number | null;
|
||||
}
|
||||
|
||||
/** name → secret_ref 對照(給熱路徑用;獨立型別別名,避免函式簽章直接內嵌逗號分隔泛型)。 */
|
||||
type CredentialRefMap = Record<string, string>;
|
||||
|
||||
function parseMeta(row: KbdbEntryRow): CredentialMeta {
|
||||
try {
|
||||
const m = row.metadata_json ? (JSON.parse(row.metadata_json) as Record<string, unknown>) : {};
|
||||
return {
|
||||
service: typeof m.service === 'string' ? m.service : null,
|
||||
sensitivity: m.sensitivity === 'high' ? 'high' : 'standard',
|
||||
secret_ref: typeof m.secret_ref === 'string' ? m.secret_ref : '',
|
||||
last_used_at: typeof m.last_used_at === 'number' ? m.last_used_at : null,
|
||||
};
|
||||
} catch {
|
||||
// 壞資料誠實視為空目錄列,不讓損毀的 metadata_json 炸整條路徑
|
||||
return { service: null, sensitivity: 'standard', secret_ref: '', last_used_at: null };
|
||||
}
|
||||
}
|
||||
|
||||
/** 對 KBDB base 發 request(server 端直連,不經 /kbdb/* proxy——那支是給 CLI 用的)。 */
|
||||
async function kbdbCredFetch(env: Bindings, path: string, init?: RequestInit): Promise<Response> {
|
||||
const { base, headers } = kbdbBase(env);
|
||||
return fetch(`${base}${path}`, {
|
||||
...init,
|
||||
headers: { ...headers, ...(init?.headers as Record<string, string> | undefined) },
|
||||
});
|
||||
}
|
||||
|
||||
// ── 熱路徑快取(D38 效能要求:這份映射幾乎不變,帶快取才不會比舊版 D1 直查慢)─────────
|
||||
//
|
||||
// per-isolate 記憶體快取,key=apiKey,TTL 60 秒。auth-dispatcher.ts 的
|
||||
// resolveSecretsFromNewHome() 每次 workflow 執行都會呼叫,命中快取=零網路呼叫;
|
||||
// 未命中才打一次 KBDB(一次列出該租戶全部 credential,通常個位數到十位數筆,遠比逐名查便宜)。
|
||||
// 寫入路徑(upsert/delete)主動 invalidate,保證「剛存的 credential 立刻查得到」不受 TTL 拖延。
|
||||
// 快取容器用 plain object——apiKey 皆為服務端衍生字串,非使用者可控鍵名。
|
||||
interface CachedDirRow {
|
||||
id: string;
|
||||
name: string;
|
||||
secret_ref: string;
|
||||
service: string | null;
|
||||
sensitivity: 'standard' | 'high';
|
||||
last_used_at: number | null;
|
||||
}
|
||||
interface CachedDir {
|
||||
rows: CachedDirRow[];
|
||||
fetchedAt: number;
|
||||
}
|
||||
const DIR_CACHE_TTL_MS = 60_000;
|
||||
const dirCache: Record<string, CachedDir> = {};
|
||||
|
||||
/** 寫入(建立/覆寫/刪除)後呼叫,讓下次熱路徑查詢重新打一次 KBDB(不吃到過期快取)。 */
|
||||
export function invalidateCredentialCache(apiKey: string): void {
|
||||
delete dirCache[apiKey];
|
||||
}
|
||||
|
||||
/** 拉某租戶全部 credential 目錄列(快取層,60 秒 TTL)。給熱路徑(auth-dispatcher)與治理端點共用。 */
|
||||
async function getCredentialDirectory(env: Bindings, apiKey: string): Promise<CachedDirRow[]> {
|
||||
const now = Date.now();
|
||||
const cached = dirCache[apiKey];
|
||||
if (cached && now - cached.fetchedAt < DIR_CACHE_TTL_MS) return cached.rows;
|
||||
|
||||
const qs = new URLSearchParams({ owner_id: apiKey, entry_type: CREDENTIAL_ENTRY_TYPE, limit: '200' });
|
||||
const res = await kbdbCredFetch(env, `/entries?${qs.toString()}`);
|
||||
if (!res.ok) {
|
||||
// KBDB 不可達 / 回錯:誠實回空(呼叫端各自決定 fallback,不快取失敗結果避免卡住恢復)
|
||||
return [];
|
||||
}
|
||||
const body = (await res.json().catch(() => null)) as { entries?: KbdbEntryRow[] } | null;
|
||||
const rows: CachedDirRow[] = (body?.entries ?? [])
|
||||
.filter((e): e is KbdbEntryRow & { page_name: string } => !!e.page_name)
|
||||
.map((e) => {
|
||||
const meta = parseMeta(e);
|
||||
return {
|
||||
id: e.id,
|
||||
name: e.page_name,
|
||||
secret_ref: meta.secret_ref,
|
||||
service: meta.service,
|
||||
sensitivity: meta.sensitivity,
|
||||
last_used_at: meta.last_used_at,
|
||||
};
|
||||
});
|
||||
dirCache[apiKey] = { rows, fetchedAt: now };
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* D1 upsert credential 目錄 row(不含密文)。
|
||||
* created_at 只在首次建立時寫入;覆寫(PUT/重複 POST)保留原 created_at,只更新
|
||||
* service/sensitivity/secret_ref(secret_ref 是純函式衍生自 api_key+name,理論上覆寫時
|
||||
* 值不會變,這裡仍寫入以求同一份 SQL 同時支援「首次建立」與「覆寫」兩種呼叫路徑)。
|
||||
* 給熱路徑(auth-dispatcher.ts)用:回這個租戶所有 credential 的 name→secret_ref 對照。
|
||||
* 快取命中=零網路呼叫;未命中打一次 KBDB list(見 getCredentialDirectory)。
|
||||
*/
|
||||
async function upsertCredentialRow(
|
||||
db: D1Database,
|
||||
export async function getCredentialSecretRefs(env: Bindings, apiKey: string): Promise<CredentialRefMap> {
|
||||
const rows = await getCredentialDirectory(env, apiKey);
|
||||
const out: CredentialRefMap = {};
|
||||
for (const r of rows) {
|
||||
if (r.secret_ref) out[r.name] = r.secret_ref;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 治理面 last_used_at 更新(非關鍵路徑,best-effort,不阻塞呼叫端)。
|
||||
* 直接用快取裡已知的 id/其餘欄位組 PATCH,不額外多打一次查詢。找不到快取(代表這個租戶
|
||||
* 本次請求根本沒查到目錄,不太可能發生——resolveSecretsFromNewHome 只在有 secret_ref 命中時
|
||||
* 才會呼叫本函式)就跳過,不為了治理欄位額外多打一輪 KBDB。
|
||||
* 呼叫端刻意不 await 本函式的內部 fetch(fire-and-forget,見 auth-dispatcher.ts),失敗吞掉。
|
||||
*/
|
||||
export function touchLastUsed(env: Bindings, apiKey: string, names: string[]): void {
|
||||
const cached = dirCache[apiKey];
|
||||
if (!cached || names.length === 0) return;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
for (const r of cached.rows) {
|
||||
if (!names.includes(r.name)) continue;
|
||||
const meta: CredentialMeta = {
|
||||
service: r.service, sensitivity: r.sensitivity, secret_ref: r.secret_ref, last_used_at: now,
|
||||
};
|
||||
kbdbCredFetch(env, `/entries/${encodeURIComponent(r.id)}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ metadata_json: JSON.stringify(meta) }),
|
||||
}).catch(() => { /* 治理面欄位,非關鍵路徑,失敗不影響任何主流程 */ });
|
||||
r.last_used_at = now; // 快取內同步更新,避免同一 TTL 視窗內下一次讀到舊值
|
||||
}
|
||||
}
|
||||
|
||||
/** 找某租戶某 credential 的 entry(page_name=name 精確比對,entry_type=credential 隔離)。 */
|
||||
async function findCredentialEntry(env: Bindings, apiKey: string, name: string): Promise<KbdbEntryRow | null> {
|
||||
const qs = new URLSearchParams({
|
||||
owner_id: apiKey, entry_type: CREDENTIAL_ENTRY_TYPE, page_name: name, limit: '1',
|
||||
});
|
||||
const res = await kbdbCredFetch(env, `/entries?${qs.toString()}`);
|
||||
if (!res.ok) throw new Error(`KBDB /entries 查詢失敗:HTTP ${res.status}`);
|
||||
const body = (await res.json().catch(() => null)) as { entries?: KbdbEntryRow[] } | null;
|
||||
return body?.entries?.[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* upsert credential 目錄列(不含密文)。
|
||||
* created_at 只在首次建立時寫入(entries 表自帶 created_at,PATCH 不會動它);
|
||||
* last_used_at 覆寫時保留原值——secret_ref 是純函式衍生自 api_key+name,理論上覆寫時值不會
|
||||
* 變,這裡仍走同一條寫入路徑以求同時支援「首次建立」與「覆寫」兩種呼叫路徑(比照舊 D1 版本)。
|
||||
*/
|
||||
async function upsertCredentialEntry(
|
||||
env: Bindings,
|
||||
apiKey: string,
|
||||
name: string,
|
||||
service: string | null,
|
||||
sensitivity: 'standard' | 'high',
|
||||
secretRef: string,
|
||||
): Promise<void> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO credentials (api_key, name, service, sensitivity, secret_ref, created_at, last_used_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, NULL)
|
||||
ON CONFLICT(api_key, name) DO UPDATE SET
|
||||
service = excluded.service,
|
||||
sensitivity = excluded.sensitivity,
|
||||
secret_ref = excluded.secret_ref`,
|
||||
)
|
||||
.bind(apiKey, name, service, sensitivity, secretRef, now)
|
||||
.run();
|
||||
const existing = await findCredentialEntry(env, apiKey, name);
|
||||
const meta: CredentialMeta = {
|
||||
service, sensitivity, secret_ref: secretRef,
|
||||
last_used_at: existing ? parseMeta(existing).last_used_at : null,
|
||||
};
|
||||
if (existing) {
|
||||
const res = await kbdbCredFetch(env, `/entries/${encodeURIComponent(existing.id)}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ metadata_json: JSON.stringify(meta) }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`credential 目錄更新失敗:HTTP ${res.status}`);
|
||||
} else {
|
||||
const res = await kbdbCredFetch(env, `/entries`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
entry_type: CREDENTIAL_ENTRY_TYPE, owner_id: apiKey, page_name: name,
|
||||
metadata_json: JSON.stringify(meta),
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`credential 目錄建立失敗:HTTP ${res.status}`);
|
||||
}
|
||||
invalidateCredentialCache(apiKey);
|
||||
}
|
||||
|
||||
interface CredentialRow {
|
||||
@@ -160,25 +342,26 @@ interface CredentialRow {
|
||||
last_used_at: number | null;
|
||||
}
|
||||
|
||||
/** D1 目錄 list(不含 secret_ref、不含值)——`GET /credentials` 與 `/credentials/catalog` 共用。 */
|
||||
async function listCredentialRows(db: D1Database, apiKey: string): Promise<CredentialRow[]> {
|
||||
const rows = await db
|
||||
.prepare(
|
||||
`SELECT name, service, sensitivity, created_at, last_used_at
|
||||
FROM credentials WHERE api_key = ? ORDER BY created_at DESC`,
|
||||
)
|
||||
.bind(apiKey)
|
||||
.all<CredentialRow>();
|
||||
return rows.results ?? [];
|
||||
/** KBDB 目錄 list(不含 secret_ref、不含值)——`GET /credentials` 與 `/credentials/catalog` 共用。 */
|
||||
async function listCredentialRows(env: Bindings, apiKey: string): Promise<CredentialRow[]> {
|
||||
const qs = new URLSearchParams({ owner_id: apiKey, entry_type: CREDENTIAL_ENTRY_TYPE, limit: '200' });
|
||||
const res = await kbdbCredFetch(env, `/entries?${qs.toString()}`);
|
||||
if (!res.ok) throw new Error(`credential 目錄查詢失敗:HTTP ${res.status}`);
|
||||
const body = (await res.json().catch(() => null)) as { entries?: KbdbEntryRow[] } | null;
|
||||
const rows = (body?.entries ?? [])
|
||||
.filter((e): e is KbdbEntryRow & { page_name: string } => !!e.page_name)
|
||||
.map((e) => {
|
||||
const meta = parseMeta(e);
|
||||
return { name: e.page_name, service: meta.service, sensitivity: meta.sensitivity, created_at: e.created_at, last_used_at: meta.last_used_at };
|
||||
});
|
||||
// entries API 已用 created_at DESC 排序,這裡不重排(保持與舊版 D1 query 相同排序語意)
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** 查單一 credential 的 secret_ref(治理端點刪除用;不對外回傳 secret_ref 本身,只內部使用)。 */
|
||||
async function findSecretRef(db: D1Database, apiKey: string, name: string): Promise<string | null> {
|
||||
const row = await db
|
||||
.prepare(`SELECT secret_ref FROM credentials WHERE api_key = ? AND name = ?`)
|
||||
.bind(apiKey, name)
|
||||
.first<{ secret_ref: string }>();
|
||||
return row?.secret_ref ?? null;
|
||||
/** 給 `GET /portal/admin/ai` 之類「只要知道有沒有存過、不要值」的呼叫端用。 */
|
||||
export async function hasCredential(env: Bindings, apiKey: string, name: string): Promise<boolean> {
|
||||
const entry = await findCredentialEntry(env, apiKey, name);
|
||||
return entry !== null;
|
||||
}
|
||||
|
||||
interface CredentialWriteBody {
|
||||
@@ -203,13 +386,13 @@ async function writeCredential(
|
||||
// 1. 密文值進 Workers Secrets(唯寫,arcrun 自己也讀不回)
|
||||
await putWorkerSecret(env, secretRef, value);
|
||||
|
||||
// 2. D1 目錄(不含密文)
|
||||
await upsertCredentialRow(env.CREDENTIALS_DB, apiKey, name, service ?? null, sensitivity, secretRef);
|
||||
// 2. KBDB 目錄(不含密文)
|
||||
await upsertCredentialEntry(env, apiKey, name, service ?? null, sensitivity, secretRef);
|
||||
|
||||
return { secretRef, sensitivity };
|
||||
}
|
||||
|
||||
// POST /credentials — 建立/覆寫 credential(新家:Workers Secrets + D1 目錄)
|
||||
// POST /credentials — 建立/覆寫 credential(新家:Workers Secrets + KBDB entries 目錄)
|
||||
credentialsRouter.post('/credentials', async (c) => {
|
||||
const apiKey = c.req.header('X-Arcrun-API-Key');
|
||||
if (!apiKey) {
|
||||
@@ -272,17 +455,17 @@ credentialsRouter.delete('/credentials/:name', async (c) => {
|
||||
|
||||
const name = c.req.param('name');
|
||||
try {
|
||||
const secretRef = await findSecretRef(c.env.CREDENTIALS_DB, apiKey, name);
|
||||
if (secretRef) {
|
||||
await deleteWorkerSecret(c.env, secretRef);
|
||||
await c.env.CREDENTIALS_DB
|
||||
.prepare(`DELETE FROM credentials WHERE api_key = ? AND name = ?`)
|
||||
.bind(apiKey, name)
|
||||
.run();
|
||||
const entry = await findCredentialEntry(c.env, apiKey, name);
|
||||
if (entry) {
|
||||
const meta = parseMeta(entry);
|
||||
if (meta.secret_ref) await deleteWorkerSecret(c.env, meta.secret_ref);
|
||||
const res = await kbdbCredFetch(c.env, `/entries/${encodeURIComponent(entry.id)}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(`credential 目錄刪除失敗:HTTP ${res.status}`);
|
||||
invalidateCredentialCache(apiKey);
|
||||
return c.json({ success: true, name, source: 'workers-secrets' });
|
||||
}
|
||||
// D1 沒有 row:這個 credential 可能從未回填過(只存在舊 KV),fallback 刪舊路徑,
|
||||
// 避免「GET 改讀 D1 看不到、DELETE 卻刪不掉」的孤兒資料。
|
||||
// KBDB 沒有這筆 entry:這個 credential 可能從未回填過(只存在舊 KV),fallback 刪舊路徑,
|
||||
// 避免「GET 改讀新家看不到、DELETE 卻刪不掉」的孤兒資料。
|
||||
await c.env.CREDENTIALS_KV.delete(`${apiKey}:cred:${name}`);
|
||||
return c.json({ success: true, name, source: 'legacy-kv' });
|
||||
} catch (e) {
|
||||
@@ -290,8 +473,8 @@ credentialsRouter.delete('/credentials/:name', async (c) => {
|
||||
}
|
||||
});
|
||||
|
||||
// GET /credentials/catalog — D1 目錄唯讀 list(Mira Console 完整版,Arcrun#3 console 系)。
|
||||
// 與 GET /credentials(下方,T9 起改讀同一份 D1 查詢)是同一份資料的兩個路徑;
|
||||
// GET /credentials/catalog — 目錄唯讀 list(Mira Console 完整版,Arcrun#3 console 系)。
|
||||
// 與 GET /credentials(下方,改讀同一份 KBDB 查詢)是同一份資料的兩個路徑;
|
||||
// /catalog 保留給既有 Console 呼叫,避免破壞既有前端整合。
|
||||
credentialsRouter.get('/credentials/catalog', async (c) => {
|
||||
const apiKey = c.req.header('X-Arcrun-API-Key');
|
||||
@@ -299,22 +482,22 @@ credentialsRouter.get('/credentials/catalog', async (c) => {
|
||||
return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
|
||||
}
|
||||
try {
|
||||
const rows = await listCredentialRows(c.env.CREDENTIALS_DB, apiKey);
|
||||
const rows = await listCredentialRows(c.env, apiKey);
|
||||
return c.json({ success: true, credentials: rows, total: rows.length });
|
||||
} catch (e) {
|
||||
// 誠實回報:D1 未建表 / migration 未跑(不假綠回空陣列裝沒事)
|
||||
// 誠實回報:KBDB 不可達 / 回錯(不假綠回空陣列裝沒事)
|
||||
return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 502);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /credentials — 列出 credential 目錄(T9:改讀 D1,只回 metadata,絕不含值/secret_ref)
|
||||
// GET /credentials — 列出 credential 目錄(改讀 KBDB,只回 metadata,絕不含值/secret_ref)
|
||||
credentialsRouter.get('/credentials', async (c) => {
|
||||
const apiKey = c.req.header('X-Arcrun-API-Key');
|
||||
if (!apiKey) {
|
||||
return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
|
||||
}
|
||||
try {
|
||||
const rows = await listCredentialRows(c.env.CREDENTIALS_DB, apiKey);
|
||||
const rows = await listCredentialRows(c.env, apiKey);
|
||||
return c.json({ success: true, credentials: rows, total: rows.length });
|
||||
} catch (e) {
|
||||
return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 502);
|
||||
|
||||
@@ -1,16 +1,55 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { handleCypherSearch, handleCypherExecute } from '../actions/cypher-handlers';
|
||||
import { searchByTarget } from '../actions/target-search';
|
||||
|
||||
export const cypherRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
const VALID_TARGETS = new Set(['component', 'recipe', 'workflow']);
|
||||
|
||||
// POST /cypher/search — 三元組 → 解析節點 → 語意搜尋零件 → 回傳 Cypher JSON (開發友善格式)
|
||||
//
|
||||
// t159(leo 07-31):加 `target` 指定搜尋對象(component/recipe/workflow)+`query` 名字搜尋。
|
||||
// - triplets(不給 target)=混搜兩庫+意圖節點替換(步驟 4)
|
||||
// - triplets + target=component|recipe=只查該庫
|
||||
// - query + target=名字搜尋,各自走**既有**機制(registry search/私庫 RECIPES/workflows/search)
|
||||
cypherRouter.post('/cypher/search', async (c) => {
|
||||
const body = await c.req.json() as { triplets?: unknown };
|
||||
const body = await c.req.json() as { triplets?: unknown; mode?: unknown; target?: unknown; query?: unknown };
|
||||
const rawTriplets = body?.triplets;
|
||||
|
||||
// ── target 驗證(component / recipe / workflow)─────────────────────────────
|
||||
const target = typeof body?.target === 'string' ? body.target : undefined;
|
||||
if (target !== undefined && !VALID_TARGETS.has(target)) {
|
||||
return c.json({ error: `target 只接受 component/recipe/workflow,收到「${target}」` }, 400);
|
||||
}
|
||||
|
||||
// ── query 名字搜尋分支(需 target)──────────────────────────────────────────
|
||||
const query = typeof body?.query === 'string' ? body.query.trim() : '';
|
||||
if (query) {
|
||||
if (!target) {
|
||||
return c.json({ error: '給 query 必須同時給 target(component/recipe/workflow),指明要搜哪個庫' }, 400);
|
||||
}
|
||||
const apiKey = c.req.header('X-Arcrun-API-Key') ?? undefined;
|
||||
const r = await searchByTarget(target as 'component' | 'recipe' | 'workflow', query, c.env, apiKey);
|
||||
if (!r.ok) return c.json({ error: r.error }, r.status);
|
||||
return c.json(r.body);
|
||||
}
|
||||
|
||||
if (!Array.isArray(rawTriplets) || rawTriplets.length === 0) {
|
||||
return c.json({ error: 'triplets 必須為非空字串陣列' }, 400);
|
||||
return c.json({ error: 'triplets 必須為非空字串陣列(或給 query + target 做名字搜尋)' }, 400);
|
||||
}
|
||||
|
||||
// t158「部署≠發現」:mode=compile=純編圖(安裝器/acr push 的複製路徑,零存在性查詢);
|
||||
// 預設 discover=誠實查詢(AI 問「有沒有」的既有契約,not_found+指路照舊)。
|
||||
const mode = body?.mode === 'compile' ? 'compile' : 'discover';
|
||||
|
||||
// target 限庫只屬於 discover(compile=純複製,不查任何庫,target 無意義)
|
||||
if (target && mode === 'compile') {
|
||||
return c.json({ error: 'mode=compile(複製路徑)不查庫,不接受 target;要指定搜尋對象請用 discover(預設)' }, 400);
|
||||
}
|
||||
// workflow 是名字搜尋,不參與三元組編圖——請帶 query
|
||||
if (target === 'workflow') {
|
||||
return c.json({ error: 'target=workflow 是名字搜尋,請改帶 { target: "workflow", query: "..." }(不吃 triplets)' }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -18,7 +57,7 @@ cypherRouter.post('/cypher/search', async (c) => {
|
||||
const timestamp = now.toISOString();
|
||||
const versionId = `search-v1-${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}-${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`;
|
||||
|
||||
const result = await handleCypherSearch(rawTriplets, c.env);
|
||||
const result = await handleCypherSearch(rawTriplets, c.env, mode, target as 'component' | 'recipe' | undefined);
|
||||
|
||||
const response = {
|
||||
version: versionId,
|
||||
|
||||
@@ -27,14 +27,14 @@ executeRouter.post('/execute', async (c) => {
|
||||
const result = await executor.execute(graph as ExecutionGraph, context, c.env.EXEC_CONTEXT);
|
||||
const duration_ms = Date.now() - start;
|
||||
c.executionCtx.waitUntil(
|
||||
writeExecutionVerdict(c.env, graph.id, graph.nodes, 'success', duration_ms, '執行完成')
|
||||
writeExecutionVerdict(c.env, graph.id, graph.nodes, 'success', duration_ms, '執行完成', context, apiKey)
|
||||
);
|
||||
return c.json({ success: true, data: result.data, trace: result.trace, duration_ms });
|
||||
} catch (err) {
|
||||
const duration_ms = Date.now() - start;
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
c.executionCtx.waitUntil(
|
||||
writeExecutionVerdict(c.env, graph.id, graph.nodes, 'failed', duration_ms, errMsg.slice(0, 100))
|
||||
writeExecutionVerdict(c.env, graph.id, graph.nodes, 'failed', duration_ms, errMsg.slice(0, 100), context, apiKey)
|
||||
);
|
||||
if (err instanceof ExecutionError) {
|
||||
const traceFormatted = err.trace.map(s => ({
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { listPausedRunsByApiKey } from '../lib/paused-runs';
|
||||
import { kbdbBase } from './kbdb-proxy';
|
||||
|
||||
export const executionsRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
@@ -132,11 +133,13 @@ executionsRouter.get('/executions/:task_id', async (c) => {
|
||||
/**
|
||||
* GET /workflows/:name/executions — 看某 workflow 最近 N 次執行 verdict
|
||||
*
|
||||
* 走 ANALYTICS_KV `stats:{workflowId}:*` prefix scan。
|
||||
* KV 額度事故修復(2026-08-07):改打 KBDB `GET /execution-log`(原走 ANALYTICS_KV
|
||||
* `stats:{workflowId}:*` prefix scan,免費層 list 也是 1,000/日,裝十幾支 workflow
|
||||
* 的實例刷 90 次 portal 就見底)。KBDB=API-as-Wall(leo 2026-06-14):本路由**不直連
|
||||
* 任何 D1**,一律走 HTTP,連法比照既有 kbdbBase() 慣例(kbdb-proxy.ts)。
|
||||
*
|
||||
* workflowId 等於 webhook name(execution-logger 寫入時用 graph.id ?? name)。
|
||||
*
|
||||
* 限制:ANALYTICS_KV list 沒辦法依 timestamp 排序,只能拿 key 後段 timestamp 排。
|
||||
* workflowId 等於 webhook name(execution-logger 寫入時用 graph.id ?? name,與舊 KV
|
||||
* key 同語意,沿用既有限制不在這次修復裡處理)。
|
||||
*/
|
||||
executionsRouter.get('/workflows/:name/executions', async (c) => {
|
||||
const apiKey = c.req.header('X-Arcrun-API-Key');
|
||||
@@ -164,30 +167,21 @@ executionsRouter.get('/workflows/:name/executions', async (c) => {
|
||||
}, 404);
|
||||
}
|
||||
|
||||
// 撈 stats:{name}:* 全 list(每個 key 含 timestamp 後綴)
|
||||
const list = await c.env.ANALYTICS_KV.list({ prefix: `stats:${name}:`, limit: 1000 });
|
||||
const { base, headers } = kbdbBase(c.env);
|
||||
const params = new URLSearchParams({ workflow_id: name, owner_id: apiKey, limit: String(limit) });
|
||||
const kbdbRes = await fetch(`${base}/execution-log?${params.toString()}`, { headers });
|
||||
const kbdbBody = await kbdbRes.json().catch(() => null) as { success?: boolean; executions?: Array<{
|
||||
verdict: string; duration_ms: number; message: string; target?: string; recorded_at: number;
|
||||
}> } | null;
|
||||
|
||||
// 按 timestamp 降序(key suffix 是 unix ms)
|
||||
const sorted = [...list.keys].sort((a, b) => {
|
||||
const ta = parseInt(a.name.split(':').pop() ?? '0', 10);
|
||||
const tb = parseInt(b.name.split(':').pop() ?? '0', 10);
|
||||
return tb - ta;
|
||||
}).slice(0, limit);
|
||||
|
||||
const executions = [];
|
||||
for (const key of sorted) {
|
||||
const raw = await c.env.ANALYTICS_KV.get(key.name);
|
||||
if (!raw) continue;
|
||||
try {
|
||||
const record = JSON.parse(raw);
|
||||
executions.push({
|
||||
timestamp: key.name.split(':').pop(),
|
||||
...record,
|
||||
});
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
const executions = (kbdbRes.ok && kbdbBody?.success ? kbdbBody.executions ?? [] : []).map((r) => ({
|
||||
timestamp: String(r.recorded_at),
|
||||
workflow_id: name,
|
||||
verdict: r.verdict,
|
||||
duration_ms: r.duration_ms,
|
||||
message: r.message ?? '',
|
||||
...(r.target ? { target: r.target } : {}),
|
||||
}));
|
||||
|
||||
return c.json({
|
||||
ok: true,
|
||||
@@ -197,7 +191,7 @@ executionsRouter.get('/workflows/:name/executions', async (c) => {
|
||||
executions,
|
||||
},
|
||||
hints: executions.length === 0
|
||||
? ['尚未有任何執行紀錄(或都過了 90d TTL)。先 call /webhooks/named/:name/trigger 跑一次']
|
||||
? ['尚未有任何執行紀錄。先 call /webhooks/named/:name/trigger 跑一次']
|
||||
: [`最近 ${executions.length} 次。看到 verdict=failed 的,call /executions/:task_id 看 paused state 或繼續 debug`],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,28 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { authStoreStatus } from '../lib/portal-auth-store';
|
||||
|
||||
export const healthRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
healthRouter.get('/health', (c) =>
|
||||
c.json({ ok: true })
|
||||
);
|
||||
// t162(leo 07-31 實撞:「小幫手一直顯示知識庫需要更新…重新更新後並不會消失」):
|
||||
// daemon cloudVersionStale() 讀 /health 的 `bundle_version` 判斷是否過舊——
|
||||
// 但本端點過去只回 {ok:true},**從沒吐這個欄位** ⇒ daemon 恆讀到空字串
|
||||
// ⇒ 恆判 stale ⇒ 假警報永遠不消失(安裝器其實一直有注入 ARCRUN_BUNDLE_VERSION var,
|
||||
// 只是沒有人把它吐出來)。修=誠實回報本實例的 bundle 版本。
|
||||
// 未注入(本地 dev/很舊的實例)就省略該欄——daemon 對空字串仍判 stale,
|
||||
// 那是**正確的**(真的是老實例,該更新)。
|
||||
// D61(ADR D61 / Leo/arcrun-rag#55):多吐一個 `auth_store`——「認證住哪、寫不寫得進去」
|
||||
// 要在實例自己這一側就看得出來,不是等用戶登不進去才發現(#10「寧可明顯失敗」)。
|
||||
// 只回統計不回內容(帳號數/有沒有 console 帳密/分片數),不洩漏任何 email 或雜湊。
|
||||
// bundle_version 的既有行為不動(未注入就省略該欄——daemon 對空字串判 stale 是正確的)。
|
||||
healthRouter.get('/health', (c) => {
|
||||
const bundleVersion = c.env.ARCRUN_BUNDLE_VERSION;
|
||||
return c.json({
|
||||
ok: true,
|
||||
...(bundleVersion ? { bundle_version: bundleVersion } : {}),
|
||||
auth_store: authStoreStatus(c.env),
|
||||
});
|
||||
});
|
||||
|
||||
healthRouter.get('/', (c) =>
|
||||
c.json({
|
||||
|
||||
@@ -50,6 +50,13 @@ initSeedRouter.post('/init/seed', async (c) => {
|
||||
endpoint: seed.endpoint,
|
||||
method: (seed.method ?? 'POST').toUpperCase(),
|
||||
auth_service: seed.auth_service,
|
||||
// ③ payload/回應/binding 三層(3.12):不列進來的欄位會被**靜默吃掉**——
|
||||
// 種子帶了 body_template/response_map/auth 卻沒進 KV,症狀是 recipe 存在但跑起來
|
||||
// 「像沒設定過」,且哪裡都不會紅(08-02 manifest.daemon 欄被列舉式重建吃掉的同型)。
|
||||
body_template: seed.body_template,
|
||||
response_map: seed.response_map,
|
||||
auth: seed.auth,
|
||||
binding_name: seed.binding_name,
|
||||
created_at: existing?.created_at ?? now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { Context } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { kbdbFetch, run, requirePortalUser, parseLibraries, portalTenant, hasGraphAccess, workflowsVisible, uploadEnabled } from './portal';
|
||||
import { kbdbFetch, run, requirePortalUser, parseLibraries, portalTenant, hasGraphAccess, workflowsVisible, uploadEnabled, buildDiagnostics } from './portal';
|
||||
import { graphBase } from './kbdb-proxy';
|
||||
import { executeWebhookGraph } from '../actions/webhook-handlers';
|
||||
|
||||
@@ -73,6 +73,32 @@ export function mapGraphWorkflowOutput(data: unknown): { neighbors: unknown[]; e
|
||||
return { neighbors, edges, count: neighbors.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* 出處清單按 page_name 去重(t129):
|
||||
* rag_chat workflow 把同一張卡拆成多個 block,每個 block 各回一筆 source(同頁名)→ 前端列一整頁重複。
|
||||
* 後端去重:同一個 page_name / page 只保留第一筆,hit_count > 1 時附計數。
|
||||
* page_name 優先;page 備用;兩者皆無 → key 為空字串(歸為同一「無頁名」組)。
|
||||
* 純函式,單測用 export。
|
||||
*/
|
||||
export function dedupeSourcesByPage(sources: unknown[]): unknown[] {
|
||||
const seen = new Map<string, { item: Record<string, unknown>; count: number }>();
|
||||
for (const s of sources) {
|
||||
if (!s || typeof s !== 'object') continue;
|
||||
const item = s as Record<string, unknown>;
|
||||
const page = typeof item.page_name === 'string' ? item.page_name :
|
||||
typeof item.page === 'string' ? item.page : '';
|
||||
const existing = seen.get(page);
|
||||
if (existing) {
|
||||
existing.count += 1;
|
||||
} else {
|
||||
seen.set(page, { item, count: 1 });
|
||||
}
|
||||
}
|
||||
return [...seen.values()].map(({ item, count }) =>
|
||||
count > 1 ? { ...item, hit_count: count } : item,
|
||||
);
|
||||
}
|
||||
|
||||
/** 越庫/不存在 一律同一句 404(不洩存在性)。 */
|
||||
function notFound(c: Context<{ Bindings: Bindings }>): Response {
|
||||
return c.json({ error: '找不到這筆資料' }, 404);
|
||||
@@ -103,7 +129,10 @@ function canReadLibrary(userLibraries: string[], library: string): boolean {
|
||||
* metadata_json parse 失敗 → 視為保留(治標不誤殺;壞 metadata ≠ deprecated)。
|
||||
* 純函式(單測用 export)。
|
||||
*/
|
||||
const INTERNAL_ENTRY_TYPES = new Set(['value', 'workflow']);
|
||||
// execution_log/execution_log_usage(KV 額度事故修復,2026-08-07):workflow 執行紀錄與其內部
|
||||
// 用量計數器,entry_type 與既有 value/workflow 同層級的內部型別——一併排除,避免用戶搜尋知識時
|
||||
// 混進執行 log(同層防線:本模組也從不設 metadata_json.embed=true,永不進語意搜尋索引)。
|
||||
const INTERNAL_ENTRY_TYPES = new Set(['value', 'workflow', 'execution_log', 'execution_log_usage']);
|
||||
|
||||
export function filterDeprecatedEntries<T extends { metadata_json?: string | null; content?: string | null; entry_type?: string | null }>(
|
||||
entries: T[],
|
||||
@@ -121,6 +150,62 @@ export function filterDeprecatedEntries<T extends { metadata_json?: string | nul
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* CJK/ASCII 邊界插空白正規化(t95):
|
||||
* 「AI協作」→「AI 協作」;「協作AI」→「協作 AI」;已有空白不重複插。
|
||||
* 只動查詢端,不動索引端。純函式,單測用 export。
|
||||
*/
|
||||
export function normalizeCjkQuery(q: string): string {
|
||||
// U+3040-U+9FFF: Hiragana/Katakana/CJK Ext.A/CJK main; U+F900-U+FAFF: CJK Compat.
|
||||
const isCjk = (c: string) => /[-鿿豈-]/.test(c);
|
||||
const isAsciiAlnum = (c: string) => /[-鿿豈-]/.test(c);
|
||||
let result = '';
|
||||
for (let i = 0; i < q.length; i++) {
|
||||
const ch = q[i];
|
||||
if (result.length > 0) {
|
||||
const prev = result[result.length - 1];
|
||||
if (prev !== ' ' && ch !== ' ' &&
|
||||
((isCjk(prev) && /[A-Za-z0-9]/.test(ch)) || (/[A-Za-z0-9]/.test(prev) && isCjk(ch)))) {
|
||||
result += ' ';
|
||||
}
|
||||
}
|
||||
result += ch;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 從三元組節點名清單找最佳比對(t96 fuzzy fallback 用):
|
||||
* 正規化後做 contains 比對;多命中取最短名(前綴/最精確優先)。純函式,單測用 export。
|
||||
*/
|
||||
export function findBestNodeMatch(searchTerm: string, nodeNames: string[]): string | null {
|
||||
const term = normalizeCjkQuery(searchTerm).toLowerCase();
|
||||
if (!term) return null;
|
||||
const hits = nodeNames.filter(n => normalizeCjkQuery(n).toLowerCase().includes(term));
|
||||
if (hits.length === 0) return null;
|
||||
return hits.reduce((a, b) => a.length <= b.length ? a : b);
|
||||
}
|
||||
|
||||
/** 從 KBDB triplet records 找最佳比對節點名(t96 plugin fuzzy fallback 用)。 */
|
||||
async function fuzzyFindNode(env: Bindings, tenant: string, searchTerm: string): Promise<string | null> {
|
||||
try {
|
||||
const res = await kbdbFetch(env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant)}`);
|
||||
if (!res.ok) return null;
|
||||
const body = (await res.json().catch(() => null)) as { records?: { values?: Record<string, unknown> }[] } | null;
|
||||
if (!body || !Array.isArray(body.records)) return null;
|
||||
const nodeNames = new Set<string>();
|
||||
for (const r of body.records) {
|
||||
const v = r?.values;
|
||||
if (!v || typeof v !== 'object') continue;
|
||||
if (typeof v.subject === 'string' && v.subject.trim()) nodeNames.add(v.subject.trim());
|
||||
if (typeof v.object === 'string' && v.object.trim()) nodeNames.add(v.object.trim());
|
||||
}
|
||||
return findBestNodeMatch(searchTerm, [...nodeNames]);
|
||||
} catch {
|
||||
return null; // fallback 失敗靜默略過,原本 0 結果直接回
|
||||
}
|
||||
}
|
||||
|
||||
// GET /portal/data/search?q=&mode=&entry_type=&limit= — 三模式中的 keyword/semantic
|
||||
//(graph 走 /portal/data/graph/*)。server 注入 owner_id+library;回應照 KBDB 原形
|
||||
//(entries 含 metadata_json,前端自取 source 溯源;mode/capability_hint 誠實透傳——
|
||||
@@ -129,8 +214,9 @@ portalDataRouter.get('/portal/data/search', (c) =>
|
||||
run(c, async () => {
|
||||
const auth = await requirePortalUser(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const q = c.req.query('q');
|
||||
if (!q) return c.json({ error: 'q 必填' }, 400);
|
||||
const qRaw = c.req.query('q');
|
||||
if (!qRaw) return c.json({ error: 'q 必填' }, 400);
|
||||
const q = normalizeCjkQuery(qRaw); // t95: CJK/ASCII 邊界補空白(只動查詢端)
|
||||
|
||||
const libraries = parseLibraries(auth.user.values.libraries);
|
||||
if (libraries.length === 0) {
|
||||
@@ -142,7 +228,32 @@ portalDataRouter.get('/portal/data/search', (c) =>
|
||||
if (!libraries.includes('*')) params.set('library', libraries.join(','));
|
||||
// 透傳的只有「在權限範圍內再收窄」的 filter;owner_id/library 上面已由 server 定死,
|
||||
// caller 傳什麼都不看(URLSearchParams 是新建的,蓋不掉)。
|
||||
if (c.req.query('mode') === 'semantic') params.set('mode', 'semantic');
|
||||
if (c.req.query('mode') === 'semantic') {
|
||||
params.set('mode', 'semantic');
|
||||
// 🔴 t183(leo 08-04 實撞:「語義搜尋搜到一大堆不相關的內容」
|
||||
// ——搜「火星座標」卻跑出 n8n 版本比較表、Leo 填答):
|
||||
// Vectorize 會**硬湊滿 topK 筆**,湊不到就把低分的塞進來 ⇒ 尾巴全是無關內容。
|
||||
// kbdb 早就支援 min_score(`kbdb/src/embed.ts:225`,issue #67),
|
||||
// 但 portal **從來沒傳** ⇒ 等同沒有閾值,低分尾全端到用戶面前。
|
||||
//
|
||||
// 0.75 怎麼來的(**實測分數分布,不是猜的**;youlin 實例搜「火星座標 奧林帕斯山」):
|
||||
// 0.908 / 0.881 / 0.881 / 0.880 / 0.870 / 0.815 / 0.798 / 0.787 ← 全是火星座標,真相關
|
||||
// ─────────────────────── 斷崖 ───────────────────────
|
||||
// 0.742 姨媽說故事 / 0.740 ax-academy / 0.739×8 n8n 版本比較表 ← 全是雜訊
|
||||
// 斷崖落在 0.787 與 0.742 之間 ⇒ 取 0.75:相關的全留、雜訊全砍。
|
||||
//
|
||||
// 允許前端覆寫(想放寬看更多可傳 min_score),但**不接受 0/負數**
|
||||
// ——那等於關掉閾值,正是 t183 要修的病本身。
|
||||
//
|
||||
// 🔴 2026-08-05 修正(leo 實撞「語義搜尋 0 命中」):**這裡不再硬寫預設值**。
|
||||
// 上面 0.75 是照**舊模型 bge-base-en-v1.5** 的分數分布定的;08-05 換 bge-m3 後
|
||||
// 分數尺度整體下移,0.75 砍掉的變成正解 ⇒ 新上傳的檔一律 0 命中。
|
||||
// 根因=**閾值是模型的性質,卻被複製到呼叫端**,換模型時沒人想到要回來改這行。
|
||||
// ⇒ 預設值移到 `kbdb/src/embed.ts` 的 `DEFAULT_MIN_SCORE`(緊鄰 DEFAULT_EMBED_MODEL),
|
||||
// portal 只在**使用者顯式指定**時才傳。**不要把數字搬回來。**
|
||||
const msRaw = Number(c.req.query('min_score'));
|
||||
if (Number.isFinite(msRaw) && msRaw > 0 && msRaw < 1) params.set('min_score', String(msRaw));
|
||||
}
|
||||
const entryType = c.req.query('entry_type');
|
||||
if (entryType) params.set('entry_type', entryType);
|
||||
const limit = c.req.query('limit');
|
||||
@@ -205,6 +316,9 @@ portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) =>
|
||||
return c.json({ error: '無知識圖譜檢視權限' }, 403);
|
||||
}
|
||||
|
||||
// t95/t96: CJK 正規化後再用(避免「AI協作」找不到「AI 協作」節點)
|
||||
const nodeName = normalizeCjkQuery(c.req.param('name'));
|
||||
|
||||
// ① tenant workflow 路徑(存在才走;input:node=path、depth=query 預設 2、namespace/owner=tenant)
|
||||
const tenant = portalTenant(c.env);
|
||||
const wfGraph = await getTenantWorkflowGraph(c.env, 'graph_neighbors');
|
||||
@@ -214,7 +328,8 @@ portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) =>
|
||||
const result = await executeWebhookGraph(
|
||||
c.env,
|
||||
wfGraph,
|
||||
{ node: c.req.param('name'), depth, namespace: tenant, owner: tenant },
|
||||
// t116: 補傳 kbdb_base;t128: 補傳 template(workflow fetch_triplets.url 用 {{input.template}})
|
||||
{ node: nodeName, depth, namespace: tenant, owner: tenant, kbdb_base: c.env.KBDB_BASE_URL ?? '', template: 'triplet' },
|
||||
'graph_neighbors',
|
||||
tenant,
|
||||
c.executionCtx,
|
||||
@@ -231,8 +346,23 @@ portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) =>
|
||||
const headers: Record<string, string> = {};
|
||||
if (c.env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`;
|
||||
try {
|
||||
const res = await fetch(`${base}/graph/neighbors/${encodeURIComponent(c.req.param('name'))}`, { headers });
|
||||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||
const res = await fetch(`${base}/graph/neighbors/${encodeURIComponent(nodeName)}`, { headers });
|
||||
if (!res.ok) {
|
||||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
// t96: 精確命中 0 鄰居 → 試 substring fallback 找最佳節點名(如「AI 協作」→「AI 協作規範書」)
|
||||
const resText = await res.text().catch(() => '');
|
||||
let data: { neighbors?: unknown[]; edges?: unknown[] } | null = null;
|
||||
try { data = JSON.parse(resText) as typeof data; } catch { /* 非 JSON → 直接透傳 */ }
|
||||
if (data && Array.isArray(data.neighbors) && data.neighbors.length === 0 &&
|
||||
Array.isArray(data.edges) && data.edges.length === 0) {
|
||||
const fallbackName = await fuzzyFindNode(c.env, tenant, nodeName);
|
||||
if (fallbackName && fallbackName !== nodeName) {
|
||||
const res2 = await fetch(`${base}/graph/neighbors/${encodeURIComponent(fallbackName)}`, { headers });
|
||||
return new Response(res2.body, { status: res2.status, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
}
|
||||
return new Response(resText, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||
} catch (e) {
|
||||
// plugin 沒部署/不可達 → 誠實 502(前端顯示「關聯服務不可達」,不假裝無關聯)
|
||||
return c.json({ error: `kbdb-graph-plugin 不可達:${e instanceof Error ? e.message : String(e)}` }, 502);
|
||||
@@ -315,10 +445,12 @@ portalDataRouter.get('/portal/data/chat', (c) =>
|
||||
return c.json({ error: `rag_chat workflow 執行失敗:${result.error ?? '未知錯誤'}` }, 502);
|
||||
}
|
||||
// 回 workflow 回應內層 data:{answer, sources, graph_facts}(缺欄位誠實回空,不編造)
|
||||
// t129: sources 按 page_name 去重——同一卡拆多 block 每個各一筆,前端列一整頁重複;後端去重後乾淨。
|
||||
const inner = unwrapWorkflowData(result.data, 'answer');
|
||||
const rawSources = Array.isArray(inner.sources) ? inner.sources : [];
|
||||
return c.json({
|
||||
answer: typeof inner.answer === 'string' ? inner.answer : '',
|
||||
sources: Array.isArray(inner.sources) ? inner.sources : [],
|
||||
sources: dedupeSourcesByPage(rawSources),
|
||||
graph_facts: inner.graph_facts ?? null,
|
||||
});
|
||||
}),
|
||||
@@ -431,23 +563,20 @@ portalDataRouter.get('/portal/data/workflows', (c) =>
|
||||
/* 壞 record 誠實留空 */
|
||||
}
|
||||
}
|
||||
// 最近一次執行:ANALYTICS_KV stats:{name}:{unix_ms}——key 後綴定長毫秒 timestamp,
|
||||
// 字典序=時間序,取最後一把 key 即最新(同 /workflows/:name/executions 的排序邏輯)。
|
||||
// 最近一次執行:KV 額度事故修復(2026-08-07)改打 KBDB GET /execution-log/latest
|
||||
// (原走 ANALYTICS_KV stats:{name}:* list,免費層 list 也是 1,000/日)。KBDB=
|
||||
// API-as-Wall:不直連 D1,走既有 kbdbFetch(本檔已在用,見上方 import)。
|
||||
let last_execution: { timestamp: string; verdict?: string } | null = null;
|
||||
const stats = await c.env.ANALYTICS_KV.list({ prefix: `stats:${name}:`, limit: 1000 });
|
||||
if (stats.keys.length > 0) {
|
||||
const latest = stats.keys.reduce((a, b) => (a.name > b.name ? a : b));
|
||||
const ts = latest.name.split(':').pop() ?? '';
|
||||
const rawStat = await c.env.ANALYTICS_KV.get(latest.name);
|
||||
let verdict: string | undefined;
|
||||
if (rawStat) {
|
||||
try {
|
||||
verdict = (JSON.parse(rawStat) as { verdict?: string }).verdict;
|
||||
} catch {
|
||||
/* 壞 record 誠實留空 */
|
||||
}
|
||||
}
|
||||
last_execution = { timestamp: ts, verdict };
|
||||
const execRes = await kbdbFetch(
|
||||
c.env,
|
||||
`/execution-log/latest?${new URLSearchParams({ workflow_id: name, owner_id: tenant }).toString()}`,
|
||||
);
|
||||
const execBody = await execRes.json().catch(() => null) as {
|
||||
success?: boolean;
|
||||
execution?: { verdict: string; recorded_at: number } | null;
|
||||
} | null;
|
||||
if (execRes.ok && execBody?.success && execBody.execution) {
|
||||
last_execution = { timestamp: String(execBody.execution.recorded_at), verdict: execBody.execution.verdict };
|
||||
}
|
||||
return { name, description, created_at, cron_expr, last_execution };
|
||||
}),
|
||||
@@ -455,3 +584,36 @@ portalDataRouter.get('/portal/data/workflows', (c) =>
|
||||
return c.json({ success: true, workflows, total: workflows.length, read_only: true });
|
||||
}),
|
||||
);
|
||||
|
||||
// GET /portal/data/diagnostics — 檢修孔(2026-08-07 leo 直接指令):
|
||||
//
|
||||
// 「可以很簡單,就是一顆按鈕在設定裡,他按鈕下載一個檔案,把檔案發給我,你看那個檔。」
|
||||
//
|
||||
// 設定頁「匯出診斷檔給我們看」按鈕打這支,前端把回應存成單一 JSON 檔下載。
|
||||
//
|
||||
// 🔴 t213(2026-08-08,InkStoneCo 總管交辦):leo 實測拿真檔驗四個真實問題,只答得出一題
|
||||
// (雲端這半的 bundle_version)——其餘三題(本機檔案總量、失敗分類統計、daemon 版本/
|
||||
// 自我更新狀態)需要本機資料,雲端這支端點天生構不到(封測者的瀏覽器與他電腦上的
|
||||
// daemon 是兩個獨立行程)。核准方案:本機那半改由 arcrun-app(daemon 桌面殼)匯出時
|
||||
// 直接讀本機檔案,並改打**新增的** `GET /portal/daemon/diagnostics`(X-Arcrun-API-Key
|
||||
// 認證,免帳密)取雲端這半,兩者合併成一份完整診斷檔——arcrun-app 那半見
|
||||
// products/arcrun-rag repo t213 phase 2。本端點(portal 網頁版)保留當退路(daemon
|
||||
// 完全掛掉時仍按得到),文案需誠實講清楚自己只有一半,完整診斷請去 daemon 匯出
|
||||
// (portal 前端文案改動不在本次 matrix/arcrun 範圍內,由 arcrun-rag 那邊處理)。
|
||||
//
|
||||
// 兩條紅線、embedding 健康檢查涵蓋範圍、認證機制皆不變,核心邏輯已抽成 buildDiagnostics()
|
||||
// (portal.ts)——與新的 daemon 版共用同一份查詢邏輯(薄殼原則)。
|
||||
portalDataRouter.get('/portal/data/diagnostics', (c) =>
|
||||
run(c, async () => {
|
||||
const auth = await requirePortalUser(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const tenant = portalTenant(c.env);
|
||||
const core = await buildDiagnostics(c.env, tenant);
|
||||
return c.json({
|
||||
generated_at: new Date().toISOString(),
|
||||
instance_url: new URL(c.req.url).origin,
|
||||
bundle_version: c.env.ARCRUN_BUNDLE_VERSION ?? null,
|
||||
...core,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,7 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { deriveRecipeHash } from '../lib/hash';
|
||||
import type { ResponseMap } from '../lib/recipe-payload';
|
||||
|
||||
export const recipesRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
@@ -34,6 +35,26 @@ export interface RecipeDefinition {
|
||||
method?: string; // GET | POST | PUT | PATCH | DELETE,預設 POST
|
||||
headers?: Record<string, string>;
|
||||
body?: Record<string, unknown>;
|
||||
/**
|
||||
* ③ payload 層(SDD workflow-discovery 3.12):帶 body 的 API 把 payload 收回 recipe,
|
||||
* 不必寫進 workflow code。與 `body` 的差別=支援巢狀 {{var}} 與 dot path、
|
||||
* 單一引用保留原型別。兩者並存時 body_template 優先(新欄位贏,舊 recipe 不受影響)。
|
||||
*/
|
||||
body_template?: Record<string, unknown>;
|
||||
/**
|
||||
* ③ 回應正規化層:各家 API 回應形狀不同(Gemini/Claude/Workers AI),
|
||||
* 取值路徑・思考型模型旗標・淨化規則**隨 recipe 走** ⇒ 換源=換 recipe,不必改 workflow。
|
||||
* 未設=原樣回傳(既有 recipe 行為零變化)。
|
||||
*/
|
||||
response_map?: ResponseMap;
|
||||
/**
|
||||
* 認證型別。未設=沿用既有 auth_service 判斷(向後相容)。
|
||||
* `binding`=**免金鑰**,用平台內建能力(env.AI/VECTORIZE/BROWSER/QUEUE),
|
||||
* 不是為 Workers AI 開特例——Cloudflare 這一整類都被舊抽象(只認 HTTP+金鑰)排除在外。
|
||||
*/
|
||||
auth?: 'static_key' | 'service_account' | 'oauth2' | 'binding';
|
||||
/** auth='binding' 時指定用哪個 binding(例 'AI'/'VECTORIZE')。 */
|
||||
binding_name?: string;
|
||||
/**
|
||||
* 此 recipe 要用哪個 auth recipe(auth_recipe:{auth_service})。
|
||||
* 讓多個 recipe 共用同一把 auth(例:kbdb_get / kbdb_create_block 都設 "kbdb")。
|
||||
@@ -116,6 +137,11 @@ recipesRouter.post('/recipes', async (c) => {
|
||||
method: (body.method ?? 'POST').toUpperCase(),
|
||||
headers: body.headers,
|
||||
body: body.body,
|
||||
// ③ payload/回應/binding 三層(3.12):全選填,沒給就是 undefined=既有行為
|
||||
body_template: body.body_template,
|
||||
response_map: body.response_map,
|
||||
auth: body.auth,
|
||||
binding_name: body.binding_name,
|
||||
auth_service: body.auth_service,
|
||||
credentials_required: body.credentials_required,
|
||||
created_at: existing?.created_at ?? now,
|
||||
|
||||
@@ -30,6 +30,7 @@ import type { GraphNode } from '../types';
|
||||
import { extractCronExpr } from '../lib/cron-match';
|
||||
import { updateCronIndexEntry, CRON_INDEX_KEY } from '../lib/cron-index';
|
||||
import { recordTelemetry } from '../lib/telemetry';
|
||||
import { fetchTenantWorkflowSearch } from '../lib/workflow-search';
|
||||
|
||||
export const webhooksNamedRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
@@ -177,16 +178,9 @@ webhooksNamedRouter.get('/workflows/search', async (c) => {
|
||||
// 預設優先語意;caller 傳 mode=keyword 才強制關鍵字。KBDB 端未開 Vectorize 會自動降級。
|
||||
const mode = c.req.query('mode') === 'keyword' ? 'keyword' : 'semantic';
|
||||
|
||||
const base = (c.env.KBDB_BASE_URL ?? 'https://arcrun-kbdb.uncle6-me.workers.dev').replace(/\/$/, '');
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (c.env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`;
|
||||
const params = new URLSearchParams({
|
||||
q,
|
||||
owner_id: apiKey, // 租戶隔離(只搜本租戶的 workflow)
|
||||
entry_type: 'workflow', // base 通用 filter(Q4),只回 workflow entry
|
||||
mode,
|
||||
});
|
||||
const res = await fetch(`${base}/entries/search?${params.toString()}`, { headers });
|
||||
// KBDB 轉發抽到 lib/workflow-search.ts(t159 target 參數):本路由與
|
||||
// POST /cypher/search { target:"workflow" } 共用同一條路,行為必然一致。
|
||||
const res = await fetchTenantWorkflowSearch(c.env, apiKey, q, mode);
|
||||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||
});
|
||||
|
||||
@@ -318,7 +312,7 @@ async function triggerNamed(
|
||||
c.executionCtx.waitUntil(
|
||||
executeWebhookGraph(c.env, record.graph, triggerContext, name, apiKey, c.executionCtx, userAgent)
|
||||
.then(result =>
|
||||
writeExecutionVerdict(c.env, workflowId, nodes, result.success ? 'success' : 'failed', result.duration_ms, result.error ?? ''),
|
||||
writeExecutionVerdict(c.env, workflowId, nodes, result.success ? 'success' : 'failed', result.duration_ms, result.error ?? '', triggerContext, apiKey),
|
||||
),
|
||||
);
|
||||
return c.json({ accepted: true }, 202);
|
||||
@@ -335,7 +329,7 @@ async function triggerNamed(
|
||||
);
|
||||
|
||||
c.executionCtx.waitUntil(
|
||||
writeExecutionVerdict(c.env, workflowId, nodes, result.success ? 'success' : 'failed', result.duration_ms, result.error ?? ''),
|
||||
writeExecutionVerdict(c.env, workflowId, nodes, result.success ? 'success' : 'failed', result.duration_ms, result.error ?? '', triggerContext, apiKey),
|
||||
);
|
||||
|
||||
return c.json(result, result.success ? 200 : 500);
|
||||
@@ -407,7 +401,7 @@ async function queryNamed(
|
||||
|
||||
// 執行判決寫入不阻塞回應(waitUntil,與 /trigger 一致)。
|
||||
c.executionCtx.waitUntil(
|
||||
writeExecutionVerdict(c.env, workflowId, nodes, result.success ? 'success' : 'failed', result.duration_ms, result.error ?? ''),
|
||||
writeExecutionVerdict(c.env, workflowId, nodes, result.success ? 'success' : 'failed', result.duration_ms, result.error ?? '', triggerContext, apiKey),
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
@@ -471,6 +465,28 @@ webhooksNamedRouter.get('/q/:ns/:name', async (c) => {
|
||||
return queryNamed(c, c.req.param('ns'), c.req.param('name'), queryStringContext(c));
|
||||
});
|
||||
|
||||
// GET /webhooks/named/:name/definition — 吐 workflow 的可攜定義(t158 export 原語)。
|
||||
// leo 07-31:「如果我要把我做的工作流分享給同事,我要怎麼 export?他要如何 import?
|
||||
// 在從前就是寫成幾個 yaml 丟過去讓新的送進 KBDB 不是嗎?」
|
||||
// 回 record 原樣(graph+config+description)=import 端可直接 POST /webhooks/named 送進
|
||||
// 任何實例(acr workflow import/安裝器同一條路)。執行語義不驗證(部署≠發現)。
|
||||
webhooksNamedRouter.get('/webhooks/named/:name/definition', async (c) => {
|
||||
const apiKey = c.req.header('X-Arcrun-API-Key');
|
||||
if (!apiKey) return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
|
||||
const name = c.req.param('name');
|
||||
const raw = await c.env.WEBHOOKS.get(kvKey(apiKey, name), 'text');
|
||||
if (!raw) return c.json({ error: `找不到 workflow "${name}"` }, 404);
|
||||
const rec = JSON.parse(raw) as NamedWorkflowRecord;
|
||||
return c.json({
|
||||
name: rec.name,
|
||||
description: rec.description ?? '',
|
||||
graph: rec.graph,
|
||||
config: rec.config ?? {},
|
||||
created_at: rec.created_at ?? '',
|
||||
...(rec.cron_expr ? { cron_expr: rec.cron_expr } : {}),
|
||||
});
|
||||
});
|
||||
|
||||
// GET /webhooks/named — 列出當前 api_key 下所有 workflow
|
||||
webhooksNamedRouter.get('/webhooks/named', async (c) => {
|
||||
const apiKey = c.req.header('X-Arcrun-API-Key');
|
||||
|
||||
@@ -73,7 +73,7 @@ webhooksRouter.post('/webhooks/:token/trigger', async (c) => {
|
||||
const workflowId = graph.id ?? token;
|
||||
const nodes = Array.isArray(graph.nodes) ? (graph.nodes as import('../types').GraphNode[]) : [];
|
||||
c.executionCtx.waitUntil(
|
||||
writeExecutionVerdict(c.env, workflowId, nodes, result.success ? 'success' : 'failed', result.duration_ms, result.error ?? ''),
|
||||
writeExecutionVerdict(c.env, workflowId, nodes, result.success ? 'success' : 'failed', result.duration_ms, result.error ?? '', triggerContext, apiKey),
|
||||
);
|
||||
|
||||
return c.json(result, result.success ? 200 : 500);
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* 2. 在記憶體比對每筆 cron_expr 跟 event.scheduledTime(UTC 分鐘精度)
|
||||
* 3. 匹配才去讀完整 workflow record({apiKey}:wf:{name})
|
||||
* 4. 匹配 → executeWebhookGraph 跑(waitUntil 背景,不擋)
|
||||
* 5. 每天固定一分鐘(UTC 02:30)順便叫 KBDB 清一批過期執行紀錄(P7 保留期,見下方 §5)
|
||||
*
|
||||
* 8.P0 止血(SDD §8.2):原本每分鐘 WEBHOOKS.list('cron-idx:') = 1440 list/日 爆 KV 上限,
|
||||
* 改成單一固定 key 只 get 一次 → list 歸零。
|
||||
@@ -18,6 +19,7 @@ import type { Bindings } from './types';
|
||||
import { cronMatch } from './lib/cron-match';
|
||||
import { readCronIndex, parseCronEntryKey } from './lib/cron-index';
|
||||
import { executeWebhookGraph } from './actions/webhook-handlers';
|
||||
import { kbdbBase } from './routes/kbdb-proxy';
|
||||
|
||||
type StoredWorkflowRecord = {
|
||||
graph: Record<string, unknown>;
|
||||
@@ -73,4 +75,22 @@ export async function handleScheduled(
|
||||
);
|
||||
}
|
||||
console.log(`[scheduled] scanned ${entries.length} cron-idx entries, ${triggered} triggered`);
|
||||
|
||||
// §5 P7 保留期清理(2026-08-09):不新增排程基礎設施(wrangler.toml [triggers] 是受保護
|
||||
// 檔案,AI 不可編輯——見 InkStoneCo 頂層 pending-changes.md P9 段 L1 權限閘),改「搭便車」:
|
||||
// 這支 handler 本來就每分鐘醒一次(給上面的 cron workflow 用),挑固定一分鐘(UTC 02:30,
|
||||
// 避開整點/半點常見的 cron 表達式擁擠時段)順手打一次 fire-and-forget 給 KBDB 的
|
||||
// POST /execution-log/cleanup。頻率仍是「一天一次」,不是輪詢外部系統要狀態,是既有 tick
|
||||
// 順手打理自己的表。呼叫失敗不影響上面的 cron workflow 觸發(各自 try/catch,互不拖累)。
|
||||
if (now.getUTCHours() === 2 && now.getUTCMinutes() === 30) {
|
||||
const { base, headers } = kbdbBase(env);
|
||||
ctx.waitUntil(
|
||||
fetch(`${base}/execution-log/cleanup`, { method: 'POST', headers })
|
||||
.then(async (r) => {
|
||||
const body = await r.json().catch(() => null);
|
||||
console.log('[scheduled] execution-log cleanup', r.status, JSON.stringify(body));
|
||||
})
|
||||
.catch((e) => console.error('[scheduled] execution-log cleanup failed', e)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,11 +30,11 @@ export type Bindings = {
|
||||
// Credential Store:AES-GCM 加密存放用戶 API token(舊家;credential-store-migration T7
|
||||
// 雙讀過渡期間仍是 fallback 讀路徑,本次 T5 只改「新寫入」,不動這裡)
|
||||
CREDENTIALS_KV: KVNamespace;
|
||||
// credential-store-migration T2/T5(D19「擁有目錄,不擁有內容物」):credential 目錄表
|
||||
// (api_key/name/service/sensitivity/secret_ref/created_at/last_used_at,不含密文)。
|
||||
// 與 KBDB base 共用同一顆 arcrun-kbdb D1(self-hosted 由 deploy.ts 注入用戶自己的
|
||||
// database_id,比照 kbdb/wrangler.toml 同一套 database_id 注入機制)。密文本體不在這裡,
|
||||
// 住在 Workers per-script Secrets(見 CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID)。
|
||||
// ⚠️ D38 圍牆修復(2026-08-07)後零讀寫點:credential 目錄已改走 KBDB entries HTTP API
|
||||
// (見 cypher-executor/src/routes/credentials.ts),不再對這顆 D1 下任何 SQL。binding
|
||||
// 因 wrangler.toml 被權限鎖住(D38 決策所述)暫留宣告,比照 ANALYTICS_KV 同一模式
|
||||
// (commit 60688c3:binding 留在 toml,程式碼零讀寫點)。舊表資料遷移路徑見
|
||||
// kbdb/migrations/0006_drop_credentials_table.sql。
|
||||
CREDENTIALS_DB: D1Database;
|
||||
// Analytics:執行統計(fire-and-forget,key = stats:{workflowId}:{timestamp})
|
||||
ANALYTICS_KV: KVNamespace;
|
||||
@@ -68,6 +68,13 @@ export type Bindings = {
|
||||
// 必填:cypher-executor 用此組出 component worker URL(避開同 zone 自循環死鎖,見 P0 #9)
|
||||
// self-hosted fork 必須改 wrangler.toml [vars] 為自己的帳號 subdomain
|
||||
WORKER_SUBDOMAIN: string;
|
||||
/**
|
||||
* t162:本實例安裝時的 bundle 版本(格式 `YYYY-MM-DD+<commit7>`)。
|
||||
* 由安裝器 deployBundledWorker 注入(worker.js:805),**給 daemon 比對用**——
|
||||
* daemon `/health` 讀不到就恆判「需要更新」(假警報迴圈,leo 07-31 實撞)。
|
||||
* 未注入(本地 dev/舊實例)= undefined,/health 省略該欄。
|
||||
*/
|
||||
ARCRUN_BUNDLE_VERSION?: string;
|
||||
// Platform telemetry api_key(可選,wrangler secret)
|
||||
// 對應 SDD .agents/specs/llm-interface/ M1.2
|
||||
// 設了會把 agent-telemetry block 都聚集在 platform_telemetry user_id 下
|
||||
@@ -96,6 +103,9 @@ export type Bindings = {
|
||||
GITEA_TOKEN?: string; // wrangler secret(建議唯讀 scope token)
|
||||
GITEA_SPRINT_REPO?: string; // 預設 Leo/InkStoneCo
|
||||
GITEA_SPRINT_DIR?: string; // 預設 system-dev/docs/3-specs/autonomy-dispatch
|
||||
// 安裝器部署時注入的 bundle 版本(格式 "YYYY-MM-DD/commit",老實例無此 var)。
|
||||
// daemon 比對此值決定是否提示用戶更新(/health 曝露,缺 var 時回空字串)。
|
||||
ARCRUN_BUNDLE_VERSION?: string;
|
||||
// MCP access_token 存活秒數的「顯示鏡像」(console 設定頁 MCP TTL 佔位區塊用)。
|
||||
// 真相住在 mcp worker 的同名 env(mcp/src/types.ts,預設 2592000=30 天);cypher 這份
|
||||
// 只供顯示,兩處部署時要一致(#32 形態 config 同步教訓)。未設 → 頁面如實標「預設值」。
|
||||
@@ -124,6 +134,10 @@ export type Bindings = {
|
||||
// 未設 → 純文字顯示,行為與現狀一字不變。知識庫 repo 是 private 時點了會要登入——要不要
|
||||
// 設由實例自己決定(demo 知識庫是 public,適用)。
|
||||
PORTAL_SOURCE_WEB_BASE?: string;
|
||||
// 零件 registry worker base URL(可選,非機密)。未設 → 用 WORKER_SUBDOMAIN 現算
|
||||
// https://arcrun-registry.<subdomain>.workers.dev(wasmWorkerUrl 慣例)。
|
||||
// 本地 wrangler dev/self-hosted 把 registry 掛別處時覆蓋(/cypher/search 存在性查詢用)。
|
||||
REGISTRY_BASE_URL?: string;
|
||||
// kbdb-graph-plugin worker base URL(可選)。未設 → 用 WORKER_SUBDOMAIN 現算
|
||||
// https://kbdb-graph-plugin.<subdomain>.workers.dev(該 repo wrangler.toml name 固定)。
|
||||
// console 卡片詳頁「關聯視圖」經 cypher proxy 打它(kbdb-proxy.ts /kbdb/graph/neighbors/:name)。
|
||||
@@ -144,6 +158,7 @@ export type GraphNode = {
|
||||
export type EdgeType =
|
||||
| 'PIPE' | 'IF' | 'FOREACH' | 'CONTINUE' // 現有
|
||||
| 'IS_A' | 'ON_SUCCESS' | 'ON_FAIL' // 執行語意
|
||||
| 'ON_TRUE' | 'ON_FALSE' | 'ON_BRANCH' // 條件語意(SDD workflow-discovery 3.11)
|
||||
| 'ON_CLICK' | 'CALLS_SUBFLOW' // 觸發語意
|
||||
| 'CONTAINS' | 'HAS_STYLE' | 'HAS_BEHAVIOR'; // 結構語意(記錄圖結構,不執行)
|
||||
|
||||
@@ -153,6 +168,8 @@ export type GraphEdge = {
|
||||
type: EdgeType;
|
||||
condition?: string; // IF 的條件表達式
|
||||
iterator?: string; // FOREACH 的迭代變數名
|
||||
/** ON_BRANCH 的具名分支(對應 switch 零件 output 的 data.branch) */
|
||||
branch?: string;
|
||||
};
|
||||
|
||||
export type ExecutionGraph = {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* 逐顆查詢的回應要自我說明分支用法(SDD workflow-discovery 3.11;總管 08-01 抽驗第 3 點)
|
||||
*
|
||||
* 判準(leo/總管一致):**AI 只看那一顆的回應,就知道怎麼接下一步**——
|
||||
* 不必回頭讀 skill、不必猜。看得到分支說明才算數。
|
||||
*
|
||||
* 取證背景(08-01 prod):逐顆查 if_control 只回
|
||||
* status/componentId/type/source/input_schema{condition,input}/success_rate/stability
|
||||
* ⇒ **沒有任何欄位說明分支怎麼接** ⇒ 走 n8n 式逐顆查的 AI 只好寫 code。
|
||||
*
|
||||
* 本檔直接驗 `branchHintFor()`(回應裡那個欄位的來源),並把 AI 實際會看到的內容印出來。
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { branchHintFor } from '../src/lib/branch-hints';
|
||||
|
||||
describe('三顆分支零件的查詢回應自帶用法(AI 看一眼就知道怎麼接)', () => {
|
||||
for (const id of ['if_control', 'switch', 'try_catch']) {
|
||||
it(`${id}:回應含 branch_field/branches/edge_types/usage/example`, () => {
|
||||
const hint = branchHintFor(id);
|
||||
expect(hint).toBeDefined();
|
||||
|
||||
// 這一顆會輸出哪個欄位當分支標籤
|
||||
expect(hint!.branch_field).toBe('data.branch');
|
||||
// 接下游要用哪些邊型
|
||||
expect(hint!.edge_types.length).toBeGreaterThan(0);
|
||||
// 一行說明 + 可照抄範例(缺任一個,AI 都得自己猜)
|
||||
expect(hint!.usage.length).toBeGreaterThan(0);
|
||||
expect(hint!.example.length).toBeGreaterThan(0);
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`\n──────── 逐顆查 ${id} 時,AI 會看到的 branch_hint ────────\n` +
|
||||
JSON.stringify(hint, null, 2),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
it('if_control 明說 ON_TRUE/ON_FALSE 兩條邊', () => {
|
||||
const h = branchHintFor('if_control')!;
|
||||
expect(h.edge_types).toContain('ON_TRUE');
|
||||
expect(h.edge_types).toContain('ON_FALSE');
|
||||
expect(h.branches).toEqual(['true', 'false']);
|
||||
// 明說「不需要自己寫 code 判斷」——這句是防腹語術的關鍵
|
||||
expect(h.usage).toContain('不需要自己寫 code');
|
||||
});
|
||||
|
||||
it('switch 明說用 ON_BRANCH 並在邊上標 case 名,且 default 不需特別邊型', () => {
|
||||
const h = branchHintFor('switch')!;
|
||||
expect(h.edge_types).toContain('ON_BRANCH');
|
||||
expect(h.usage).toContain('ON_BRANCH');
|
||||
expect(h.usage).toContain('default_branch');
|
||||
// branches 是動態的(由 cases 決定),要誠實說明而非給死清單
|
||||
expect(typeof h.branches).toBe('string');
|
||||
});
|
||||
|
||||
it('try_catch 明說 try/catch 兩條標籤,錯誤處理不必寫 code', () => {
|
||||
const h = branchHintFor('try_catch')!;
|
||||
expect(h.branches).toEqual(['try', 'catch']);
|
||||
expect(h.edge_types).toContain('ON_BRANCH');
|
||||
expect(h.usage).toContain('不需要寫 code');
|
||||
});
|
||||
|
||||
it('不分岔的零件沒有 branch_hint(不加噪音)', () => {
|
||||
expect(branchHintFor('http_request')).toBeUndefined();
|
||||
expect(branchHintFor('code')).toBeUndefined();
|
||||
expect(branchHintFor(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* 三型分支零件「真的接上引擎」的實測(SDD workflow-discovery 3.11)
|
||||
*
|
||||
* 為什麼要另立這一支(總管 08-01 抽驗要求,正確的要求):
|
||||
* conditional-edges.test.ts 是用 Input 節點**手餵分支形狀**測引擎走邊邏輯,
|
||||
* 那證明的是「引擎依標籤選邊」,**沒有證明「真零件吐出來的標籤真的對得上」**。
|
||||
* leo 特別點名 switch/try_catch,且 `ON_CASE`/`ON_CATCH` grep=0
|
||||
* ⇒ 必須排除「機制通用所以理論上支援」這種推論。
|
||||
*
|
||||
* 本檔的 given 全部是**真 WASM 零件的實跑輸出**(wasmtime 執行 .component-builds/*.wasm
|
||||
* 抓回來的原文,非杜撰),再送進引擎驗證走對邊。
|
||||
*
|
||||
* 真零件實跑指令(可復驗):
|
||||
* cd .component-builds
|
||||
* echo '{"condition":"status == active","input":{"status":"active"}}' | wasmtime if_control/component.wasm
|
||||
* echo '{"value":"pending","cases":[...],"default_branch":"branch_default"}' | wasmtime switch/component.wasm
|
||||
* echo '{"result":null,"error":"boom"}' | wasmtime try_catch/component.wasm
|
||||
*/
|
||||
import { SELF } from 'cloudflare:test';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
async function run(graph: unknown) {
|
||||
const res = await SELF.fetch('http://localhost/execute', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ graph, context: {} }),
|
||||
});
|
||||
const body = (await res.json()) as {
|
||||
success: boolean;
|
||||
trace?: Array<{ nodeId: string }>;
|
||||
};
|
||||
return { body, visited: (body.trace ?? []).map(t => t.nodeId) };
|
||||
}
|
||||
|
||||
/** 真零件輸出 → 當作上游節點的 output 餵進圖 */
|
||||
function graphWith(realOutput: unknown, edges: Array<Record<string, unknown>>, extraNodes: string[]) {
|
||||
return {
|
||||
id: 'real-branch',
|
||||
name: '真零件輸出走邊',
|
||||
nodes: [
|
||||
{ id: 'ctrl', type: 'Input', data: realOutput },
|
||||
...extraNodes.map(id => ({
|
||||
id, type: 'Component', componentId: 'comp_uppercase', data: { text: id },
|
||||
})),
|
||||
],
|
||||
edges,
|
||||
};
|
||||
}
|
||||
|
||||
describe('if_control 真輸出 → 引擎走對邊', () => {
|
||||
// 真跑:echo '{"condition":"status == active","input":{"status":"active"}}' | wasmtime if_control/component.wasm
|
||||
const REAL_TRUE = { data: { branch: 'true', result: true }, success: true };
|
||||
// 真跑:input.status = "inactive"
|
||||
const REAL_FALSE = { data: { branch: 'false', result: false }, success: true };
|
||||
|
||||
const edges = [
|
||||
{ from: 'ctrl', to: 'yes', type: 'ON_TRUE' },
|
||||
{ from: 'ctrl', to: 'no', type: 'ON_FALSE' },
|
||||
];
|
||||
|
||||
it('條件成立(真輸出 branch="true")→ 走 ON_TRUE', async () => {
|
||||
const { body, visited } = await run(graphWith(REAL_TRUE, edges, ['yes', 'no']));
|
||||
expect(body.success).toBe(true);
|
||||
expect(visited).toContain('yes');
|
||||
expect(visited).not.toContain('no');
|
||||
});
|
||||
|
||||
it('條件不成立(真輸出 branch="false")→ 走 ON_FALSE', async () => {
|
||||
const { visited } = await run(graphWith(REAL_FALSE, edges, ['yes', 'no']));
|
||||
expect(visited).toContain('no');
|
||||
expect(visited).not.toContain('yes');
|
||||
});
|
||||
});
|
||||
|
||||
describe('switch 真輸出 → 引擎走對邊(多路+default,leo:「switch 更嚴重」)', () => {
|
||||
// 真跑(三個 case + default_branch):
|
||||
// value="active" → {"data":{"branch":"branch_active"},"success":true}
|
||||
// value="pending" → {"data":{"branch":"branch_pending"},"success":true}
|
||||
// value="zzz" → {"data":{"branch":"branch_default"},"success":true}
|
||||
const REAL_CASE1 = { data: { branch: 'branch_active' }, success: true };
|
||||
const REAL_CASE3 = { data: { branch: 'branch_pending' }, success: true };
|
||||
const REAL_DEFAULT = { data: { branch: 'branch_default' }, success: true };
|
||||
|
||||
const targets = ['p_active', 'p_inactive', 'p_pending', 'p_default'];
|
||||
const edges = [
|
||||
{ from: 'ctrl', to: 'p_active', type: 'ON_BRANCH', branch: 'branch_active' },
|
||||
{ from: 'ctrl', to: 'p_inactive', type: 'ON_BRANCH', branch: 'branch_inactive' },
|
||||
{ from: 'ctrl', to: 'p_pending', type: 'ON_BRANCH', branch: 'branch_pending' },
|
||||
{ from: 'ctrl', to: 'p_default', type: 'ON_BRANCH', branch: 'branch_default' },
|
||||
];
|
||||
|
||||
it('第 1 條 case(真輸出 branch_active)→ 只走 p_active', async () => {
|
||||
const { body, visited } = await run(graphWith(REAL_CASE1, edges, targets));
|
||||
expect(body.success).toBe(true);
|
||||
expect(visited).toContain('p_active');
|
||||
expect(visited).not.toContain('p_inactive');
|
||||
expect(visited).not.toContain('p_pending');
|
||||
expect(visited).not.toContain('p_default');
|
||||
});
|
||||
|
||||
it('第 3 條 case(真輸出 branch_pending)→ 只走 p_pending(證明第 N 條路走得對)', async () => {
|
||||
const { visited } = await run(graphWith(REAL_CASE3, edges, targets));
|
||||
expect(visited).toContain('p_pending');
|
||||
expect(visited).not.toContain('p_active');
|
||||
expect(visited).not.toContain('p_inactive');
|
||||
expect(visited).not.toContain('p_default');
|
||||
});
|
||||
|
||||
it('無匹配(真輸出 branch_default)→ 只走 p_default', async () => {
|
||||
const { visited } = await run(graphWith(REAL_DEFAULT, edges, targets));
|
||||
expect(visited).toContain('p_default');
|
||||
expect(visited).not.toContain('p_active');
|
||||
expect(visited).not.toContain('p_pending');
|
||||
});
|
||||
});
|
||||
|
||||
describe('try_catch 真輸出 → 引擎走對邊(ok/catch 兩路都驗)', () => {
|
||||
// 真跑:echo '{"result":{"value":42},"error":""}' | wasmtime try_catch/component.wasm
|
||||
const REAL_TRY = { data: { branch: 'try', result: { value: 42 } }, success: true };
|
||||
// 真跑:echo '{"result":null,"error":"boom"}' | wasmtime try_catch/component.wasm
|
||||
const REAL_CATCH = { data: { branch: 'catch', error: 'boom' }, success: true };
|
||||
|
||||
const edges = [
|
||||
{ from: 'ctrl', to: 'normal', type: 'ON_BRANCH', branch: 'try' },
|
||||
{ from: 'ctrl', to: 'rescue', type: 'ON_BRANCH', branch: 'catch' },
|
||||
];
|
||||
|
||||
it('成功(真輸出 branch="try")→ 走 normal,不走 rescue', async () => {
|
||||
const { body, visited } = await run(graphWith(REAL_TRY, edges, ['normal', 'rescue']));
|
||||
expect(body.success).toBe(true);
|
||||
expect(visited).toContain('normal');
|
||||
expect(visited).not.toContain('rescue');
|
||||
});
|
||||
|
||||
it('失敗(真輸出 branch="catch")→ 走 rescue,不走 normal', async () => {
|
||||
const { visited } = await run(graphWith(REAL_CATCH, edges, ['normal', 'rescue']));
|
||||
expect(visited).toContain('rescue');
|
||||
expect(visited).not.toContain('normal');
|
||||
});
|
||||
|
||||
it('try_catch 的 catch 路承接了「上游失敗」——不必寫 code try 一遍', async () => {
|
||||
// 這是 leo 點名 try_catch 的原因:schema 用文字寫「走 catch 分支」但機器層沒有那條路。
|
||||
// 現在有了:catch 標籤 → ON_BRANCH branch="catch" → 補救節點。
|
||||
const { visited } = await run(graphWith(REAL_CATCH, edges, ['normal', 'rescue']));
|
||||
expect(visited).toContain('rescue');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* 條件邊 ON_TRUE / ON_FALSE / ON_BRANCH —— CP `arcrun-usable` 步驟 5 缺口①
|
||||
* SDD: workflow-discovery tasks 3.11
|
||||
*
|
||||
* 為什麼要有這組測試(別刪):
|
||||
* `if_control` 零件回 `{success, data:{result, branch}}`,但引擎過去只有
|
||||
* ON_SUCCESS / IF / FOREACH ⇒ 就算照規矩用 if_control,也只拿到布林值,
|
||||
* 還是得寫 code 判斷該走哪條路 ⇒ 這正是「全變成 code」的根(Arcrun#5)。
|
||||
*
|
||||
* 本檔先寫測試再改引擎(引擎核心風險最高,紅線要求)。
|
||||
* 既有邊行為的零變化迴歸另見 executor.test.ts(PIPE/IF/ON_SUCCESS 原樣通過)。
|
||||
*/
|
||||
import { SELF } from 'cloudflare:test';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
/** 送一張圖進 /execute,回 parsed JSON */
|
||||
async function run(graph: unknown, context: Record<string, unknown> = {}) {
|
||||
const res = await SELF.fetch('http://localhost/execute', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ graph, context }),
|
||||
});
|
||||
return {
|
||||
status: res.status,
|
||||
body: (await res.json()) as {
|
||||
success: boolean;
|
||||
data: Record<string, unknown>;
|
||||
trace?: Array<{ nodeId: string }>;
|
||||
error?: string;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 用 Input 節點直接餵出 if_control 形狀的 output({data:{result,branch}}),
|
||||
* 避免測試依賴真的 WASM 零件(單元層只驗「引擎怎麼走邊」)。
|
||||
*/
|
||||
function branchGraph(branch: 'true' | 'false', edges: Array<Record<string, unknown>>) {
|
||||
return {
|
||||
id: `g-branch-${branch}`,
|
||||
name: '條件邊測試',
|
||||
nodes: [
|
||||
// 模擬 if_control 的輸出形狀
|
||||
{ id: 'cond', type: 'Input', data: { success: true, data: { result: branch === 'true', branch } } },
|
||||
{ id: 'yes', type: 'Component', componentId: 'comp_uppercase', data: { text: 'yes' } },
|
||||
{ id: 'no', type: 'Component', componentId: 'comp_uppercase', data: { text: 'no' } },
|
||||
],
|
||||
edges,
|
||||
};
|
||||
}
|
||||
|
||||
describe('條件邊:ON_TRUE / ON_FALSE(缺口① Arcrun#5 根治)', () => {
|
||||
it('branch=true → 只走 ON_TRUE 那條,ON_FALSE 那條不執行', async () => {
|
||||
const { body } = await run(
|
||||
branchGraph('true', [
|
||||
{ from: 'cond', to: 'yes', type: 'ON_TRUE' },
|
||||
{ from: 'cond', to: 'no', type: 'ON_FALSE' },
|
||||
]),
|
||||
);
|
||||
expect(body.success).toBe(true);
|
||||
const visited = (body.trace ?? []).map(t => t.nodeId);
|
||||
expect(visited).toContain('yes');
|
||||
expect(visited).not.toContain('no');
|
||||
});
|
||||
|
||||
it('branch=false → 只走 ON_FALSE 那條,ON_TRUE 那條不執行', async () => {
|
||||
const { body } = await run(
|
||||
branchGraph('false', [
|
||||
{ from: 'cond', to: 'yes', type: 'ON_TRUE' },
|
||||
{ from: 'cond', to: 'no', type: 'ON_FALSE' },
|
||||
]),
|
||||
);
|
||||
expect(body.success).toBe(true);
|
||||
const visited = (body.trace ?? []).map(t => t.nodeId);
|
||||
expect(visited).toContain('no');
|
||||
expect(visited).not.toContain('yes');
|
||||
});
|
||||
|
||||
it('result 是布林但沒有 branch 欄位 → 仍judged得出(相容 {result:true} 形狀)', async () => {
|
||||
const graph = {
|
||||
id: 'g-bool-only',
|
||||
name: '只有 result',
|
||||
nodes: [
|
||||
{ id: 'cond', type: 'Input', data: { result: true } },
|
||||
{ id: 'yes', type: 'Component', componentId: 'comp_uppercase', data: { text: 'yes' } },
|
||||
{ id: 'no', type: 'Component', componentId: 'comp_uppercase', data: { text: 'no' } },
|
||||
],
|
||||
edges: [
|
||||
{ from: 'cond', to: 'yes', type: 'ON_TRUE' },
|
||||
{ from: 'cond', to: 'no', type: 'ON_FALSE' },
|
||||
],
|
||||
};
|
||||
const { body } = await run(graph);
|
||||
const visited = (body.trace ?? []).map(t => t.nodeId);
|
||||
expect(visited).toContain('yes');
|
||||
expect(visited).not.toContain('no');
|
||||
});
|
||||
|
||||
it('條件邊的下游拿得到上游 context(propagateCtx 一致)', async () => {
|
||||
const graph = {
|
||||
id: 'g-ctx',
|
||||
name: 'context 傳遞',
|
||||
nodes: [
|
||||
{ id: 'cond', type: 'Input', data: { data: { result: true, branch: 'true' }, carried: 'keep-me' } },
|
||||
{ id: 'yes', type: 'Component', componentId: 'comp_passthrough' },
|
||||
],
|
||||
edges: [{ from: 'cond', to: 'yes', type: 'ON_TRUE' }],
|
||||
};
|
||||
const { body } = await run(graph);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.carried).toBe('keep-me');
|
||||
});
|
||||
|
||||
it('兩條 ON_TRUE 並存 → 都走(同分支多下游是合法 fan-out)', async () => {
|
||||
const graph = {
|
||||
id: 'g-fanout',
|
||||
name: '同分支多下游',
|
||||
nodes: [
|
||||
{ id: 'cond', type: 'Input', data: { data: { result: true, branch: 'true' } } },
|
||||
{ id: 'a', type: 'Component', componentId: 'comp_uppercase', data: { text: 'a' } },
|
||||
{ id: 'b', type: 'Component', componentId: 'comp_uppercase', data: { text: 'b' } },
|
||||
],
|
||||
edges: [
|
||||
{ from: 'cond', to: 'a', type: 'ON_TRUE' },
|
||||
{ from: 'cond', to: 'b', type: 'ON_TRUE' },
|
||||
],
|
||||
};
|
||||
const { body } = await run(graph);
|
||||
const visited = (body.trace ?? []).map(t => t.nodeId);
|
||||
expect(visited).toContain('a');
|
||||
expect(visited).toContain('b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('條件邊:ON_BRANCH(switch 具名分支)', () => {
|
||||
/** switch 零件回 {success, data:{branch:"branch_a"}} */
|
||||
function switchGraph(branch: string) {
|
||||
return {
|
||||
id: 'g-switch',
|
||||
name: 'switch 具名分支',
|
||||
nodes: [
|
||||
{ id: 'sw', type: 'Input', data: { success: true, data: { branch } } },
|
||||
{ id: 'a', type: 'Component', componentId: 'comp_uppercase', data: { text: 'a' } },
|
||||
{ id: 'z', type: 'Component', componentId: 'comp_uppercase', data: { text: 'z' } },
|
||||
],
|
||||
edges: [
|
||||
{ from: 'sw', to: 'a', type: 'ON_BRANCH', branch: 'branch_a' },
|
||||
{ from: 'sw', to: 'z', type: 'ON_BRANCH', branch: 'fallback' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
it('branch=branch_a → 只走標 branch_a 的邊', async () => {
|
||||
const { body } = await run(switchGraph('branch_a'));
|
||||
const visited = (body.trace ?? []).map(t => t.nodeId);
|
||||
expect(visited).toContain('a');
|
||||
expect(visited).not.toContain('z');
|
||||
});
|
||||
|
||||
it('branch=fallback → 只走標 fallback 的邊', async () => {
|
||||
const { body } = await run(switchGraph('fallback'));
|
||||
const visited = (body.trace ?? []).map(t => t.nodeId);
|
||||
expect(visited).toContain('z');
|
||||
expect(visited).not.toContain('a');
|
||||
});
|
||||
|
||||
it('沒有任何邊匹配 → 誠實地不走(不亂挑一條,也不報錯)', async () => {
|
||||
const { body } = await run(switchGraph('no_such_branch'));
|
||||
expect(body.success).toBe(true);
|
||||
const visited = (body.trace ?? []).map(t => t.nodeId);
|
||||
expect(visited).not.toContain('a');
|
||||
expect(visited).not.toContain('z');
|
||||
});
|
||||
});
|
||||
|
||||
describe('通用具名分支涵蓋三型零件(leo 08-01:switch 比 if 更嚴重)', () => {
|
||||
/**
|
||||
* 三顆流程控制零件的 output_schema 都收斂到同一個形狀 `data.branch: string`:
|
||||
* if_control → "true" | "false"(布林兩路)
|
||||
* switch → case 的 branch 名 | default_branch(N 路)
|
||||
* try_catch → "try" | "catch"(成功/失敗兩路)
|
||||
* ⇒ 引擎只需要「依標籤選邊」這一個機制,不是為每顆零件開特例。
|
||||
* ON_TRUE / ON_FALSE 只是 if 布林路的語法糖,底層與 ON_BRANCH 同一條路。
|
||||
*/
|
||||
async function branchTo(branch: string, edges: Array<Record<string, unknown>>) {
|
||||
return run({
|
||||
id: `g-generic-${branch}`,
|
||||
name: '通用具名分支',
|
||||
nodes: [
|
||||
{ id: 'ctrl', type: 'Input', data: { success: true, data: { branch } } },
|
||||
{ id: 'p1', type: 'Component', componentId: 'comp_uppercase', data: { text: 'p1' } },
|
||||
{ id: 'p2', type: 'Component', componentId: 'comp_uppercase', data: { text: 'p2' } },
|
||||
{ id: 'p3', type: 'Component', componentId: 'comp_uppercase', data: { text: 'p3' } },
|
||||
],
|
||||
edges,
|
||||
});
|
||||
}
|
||||
|
||||
const threeWay = [
|
||||
{ from: 'ctrl', to: 'p1', type: 'ON_BRANCH', branch: 'branch_active' },
|
||||
{ from: 'ctrl', to: 'p2', type: 'ON_BRANCH', branch: 'branch_inactive' },
|
||||
{ from: 'ctrl', to: 'p3', type: 'ON_BRANCH', branch: 'branch_default' },
|
||||
];
|
||||
|
||||
it('switch 多路:branch_active → 只走第一條,其餘兩條不走', async () => {
|
||||
const { body } = await branchTo('branch_active', threeWay);
|
||||
const visited = (body.trace ?? []).map(t => t.nodeId);
|
||||
expect(visited).toContain('p1');
|
||||
expect(visited).not.toContain('p2');
|
||||
expect(visited).not.toContain('p3');
|
||||
});
|
||||
|
||||
it('switch 多路:branch_inactive → 只走第二條', async () => {
|
||||
const { body } = await branchTo('branch_inactive', threeWay);
|
||||
const visited = (body.trace ?? []).map(t => t.nodeId);
|
||||
expect(visited).toContain('p2');
|
||||
expect(visited).not.toContain('p1');
|
||||
expect(visited).not.toContain('p3');
|
||||
});
|
||||
|
||||
it('switch default:無匹配 case 時零件回 default_branch → 走 default 那條', async () => {
|
||||
// 注意:挑 default 是 switch 零件內部的事(它回 default_branch 名);
|
||||
// 引擎這層看到的一律是「一個標籤」,故 default 不需要引擎特別處理。
|
||||
const { body } = await branchTo('branch_default', threeWay);
|
||||
const visited = (body.trace ?? []).map(t => t.nodeId);
|
||||
expect(visited).toContain('p3');
|
||||
expect(visited).not.toContain('p1');
|
||||
expect(visited).not.toContain('p2');
|
||||
});
|
||||
|
||||
it('try_catch 成功路:branch=try → 走 try 邊,不走 catch 邊', async () => {
|
||||
const { body } = await branchTo('try', [
|
||||
{ from: 'ctrl', to: 'p1', type: 'ON_BRANCH', branch: 'try' },
|
||||
{ from: 'ctrl', to: 'p2', type: 'ON_BRANCH', branch: 'catch' },
|
||||
]);
|
||||
const visited = (body.trace ?? []).map(t => t.nodeId);
|
||||
expect(visited).toContain('p1');
|
||||
expect(visited).not.toContain('p2');
|
||||
});
|
||||
|
||||
it('try_catch 失敗路:branch=catch → 走 catch 邊,不走 try 邊', async () => {
|
||||
const { body } = await branchTo('catch', [
|
||||
{ from: 'ctrl', to: 'p1', type: 'ON_BRANCH', branch: 'try' },
|
||||
{ from: 'ctrl', to: 'p2', type: 'ON_BRANCH', branch: 'catch' },
|
||||
]);
|
||||
const visited = (body.trace ?? []).map(t => t.nodeId);
|
||||
expect(visited).toContain('p2');
|
||||
expect(visited).not.toContain('p1');
|
||||
});
|
||||
|
||||
it('ON_TRUE 與 ON_BRANCH branch="true" 等價(語法糖,底層同一條路)', async () => {
|
||||
const sugar = await branchTo('true', [{ from: 'ctrl', to: 'p1', type: 'ON_TRUE' }]);
|
||||
const raw = await branchTo('true', [{ from: 'ctrl', to: 'p1', type: 'ON_BRANCH', branch: 'true' }]);
|
||||
const v1 = (sugar.body.trace ?? []).map(t => t.nodeId);
|
||||
const v2 = (raw.body.trace ?? []).map(t => t.nodeId);
|
||||
expect(v1).toEqual(v2);
|
||||
expect(v1).toContain('p1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('零變化保證:新邊型不影響既有邊', () => {
|
||||
it('ON_TRUE 邊存在時,同圖的 PIPE 邊照常走', async () => {
|
||||
const graph = {
|
||||
id: 'g-mixed',
|
||||
name: '混合邊',
|
||||
nodes: [
|
||||
{ id: 'cond', type: 'Input', data: { data: { result: false, branch: 'false' }, count: 0 } },
|
||||
{ id: 'yes', type: 'Component', componentId: 'comp_uppercase', data: { text: 'yes' } },
|
||||
{ id: 'always', type: 'Component', componentId: 'comp_counter' },
|
||||
],
|
||||
edges: [
|
||||
{ from: 'cond', to: 'yes', type: 'ON_TRUE' },
|
||||
{ from: 'cond', to: 'always', type: 'PIPE' },
|
||||
],
|
||||
};
|
||||
const { body } = await run(graph);
|
||||
const visited = (body.trace ?? []).map(t => t.nodeId);
|
||||
expect(visited).not.toContain('yes'); // 條件邊擋掉
|
||||
expect(visited).toContain('always'); // PIPE 不受影響
|
||||
});
|
||||
|
||||
it('/validate 接受 ON_TRUE / ON_FALSE / ON_BRANCH(schema 已放行)', async () => {
|
||||
const res = await SELF.fetch('http://localhost/validate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: 'g-validate',
|
||||
name: 'schema 驗證',
|
||||
nodes: [
|
||||
{ id: 'a', type: 'Input' },
|
||||
{ id: 'b', type: 'Output' },
|
||||
{ id: 'c', type: 'Output' },
|
||||
],
|
||||
edges: [
|
||||
{ from: 'a', to: 'b', type: 'ON_TRUE' },
|
||||
{ from: 'a', to: 'c', type: 'ON_FALSE' },
|
||||
],
|
||||
}),
|
||||
});
|
||||
const data = (await res.json()) as { valid: boolean };
|
||||
expect(res.status).toBe(200);
|
||||
expect(data.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* console-auth.ts —— D61 舊實例相容(帳密只在舊 SESSIONS_KV,尚未搬遷過)
|
||||
*
|
||||
* 拆成獨立檔案的理由:portal-auth-store.ts 的 per-isolate overlay 是模組級全域變數,
|
||||
* 一旦某個測試讓 console 帳密的認證儲存寫入成功,overlay.console 就會在**同一支測試檔案**
|
||||
* 剩下的測試裡持續存在(不同檔案=不同 worker 執行個體,互不污染,已用小型探針驗證過)。
|
||||
* tests/console-auth.test.ts 一開始就會走一次「首次設定成功」,之後整支檔案都是「已設定」
|
||||
* 的世界;「認證儲存還是空的、帳密只活在舊 KV」這個起始狀態只有在全新檔案才測得出來。
|
||||
*/
|
||||
import { SELF, env, fetchMock } from 'cloudflare:test';
|
||||
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
|
||||
|
||||
const CF_API = 'https://api.cloudflare.com';
|
||||
const CREDS_KEY = 'console:credentials';
|
||||
|
||||
beforeAll(() => {
|
||||
fetchMock.activate();
|
||||
fetchMock.disableNetConnect();
|
||||
});
|
||||
afterEach(() => fetchMock.assertNoPendingInterceptors());
|
||||
|
||||
function json(method: string, path: string, body?: unknown) {
|
||||
return SELF.fetch(`http://localhost${path}`, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function mockAuthStoreWrite(times = 1): { puts: () => Array<{ name: string; text: string }> } {
|
||||
const captured: Array<{ name: string; text: string }> = [];
|
||||
fetchMock
|
||||
.get(CF_API)
|
||||
.intercept({ path: (p: string) => p.includes('/secrets'), method: 'PUT' })
|
||||
.reply(200, (opts) => {
|
||||
const body = JSON.parse(String(opts.body)) as { name: string; text: string };
|
||||
captured.push(body);
|
||||
return { success: true };
|
||||
})
|
||||
.times(times);
|
||||
return { puts: () => captured };
|
||||
}
|
||||
|
||||
/** 複刻 console-auth.ts 內未 export 的私有迭代雜湊(sha256(salt+password) 迭代 3 次),
|
||||
* 單純為了在測試端準備一筆能通過驗證的 legacy fixture,不是重新實作生產邏輯。 */
|
||||
async function legacyHash(password: string, salt: string): Promise<string> {
|
||||
async function sha256Hex(input: string): Promise<string> {
|
||||
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input));
|
||||
return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
let h = `${salt}:${password}`;
|
||||
for (let i = 0; i < 3; i++) h = await sha256Hex(h);
|
||||
return h;
|
||||
}
|
||||
|
||||
const EMAIL = 'legacy-owner@example.com';
|
||||
const PASSWORD = 'legacy-owner-pw-1';
|
||||
const SALT = 'deadbeef00112233';
|
||||
|
||||
describe('D61 舊實例相容:console 帳密只在舊 KV(尚未搬遷)', () => {
|
||||
it('GET /console/auth-status:讀到舊 KV 這筆、順手搬進認證儲存', async () => {
|
||||
const hash = await legacyHash(PASSWORD, SALT);
|
||||
await env.SESSIONS_KV.put(
|
||||
CREDS_KEY,
|
||||
JSON.stringify({ email: EMAIL, salt: SALT, hash, created_at: '2026-01-01T00:00:00.000Z' }),
|
||||
);
|
||||
const { puts } = mockAuthStoreWrite();
|
||||
|
||||
const res = await json('GET', '/console/auth-status');
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as {
|
||||
configured: boolean;
|
||||
credentials_source: string;
|
||||
auth_store: { console_configured: boolean };
|
||||
};
|
||||
expect(data.configured).toBe(true);
|
||||
expect(data.credentials_source).toBe('legacy-kv'); // 這次是靠回退讀到的
|
||||
// loadCredentials 內的 best-effort 搬遷在回應組出來之前就已 await 完成,
|
||||
// 故 authStoreStatus 已經反映搬遷後的狀態
|
||||
expect(data.auth_store.console_configured).toBe(true);
|
||||
|
||||
const shards = puts();
|
||||
expect(shards.length).toBe(1);
|
||||
const shard = JSON.parse(shards[0].text) as { console: { email: string; hash: string } };
|
||||
expect(shard.console.email).toBe(EMAIL);
|
||||
expect(shard.console.hash).toBe(hash); // 原樣搬過去,不重新雜湊
|
||||
});
|
||||
|
||||
it('搬遷後再打一次:新家已經有了,直接命中新家(不用再查舊 KV)', async () => {
|
||||
const res = await json('GET', '/console/auth-status');
|
||||
const data = (await res.json()) as { credentials_source: string };
|
||||
expect(data.credentials_source).toBe('secrets');
|
||||
});
|
||||
|
||||
it('用搬遷過去的帳密登入 → 200(搬遷沒有讓帳密變得登不進去)', async () => {
|
||||
const res = await json('POST', '/console/login', { email: EMAIL, password: PASSWORD });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { success: boolean };
|
||||
expect(data.success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* console-auth.ts 測試(D61:console 管理員帳密搬進認證儲存,ADR D61 / Leo/arcrun-rag#55)
|
||||
*
|
||||
* 這組帳密(/console/setup、/console/login…)原本住 SESSIONS_KV `console:credentials`
|
||||
* (沒有 TTL)——KV 靠 binding 指過去,重裝會被指到新建的空 KV ⇒ 帳密憑空消失
|
||||
* (console-auth.ts 檔頭「KV=暫存、非長期真相源」第三次被違反,這次違反的是大門的鎖)。
|
||||
* D61 起改存進認證儲存(CF Workers Secrets),SESSIONS_KV 只留為回退讀路徑。
|
||||
*
|
||||
* 覆蓋(本檔在此之前不存在,D61 交辦要求的新增覆蓋):
|
||||
* 1. 全新實例:auth-status 回 configured:false;login 回「讀不到認證資料」(不是密碼錯)。
|
||||
* 2. 首次設定成功:POST /console/setup 寫進認證儲存(CF Workers Secrets),不再寫 KV。
|
||||
* 3. 已設定過 → 409,訊息明講「你剛才輸入的密碼沒有被採用」(D61 明顯失敗,取代舊版
|
||||
* 只說「已設定過」卻不說清楚剛才那組密碼發生了什麼事的誤導文案)。
|
||||
* 4. 登入對錯:帳密正確 200;密碼錯 401。
|
||||
* 5. /console/setup/reset:舊密碼驗證+新密碼寫進新家;換密碼後舊密碼立即失效。
|
||||
*
|
||||
* 認證儲存寫入會呼叫 `https://api.cloudflare.com/.../secrets`(PUT),走 fetchMock 假 host
|
||||
* 攔截(同 portal-auth.test.ts 的 mockAuthStoreWrite),不外連;wrangler.test.toml 已預設
|
||||
* CF_SECRETS_API_TOKEN/CF_ACCOUNT_ID 就緒。
|
||||
*
|
||||
* ⚠️ 測試順序不可打亂:portal-auth-store.ts 的 per-isolate overlay 是模組級全域變數,
|
||||
* 一旦某則測試讓 /console/setup 或 reset 真的寫成功,overlay.console 就會在**這支檔案**
|
||||
* 剩下的測試裡持續存在(同檔案不會在測試之間重置模組全域,只有 KV/D1 等 storage 才有
|
||||
* isolatedStorage 重置)。因此本檔刻意排成一條線性故事:先驗證「全新、尚未設定」的分支,
|
||||
* 再做一次成功的 /console/setup(之後永久變成「已設定」),後面的測試都建立在這個已設定
|
||||
* 的基礎上。「帳密只存在舊 KV(尚未搬遷過)」這個分支需要 overlay 是空的,因此另開一支
|
||||
* 檔案 tests/console-auth-legacy.test.ts(不同檔案=不同 worker 執行個體,狀態不互相污染)。
|
||||
*/
|
||||
import { SELF, env, fetchMock } from 'cloudflare:test';
|
||||
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
|
||||
|
||||
const CF_API = 'https://api.cloudflare.com';
|
||||
|
||||
beforeAll(() => {
|
||||
fetchMock.activate();
|
||||
fetchMock.disableNetConnect();
|
||||
});
|
||||
afterEach(() => fetchMock.assertNoPendingInterceptors());
|
||||
|
||||
function json(method: string, path: string, body?: unknown, headers: Record<string, string> = {}) {
|
||||
return SELF.fetch(`http://localhost${path}`, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
/** D61:認證儲存寫入路徑(同 portal-auth.test.ts 的同名 helper,那邊有完整說明)。 */
|
||||
function mockAuthStoreWrite(times = 1): { puts: () => Array<{ name: string; text: string }> } {
|
||||
const captured: Array<{ name: string; text: string }> = [];
|
||||
fetchMock
|
||||
.get(CF_API)
|
||||
.intercept({ path: (p: string) => p.includes('/secrets'), method: 'PUT' })
|
||||
.reply(200, (opts) => {
|
||||
const body = JSON.parse(String(opts.body)) as { name: string; text: string };
|
||||
captured.push(body);
|
||||
return { success: true };
|
||||
})
|
||||
.times(times);
|
||||
return { puts: () => captured };
|
||||
}
|
||||
|
||||
const OWNER_EMAIL = 'owner@example.com';
|
||||
const OWNER_PW = 'owner-first-pw-1';
|
||||
|
||||
// ═══════════════ 1. 全新實例(尚未設定過,必須排最前面)═══════════════
|
||||
|
||||
describe('全新實例(尚未設定過任何管理員帳密)', () => {
|
||||
it('GET /console/auth-status → configured:false,不洩漏 email', async () => {
|
||||
const res = await json('GET', '/console/auth-status');
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { configured: boolean; credentials_source: string; auth_store: { present: boolean } };
|
||||
expect(data.configured).toBe(false);
|
||||
expect(data.credentials_source).toBe('none');
|
||||
expect(JSON.stringify(data)).not.toContain('@'); // 不洩漏 email
|
||||
});
|
||||
|
||||
it('POST /console/login → 400「讀不到認證資料」,不是密碼錯(D61 明顯失敗)', async () => {
|
||||
const res = await json('POST', '/console/login', { email: 'anyone@example.com', password: 'whatever-pw-1' });
|
||||
expect(res.status).toBe(400);
|
||||
const data = (await res.json()) as { code: string; error: string };
|
||||
expect(data.code).toBe('auth_store_empty');
|
||||
expect(data.error).not.toBe('email 或密碼錯誤'); // 不是密碼錯誤路徑用的那句通用訊息
|
||||
});
|
||||
|
||||
it('POST /console/setup/reset(還沒設定過就想換密碼)→ 400,叫去用 /console/setup', async () => {
|
||||
const res = await json('POST', '/console/setup/reset', {
|
||||
current_password: 'whatever', email: 'x@y.co', password: 'newpassword1',
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 2. 首次設定:成功寫進認證儲存(D61 起唯一寫入路徑)═══════════════
|
||||
|
||||
describe('POST /console/setup — 首次設定', () => {
|
||||
it('成功:寫進認證儲存(不再寫 SESSIONS_KV),回 session_token', async () => {
|
||||
const { puts } = mockAuthStoreWrite();
|
||||
const res = await json('POST', '/console/setup', { email: OWNER_EMAIL.toUpperCase(), password: OWNER_PW });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { success: boolean; session_token: string; tenant: string };
|
||||
expect(data.success).toBe(true);
|
||||
expect(typeof data.session_token).toBe('string');
|
||||
|
||||
// 寫入認證儲存:一片、含小寫 email,明碼密碼絕不落地
|
||||
const shards = puts();
|
||||
expect(shards.length).toBe(1);
|
||||
expect(shards[0].name).toBe('ARCRUN_AUTH_STORE');
|
||||
expect(shards[0].text).not.toContain(OWNER_PW);
|
||||
const shard = JSON.parse(shards[0].text) as { console: { email: string; salt: string; hash: string } };
|
||||
expect(shard.console.email).toBe(OWNER_EMAIL); // 存小寫
|
||||
expect(typeof shard.console.salt).toBe('string');
|
||||
expect(typeof shard.console.hash).toBe('string');
|
||||
|
||||
// D61:不再寫舊 KV——這是本次變更的核心(舊版寫 SESSIONS_KV,重裝就蒸發)
|
||||
expect(await env.SESSIONS_KV.get('console:credentials')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 3. 已設定過 → 409(D61 明顯失敗:說得出「沒有被採用」)═══════════════
|
||||
|
||||
describe('POST /console/setup — 已設定過(重複設定)', () => {
|
||||
it('409,訊息明講「你剛才輸入的密碼沒有被採用」,不誤導成「設定成功」', async () => {
|
||||
const res = await json('POST', '/console/setup', { email: 'attacker@example.com', password: 'trying-to-hijack-1' });
|
||||
expect(res.status).toBe(409);
|
||||
const data = (await res.json()) as {
|
||||
error: string; code: string; password_applied: boolean; reset_path: string;
|
||||
};
|
||||
expect(data.code).toBe('already_configured');
|
||||
expect(data.password_applied).toBe(false);
|
||||
expect(data.error).toContain('沒有被採用');
|
||||
expect(data.reset_path).toBe('/console/setup/reset');
|
||||
// 攻擊者填的帳密真的沒有生效:用它登入應該失敗(下一個 describe 也會正面驗證原帳密仍有效)
|
||||
});
|
||||
|
||||
it('GET /console/auth-status → configured:true,credentials_source:secrets(新家優先命中)', async () => {
|
||||
const res = await json('GET', '/console/auth-status');
|
||||
const data = (await res.json()) as { configured: boolean; credentials_source: string; auth_store: { console_configured: boolean } };
|
||||
expect(data.configured).toBe(true);
|
||||
expect(data.credentials_source).toBe('secrets');
|
||||
expect(data.auth_store.console_configured).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 4. 登入對錯(用第 2 節設定的帳密)═══════════════
|
||||
|
||||
describe('POST /console/login', () => {
|
||||
it('帳密正確 → 200,發 session token', async () => {
|
||||
const res = await json('POST', '/console/login', { email: OWNER_EMAIL, password: OWNER_PW });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { success: boolean; session_token: string };
|
||||
expect(data.success).toBe(true);
|
||||
expect(typeof data.session_token).toBe('string');
|
||||
});
|
||||
|
||||
it('密碼錯 → 401', async () => {
|
||||
const res = await json('POST', '/console/login', { email: OWNER_EMAIL, password: 'wrong-password-x' });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('攻擊者在第 3 節試圖搶注的帳密登不進來(證明真的「沒有被採用」)', async () => {
|
||||
const res = await json('POST', '/console/login', { email: 'attacker@example.com', password: 'trying-to-hijack-1' });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 5. /console/setup/reset:換密碼,寫進新家 ═══════════════
|
||||
|
||||
describe('POST /console/setup/reset', () => {
|
||||
const NEW_PW = 'brand-new-owner-pw-1';
|
||||
|
||||
it('舊密碼錯 → 401,不寫入', async () => {
|
||||
const res = await json('POST', '/console/setup/reset', {
|
||||
current_password: 'still-wrong', email: OWNER_EMAIL, password: NEW_PW,
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('舊密碼對 → 200,新 hash 寫進新家;換完後舊密碼立即失效、新密碼生效', async () => {
|
||||
const { puts } = mockAuthStoreWrite();
|
||||
const res = await json('POST', '/console/setup/reset', {
|
||||
current_password: OWNER_PW, email: OWNER_EMAIL, password: NEW_PW,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { success: boolean };
|
||||
expect(data.success).toBe(true);
|
||||
|
||||
const shards = puts();
|
||||
expect(shards.length).toBe(1);
|
||||
expect(shards[0].text).not.toContain(NEW_PW); // 明碼不落地
|
||||
const shard = JSON.parse(shards[0].text) as { console: { email: string } };
|
||||
expect(shard.console.email).toBe(OWNER_EMAIL);
|
||||
|
||||
// 舊密碼立即失效
|
||||
const oldLogin = await json('POST', '/console/login', { email: OWNER_EMAIL, password: OWNER_PW });
|
||||
expect(oldLogin.status).toBe(401);
|
||||
// 新密碼生效
|
||||
const newLogin = await json('POST', '/console/login', { email: OWNER_EMAIL, password: NEW_PW });
|
||||
expect(newLogin.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -1,110 +1,260 @@
|
||||
/**
|
||||
* credential 治理端點測試。
|
||||
* credentials 路由測試(D38 圍牆修復後補寫,2026-08-08)
|
||||
*
|
||||
* 範圍限制(誠實記錄,非本檔缺陷):`putWorkerSecret` / `deleteWorkerSecret` 呼叫真實
|
||||
* Cloudflare API(`fetch` 到 api.cloudflare.com)。測試環境(wrangler.test.toml)刻意不設
|
||||
* CF_SECRETS_API_TOKEN/CF_ACCOUNT_ID,所以本檔只覆蓋「不需要真的打 CF API」的路徑:
|
||||
* - D1-only 的 GET /credentials、/credentials/catalog
|
||||
* - DELETE 在 D1 無 row 時 fallback 刪舊 KV(不會走到 deleteWorkerSecret)
|
||||
* 真正打 CF Workers Secrets API 成功寫入/刪除的路徑,由部署到 leo21c 帳號後的端到端
|
||||
* curl 驗證覆蓋(見 credential-store-migration.md T8/T9 完成記錄)。
|
||||
* 前身是刻意留紅的 placeholder(見 git history):2026-08-07 D38 把 credential 目錄從
|
||||
* 「獨立 credentials 表 + 原生 SQL」改成「KBDB entries(entry_type='credential')+
|
||||
* HTTP API」,舊測試全部作廢,agent 中途被中斷沒補上,故意留一個會失敗的測試佔位、
|
||||
* 避免「no tests」被誤讀成「通過」。本檔依 placeholder 頭部列的五項補齊。
|
||||
*
|
||||
* 測試手法比照姊妹模組 execution-logger.test.ts:`vi.stubGlobal('fetch', ...)` 攔截,
|
||||
* 但這裡的攔截器是**有狀態的假 KBDB**(in-memory entries store),因為 credentials.ts
|
||||
* 一次操作常涉及多輪 HTTP 呼叫(find → upsert / find → delete),單次回應的 mock 測不出
|
||||
* 「查得到剛寫的」「刪掉後真的查不到」這類語意,需要一個會記狀態的假後端。
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { env, SELF } from 'cloudflare:test';
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||
import { Hono } from 'hono';
|
||||
import { credentialsRouter, getCredentialSecretRefs, hasCredential, invalidateCredentialCache } from '../src/routes/credentials';
|
||||
import type { Bindings } from '../src/types';
|
||||
// Workers runtime(@cloudflare/vitest-pool-workers)沒有 node:fs——原始碼掃描改用 Vite 的
|
||||
// `?raw` import 取字串內容(build-time 讀檔,runtime 是純字串,不受 Workers 限制)。
|
||||
// @ts-expect-error -- vite ?raw 型別由 tsconfig 的 vite/client 提供,非本檔關注重點
|
||||
import credentialsSource from '../src/routes/credentials.ts?raw';
|
||||
|
||||
const API_KEY = 'test-tenant-t89';
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
async function insertCredentialRow(
|
||||
name: string,
|
||||
secretRef: string,
|
||||
extra: Partial<{ service: string | null; sensitivity: string; last_used_at: number | null }> = {},
|
||||
): Promise<void> {
|
||||
await env.CREDENTIALS_DB
|
||||
.prepare(
|
||||
`INSERT INTO credentials (api_key, name, service, sensitivity, secret_ref, created_at, last_used_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.bind(
|
||||
API_KEY,
|
||||
name,
|
||||
extra.service ?? null,
|
||||
extra.sensitivity ?? 'standard',
|
||||
secretRef,
|
||||
Math.floor(Date.now() / 1000),
|
||||
extra.last_used_at ?? null,
|
||||
)
|
||||
.run();
|
||||
// ── 有狀態假 KBDB:只實作 credentials.ts 實際會打的四個操作(GET list/find, POST, PATCH, DELETE)──
|
||||
interface FakeEntry {
|
||||
id: string;
|
||||
entry_type: string;
|
||||
owner_id: string;
|
||||
page_name: string;
|
||||
metadata_json: string;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
async function clearTenantRows(): Promise<void> {
|
||||
await env.CREDENTIALS_DB.prepare(`DELETE FROM credentials WHERE api_key = ?`).bind(API_KEY).run();
|
||||
function makeFakeKbdb() {
|
||||
const entries: FakeEntry[] = [];
|
||||
let idSeq = 0;
|
||||
const secretsStore = new Map<string, string>(); // secretRef -> plaintext(模擬 CF Workers Secrets,唯寫,測試用來斷言「有沒有被塞值」)
|
||||
const secretPuts: Array<{ name: string; text: string }> = [];
|
||||
const secretDeletes: string[] = [];
|
||||
const kbdbRequests: Array<{ method: string; url: string; body: unknown }> = [];
|
||||
|
||||
async function handle(url: string, init: RequestInit = {}): Promise<Response> {
|
||||
const method = (init.method ?? 'GET').toUpperCase();
|
||||
const u = new URL(url);
|
||||
|
||||
// CF Workers Scripts secrets 管理 API(唯寫,讀不回值)
|
||||
if (u.hostname === 'api.cloudflare.com') {
|
||||
if (method === 'PUT' && u.pathname.endsWith('/secrets')) {
|
||||
const body = JSON.parse(String(init.body)) as { name: string; text: string };
|
||||
secretsStore.set(body.name, body.text);
|
||||
secretPuts.push(body);
|
||||
return new Response(JSON.stringify({ success: true }), { status: 200 });
|
||||
}
|
||||
if (method === 'DELETE' && u.pathname.includes('/secrets/')) {
|
||||
const name = u.pathname.split('/secrets/')[1];
|
||||
secretsStore.delete(name);
|
||||
secretDeletes.push(name);
|
||||
return new Response(JSON.stringify({ success: true }), { status: 200 });
|
||||
}
|
||||
throw new Error(`unhandled CF API call: ${method} ${url}`);
|
||||
}
|
||||
|
||||
// KBDB entries API
|
||||
kbdbRequests.push({ method, url, body: init.body ? JSON.parse(String(init.body)) : undefined });
|
||||
|
||||
if (method === 'POST' && u.pathname === '/entries') {
|
||||
const body = JSON.parse(String(init.body)) as Partial<FakeEntry>;
|
||||
const entry: FakeEntry = {
|
||||
id: `e_${++idSeq}`,
|
||||
entry_type: body.entry_type!,
|
||||
owner_id: body.owner_id!,
|
||||
page_name: body.page_name!,
|
||||
metadata_json: body.metadata_json!,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
entries.push(entry);
|
||||
return new Response(JSON.stringify({ success: true, entry }), { status: 200 });
|
||||
}
|
||||
|
||||
if (method === 'GET' && u.pathname === '/entries') {
|
||||
const ownerId = u.searchParams.get('owner_id');
|
||||
const entryType = u.searchParams.get('entry_type');
|
||||
const pageName = u.searchParams.get('page_name');
|
||||
let rows = entries.filter((e) => e.entry_type === entryType && e.owner_id === ownerId);
|
||||
if (pageName) rows = rows.filter((e) => e.page_name === pageName);
|
||||
return new Response(JSON.stringify({ success: true, entries: rows, count: rows.length }), { status: 200 });
|
||||
}
|
||||
|
||||
if (method === 'PATCH' && u.pathname.startsWith('/entries/')) {
|
||||
const id = decodeURIComponent(u.pathname.slice('/entries/'.length));
|
||||
const body = JSON.parse(String(init.body)) as Partial<FakeEntry>;
|
||||
const entry = entries.find((e) => e.id === id);
|
||||
if (!entry) return new Response(JSON.stringify({ success: false }), { status: 404 });
|
||||
if (body.metadata_json !== undefined) entry.metadata_json = body.metadata_json;
|
||||
return new Response(JSON.stringify({ success: true, entry }), { status: 200 });
|
||||
}
|
||||
|
||||
if (method === 'DELETE' && u.pathname.startsWith('/entries/')) {
|
||||
const id = decodeURIComponent(u.pathname.slice('/entries/'.length));
|
||||
const idx = entries.findIndex((e) => e.id === id);
|
||||
if (idx === -1) return new Response(JSON.stringify({ success: false }), { status: 404 });
|
||||
entries.splice(idx, 1); // 真的從陣列移除,不是標記
|
||||
return new Response(JSON.stringify({ success: true }), { status: 200 });
|
||||
}
|
||||
|
||||
throw new Error(`unhandled KBDB call: ${method} ${url}`);
|
||||
}
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn((url: string, init?: RequestInit) => handle(url, init)));
|
||||
|
||||
return { entries, secretsStore, secretPuts, secretDeletes, kbdbRequests };
|
||||
}
|
||||
|
||||
describe('GET /credentials (D1, T9)', () => {
|
||||
beforeEach(clearTenantRows);
|
||||
function fakeEnv(): Bindings {
|
||||
return {
|
||||
KBDB_BASE_URL: 'https://kbdb.test',
|
||||
CF_SECRETS_API_TOKEN: 'fake-cf-token',
|
||||
CF_ACCOUNT_ID: 'fake-account',
|
||||
ENVIRONMENT: 'test',
|
||||
CREDENTIALS_KV: { delete: vi.fn(async () => {}) } as unknown as KVNamespace,
|
||||
} as unknown as Bindings;
|
||||
}
|
||||
|
||||
it('缺 X-Arcrun-API-Key → 401', async () => {
|
||||
const res = await SELF.fetch('https://cypher.test/credentials');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
function app() {
|
||||
const a = new Hono<{ Bindings: Bindings }>();
|
||||
a.route('/', credentialsRouter);
|
||||
return a;
|
||||
}
|
||||
|
||||
it('無資料 → 空陣列(非拋錯)', async () => {
|
||||
const res = await SELF.fetch('https://cypher.test/credentials', {
|
||||
headers: { 'X-Arcrun-API-Key': API_KEY },
|
||||
});
|
||||
beforeEach(() => {
|
||||
invalidateCredentialCache('tenant-a');
|
||||
invalidateCredentialCache('tenant-b');
|
||||
});
|
||||
|
||||
describe('1. 寫入走 KBDB HTTP API,且 owner_id = api_key(租戶隔離)', () => {
|
||||
it('POST /credentials 寫入後,entries 裡的 owner_id 就是呼叫者的 api_key', async () => {
|
||||
const fake = makeFakeKbdb();
|
||||
const env = fakeEnv();
|
||||
const a = app();
|
||||
const res = await a.request('/credentials', {
|
||||
method: 'POST',
|
||||
headers: { 'X-Arcrun-API-Key': 'tenant-a', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'telegram_bot_token', value: 'secret-plaintext-value', service: 'telegram' }),
|
||||
}, env);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { success: boolean; credentials: unknown[]; total: number };
|
||||
const body = (await res.json()) as { success: boolean };
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.credentials).toEqual([]);
|
||||
expect(body.total).toBe(0);
|
||||
expect(fake.entries).toHaveLength(1);
|
||||
expect(fake.entries[0].owner_id).toBe('tenant-a');
|
||||
expect(fake.entries[0].page_name).toBe('telegram_bot_token');
|
||||
});
|
||||
|
||||
it('回傳 metadata,絕不含 secret_ref 或值', async () => {
|
||||
await insertCredentialRow('telegram_bot_token', 'CRED_TELEGRAM_BOT_TOKEN_ABCDEF01', { service: 'telegram' });
|
||||
const res = await SELF.fetch('https://cypher.test/credentials', {
|
||||
headers: { 'X-Arcrun-API-Key': API_KEY },
|
||||
});
|
||||
const body = await res.json() as { success: boolean; credentials: Array<Record<string, unknown>> };
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.credentials).toHaveLength(1);
|
||||
const row = body.credentials[0];
|
||||
expect(row.name).toBe('telegram_bot_token');
|
||||
expect(row.service).toBe('telegram');
|
||||
expect(row).not.toHaveProperty('secret_ref');
|
||||
expect(row).not.toHaveProperty('value');
|
||||
expect(JSON.stringify(row)).not.toMatch(/CRED_/);
|
||||
});
|
||||
|
||||
it('/credentials/catalog 回同一份資料(Console 相容別名)', async () => {
|
||||
await insertCredentialRow('notion_token', 'CRED_NOTION_TOKEN_ABCDEF01');
|
||||
const [listRes, catalogRes] = await Promise.all([
|
||||
SELF.fetch('https://cypher.test/credentials', { headers: { 'X-Arcrun-API-Key': API_KEY } }),
|
||||
SELF.fetch('https://cypher.test/credentials/catalog', { headers: { 'X-Arcrun-API-Key': API_KEY } }),
|
||||
]);
|
||||
const [listBody, catalogBody] = await Promise.all([listRes.json(), catalogRes.json()]) as Array<{
|
||||
credentials: Array<{ name: string }>;
|
||||
}>;
|
||||
expect(listBody.credentials.map(r => r.name)).toEqual(catalogBody.credentials.map(r => r.name));
|
||||
it('兩個不同 api_key 各自建立的同名 credential 落在不同 owner_id、互不覆蓋', async () => {
|
||||
const fake = makeFakeKbdb();
|
||||
const env = fakeEnv();
|
||||
const a = app();
|
||||
await a.request('/credentials', {
|
||||
method: 'POST', headers: { 'X-Arcrun-API-Key': 'tenant-a', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'gemini_api_key', value: 'value-a' }),
|
||||
}, env);
|
||||
await a.request('/credentials', {
|
||||
method: 'POST', headers: { 'X-Arcrun-API-Key': 'tenant-b', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'gemini_api_key', value: 'value-b' }),
|
||||
}, env);
|
||||
expect(fake.entries).toHaveLength(2);
|
||||
const owners = fake.entries.map((e) => e.owner_id).sort();
|
||||
expect(owners).toEqual(['tenant-a', 'tenant-b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /credentials/:name (T9)', () => {
|
||||
beforeEach(clearTenantRows);
|
||||
describe('2. 讀取查得回 secret_ref,且查不到別的租戶的', () => {
|
||||
it('getCredentialSecretRefs 回該租戶的 name→secret_ref 對照,不含其他租戶的', async () => {
|
||||
makeFakeKbdb();
|
||||
const env = fakeEnv();
|
||||
const a = app();
|
||||
await a.request('/credentials', {
|
||||
method: 'POST', headers: { 'X-Arcrun-API-Key': 'tenant-a', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'gemini_api_key', value: 'value-a' }),
|
||||
}, env);
|
||||
await a.request('/credentials', {
|
||||
method: 'POST', headers: { 'X-Arcrun-API-Key': 'tenant-b', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'other_key', value: 'value-b' }),
|
||||
}, env);
|
||||
|
||||
it('D1 無 row(從未回填)→ fallback 刪舊 KV,不誤報找不到', async () => {
|
||||
await env.CREDENTIALS_KV.put(
|
||||
`${API_KEY}:cred:legacy_only`,
|
||||
JSON.stringify({ encrypted: 'x', iv: 'y' }),
|
||||
);
|
||||
const res = await SELF.fetch('https://cypher.test/credentials/legacy_only', {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-Arcrun-API-Key': API_KEY },
|
||||
});
|
||||
const body = await res.json() as { success: boolean; source: string };
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.source).toBe('legacy-kv');
|
||||
const raw = await env.CREDENTIALS_KV.get(`${API_KEY}:cred:legacy_only`);
|
||||
expect(raw).toBeNull();
|
||||
const refsA = await getCredentialSecretRefs(env, 'tenant-a');
|
||||
expect(Object.keys(refsA)).toEqual(['gemini_api_key']);
|
||||
expect(refsA.gemini_api_key).toMatch(/^CRED_GEMINI_API_KEY_/);
|
||||
expect(refsA.other_key).toBeUndefined(); // 查不到別租戶的
|
||||
|
||||
const refsB = await getCredentialSecretRefs(env, 'tenant-b');
|
||||
expect(Object.keys(refsB)).toEqual(['other_key']);
|
||||
});
|
||||
|
||||
it('hasCredential:查得到自己的,查不到別租戶的同名 credential', async () => {
|
||||
makeFakeKbdb();
|
||||
const env = fakeEnv();
|
||||
const a = app();
|
||||
await a.request('/credentials', {
|
||||
method: 'POST', headers: { 'X-Arcrun-API-Key': 'tenant-a', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'kbdb_internal_token', value: 'v' }),
|
||||
}, env);
|
||||
expect(await hasCredential(env, 'tenant-a', 'kbdb_internal_token')).toBe(true);
|
||||
expect(await hasCredential(env, 'tenant-b', 'kbdb_internal_token')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('3. 刪除是真的刪(不是 deprecated 標記)', () => {
|
||||
it('DELETE /credentials/:name 後,該筆 entries row 從 KBDB 消失(不是 metadata 打 deprecated 標記)', async () => {
|
||||
const fake = makeFakeKbdb();
|
||||
const env = fakeEnv();
|
||||
const a = app();
|
||||
await a.request('/credentials', {
|
||||
method: 'POST', headers: { 'X-Arcrun-API-Key': 'tenant-a', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'to_delete', value: 'v' }),
|
||||
}, env);
|
||||
expect(fake.entries).toHaveLength(1);
|
||||
|
||||
const res = await a.request('/credentials/to_delete', {
|
||||
method: 'DELETE', headers: { 'X-Arcrun-API-Key': 'tenant-a' },
|
||||
}, env);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { success: boolean; source: string };
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.source).toBe('workers-secrets');
|
||||
|
||||
// 真的從陣列移除,不是留著、metadata 打上 status:deprecated
|
||||
expect(fake.entries).toHaveLength(0);
|
||||
// Workers Secret 本體也真的被刪(DELETE 呼叫過),不是只刪目錄留孤兒密文
|
||||
expect(fake.secretDeletes.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('4. 零原生 SQL:整支檔案不得出現 .prepare/.exec/.batch', () => {
|
||||
it('routes/credentials.ts 原始碼掃描:沒有任何 D1 原生呼叫語法', () => {
|
||||
expect(/\.\s*(prepare|exec|batch)\s*\(/.test(credentialsSource)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('5. 密文本體不落 KBDB(只有 secret_ref 指標)—— D19 不變', () => {
|
||||
it('送去 KBDB 的 body 裡從頭到尾沒有明文 credential value,只有 secret_ref', async () => {
|
||||
const fake = makeFakeKbdb();
|
||||
const env = fakeEnv();
|
||||
const a = app();
|
||||
const plaintext = 'super-secret-plaintext-should-never-leave-workers-secrets';
|
||||
await a.request('/credentials', {
|
||||
method: 'POST', headers: { 'X-Arcrun-API-Key': 'tenant-a', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'sensitive_key', value: plaintext }),
|
||||
}, env);
|
||||
|
||||
// 明文只出現在 CF Workers Secrets 的 PUT(唯寫 API),不出現在任何打去 KBDB 的請求 body 裡
|
||||
expect(fake.secretPuts.some((p) => p.text === plaintext)).toBe(true);
|
||||
for (const req of fake.kbdbRequests) {
|
||||
expect(JSON.stringify(req.body ?? '')).not.toContain(plaintext);
|
||||
}
|
||||
// entries 裡存的是 secret_ref 指標,不是值
|
||||
expect(fake.entries[0].metadata_json).not.toContain(plaintext);
|
||||
expect(fake.entries[0].metadata_json).toContain('secret_ref');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// 單元測試:execution-evaluator — 從 trace 導出每顆零件成敗 + 回寫 registry
|
||||
// SDD: system-dev/docs/3-specs/arcrun-core-mvp/design.md「執行統計設計」
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { componentVerdictsFromTrace, recordComponentStats } from '../src/actions/execution-evaluator';
|
||||
import type { GraphNode, TraceStep } from '../src/types';
|
||||
|
||||
const NODES: GraphNode[] = [
|
||||
{ id: 'input', type: 'Input' },
|
||||
{ id: 'fetch', type: 'Component', componentId: 'http_request' },
|
||||
{ id: 'transform', type: 'Component', componentId: 'code' },
|
||||
{ id: 'output', type: 'Output' },
|
||||
];
|
||||
|
||||
function step(nodeId: string, over: Partial<TraceStep> = {}): TraceStep {
|
||||
return { nodeId, type: 'Component', input: {}, output: { ok: true }, duration_ms: 10, ...over };
|
||||
}
|
||||
|
||||
describe('componentVerdictsFromTrace', () => {
|
||||
it('只算 Component 節點;Input/Output 跳過', () => {
|
||||
const verdicts = componentVerdictsFromTrace(NODES, [
|
||||
step('input', { type: 'Input' }),
|
||||
step('fetch'),
|
||||
step('output', { type: 'Output' }),
|
||||
]);
|
||||
expect(verdicts).toEqual([{ component_id: 'http_request', success: true, duration_ms: 10 }]);
|
||||
});
|
||||
|
||||
it('trace 有 error → 該零件記失敗', () => {
|
||||
const verdicts = componentVerdictsFromTrace(NODES, [
|
||||
step('fetch', { error: 'boom', output: null }),
|
||||
]);
|
||||
expect(verdicts).toEqual([{ component_id: 'http_request', success: false, duration_ms: 10 }]);
|
||||
});
|
||||
|
||||
it('output.success === false → 記失敗(makeHttpRunner 對非 2xx 不 throw)', () => {
|
||||
const verdicts = componentVerdictsFromTrace(NODES, [
|
||||
step('fetch', { output: { success: false, status: 500, error: 'oops' } }),
|
||||
]);
|
||||
expect(verdicts[0].success).toBe(false);
|
||||
});
|
||||
|
||||
it('FOREACH 同節點多筆 trace → 每次執行各記一次樣本', () => {
|
||||
const verdicts = componentVerdictsFromTrace(NODES, [
|
||||
step('fetch'),
|
||||
step('fetch', { error: 'x', output: null }),
|
||||
step('fetch'),
|
||||
]);
|
||||
expect(verdicts).toHaveLength(3);
|
||||
expect(verdicts.map(v => v.success)).toEqual([true, false, true]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordComponentStats', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('對每顆零件各發一次 POST /analytics/record(fire-and-forget)', async () => {
|
||||
const calls: Array<{ url: string; body: Record<string, unknown> }> = [];
|
||||
vi.stubGlobal('fetch', vi.fn(async (url: string, init: RequestInit) => {
|
||||
calls.push({ url: String(url), body: JSON.parse(String(init.body)) });
|
||||
return new Response('{}', { status: 200 });
|
||||
}));
|
||||
|
||||
await recordComponentStats(
|
||||
{ REGISTRY_BASE_URL: 'http://registry.local' },
|
||||
NODES,
|
||||
[step('fetch'), step('transform', { error: 'bad', output: null })],
|
||||
);
|
||||
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[0].url).toBe('http://registry.local/analytics/record');
|
||||
expect(calls[0].body).toEqual({ canonical_id: 'http_request', success: true, duration_ms: 10 });
|
||||
expect(calls[1].body).toEqual({ canonical_id: 'code', success: false, duration_ms: 10 });
|
||||
});
|
||||
|
||||
it('registry 打不到也不 throw(統計失敗不影響執行)', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('network down'); }));
|
||||
await expect(
|
||||
recordComponentStats({ REGISTRY_BASE_URL: 'http://registry.local' }, NODES, [step('fetch')]),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('無 REGISTRY_BASE_URL 也無 WORKER_SUBDOMAIN → 靜默略過不打', async () => {
|
||||
const fetchSpy = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
await recordComponentStats({}, NODES, [step('fetch')]);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('未設 REGISTRY_BASE_URL → 用 wasmWorkerUrl 慣例組 registry URL', async () => {
|
||||
const calls: string[] = [];
|
||||
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
|
||||
calls.push(String(url));
|
||||
return new Response('{}', { status: 200 });
|
||||
}));
|
||||
await recordComponentStats({ WORKER_SUBDOMAIN: 'uncle6-me' }, NODES, [step('fetch')]);
|
||||
expect(calls[0]).toBe('https://arcrun-registry.uncle6-me.workers.dev/analytics/record');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* execution-logger 測試(KV 額度事故修復,2026-08-07)
|
||||
*
|
||||
* KBDB=API-as-Wall(leo 2026-06-14):cypher-executor 端不直連任何 D1,一律 fire-and-forget
|
||||
* fetch KBDB `/execution-log/record`。本檔驗的是「cypher 這一側」的職責,測試手法比照姊妹模組
|
||||
* execution-evaluator.test.ts(recordComponentStats,同款「fire-and-forget POST 統計」):
|
||||
* `vi.stubGlobal('fetch', ...)` 直接攔截,不用 fetchMock。
|
||||
* 1. 送出的 payload 形狀正確(workflow_id/owner_id/verdict/duration_ms/message/target)
|
||||
* 2. target 從 trigger context 的 page_name/path 擷取(少記:不整包送 input)
|
||||
* 3. 任何錯誤(fetch reject、KBDB 回非 2xx)都不影響呼叫端(永不 throw)
|
||||
*
|
||||
* 「少記截斷長度」「A2 自我降級」的實際邏輯與驗證在 KBDB 端(kbdb/tests/execution-log.test.ts),
|
||||
* 因為決策/儲存都搬到 KBDB 做了,cypher 只是薄殼呼叫端。
|
||||
*/
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { writeExecutionVerdict } from '../src/actions/execution-logger';
|
||||
import type { Bindings } from '../src/types';
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
function fakeEnv(): Bindings {
|
||||
return {
|
||||
KBDB_BASE_URL: 'https://kbdb.test',
|
||||
ENVIRONMENT: 'test',
|
||||
} as unknown as Bindings;
|
||||
}
|
||||
|
||||
function stubFetchCapture(): { calls: Array<{ url: string; body: Record<string, unknown> }> } {
|
||||
const calls: Array<{ url: string; body: Record<string, unknown> }> = [];
|
||||
vi.stubGlobal('fetch', vi.fn(async (url: string, init: RequestInit) => {
|
||||
calls.push({ url: String(url), body: JSON.parse(String(init.body)) });
|
||||
return new Response(JSON.stringify({ success: true, written: true, mode: 'log' }), { status: 200 });
|
||||
}));
|
||||
return { calls };
|
||||
}
|
||||
|
||||
describe('writeExecutionVerdict — 送出正確 payload(少記,不整包 input)', () => {
|
||||
it('成功:POST 到 KBDB_BASE_URL/execution-log/record,帶 workflow_id/owner_id/verdict/duration_ms/message', async () => {
|
||||
const { calls } = stubFetchCapture();
|
||||
await writeExecutionVerdict(
|
||||
fakeEnv(), 'wf-1', [], 'success', 123, '執行完成', { page_name: 'a.md' }, 'ak_test',
|
||||
);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].url).toBe('https://kbdb.test/execution-log/record');
|
||||
expect(calls[0].body).toEqual({
|
||||
workflow_id: 'wf-1',
|
||||
owner_id: 'ak_test',
|
||||
verdict: 'success',
|
||||
duration_ms: 123,
|
||||
message: '執行完成',
|
||||
target: 'a.md',
|
||||
});
|
||||
});
|
||||
|
||||
it('target:page_name 優先,沒有時 fallback path;都沒有則為 null', async () => {
|
||||
const { calls: calls1 } = stubFetchCapture();
|
||||
await writeExecutionVerdict(fakeEnv(), 'wf-2', [], 'failed', 1, 'err', { path: 'docs/x.md' });
|
||||
expect(calls1[0].body.target).toBe('docs/x.md');
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
const { calls: calls2 } = stubFetchCapture();
|
||||
await writeExecutionVerdict(fakeEnv(), 'wf-3', [], 'failed', 1, 'err', undefined);
|
||||
expect(calls2[0].body.target).toBeNull();
|
||||
});
|
||||
|
||||
it('不整包送 input:巨大的無關欄位不會出現在送出的 payload 裡', async () => {
|
||||
const { calls } = stubFetchCapture();
|
||||
await writeExecutionVerdict(fakeEnv(), 'wf-4', [], 'failed', 1, 'err', {
|
||||
page_name: 'a.md',
|
||||
unrelated_huge_field: 'z'.repeat(10000),
|
||||
});
|
||||
expect(Object.keys(calls[0].body).sort()).toEqual(
|
||||
['duration_ms', 'message', 'owner_id', 'target', 'verdict', 'workflow_id'],
|
||||
);
|
||||
});
|
||||
|
||||
it('沒有 apiKey(/execute 舊路徑):owner_id 送 null,不炸', async () => {
|
||||
const { calls } = stubFetchCapture();
|
||||
await writeExecutionVerdict(fakeEnv(), 'wf-5', [], 'success', 1, 'ok');
|
||||
expect(calls[0].body.owner_id).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeExecutionVerdict — 記錄失敗不影響主流程(永不 throw)', () => {
|
||||
it('KBDB 端點連不上(fetch reject):函式仍正常 resolve', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('network down'); }));
|
||||
await expect(
|
||||
writeExecutionVerdict(fakeEnv(), 'wf-broken', [], 'failed', 1, '任何訊息'),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('KBDB 回非 2xx(例如額度打滿的 5xx):函式仍正常 resolve', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () =>
|
||||
new Response(JSON.stringify({ success: false, error: 'quota exceeded' }), { status: 500 }),
|
||||
));
|
||||
await expect(
|
||||
writeExecutionVerdict(fakeEnv(), 'wf-broken2', [], 'failed', 1, '任何訊息'),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* GET /workflows/:name/executions — KV 額度事故修復(2026-08-07)路由測試。
|
||||
* 改打 KBDB GET /execution-log(原走 ANALYTICS_KV list);KBDB=API-as-Wall,
|
||||
* 本檔一律 fetchMock 攔截,不碰任何 D1(比照 tests/portal-data.test.ts 慣例)。
|
||||
*/
|
||||
import { SELF, env, fetchMock } from 'cloudflare:test';
|
||||
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
|
||||
|
||||
const KBDB = 'https://kbdb.test'; // wrangler.test.toml KBDB_BASE_URL
|
||||
const API_KEY = 'ak_exec_test';
|
||||
|
||||
beforeAll(() => {
|
||||
fetchMock.activate();
|
||||
fetchMock.disableNetConnect();
|
||||
});
|
||||
afterEach(() => fetchMock.assertNoPendingInterceptors());
|
||||
|
||||
function get(path: string, headers: Record<string, string> = {}) {
|
||||
return SELF.fetch(`http://localhost${path}`, { headers });
|
||||
}
|
||||
|
||||
describe('GET /workflows/:name/executions', () => {
|
||||
it('缺 X-Arcrun-API-Key → 401,不打 KBDB', async () => {
|
||||
const res = await get('/workflows/wf-x/executions');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('workflow 不存在或不屬於該 api_key → 404,不打 KBDB', async () => {
|
||||
const res = await get('/workflows/nope/executions', { 'X-Arcrun-API-Key': API_KEY });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('workflow 存在 → 轉發打 KBDB GET /execution-log,回傳其 executions', async () => {
|
||||
await env.WEBHOOKS.put(
|
||||
`${API_KEY}:wf:daily_report`,
|
||||
JSON.stringify({ graph: { id: 'daily_report', nodes: [] }, description: 'x', created_at: '2026-08-07T00:00:00Z' }),
|
||||
);
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({
|
||||
path: (p: string) => p.startsWith('/execution-log?'),
|
||||
method: 'GET',
|
||||
})
|
||||
.reply(200, {
|
||||
success: true,
|
||||
executions: [
|
||||
{ verdict: 'success', duration_ms: 100, message: 'ok', recorded_at: 1783500000 },
|
||||
{ verdict: 'failed', duration_ms: 50, message: '找不到 workflow', target: 'a.md', recorded_at: 1783400000 },
|
||||
],
|
||||
});
|
||||
|
||||
const res = await get('/workflows/daily_report/executions', { 'X-Arcrun-API-Key': API_KEY });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as {
|
||||
ok: boolean;
|
||||
data: { workflow_name: string; count: number; executions: Array<{ verdict: string; target?: string }> };
|
||||
};
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.data.count).toBe(2);
|
||||
expect(body.data.executions[0].verdict).toBe('success');
|
||||
expect(body.data.executions[1].target).toBe('a.md');
|
||||
|
||||
await env.WEBHOOKS.delete(`${API_KEY}:wf:daily_report`);
|
||||
});
|
||||
|
||||
it('KBDB 回非 success(例如全降級停記錄後空清單)→ 誠實回空陣列,不是假資料', async () => {
|
||||
await env.WEBHOOKS.put(
|
||||
`${API_KEY}:wf:empty_wf`,
|
||||
JSON.stringify({ graph: { id: 'empty_wf', nodes: [] }, description: 'x', created_at: '2026-08-07T00:00:00Z' }),
|
||||
);
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/execution-log?'), method: 'GET' })
|
||||
.reply(200, { success: true, executions: [] });
|
||||
|
||||
const res = await get('/workflows/empty_wf/executions', { 'X-Arcrun-API-Key': API_KEY });
|
||||
const body = await res.json() as { data: { count: number; executions: unknown[] } };
|
||||
expect(body.data.count).toBe(0);
|
||||
expect(body.data.executions).toEqual([]);
|
||||
|
||||
await env.WEBHOOKS.delete(`${API_KEY}:wf:empty_wf`);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
// Cypher Executor 端到端測試
|
||||
import { SELF } from 'cloudflare:test';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { GraphExecutor } from '../src/graph-executor';
|
||||
import type { ComponentRunner, ExecutionGraph } from '../src/types';
|
||||
|
||||
describe('GET /', () => {
|
||||
it('回傳服務狀態', async () => {
|
||||
@@ -191,4 +193,141 @@ describe('POST /execute', () => {
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// t117: FOREACH 全部項目失敗 → 錯誤訊息含 status code(GraphExecutor 單元測試)
|
||||
describe('t117: FOREACH 全項失敗 → ExecutionError 含 status code', () => {
|
||||
it('FOREACH 所有項目 success:false(含 status 401)→ executor.execute() 拋出含 "401" 的錯誤', async () => {
|
||||
// mock loader:任何零件都回 {success:false, status:401, error:"HTTP 401"}
|
||||
const failLoader = async (_: string): Promise<ComponentRunner> =>
|
||||
async () => ({ success: false, status: 401, error: 'HTTP 401', data: { body: 'Unauthorized' } });
|
||||
|
||||
const executor = new GraphExecutor(failLoader);
|
||||
|
||||
const graph: ExecutionGraph = {
|
||||
id: 'foreach-fail-t117',
|
||||
name: 'FOREACH 全失敗',
|
||||
nodes: [
|
||||
{ id: 'input', type: 'Input', data: { items: ['a', 'b'] } },
|
||||
{ id: 'writer', type: 'Component', componentId: 'http_request' },
|
||||
],
|
||||
edges: [
|
||||
{ from: 'input', to: 'writer', type: 'FOREACH', iterator: 'item' },
|
||||
],
|
||||
};
|
||||
|
||||
// t117 核心驗證:全部失敗 → throw(不再靜默)
|
||||
await expect(executor.execute(graph, {})).rejects.toThrow(/401/);
|
||||
});
|
||||
|
||||
it('FOREACH 部分項目成功 → 不拋出(只有全部失敗才報錯)', async () => {
|
||||
let callCount = 0;
|
||||
// 第一次呼叫失敗,第二次成功(部分失敗不觸發 t117 all-fail 路徑)
|
||||
const mixedLoader = async (_: string): Promise<ComponentRunner> =>
|
||||
async () => {
|
||||
callCount++;
|
||||
if (callCount === 1) return { success: false, status: 401, error: 'HTTP 401' };
|
||||
return { success: true, data: { ok: true } };
|
||||
};
|
||||
|
||||
const executor = new GraphExecutor(mixedLoader);
|
||||
|
||||
const graph: ExecutionGraph = {
|
||||
id: 'foreach-mixed-t117',
|
||||
name: 'FOREACH 部分失敗',
|
||||
nodes: [
|
||||
{ id: 'input', type: 'Input', data: { items: ['a', 'b'] } },
|
||||
{ id: 'writer', type: 'Component', componentId: 'http_request' },
|
||||
],
|
||||
edges: [
|
||||
{ from: 'input', to: 'writer', type: 'FOREACH', iterator: 'item' },
|
||||
],
|
||||
};
|
||||
|
||||
// 部分失敗 → 不拋出,正常回傳 results 陣列
|
||||
const result = await executor.execute(graph, {});
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// P8 短板齊平(2026-08-09):節點輸出只在「下游有 PIPE 邊會讀」時才寫 KV。
|
||||
// 背景:BUILD-006 原本每個節點(含 FOREACH 每一圈)都 put 一次 EXEC_CONTEXT,
|
||||
// 但全 codebase 唯一讀點是 PIPE 邊的 kvGetNodeOutput——rag 系工作流
|
||||
// (ON_SUCCESS+對每個)一張卡白燒 15 次 KV write,把免費層 1,000/日
|
||||
// 壓成比 Workers AI neurons 更短的板。此測試鎖住「無 PIPE 出邊=零 KV put」
|
||||
// 與「有 PIPE 出邊=照舊寫、_kv_outputs 照舊可讀」兩個行為。
|
||||
describe('P8:節點輸出 KV 寫入只服務 PIPE 讀者', () => {
|
||||
// 計數型 KV mock:只記 put 次數(kvSetNodeOutput 只用到 put;get 給 PIPE 讀)
|
||||
function countingKv() {
|
||||
const store = new Map<string, string>();
|
||||
let puts = 0;
|
||||
const kv = {
|
||||
put: async (k: string, v: string) => { puts++; store.set(k, v); },
|
||||
get: async (k: string) => store.get(k) ?? null,
|
||||
} as unknown as KVNamespace;
|
||||
return { kv, getPuts: () => puts };
|
||||
}
|
||||
|
||||
it('ON_SUCCESS+FOREACH 工作流(rag_ingest_card 形狀)→ 零 KV put', async () => {
|
||||
const loader = async (id: string): Promise<ComponentRunner> => async () => {
|
||||
if (id === 'parse') {
|
||||
return { success: true, blocks: [{ n: 1 }, { n: 2 }, { n: 3 }], rels: [{ r: 1 }, { r: 2 }] };
|
||||
}
|
||||
return { success: true, data: { ok: true } };
|
||||
};
|
||||
const executor = new GraphExecutor(loader);
|
||||
const graph: ExecutionGraph = {
|
||||
id: 'p8-no-pipe',
|
||||
name: 'rag 形狀(無 PIPE 邊)',
|
||||
nodes: [
|
||||
{ id: 'input', type: 'Input', data: {} },
|
||||
{ id: 'list_old', type: 'Component', componentId: 'http_request' },
|
||||
{ id: 'parse_card', type: 'Component', componentId: 'parse' },
|
||||
{ id: 'post_block', type: 'Component', componentId: 'http_request' },
|
||||
{ id: 'post_triplet', type: 'Component', componentId: 'http_request' },
|
||||
],
|
||||
edges: [
|
||||
{ from: 'input', to: 'list_old', type: 'ON_SUCCESS' },
|
||||
{ from: 'list_old', to: 'parse_card', type: 'ON_SUCCESS' },
|
||||
{ from: 'parse_card', to: 'post_block', type: 'FOREACH', iterator: 'block' },
|
||||
{ from: 'parse_card', to: 'post_triplet', type: 'FOREACH', iterator: 'rel' },
|
||||
],
|
||||
};
|
||||
const { kv, getPuts } = countingKv();
|
||||
const result = await executor.execute(graph, {}, kv);
|
||||
expect(result).toBeDefined();
|
||||
// 修法前這裡是 8(list_old + parse_card + 3×post_block + 2×post_triplet + input 不寫)
|
||||
expect(getPuts()).toBe(0);
|
||||
});
|
||||
|
||||
it('PIPE 工作流 → 照舊寫 KV 且 _kv_outputs 傳遞不變(BUILD-006 語意保留)', async () => {
|
||||
const seen: Record<string, unknown>[] = [];
|
||||
const loader = async (id: string): Promise<ComponentRunner> => async (ctx) => {
|
||||
seen.push(ctx as Record<string, unknown>);
|
||||
return { success: true, data: { from: id } };
|
||||
};
|
||||
const executor = new GraphExecutor(loader);
|
||||
const graph: ExecutionGraph = {
|
||||
id: 'p8-pipe',
|
||||
name: 'PIPE 鏈',
|
||||
nodes: [
|
||||
{ id: 'input', type: 'Input', data: { message: 'hi' } },
|
||||
{ id: 'a', type: 'Component', componentId: 'comp_a' },
|
||||
{ id: 'b', type: 'Component', componentId: 'comp_b' },
|
||||
],
|
||||
edges: [
|
||||
{ from: 'input', to: 'a', type: 'PIPE' },
|
||||
{ from: 'a', to: 'b', type: 'PIPE' },
|
||||
],
|
||||
};
|
||||
const { kv, getPuts } = countingKv();
|
||||
const result = await executor.execute(graph, {}, kv);
|
||||
expect(result).toBeDefined();
|
||||
// a 有 PIPE 出邊 → 寫;b 沒有出邊 → 不寫(原本 a、b 都寫=2)
|
||||
expect(getPuts()).toBe(1);
|
||||
// 下游 b 收到的 context 帶 _kv_outputs.a(BUILD-006 讀路徑不變)
|
||||
const bCtx = seen[seen.length - 1];
|
||||
expect((bCtx._kv_outputs as Record<string, unknown>)?.a).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { SELF } from 'cloudflare:test';
|
||||
import { healthRouter } from '../src/routes/health';
|
||||
import type { Bindings, ExecutionContext } from '../src/types';
|
||||
|
||||
describe('GET /health — bundle_version 欄位', () => {
|
||||
it('無 ARCRUN_BUNDLE_VERSION 時省略該欄(老實例情境)', async () => {
|
||||
// wrangler.test.toml 不設此 var → health.ts 省略 bundle_version 欄位。
|
||||
// daemon 端讀不到該欄=當作空字串=判 stale,對老實例而言**這是正確行為**
|
||||
//(見 health.ts 檔頭註解)。此處驗「省略」而非「回空字串」,與實作對齊。
|
||||
const res = await SELF.fetch('http://localhost/health');
|
||||
const data = await res.json() as { ok: boolean; bundle_version?: string };
|
||||
expect(res.status).toBe(200);
|
||||
expect(data.ok).toBe(true);
|
||||
expect(data.bundle_version).toBeUndefined();
|
||||
});
|
||||
|
||||
it('有 ARCRUN_BUNDLE_VERSION 時回其值(安裝器注入情境)', async () => {
|
||||
const fakeEnv = { ARCRUN_BUNDLE_VERSION: '2026-07-28/6d06162' } as unknown as Bindings;
|
||||
const res = await healthRouter.fetch(
|
||||
new Request('http://localhost/health'),
|
||||
fakeEnv,
|
||||
{} as ExecutionContext,
|
||||
);
|
||||
const data = await res.json() as { ok: boolean; bundle_version: string };
|
||||
expect(data.ok).toBe(true);
|
||||
expect(data.bundle_version).toBe('2026-07-28/6d06162');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* /init/seed 必須把種子的 3.12 三層欄位原樣寫進 KV —— SDD: workflow-discovery task 3.12/3.13
|
||||
*
|
||||
* 為什麼要有這個測試(別刪):
|
||||
* 3.12 給 `RecipeDefinition` 加了 body_template / response_map / auth / binding_name,
|
||||
* 但 `/init/seed` 當時是**列舉欄位重建** recipe record ⇒ 不在名單上的欄位被靜默吃掉。
|
||||
* 症狀最惡劣的地方在於「哪裡都不會紅」:recipe 查得到、canonical_id 對、endpoint 對,
|
||||
* 只有跑起來像沒設定過(auth 掉了 ⇒ 走 HTTP 路徑去 fetch「@cf/…」這種不是網址的字串)。
|
||||
* 這與 2026-08-02 `syncManifest()` 列舉式重建吃掉 `manifest.daemon` 欄是同一型事故——
|
||||
* 當時的教訓寫著:「**東西還在不在**也要進機械閘」,本檔就是那道閘。
|
||||
*
|
||||
* 範圍:只驗「種子 → KV」這段(純資料搬運)。真的呼叫 Workers AI 由實例端到端驗。
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { env, SELF } from 'cloudflare:test';
|
||||
import { API_RECIPE_SEEDS } from '../src/lib/api-recipe-seeds';
|
||||
|
||||
type StoredRecipe = {
|
||||
canonical_id: string;
|
||||
endpoint: string;
|
||||
auth?: string;
|
||||
binding_name?: string;
|
||||
body_template?: Record<string, unknown>;
|
||||
response_map?: { text_path?: string; answer_marker?: string; strip_prefixes?: string[] };
|
||||
};
|
||||
|
||||
async function seedThenRead(canonicalId: string): Promise<StoredRecipe> {
|
||||
const res = await SELF.fetch('https://example.com/init/seed', { method: 'POST' });
|
||||
// 測試環境沒有 KBDB binding ⇒ portal template 那段必然失敗、整體回 207(誠實回報,非本測目標)。
|
||||
// 本檔只管 API recipe 那半,所以驗它自己的計數,不驗整體 status。
|
||||
const body = await res.json<{ api_recipes: { seeded: number; failed: number; errors: string[] } }>();
|
||||
expect(body.api_recipes.errors).toEqual([]);
|
||||
expect(body.api_recipes.failed).toBe(0);
|
||||
const uuid = await env.RECIPES.get(`idx:installed:${canonicalId}`);
|
||||
expect(uuid, `${canonicalId} 沒有被 seed 進 KV`).toBeTruthy();
|
||||
return JSON.parse((await env.RECIPES.get(`recipe:${uuid}`))!) as StoredRecipe;
|
||||
}
|
||||
|
||||
describe('/init/seed 不得靜默吃掉 recipe 的 3.12 欄位', () => {
|
||||
it('workers_ai_chat 種子本身宣告齊四個欄位(種子端)', () => {
|
||||
const seed = API_RECIPE_SEEDS.find(s => s.canonical_id === 'workers_ai_chat');
|
||||
expect(seed, 'workers_ai_chat 種子不存在=裝完不會有免金鑰問答').toBeDefined();
|
||||
expect(seed!.auth).toBe('binding');
|
||||
expect(seed!.binding_name).toBe('AI');
|
||||
expect(seed!.endpoint.startsWith('@cf/'), 'binding 型的 endpoint=模型 id').toBe(true);
|
||||
expect(seed!.body_template).toBeDefined();
|
||||
expect(seed!.response_map?.text_path).toBe('response');
|
||||
});
|
||||
|
||||
it('seed 之後 KV 裡讀回來的仍帶 auth/binding_name/body_template/response_map(KV 端)', async () => {
|
||||
const stored = await seedThenRead('workers_ai_chat');
|
||||
expect(stored.auth, 'auth 掉了 ⇒ 會被當成 HTTP recipe 去 fetch 一個不是網址的字串').toBe('binding');
|
||||
expect(stored.binding_name).toBe('AI');
|
||||
expect(stored.body_template, 'body_template 掉了 ⇒ 整包 ctx 被當 payload 送給模型').toBeDefined();
|
||||
expect(stored.response_map?.text_path, 'response_map 掉了 ⇒ 下游拿不到 text').toBe('response');
|
||||
expect(stored.response_map?.answer_marker).toBe('【答】');
|
||||
});
|
||||
|
||||
it('既有 HTTP 種子不受影響:沒宣告新欄位就是 undefined,不憑空長出來', async () => {
|
||||
const stored = await seedThenRead('telegram_send');
|
||||
expect(stored.auth).toBeUndefined();
|
||||
expect(stored.binding_name).toBeUndefined();
|
||||
expect(stored.body_template).toBeUndefined();
|
||||
expect(stored.response_map).toBeUndefined();
|
||||
expect(stored.endpoint).toContain('api.telegram.org');
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user