merge main:把 t176(拔雲端 LLM 下發)與 workers_ai_chat 種子併進 CIS 分支
出貨前發現兩條分支各有一半: main → t176 拔掉雲端下發 extractor/刪 admin/extractor/workers_ai_chat 種子 fix/cis-round3-portal → t181 新萃取端點 /portal/daemon/extract、CIS 視覺 **要合起來才是完整的出貨內容**(實測:合併前 bundle 仍含 admin/extractor ×2)。 原始碼三檔全自動合併;只有 portal-admin.test.ts 衝突—— HEAD 側是**過時的 t131/t122 測試**(測 main 已刪的端點,留著必紅), main 側是刪除。解法:接受刪除、保留我這側的 t181 守衛。 測試 31 passed,唯一 failed 是基準線既有的 GET /portal 靜態資源案。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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"
|
||||
@@ -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[] };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,直接託管)",
|
||||
"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"
|
||||
"preview": "npx serve public"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -186,7 +186,23 @@
|
||||
.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>
|
||||
// apiBase 由安裝器注入的 config.js 提供(實查用戶實例:
|
||||
// window.ARCRUN_CONFIG = { apiBase: "https://arcrun-cypher-executor.<subdomain>.workers.dev" })。
|
||||
// 🔴 2026-08-01 拔掉舊的 `|| "https://cypher.arcrun.dev"` fallback:
|
||||
// 那是「靜默打到別人家」的未爆彈——config.js 一旦沒載入/被擋/改名,
|
||||
// 前端會安靜地把請求(含**用戶金鑰**)送去中央實例,而不是明顯壞掉。
|
||||
// **寧可明顯失敗,不要靜默錯置。**
|
||||
window.ARCRUN_API_BASE = (window.ARCRUN_CONFIG && window.ARCRUN_CONFIG.apiBase) || "";
|
||||
if (!window.ARCRUN_API_BASE) {
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var b = document.createElement('div');
|
||||
b.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:99999;background:#b4462f;color:#fff;padding:12px 16px;font:14px/1.5 system-ui;text-align:center';
|
||||
b.textContent = '設定檔沒載入(config.js),這個頁面連不到你的服務。請重新整理;若持續發生,請重跑一次安裝。';
|
||||
document.body.appendChild(b);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -698,11 +714,19 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
if (!location.hash) location.hash = '#/' + HOME;
|
||||
route();
|
||||
}
|
||||
function dropSession() {
|
||||
// t161(leo 07-31 實撞:「實際上根本沒連上任何庫,清單空白」):session 過期時
|
||||
// 舊行為=靜默清 token 跳回登入頁,**畫面不說任何原因** ⇒ 用戶以為「庫不見了/系統壞了」。
|
||||
// 友善=前端:踢回登入頁時一定要說「為什麼」,用戶才知道下一步做什麼(重新登入即可,資料都在)。
|
||||
function dropSession(reason) {
|
||||
S.token = '';
|
||||
S.profile = null;
|
||||
try { localStorage.removeItem('arcrun_portal_session'); } catch (e) { /* noop */ }
|
||||
showAuth();
|
||||
var msg = reason || '你的登入已過期,請重新登入(你的資料都還在,不會遺失)。';
|
||||
try {
|
||||
var el = $('login-status');
|
||||
if (el) el.textContent = msg;
|
||||
} catch (e) { /* 登入殼還沒渲染就算了 */ }
|
||||
}
|
||||
function boot() {
|
||||
if (!S.token) { showAuth(); return; }
|
||||
|
||||
@@ -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.');
|
||||
@@ -39,8 +39,17 @@ console.log(` apiBase :${t.apiBase}\n`);
|
||||
|
||||
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);
|
||||
// t160(leo 07-31:「如果你會搞不清楚,就把錯的東西刪掉」):build 步驟已隨舊世代
|
||||
// src/ 一起 git rm——public/ 是唯一世代真身(手改演進),deploy=直接託管它。
|
||||
// 病史:src/(舊代 renderer 快照)與 public/(新代真身)並存,deploy 自動跑 build
|
||||
// 從舊 src 重產 public ⇒ 任何一次部署都可能把 UI 打回舊世代(07-27 記帳、07-31 引爆:
|
||||
// t159 重打包用了舊 public 的分支副本,leo 刷新看到被淘汰的「登記新庫」表單)。
|
||||
// 世代閘:部署前驗 public 指紋,舊世代(缺新文案/含人工建庫表單)直接拒部。
|
||||
const portalHtml = readFileSync(join(ROOT, 'public', 'portal', 'index.html'), 'utf8');
|
||||
if (!portalHtml.includes('不需要人工新增') || portalHtml.includes('登記新庫')) {
|
||||
console.error('✘ 世代閘:public/portal/index.html 不是現行世代(缺「不需要人工新增」或含「登記新庫」)——拒絕部署舊 UI。');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// --commit-dirty:本地部署常有未提交變更,不因此中斷
|
||||
const deploy = spawnSync(
|
||||
|
||||
@@ -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
@@ -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:不拋錯,不影響主流程
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -478,6 +478,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)
|
||||
@@ -651,6 +682,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,10 +39,15 @@ 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);
|
||||
// ⚠️ 非瀏覽器請求(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 未設定=只用靜態白名單 */ }
|
||||
return [...STATIC_ORIGINS, ...extra].includes(origin) ? origin : null;
|
||||
},
|
||||
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
|
||||
@@ -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,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)
|
||||
})),
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -3,9 +3,19 @@ import type { Bindings } from '../types';
|
||||
|
||||
export const healthRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
healthRouter.get('/health', (c) =>
|
||||
c.json({ ok: true, bundle_version: c.env.ARCRUN_BUNDLE_VERSION ?? '' })
|
||||
);
|
||||
// t162(leo 07-31 實撞:「小幫手一直顯示知識庫需要更新…重新更新後並不會消失」):
|
||||
// daemon cloudVersionStale() 讀 /health 的 `bundle_version` 判斷是否過舊——
|
||||
// 但本端點過去只回 {ok:true},**從沒吐這個欄位** ⇒ daemon 恆讀到空字串
|
||||
// ⇒ 恆判 stale ⇒ 假警報永遠不消失(安裝器其實一直有注入 ARCRUN_BUNDLE_VERSION var,
|
||||
// 只是沒有人把它吐出來)。修=誠實回報本實例的 bundle 版本。
|
||||
// 未注入(本地 dev/很舊的實例)就省略該欄——daemon 對空字串仍判 stale,
|
||||
// 那是**正確的**(真的是老實例,該更新)。
|
||||
healthRouter.get('/health', (c) => {
|
||||
const bundleVersion = c.env.ARCRUN_BUNDLE_VERSION;
|
||||
return c.json(
|
||||
bundleVersion ? { ok: true, bundle_version: bundleVersion } : { ok: true },
|
||||
);
|
||||
});
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -25,6 +25,9 @@ import { kbdbBase } from './kbdb-proxy';
|
||||
import { validateConsoleSession } from './console-auth';
|
||||
import { hashPassword, verifyPassword, randomHex, generatePassword } from '../lib/portal-auth';
|
||||
import { PORTAL_TEMPLATE_SEEDS } from '../lib/portal-seeds';
|
||||
// arcrun-rag#10:/portal/admin/ai 存 Gemini key 走 credentials.ts 的**唯一**寫入路徑,
|
||||
// 不在 portal 這層另造第二套儲存(D36:值進 Workers Secret,D1 只留 ref)。
|
||||
import { storeCredential } from './credentials';
|
||||
|
||||
export const portalRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
@@ -840,26 +843,9 @@ portalRouter.post('/portal/daemon/libraries', (c) =>
|
||||
}),
|
||||
);
|
||||
|
||||
// ── t122 萃取引擎設定(daemon 萃取用;與 chat-key AI 問答金鑰獨立管理)──────────────
|
||||
// KV key = {tenant}:portal:extractor_config,存在 WEBHOOKS KV(同 chat-key 手法)。
|
||||
// 金鑰不落 log;GET 只回 has_key:bool,不回明文。
|
||||
// daemon 未設定時預設 gemma(封測者不會有 claude,以 gemma 為友善預設)。
|
||||
|
||||
interface ExtractorConfig {
|
||||
engine: 'gemma' | 'claude';
|
||||
gemini_api_key?: string;
|
||||
llm_model?: string;
|
||||
}
|
||||
|
||||
function extractorConfigKey(env: Bindings): string {
|
||||
return `${portalTenant(env)}:portal:extractor_config`;
|
||||
}
|
||||
|
||||
async function getExtractorConfig(env: Bindings): Promise<ExtractorConfig | null> {
|
||||
const raw = await env.WEBHOOKS.get(extractorConfigKey(env), 'text');
|
||||
if (!raw) return null;
|
||||
try { return JSON.parse(raw) as ExtractorConfig; } catch { return null; }
|
||||
}
|
||||
// t176:t122 的 extractor_config(雲端指定地端萃取引擎)整組移除——
|
||||
// interface/KV key/讀取函式都不再需要,因為雲端已不下發、也不再有設定入口。
|
||||
// ⚠️ 舊實例的 KV 殘值無害:daemon 端 t176 起也不吃這個欄位了。
|
||||
|
||||
// POST /portal/daemon/config — body {email, password}。同步小幫手憑「用戶剛設的帳密」
|
||||
// 直接換到自己的設定(t54,leo 07-25:「最好的就是把它的帳密直接輸入」)——
|
||||
@@ -888,164 +874,42 @@ portalRouter.post('/portal/daemon/config', (c) =>
|
||||
}
|
||||
await clearLoginFail(c.env, email);
|
||||
const tenant = portalTenant(c.env);
|
||||
const extractorCfg = await getExtractorConfig(c.env);
|
||||
const engine = extractorCfg?.engine ?? 'gemma';
|
||||
// t176(leo 08-03 架構翻案):**不再下發任何 LLM 設定**(extractor/金鑰/模型)。
|
||||
// 地端用哪個模型、哪把金鑰,由使用者在同步小幫手的托盤「AI 設定…」自己設。
|
||||
//
|
||||
// 為什麼拔掉:extractor_config 這把 KV 的 key 是 `${portalTenant(env)}:portal:extractor_config`,
|
||||
// 而 portalTenant 是 **worker 層級**環境變數(見本檔 43 行)=**全租戶共用一把**。
|
||||
// 任一處設了 claude,所有人的 daemon 都會收到 claude;沒裝 Claude Code 的機器
|
||||
// 會萃取全滅,而 portal 的 Claude 勾選框又恆為 disabled(daemon 從未回報 has_claude)
|
||||
// ⇒ 用戶自己解不開(08-03 封測實證:雲端同步成功、金鑰有效,卻零張卡)。
|
||||
// leo:「地端要用什麼模型就在 daemon 上輸入 API Key 設置,而不是雲端設置後控制地端」。
|
||||
//
|
||||
// ⚠️ 只拔 LLM 欄位——連線欄位(cypher_url/namespace/library)與本 route 本身照舊,
|
||||
// daemon 靠它上線;資料夾/庫管理(daemon/libraries)也完全不動(leo 明確劃界)。
|
||||
const daemonCfg: Record<string, string> = {
|
||||
cypher_url: new URL(c.req.url).origin,
|
||||
namespace: tenant,
|
||||
library: 'kb',
|
||||
extractor: engine,
|
||||
email,
|
||||
instance_name: String(rec.values.display_name ?? ''),
|
||||
};
|
||||
if (engine === 'gemma' && extractorCfg?.gemini_api_key) {
|
||||
daemonCfg.gemini_api_key = extractorCfg.gemini_api_key;
|
||||
}
|
||||
if (extractorCfg?.llm_model) daemonCfg.llm_model = extractorCfg.llm_model;
|
||||
return c.json({ success: true, config: daemonCfg });
|
||||
}),
|
||||
);
|
||||
|
||||
// ── t131 合併 AI 設定(Gemini API Key 同時設 chat+extractor;has_claude 由 daemon 回報)─────
|
||||
// KV key = {tenant}:portal:ai_config,存在 WEBHOOKS KV。
|
||||
// KV key = {tenant}:portal:daemon_caps,存 daemon 回報的能力(TTL 7 天)。
|
||||
// t176:t131 這一版 `/portal/admin/ai`(POST/GET)**整組移除**。兩個原因:
|
||||
// ① 它是**重複註冊**——本檔後段(arcrun-rag#10 那版)另有一組同路徑 route。
|
||||
// Hono 先到先比 ⇒ 舊的這組一直贏,後段那組修好的「金鑰真的寫進 credentials」形同死碼。
|
||||
// 這正是「key 從來沒存進去」的 bug 在 merge 後仍可能復發的原因。
|
||||
// ② 它把 use_claude_for_extract 同步進 extractor_config 下發給 daemon
|
||||
// (syncExtractorFromAiConfig),而那把 KV 是**全租戶共用**——正是 08-03 事故根因。
|
||||
// 保留的是後段那組(只管 Gemini 金鑰,走 storeCredential 唯一寫入路徑,不碰 extractor)。
|
||||
|
||||
interface AiConfig {
|
||||
gemini_api_key?: string;
|
||||
use_claude_for_extract?: boolean;
|
||||
}
|
||||
interface DaemonCapabilities {
|
||||
has_claude: boolean;
|
||||
daemon_version?: string;
|
||||
os?: string;
|
||||
}
|
||||
|
||||
function aiConfigKey(env: Bindings): string { return `${portalTenant(env)}:portal:ai_config`; }
|
||||
function daemonCapsKey(env: Bindings): string { return `${portalTenant(env)}:portal:daemon_caps`; }
|
||||
|
||||
async function getAiConfig(env: Bindings): Promise<AiConfig | null> {
|
||||
const raw = await env.WEBHOOKS.get(aiConfigKey(env), 'text');
|
||||
if (!raw) return null;
|
||||
try { return JSON.parse(raw) as AiConfig; } catch { return null; }
|
||||
}
|
||||
async function getDaemonCaps(env: Bindings): Promise<DaemonCapabilities | null> {
|
||||
const raw = await env.WEBHOOKS.get(daemonCapsKey(env), 'text');
|
||||
if (!raw) return null;
|
||||
try { return JSON.parse(raw) as DaemonCapabilities; } catch { return null; }
|
||||
}
|
||||
|
||||
// 將 ai_config 同步回 extractor_config(daemon/config 讀 extractor_config,保持相容)。
|
||||
async function syncExtractorFromAiConfig(env: Bindings, cfg: AiConfig): Promise<void> {
|
||||
const exCfg: ExtractorConfig = {
|
||||
engine: cfg.use_claude_for_extract ? 'claude' : 'gemma',
|
||||
};
|
||||
if (!cfg.use_claude_for_extract && cfg.gemini_api_key) {
|
||||
exCfg.gemini_api_key = cfg.gemini_api_key;
|
||||
}
|
||||
await env.WEBHOOKS.put(extractorConfigKey(env), JSON.stringify(exCfg));
|
||||
}
|
||||
|
||||
// POST /portal/admin/ai — body {gemini_api_key?, use_claude_for_extract?}(t131)。
|
||||
// 同時設定 AI 問答金鑰(chat)與萃取引擎(extractor)。admin 閘。
|
||||
portalRouter.post('/portal/admin/ai', (c) =>
|
||||
run(c, async () => {
|
||||
const auth = await requirePortalAdmin(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const body = (await c.req.json().catch(() => null)) as { gemini_api_key?: string; use_claude_for_extract?: boolean } | null;
|
||||
const newKey = String(body?.gemini_api_key ?? '').trim();
|
||||
const useClause = typeof body?.use_claude_for_extract === 'boolean' ? body.use_claude_for_extract : undefined;
|
||||
|
||||
// 讀現有設定做合併(留空欄位=不變更)
|
||||
const existing = await getAiConfig(c.env) ?? {};
|
||||
const merged: AiConfig = {
|
||||
gemini_api_key: newKey || existing.gemini_api_key,
|
||||
use_claude_for_extract: useClause !== undefined ? useClause : (existing.use_claude_for_extract ?? false),
|
||||
};
|
||||
if (!merged.gemini_api_key) return c.json({ error: '請貼上你的 Gemini API Key' }, 400);
|
||||
|
||||
// 更新 chat(rag_chat workflow)——容忍 404(workflow 未安裝時暫存,安裝後再寫入)
|
||||
if (newKey) {
|
||||
const tenant = portalTenant(c.env);
|
||||
const kvKey = `${tenant}:wf:rag_chat`;
|
||||
const raw = await c.env.WEBHOOKS.get(kvKey, 'text');
|
||||
if (raw) {
|
||||
try {
|
||||
const record = JSON.parse(raw) as Record<string, unknown>;
|
||||
const visit = (o: unknown): void => {
|
||||
if (Array.isArray(o)) { o.forEach(visit); return; }
|
||||
if (o && typeof o === 'object') {
|
||||
const rec = o as Record<string, unknown>;
|
||||
for (const k of Object.keys(rec)) {
|
||||
if (k.toLowerCase() === 'x-goog-api-key') { rec[k] = newKey; }
|
||||
else visit(rec[k]);
|
||||
}
|
||||
}
|
||||
};
|
||||
visit(record['graph']);
|
||||
visit(record['config']);
|
||||
await c.env.WEBHOOKS.put(kvKey, JSON.stringify(record));
|
||||
} catch { /* 工作流記錄損壞時靜默略過,金鑰仍存 ai_config */ }
|
||||
}
|
||||
// 若 rag_chat 不存在(raw===null),跳過,等 acr init 安裝後再用舊 chat-key 端點補入
|
||||
}
|
||||
|
||||
// 存合併設定
|
||||
await c.env.WEBHOOKS.put(aiConfigKey(c.env), JSON.stringify(merged));
|
||||
// 同步回 extractor_config(daemon/config 走這個)
|
||||
await syncExtractorFromAiConfig(c.env, merged);
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
has_key: true,
|
||||
use_claude_for_extract: merged.use_claude_for_extract ?? false,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// GET /portal/admin/ai — 回 has_key/use_claude_for_extract/claude_available(t131)。
|
||||
portalRouter.get('/portal/admin/ai', (c) =>
|
||||
run(c, async () => {
|
||||
const auth = await requirePortalAdmin(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const cfg = await getAiConfig(c.env);
|
||||
const caps = await getDaemonCaps(c.env);
|
||||
return c.json({
|
||||
success: true,
|
||||
has_key: !!(cfg?.gemini_api_key),
|
||||
use_claude_for_extract: cfg?.use_claude_for_extract ?? false,
|
||||
claude_available: caps?.has_claude ?? false,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// POST /portal/daemon/report-capabilities — body {email, password, has_claude, daemon_version?, os?}(t131)。
|
||||
// daemon 連線成功後回報本機能力;認證同 /portal/daemon/config(帳密)。
|
||||
// ⚠️ daemon 端改動屬 arcrun-rag repo,本端只做「收端點+存 KV+供 GET /portal/admin/ai 用」。
|
||||
portalRouter.post('/portal/daemon/report-capabilities', (c) =>
|
||||
run(c, async () => {
|
||||
const body = (await c.req.json().catch(() => null)) as { email?: string; password?: string; has_claude?: boolean; daemon_version?: string; os?: string } | null;
|
||||
const email = String(body?.email ?? '').trim().toLowerCase();
|
||||
const password = String(body?.password ?? '');
|
||||
if (!email || !password) return c.json({ error: 'email 與 password 必填' }, 400);
|
||||
if (await isLocked(c.env, email)) return c.json({ error: '登入失敗次數過多', }, 429);
|
||||
const recordId = await findUserRecordId(c.env, email);
|
||||
const rec = recordId ? await getRecordById(c.env, recordId) : null;
|
||||
if (!rec) { await recordLoginFail(c.env, email); return c.json({ error: 'email 或密碼錯誤' }, 401); }
|
||||
if ((rec.values.status ?? '') !== 'active') return c.json({ error: '帳號已停用' }, 403);
|
||||
if (!(await verifyPassword(password, rec.values.password_hash ?? ''))) {
|
||||
await recordLoginFail(c.env, email); return c.json({ error: 'email 或密碼錯誤' }, 401);
|
||||
}
|
||||
await clearLoginFail(c.env, email);
|
||||
const caps: DaemonCapabilities = {
|
||||
has_claude: body?.has_claude === true,
|
||||
...(body?.daemon_version ? { daemon_version: String(body.daemon_version) } : {}),
|
||||
...(body?.os ? { os: String(body.os) } : {}),
|
||||
};
|
||||
const TTL_7D = 7 * 24 * 60 * 60;
|
||||
await c.env.WEBHOOKS.put(daemonCapsKey(c.env), JSON.stringify(caps), { expirationTtl: TTL_7D });
|
||||
return c.json({ success: true });
|
||||
}),
|
||||
);
|
||||
// t176:`POST /portal/daemon/report-capabilities` 已移除。
|
||||
// 它的用途是收 daemon 回報的 has_claude 去解鎖 portal 的 Claude 勾選框;
|
||||
// 但 daemon 端從未實作這個呼叫(arcrun-rag 全庫 grep = 0 命中),
|
||||
// 導致 daemon_caps KV 永遠空、勾選框恆 disabled。t176 起地端模型由小幫手自己設,
|
||||
// 這條回報鏈整條不需要了。
|
||||
|
||||
// POST /portal/admin/chat-key — body {key}。保留舊端點相容(新 UI 走 /portal/admin/ai)。
|
||||
// 舊版 setup checklist / 舊 UI 仍走這裡;只更新 rag_chat workflow,不同步 ai_config。
|
||||
@@ -1087,45 +951,11 @@ portalRouter.post('/portal/admin/chat-key', (c) =>
|
||||
}),
|
||||
);
|
||||
|
||||
// POST /portal/admin/extractor — body {engine, gemini_api_key?, llm_model?}(t122)。
|
||||
// 保留舊端點相容(新 UI 走 /portal/admin/ai)。
|
||||
// admin 閘(同 chat-key 等級)。金鑰不落 log;存 WEBHOOKS KV。
|
||||
portalRouter.post('/portal/admin/extractor', (c) =>
|
||||
run(c, async () => {
|
||||
const auth = await requirePortalAdmin(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const body = (await c.req.json().catch(() => null)) as { engine?: string; gemini_api_key?: string; llm_model?: string } | null;
|
||||
const engine = String(body?.engine ?? '').trim().toLowerCase();
|
||||
if (engine !== 'gemma' && engine !== 'claude') {
|
||||
return c.json({ error: 'engine 只能是 gemma 或 claude' }, 400);
|
||||
}
|
||||
const cfg: ExtractorConfig = { engine: engine as 'gemma' | 'claude' };
|
||||
if (engine === 'gemma') {
|
||||
const key = String(body?.gemini_api_key ?? '').trim();
|
||||
if (key) cfg.gemini_api_key = key;
|
||||
}
|
||||
const model = String(body?.llm_model ?? '').trim();
|
||||
if (model) cfg.llm_model = model;
|
||||
await c.env.WEBHOOKS.put(extractorConfigKey(c.env), JSON.stringify(cfg));
|
||||
return c.json({ success: true, engine: cfg.engine, has_key: engine === 'gemma' && !!cfg.gemini_api_key });
|
||||
}),
|
||||
);
|
||||
|
||||
// GET /portal/admin/extractor — 回 engine + has_key(不回金鑰明文)(t122)。
|
||||
// 保留舊端點相容(新 UI 走 /portal/admin/ai)。
|
||||
portalRouter.get('/portal/admin/extractor', (c) =>
|
||||
run(c, async () => {
|
||||
const auth = await requirePortalAdmin(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const cfg = await getExtractorConfig(c.env);
|
||||
return c.json({
|
||||
success: true,
|
||||
engine: cfg?.engine ?? 'gemma',
|
||||
has_key: cfg?.engine === 'gemma' && !!cfg?.gemini_api_key,
|
||||
llm_model: cfg?.llm_model ?? null,
|
||||
});
|
||||
}),
|
||||
);
|
||||
// t176:`POST|GET /portal/admin/extractor`(t122)已移除。
|
||||
// 這是「雲端指定地端萃取引擎」的舊入口,且**沒有任何伺服器端驗證**——
|
||||
// 只要打這條就能把 extractor_config 設成 claude,而那把 KV 全租戶共用
|
||||
// ⇒ 所有沒裝 Claude Code 的機器萃取全滅(08-03 事故)。
|
||||
// 地端模型現由同步小幫手托盤「AI 設定…」自己設,雲端不再有這個概念。
|
||||
|
||||
// GET /portal/admin/libraries — 庫目錄列表。
|
||||
// t52(leo 2026-07-26:「地端 2 個資料夾、雲端就要 2 個庫,只有一個一定被罵」):
|
||||
@@ -1201,41 +1031,67 @@ portalRouter.get('/portal/admin/libraries', (c) =>
|
||||
}),
|
||||
);
|
||||
|
||||
// POST /portal/admin/libraries — 登記一個庫。body {name, display_name?, description?}。
|
||||
portalRouter.post('/portal/admin/libraries', (c) =>
|
||||
// t160(leo 07-31:「要直通 daemon,同步,**沒有登記這回事**」):
|
||||
// 人工建庫端點 POST /portal/admin/libraries 已刪——庫只從 daemon 同步自動出現
|
||||
// (/portal/daemon/libraries,t159)。現行 UI(e28e190 起)本就零呼叫此端點(死端點);
|
||||
// 人工登記只會製造對不上的空庫(07-27 leo 拿掉表單時已定調)。
|
||||
// GET 列表與 PATCH(管理已存在的庫:改名/停用/graph_source)照舊。
|
||||
|
||||
// POST /portal/daemon/libraries — 小幫手(daemon)連線精靈時把看守資料夾的庫報上來自動登記。
|
||||
// t159(2026-07-31 leo prod 實走揪出):daemon registerLibraries(arcrun-tray main.go:505,t52)
|
||||
// 一直在打這個端點,但 cypher 從來沒有它 ⇒ 404 被 daemon「失敗不擋連線」靜默吞掉
|
||||
// ⇒ portal_library 登記簿永遠空 ⇒ portal「庫目錄管理」空(資料同步倒是全正常——
|
||||
// triplet records 的 library slot 都在,病只在登記簿沒人寫)。
|
||||
// 契約照 daemon 既有呼叫:body {email, password, libraries:[{name, display_name}]}。
|
||||
// 帳密驗證=與 /portal/session 同一套(daemon 只在精靈那一刻拿到帳密,不存)。
|
||||
// 冪等:已登記(同 name)跳過——重跑精靈不堆重複。
|
||||
portalRouter.post('/portal/daemon/libraries', (c) =>
|
||||
run(c, async () => {
|
||||
const auth = await requirePortalAdmin(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const name = String(body?.name ?? '').trim();
|
||||
if (!isValidLibraryName(name) || name === '*') {
|
||||
return c.json({ error: '庫名限 A-Za-z0-9_-(1-64 字元;"*" 是保留值不可登記)' }, 400);
|
||||
const email = String(body?.email ?? '').trim().toLowerCase();
|
||||
const password = String(body?.password ?? '');
|
||||
if (!email || !password) return c.json({ error: 'email 與 password 必填' }, 400);
|
||||
const items = Array.isArray(body?.libraries) ? body.libraries : [];
|
||||
if (items.length === 0) return c.json({ success: true, registered: [], skipped: [] });
|
||||
|
||||
// 帳密驗證(沿用 /portal/session 的鎖定與驗證機制)
|
||||
if (await isLocked(c.env, email)) return c.json({ error: '登入失敗次數過多,請稍後再試' }, 429);
|
||||
const recordId = await findUserRecordId(c.env, email);
|
||||
const rec = recordId ? await getRecordById(c.env, recordId) : null;
|
||||
if (!rec || (rec.values.status ?? '') !== 'active'
|
||||
|| !(await verifyPassword(password, rec.values.password_hash ?? ''))) {
|
||||
await recordLoginFail(c.env, email);
|
||||
return c.json({ error: 'email 或密碼錯誤' }, 401);
|
||||
}
|
||||
await clearLoginFail(c.env, email);
|
||||
|
||||
const seeded = await ensurePortalTemplates(c.env);
|
||||
if (seeded.errors.length > 0) {
|
||||
return c.json({ error: `portal templates seed 失敗:${seeded.errors.join('; ')}` }, 502);
|
||||
}
|
||||
const existing = await listRecordsByTemplate(c.env, LIBRARY_TEMPLATE);
|
||||
if (existing.some((l) => (l.values.name ?? '') === name)) {
|
||||
return c.json({ error: `庫 ${name} 已登記` }, 409);
|
||||
}
|
||||
const have = new Set(existing.map((l) => l.values.name ?? ''));
|
||||
const ns = portalNamespace(c.env);
|
||||
const res = await kbdbFetch(c.env, '/records', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
template: LIBRARY_TEMPLATE,
|
||||
owner_id: ns,
|
||||
values: {
|
||||
name,
|
||||
display_name: String(body?.display_name ?? '').trim() || name,
|
||||
description: String(body?.description ?? '').trim(),
|
||||
status: 'active',
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new KbdbError(`POST /records(portal_library)→ ${res.status}`);
|
||||
const created = (await res.json()) as { record?: PortalRecord };
|
||||
return c.json({ success: true, library: created.record ? toPublicLibrary(created.record) : { name } });
|
||||
const registered: string[] = [];
|
||||
const skipped: string[] = [];
|
||||
for (const it of items) {
|
||||
const name = String(it?.name ?? '').trim();
|
||||
const displayName = String(it?.display_name ?? '').trim() || name;
|
||||
if (!isValidLibraryName(name) || name === '*') { skipped.push(name || '(空)'); continue; }
|
||||
if (have.has(name)) { skipped.push(name); continue; }
|
||||
const res = await kbdbFetch(c.env, '/records', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
template: LIBRARY_TEMPLATE,
|
||||
owner_id: ns,
|
||||
values: { name, display_name: displayName, description: '', status: 'active' },
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new KbdbError(`POST /records(portal_library,daemon 登記)→ ${res.status}`);
|
||||
have.add(name);
|
||||
registered.push(name);
|
||||
}
|
||||
return c.json({ success: true, registered, skipped });
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1275,6 +1131,48 @@ portalRouter.patch('/portal/admin/libraries/:id', (c) =>
|
||||
}),
|
||||
);
|
||||
|
||||
// ── AI 設定(arcrun-rag#10)────────────────────────────────────────────────────
|
||||
//
|
||||
// 🔴 為什麼這段存在(2026-08-01 真因,別再讓它消失):
|
||||
// 前端設定頁**一直**在打 `GET|POST /portal/admin/ai`,但**後端從來沒有這條 route**
|
||||
// ⇒ 用戶填 Gemini key → 404 → **key 從來沒被存進任何地方**,畫面卻像存好了(藍字=假綠)。
|
||||
// leo 實撞成「重裝後 key 不見」,但真相是「從來沒存進去,所以重填也沒用」。
|
||||
// 產物層鐵證:bundle tier2/ui grep 'portal/admin/ai'=1、tier2/cypher=0。
|
||||
//
|
||||
// 設計約束:
|
||||
// - **不另造第二套儲存**:POST 內部轉呼 credentials.ts 既有的 `storeCredential()`
|
||||
// (唯一寫入路徑=Workers Secret 明文 + D1 目錄列 ref)。
|
||||
// - **永不回傳 key 本身**(D36):GET 只回 `has_key` 布林。
|
||||
// - credential 的 `api_key` 欄=租戶 slug(`portalTenant`),與安裝器 seedCredential
|
||||
// 寫 `kbdb_internal_token` 用的 ns 同一個值 ⇒ 兩者落在同一租戶分區,查得到彼此。
|
||||
// - t176:Claude 偏好(use_claude_for_extract/claude_available)已整組移除——
|
||||
// 地端用哪個模型由同步小幫手自己設,雲端不再有這個概念。這裡只管 Gemini 金鑰。
|
||||
|
||||
// GET /portal/admin/ai — 回 AI 設定現況(role=admin 閘)。**只回 has_key 布林,永不回 key**。
|
||||
portalRouter.get('/portal/admin/ai', (c) =>
|
||||
run(c, async () => {
|
||||
const auth = await requirePortalAdmin(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
|
||||
const tenantSlug = portalTenant(c.env);
|
||||
let hasKey = false;
|
||||
try {
|
||||
const row = await c.env.CREDENTIALS_DB
|
||||
.prepare('SELECT 1 FROM credentials WHERE api_key = ? AND name = ? LIMIT 1')
|
||||
.bind(tenantSlug, 'gemini_api_key')
|
||||
.first();
|
||||
hasKey = !!row;
|
||||
} catch {
|
||||
// D1 未就緒 ⇒ 當作沒設定(不擋頁面),但也不假裝有
|
||||
hasKey = false;
|
||||
}
|
||||
|
||||
// t176:不再有 claude_available/use_claude_for_extract——地端用哪個模型
|
||||
// 由同步小幫手自己設,雲端不介入(leo 08-03)。
|
||||
return c.json({ success: true, has_key: hasKey });
|
||||
}),
|
||||
);
|
||||
|
||||
// DELETE /portal/admin/libraries/by-name/:name — 移除 auto 庫(只有資料章記、無登記簿 record)。
|
||||
// 語意:把該庫的所有 entries 標 deprecated → 資料不刪、重新 ingest 可還原。
|
||||
// ⚠️ 影響資料可搜性,要求 body.confirm 等於庫名才執行(二次確認)。
|
||||
@@ -1303,6 +1201,35 @@ portalRouter.delete('/portal/admin/libraries/by-name/:name', (c) =>
|
||||
}),
|
||||
);
|
||||
|
||||
// POST /portal/admin/ai — 存 Gemini key(role=admin 閘)。body: { gemini_api_key: string }
|
||||
// t176:Claude 偏好欄位已移除(地端模型由同步小幫手自己設,雲端不下發)。
|
||||
portalRouter.post('/portal/admin/ai', (c) =>
|
||||
run(c, async () => {
|
||||
const auth = await requirePortalAdmin(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
|
||||
const body = await c.req.json().catch(() => null) as { gemini_api_key?: unknown } | null;
|
||||
const rawKey = typeof body?.gemini_api_key === 'string' ? body.gemini_api_key.trim() : '';
|
||||
if (!rawKey) {
|
||||
return c.json({ error: '沒有要變更的項目(金鑰留空)' }, 400);
|
||||
}
|
||||
|
||||
const tenantSlug = portalTenant(c.env);
|
||||
try {
|
||||
// 唯一寫入路徑(credentials.ts):Workers Secret 存值 + D1 存 ref。
|
||||
await storeCredential(c.env, tenantSlug, 'gemini_api_key', rawKey, 'gemini');
|
||||
} catch (e) {
|
||||
// 誠實回報寫入失敗——這正是本 bug 的教訓:不能讓前端以為存好了。
|
||||
return c.json(
|
||||
{ error: `金鑰儲存失敗:${e instanceof Error ? e.message : String(e)}` },
|
||||
502,
|
||||
);
|
||||
}
|
||||
|
||||
return c.json({ success: true, has_key: true });
|
||||
}),
|
||||
);
|
||||
|
||||
// DELETE /portal/admin/libraries/:id — 移除已登記庫(有 record_id 的登記簿 record)。
|
||||
// 只刪登記簿那筆 record;知識資料(entries with library=name)完全不動。
|
||||
// 資料若有的話,重新同步後會以 auto 庫重新出現。
|
||||
|
||||
@@ -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' } });
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 下
|
||||
@@ -127,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)。
|
||||
@@ -147,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'; // 結構語意(記錄圖結構,不執行)
|
||||
|
||||
@@ -156,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,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');
|
||||
});
|
||||
});
|
||||
@@ -4,13 +4,15 @@ 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 → 走 ?? '' fallback
|
||||
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 };
|
||||
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).toBe('');
|
||||
expect(data.bundle_version).toBeUndefined();
|
||||
});
|
||||
|
||||
it('有 ARCRUN_BUNDLE_VERSION 時回其值(安裝器注入情境)', async () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* 意圖語法寫得出條件分支 → 編圖帶對邊型與標籤(SDD workflow-discovery 3.11)
|
||||
*
|
||||
* 為什麼補這一支(08-01 施工中自查發現的斷點,差點漏掉):
|
||||
* 引擎支援了 ON_TRUE/ON_FALSE/ON_BRANCH,skill 文件也教了寫法,
|
||||
* 但 `graph-builder` 原本**只認得 `對每個 X` 的參數化 label**,
|
||||
* `ON_BRANCH(branch_active)` 這種帶括號的 label 會落到 `toEdgeType` 的預設值 **PIPE**
|
||||
* ⇒ 「教了語法但引擎不收,而且是靜默的」——比沒做更糟(AI 以為分支了,實際全走同一條)。
|
||||
*
|
||||
* 本檔守的就是「文件教的寫法,編圖真的收得到」這條線。
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildExecutionGraph } from '../src/actions/graph-builder';
|
||||
import { parseTriplets, resolveNodeRole } from '../src/actions/triplet-parser';
|
||||
|
||||
/** 把意圖字串編成圖(走 AI 真正會走的那條路:triplets → graph)。
|
||||
* nodeResults 用「全部 found」的最小替身——本檔只驗**邊**的編法,零件解析另有測試。 */
|
||||
function build(triplets: string[]) {
|
||||
const parsed = parseTriplets(triplets)!;
|
||||
const nodeResults: Record<string, { status: 'found'; componentId: string; type: ReturnType<typeof resolveNodeRole> }> = {};
|
||||
for (const name of parsed.nodeNames) {
|
||||
nodeResults[name] = {
|
||||
status: 'found',
|
||||
componentId: name.toLowerCase().replace(/\s+/g, '_'),
|
||||
type: resolveNodeRole(name, parsed),
|
||||
};
|
||||
}
|
||||
return buildExecutionGraph(parsed, nodeResults as never, 'test-graph', '測試');
|
||||
}
|
||||
|
||||
function edgeBetween(graph: ReturnType<typeof build>, from: string, to: string) {
|
||||
return graph.edges.find(e => e.from === from && e.to === to);
|
||||
}
|
||||
|
||||
describe('意圖語法:if_control 兩路(ON_TRUE/ON_FALSE)', () => {
|
||||
it('ON_TRUE/ON_FALSE 編成對應邊型,不會退化成 PIPE', () => {
|
||||
const g = build([
|
||||
'input >> ON_SUCCESS >> 判斷有沒有新資料',
|
||||
'判斷有沒有新資料 >> ON_TRUE >> 傳到telegram',
|
||||
'判斷有沒有新資料 >> ON_FALSE >> 結束',
|
||||
]);
|
||||
expect(edgeBetween(g, '判斷有沒有新資料', '傳到telegram')?.type).toBe('ON_TRUE');
|
||||
expect(edgeBetween(g, '判斷有沒有新資料', '結束')?.type).toBe('ON_FALSE');
|
||||
});
|
||||
|
||||
it('中文語意詞「成立時」「否則」也編得出來', () => {
|
||||
const g = build([
|
||||
'判斷有沒有新資料 >> 成立時 >> 傳到telegram',
|
||||
'判斷有沒有新資料 >> 否則 >> 結束',
|
||||
]);
|
||||
expect(edgeBetween(g, '判斷有沒有新資料', '傳到telegram')?.type).toBe('ON_TRUE');
|
||||
expect(edgeBetween(g, '判斷有沒有新資料', '結束')?.type).toBe('ON_FALSE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('意圖語法:switch 具名分支(ON_BRANCH(標籤))', () => {
|
||||
it('括號裡的標籤被抽成 edge.branch,型別是 ON_BRANCH', () => {
|
||||
const g = build([
|
||||
'my_switch >> ON_BRANCH(branch_active) >> 處理啟用',
|
||||
'my_switch >> ON_BRANCH(branch_pending) >> 處理待辦',
|
||||
'my_switch >> ON_BRANCH(branch_default) >> 其他',
|
||||
]);
|
||||
const active = edgeBetween(g, 'my_switch', '處理啟用');
|
||||
expect(active?.type).toBe('ON_BRANCH');
|
||||
expect(active?.branch).toBe('branch_active');
|
||||
|
||||
const pending = edgeBetween(g, 'my_switch', '處理待辦');
|
||||
expect(pending?.branch).toBe('branch_pending');
|
||||
|
||||
const dflt = edgeBetween(g, 'my_switch', '其他');
|
||||
expect(dflt?.branch).toBe('branch_default');
|
||||
});
|
||||
|
||||
it('全形括號也收(中文輸入法常打出全形)', () => {
|
||||
const g = build(['my_switch >> ON_BRANCH(branch_active) >> 處理啟用']);
|
||||
const e = edgeBetween(g, 'my_switch', '處理啟用');
|
||||
expect(e?.type).toBe('ON_BRANCH');
|
||||
expect(e?.branch).toBe('branch_active');
|
||||
});
|
||||
|
||||
it('try_catch 的 try/catch 標籤同樣收得到', () => {
|
||||
const g = build([
|
||||
'my_try >> ON_BRANCH(try) >> 正常流程',
|
||||
'my_try >> ON_BRANCH(catch) >> 補救流程',
|
||||
]);
|
||||
expect(edgeBetween(g, 'my_try', '正常流程')?.branch).toBe('try');
|
||||
expect(edgeBetween(g, 'my_try', '補救流程')?.branch).toBe('catch');
|
||||
});
|
||||
});
|
||||
|
||||
describe('零變化:既有語法不受影響', () => {
|
||||
it('ON_SUCCESS 仍是 ON_SUCCESS', () => {
|
||||
const g = build(['input >> ON_SUCCESS >> prep']);
|
||||
expect(edgeBetween(g, 'input', 'prep')?.type).toBe('ON_SUCCESS');
|
||||
});
|
||||
|
||||
it('「對每個 X」仍抽得到 iterator(不被新的 branch 抽取干擾)', () => {
|
||||
const g = build(['parse_card >> 對每個 block >> post_block']);
|
||||
const e = edgeBetween(g, 'parse_card', 'post_block');
|
||||
expect(e?.type).toBe('FOREACH');
|
||||
expect(e?.iterator).toBe('block');
|
||||
expect(e?.branch).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* GET|POST /portal/admin/ai —— arcrun-rag#10 迴歸守衛
|
||||
*
|
||||
* 🔴 為什麼有這支測試(別刪):
|
||||
* 這條 route **以前根本不存在**,但前端設定頁一直在打它 ⇒ 用戶填 Gemini key → 404
|
||||
* ⇒ **key 從來沒被存進任何地方**,畫面卻像存好了(藍字=假綠)。
|
||||
* leo 實撞成「重裝後 key 不見」,真相是「從來沒存進去,所以重填也沒用」。
|
||||
* 產物層鐵證(修復前):bundle tier2/ui grep 'portal/admin/ai'=1、tier2/cypher=**0**。
|
||||
* ⇒ 這支測試的存在本身就是防線:**route 消失=測試紅**。
|
||||
*
|
||||
* 覆蓋:
|
||||
* 1. 未登入 → 401;非 admin → 403(不是 404=route 真的在)
|
||||
* 2. GET 回 has_key 布林,**永不回傳 key 本身**(D36)
|
||||
* 3. POST 空 body → 400(不假裝成功)
|
||||
* 4. POST 只改 Claude 偏好(不帶 key)→ 成功,且不碰 credential
|
||||
*/
|
||||
import { SELF, env, fetchMock } from 'cloudflare:test';
|
||||
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
|
||||
import { hashPassword } from '../src/lib/portal-auth';
|
||||
|
||||
const KBDB = 'https://kbdb.test';
|
||||
|
||||
let storedHash: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
fetchMock.activate();
|
||||
fetchMock.disableNetConnect();
|
||||
storedHash = await hashPassword('unit-test-pw-1', 10_000);
|
||||
});
|
||||
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),
|
||||
});
|
||||
}
|
||||
|
||||
function mockGetRecord(recordId: string, values: Record<string, string>) {
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: `/records/${recordId}`, method: 'GET' })
|
||||
.reply(200, { success: true, record: { record_id: recordId, template_id: 'tpl_pu', values } });
|
||||
}
|
||||
|
||||
function adminValues(overrides: Record<string, string> = {}): Record<string, string> {
|
||||
return {
|
||||
email: 'admin@example.com',
|
||||
display_name: '管理員',
|
||||
status: 'active',
|
||||
role: 'admin',
|
||||
password_hash: storedHash,
|
||||
libraries: '["*"]',
|
||||
created_at: '2026-07-14T00:00:00.000Z',
|
||||
updated_at: '2026-07-14T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function seedSession(token: string, recordId: string) {
|
||||
await env.SESSIONS_KV.put(`portal_sess:${token}`, JSON.stringify({ record_id: recordId }));
|
||||
}
|
||||
|
||||
const authHdr = (t: string) => ({ Authorization: `Bearer ${t}` });
|
||||
|
||||
describe('GET /portal/admin/ai — 認證閘(route 存在的證明)', () => {
|
||||
it('未登入 → 401(不是 404 ⇒ route 真的在)', async () => {
|
||||
const res = await json('GET', '/portal/admin/ai');
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.status).not.toBe(404);
|
||||
});
|
||||
|
||||
it('非 admin → 403', async () => {
|
||||
await seedSession('tok-user', 'rec_user');
|
||||
mockGetRecord('rec_user', adminValues({ role: 'user', email: 'u@example.com' }));
|
||||
const res = await json('GET', '/portal/admin/ai', undefined, authHdr('tok-user'));
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /portal/admin/ai — 回應形狀(D36:永不回傳 key)', () => {
|
||||
it('回 has_key 布林,且回應完全不含金鑰值', async () => {
|
||||
await seedSession('tok-a1', 'rec_admin');
|
||||
mockGetRecord('rec_admin', adminValues());
|
||||
const res = await json('GET', '/portal/admin/ai', undefined, authHdr('tok-a1'));
|
||||
expect(res.status).toBe(200);
|
||||
const raw = await res.text();
|
||||
const d = JSON.parse(raw) as Record<string, unknown>;
|
||||
|
||||
expect(typeof d.has_key).toBe('boolean');
|
||||
|
||||
// D36:回應裡不得出現任何疑似金鑰的欄位
|
||||
expect(raw).not.toContain('gemini_api_key_value');
|
||||
expect(d).not.toHaveProperty('key');
|
||||
expect(d).not.toHaveProperty('value');
|
||||
expect(d).not.toHaveProperty('secret_ref');
|
||||
});
|
||||
|
||||
// t176 回歸守衛(leo 08-03):雲端不再有「地端用哪個模型」的概念。
|
||||
// 這兩個欄位若復活,代表又走回「雲端控制地端」的老路——那正是 08-03 事故根因
|
||||
//(extractor_config 全租戶共用一把,任一處設 claude 就讓所有人萃取全滅)。
|
||||
it('不再回 claude_available/use_claude_for_extract(地端模型改由小幫手自己設)', async () => {
|
||||
await seedSession('tok-a1b', 'rec_admin');
|
||||
mockGetRecord('rec_admin', adminValues());
|
||||
const res = await json('GET', '/portal/admin/ai', undefined, authHdr('tok-a1b'));
|
||||
const d = (await res.json()) as Record<string, unknown>;
|
||||
expect(d).not.toHaveProperty('claude_available');
|
||||
expect(d).not.toHaveProperty('use_claude_for_extract');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /portal/admin/ai — 不假裝成功', () => {
|
||||
it('空 body(沒帶金鑰)→ 400,不回 success', async () => {
|
||||
await seedSession('tok-a2', 'rec_admin');
|
||||
mockGetRecord('rec_admin', adminValues());
|
||||
const res = await json('POST', '/portal/admin/ai', {}, authHdr('tok-a2'));
|
||||
expect(res.status).toBe(400);
|
||||
const d = (await res.json()) as Record<string, unknown>;
|
||||
expect(d.success).toBeUndefined();
|
||||
expect(String(d.error)).toContain('沒有要變更');
|
||||
});
|
||||
|
||||
// t176 回歸守衛:只送 Claude 偏好=沒有要變更的項目 → 400(該欄位已不存在)。
|
||||
it('只送 use_claude_for_extract(已廢欄位)→ 400,不得假裝成功', async () => {
|
||||
await seedSession('tok-a3', 'rec_admin');
|
||||
mockGetRecord('rec_admin', adminValues());
|
||||
const res = await json('POST', '/portal/admin/ai', { use_claude_for_extract: true }, authHdr('tok-a3'));
|
||||
expect(res.status).toBe(400);
|
||||
const d = (await res.json()) as Record<string, unknown>;
|
||||
expect(d.success).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -300,42 +300,16 @@ describe('PATCH libraries(每帳號可查庫)', () => {
|
||||
// ═══════════════ 5. 庫目錄管理 ═══════════════
|
||||
|
||||
describe('/portal/admin/libraries', () => {
|
||||
it('POST 建庫:寫 {tenant}::portal 子 namespace;重複登記 → 409', async () => {
|
||||
await seedAdminSession();
|
||||
mockGetRecord('rec_admin', adminValues());
|
||||
mockTemplatesExist();
|
||||
mockListByTemplate('portal_library', []);
|
||||
let recordBody = '';
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: '/records', method: 'POST' })
|
||||
.reply(200, (opts) => {
|
||||
recordBody = String(opts.body);
|
||||
return {
|
||||
success: true,
|
||||
record: { record_id: 'rec_lib1', template_id: 'tpl_pl', values: { name: 'finance', display_name: '財務庫', status: 'active' } },
|
||||
};
|
||||
});
|
||||
it('t160:人工建庫端點已刪(leo「沒有登記這回事」)——POST → 404;庫只從 daemon 同步來', async () => {
|
||||
// 舊測試驗「POST 建庫 200+重複 409」——t160 拔掉人工建庫(e744ad1)後規格為:
|
||||
// 庫由 /portal/daemon/libraries(連線精靈自動登記,t159)產生,admin 只能 GET/PATCH。
|
||||
const res = await json(
|
||||
'POST',
|
||||
'/portal/admin/libraries',
|
||||
{ name: 'finance', display_name: '財務庫' },
|
||||
{ Authorization: 'Bearer tok-admin' },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const rec = JSON.parse(recordBody) as { owner_id: string; template: string };
|
||||
expect(rec.owner_id).toBe(NS);
|
||||
expect(rec.template).toBe('portal_library');
|
||||
|
||||
// 重複登記
|
||||
await seedAdminSession('tok-admin3');
|
||||
mockGetRecord('rec_admin', adminValues());
|
||||
mockTemplatesExist();
|
||||
mockListByTemplate('portal_library', [
|
||||
{ record_id: 'rec_lib1', values: { name: 'finance', display_name: '財務庫', status: 'active' } },
|
||||
]);
|
||||
const dup = await json('POST', '/portal/admin/libraries', { name: 'finance' }, { Authorization: 'Bearer tok-admin3' });
|
||||
expect(dup.status).toBe(409);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('PATCH graph_source:boolean 進、slot 存字串;非 boolean → 400', async () => {
|
||||
@@ -564,13 +538,20 @@ describe('DELETE /portal/admin/libraries(t135)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 6. t122 萃取引擎金鑰雲端下發 ═══════════════
|
||||
// ═══════════════ 6. t176:雲端不再管地端 LLM 設定(取代原 t122/t131 兩組測試)═══════════════
|
||||
//
|
||||
// leo 2026-08-03 架構翻案:「地端要用什麼模型就在 daemon 上輸入 API Key 設置,
|
||||
// 而不是雲端設置後控制地端」。原因是 extractor_config 的 KV key 由 portalTenant() 組出,
|
||||
// 而 portalTenant 是 **worker 層級**環境變數 ⇒ **全租戶共用一把**:任一處設了 claude,
|
||||
// 所有人的 daemon 都收到 claude,沒裝 Claude Code 的機器萃取全滅,
|
||||
// 而 portal 的 Claude 勾選框又恆 disabled(daemon 從未回報 has_claude)⇒ 用戶自己解不開。
|
||||
//
|
||||
// 以下是**回歸守衛**:這些端點/欄位若復活,代表又走回「雲端控制地端」的老路。
|
||||
|
||||
describe('/portal/admin/extractor + /portal/daemon/config 萃取引擎(t122)', () => {
|
||||
describe('t176:雲端不再下發/設定地端 LLM', () => {
|
||||
const USER_EMAIL = 'daemon@example.com';
|
||||
const USER_PW = 'unit-test-pw-1'; // 與 storedHash 配對(beforeAll 計算)
|
||||
const USER_PW = 'unit-test-pw-1'; // 與 storedHash 配對(外層 beforeAll 計算)
|
||||
const USER_RECORD = 'rec_daemon_user';
|
||||
const EXTRACTOR_KV_KEY = 'leo:portal:extractor_config'; // wrangler.test.toml CONSOLE_TENANT=leo
|
||||
|
||||
/** mock email head lookup(findUserRecordId 走這個路徑)*/
|
||||
function mockEmailLookup(email: string, recordId: string | null) {
|
||||
@@ -584,47 +565,37 @@ describe('/portal/admin/extractor + /portal/daemon/config 萃取引擎(t122)
|
||||
.reply(200, { success: true, entries: recordId ? [{ content: recordId }] : [], count: recordId ? 1 : 0 });
|
||||
}
|
||||
|
||||
it('未設定 → daemon/config 下發 extractor=gemma,無 gemini_api_key', async () => {
|
||||
// 確保 KV 沒有 extractor config
|
||||
await env.WEBHOOKS.delete(EXTRACTOR_KV_KEY);
|
||||
it('POST /portal/daemon/config 只回連線欄位,**不含任何 LLM 欄位**', async () => {
|
||||
mockEmailLookup(USER_EMAIL, USER_RECORD);
|
||||
mockGetRecord(USER_RECORD, adminValues({ email: USER_EMAIL, password_hash: storedHash }));
|
||||
|
||||
const res = await json('POST', '/portal/daemon/config', { email: USER_EMAIL, password: USER_PW });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { success: boolean; config: Record<string, string> };
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.config.extractor).toBe('gemma');
|
||||
expect('gemini_api_key' in data.config).toBe(false);
|
||||
const d = (await res.json()) as { config: Record<string, unknown> };
|
||||
|
||||
// 連線欄位照舊(daemon 靠它上線)
|
||||
expect(d.config.cypher_url).toBeTruthy();
|
||||
expect(d.config.namespace).toBeTruthy();
|
||||
expect(d.config.library).toBe('kb');
|
||||
|
||||
// LLM 欄位一律不下發(t176 核心)
|
||||
expect(d.config).not.toHaveProperty('extractor');
|
||||
expect(d.config).not.toHaveProperty('gemini_api_key');
|
||||
expect(d.config).not.toHaveProperty('llm_model');
|
||||
});
|
||||
|
||||
it('設定 gemma+金鑰後 → daemon/config 下發含 gemini_api_key', async () => {
|
||||
await env.WEBHOOKS.put(EXTRACTOR_KV_KEY, JSON.stringify({ engine: 'gemma', gemini_api_key: 'AIza-test-key-999' }));
|
||||
mockEmailLookup(USER_EMAIL, USER_RECORD);
|
||||
mockGetRecord(USER_RECORD, adminValues({ email: USER_EMAIL, password_hash: storedHash }));
|
||||
const res = await json('POST', '/portal/daemon/config', { email: USER_EMAIL, password: USER_PW });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { success: boolean; config: Record<string, string> };
|
||||
expect(data.config.extractor).toBe('gemma');
|
||||
expect(data.config.gemini_api_key).toBe('AIza-test-key-999');
|
||||
// cleanup
|
||||
await env.WEBHOOKS.delete(EXTRACTOR_KV_KEY);
|
||||
// 註:route 不存在 ⇒ 在認證之前就 404,因此不需要(也不能)預先掛 record mock,
|
||||
// 否則 afterEach 的 assertNoPendingInterceptors 會因「mock 沒被用到」而失敗。
|
||||
it('POST /portal/admin/extractor 已移除(雲端不再有指定地端引擎的入口)', async () => {
|
||||
const res = await json('POST', '/portal/admin/extractor', { engine: 'claude' }, { Authorization: 'Bearer tok-ex' });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('GET /portal/admin/extractor → has_key=true,回應不含金鑰明文', async () => {
|
||||
await env.WEBHOOKS.put(EXTRACTOR_KV_KEY, JSON.stringify({ engine: 'gemma', gemini_api_key: 'AIza-secret-key' }));
|
||||
await seedAdminSession();
|
||||
mockGetRecord('rec_admin', adminValues());
|
||||
const res = await json('GET', '/portal/admin/extractor', undefined, { Authorization: 'Bearer tok-admin' });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { success: boolean; engine: string; has_key: boolean };
|
||||
expect(data.engine).toBe('gemma');
|
||||
expect(data.has_key).toBe(true);
|
||||
// 回應主體不含金鑰明文
|
||||
const raw = JSON.stringify(data);
|
||||
expect(raw).not.toContain('AIza-secret-key');
|
||||
expect(raw).not.toContain('gemini_api_key');
|
||||
// cleanup
|
||||
await env.WEBHOOKS.delete(EXTRACTOR_KV_KEY);
|
||||
it('POST /portal/daemon/report-capabilities 已移除(has_claude 回報鏈整條退役)', async () => {
|
||||
const res = await json('POST', '/portal/daemon/report-capabilities', {
|
||||
email: USER_EMAIL, password: USER_PW, has_claude: true,
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -658,166 +629,8 @@ describe('GET /portal(P4 admin 頁 HTML 殼)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 8. t131 合併 AI 設定 ═══════════════
|
||||
|
||||
describe('/portal/admin/ai + /portal/daemon/report-capabilities(t131)', () => {
|
||||
const USER_EMAIL = 'ai-test@example.com';
|
||||
const USER_PW = 'unit-test-pw-1';
|
||||
const USER_RECORD = 'rec_ai_user';
|
||||
const AI_CONFIG_KEY = 'leo:portal:ai_config';
|
||||
const EXTRACTOR_KV_KEY = 'leo:portal:extractor_config';
|
||||
const DAEMON_CAPS_KEY = 'leo:portal:daemon_caps';
|
||||
|
||||
function aiAdminVals(): Record<string, string> {
|
||||
return { email: USER_EMAIL, display_name: 'AI 測試 admin', status: 'active', role: 'admin', password_hash: storedHash };
|
||||
}
|
||||
|
||||
// 與全域 seedAdminSession 相同格式(JSON.stringify({record_id})),fetchMock 由各測試自行 mock
|
||||
async function seedAiSession(token = 'tok-ai-admin', recordId = USER_RECORD) {
|
||||
await env.SESSIONS_KV.put(`portal_sess:${token}`, JSON.stringify({ record_id: recordId }));
|
||||
}
|
||||
|
||||
function mockAiRecord(recordId = USER_RECORD) {
|
||||
fetchMock.get(KBDB).intercept({ path: `/records/${recordId}`, method: 'GET' }).reply(200, {
|
||||
success: true,
|
||||
record: { record_id: recordId, template_id: 'tpl_pu', values: aiAdminVals() },
|
||||
});
|
||||
}
|
||||
|
||||
function mockEmailLookup(email: string, recordId: string | null) {
|
||||
const needle = new URLSearchParams({ page_name: email }).toString();
|
||||
fetchMock.get(KBDB).intercept({
|
||||
path: (p: string) => p.startsWith('/entries?') && p.includes(needle) && p.includes(encodeURIComponent(NS)),
|
||||
method: 'GET',
|
||||
}).reply(200, { success: true, entries: recordId ? [{ content: recordId }] : [], count: recordId ? 1 : 0 });
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await env.WEBHOOKS.delete(AI_CONFIG_KEY);
|
||||
await env.WEBHOOKS.delete(EXTRACTOR_KV_KEY);
|
||||
await env.WEBHOOKS.delete(DAEMON_CAPS_KEY);
|
||||
});
|
||||
|
||||
it('POST /ai — 首次設定:同時寫 ai_config+extractor_config+更新 rag_chat workflow', async () => {
|
||||
const ragChatKey = 'leo:wf:rag_chat';
|
||||
const workflow = { graph: { nodes: [{ config: { 'x-goog-api-key': '{{credential.gemini}}' } }] }, config: {} };
|
||||
await env.WEBHOOKS.put(ragChatKey, JSON.stringify(workflow));
|
||||
await seedAiSession();
|
||||
mockAiRecord();
|
||||
const res = await json('POST', '/portal/admin/ai',
|
||||
{ gemini_api_key: 'AIza-new-key-123', use_claude_for_extract: false },
|
||||
{ Authorization: 'Bearer tok-ai-admin' }
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { success: boolean; has_key: boolean; use_claude_for_extract: boolean };
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.has_key).toBe(true);
|
||||
expect(data.use_claude_for_extract).toBe(false);
|
||||
|
||||
const stored = JSON.parse((await env.WEBHOOKS.get(AI_CONFIG_KEY, 'text')) ?? '{}');
|
||||
expect(stored.gemini_api_key).toBe('AIza-new-key-123');
|
||||
expect(stored.use_claude_for_extract).toBe(false);
|
||||
|
||||
const exCfg = JSON.parse((await env.WEBHOOKS.get(EXTRACTOR_KV_KEY, 'text')) ?? '{}');
|
||||
expect(exCfg.engine).toBe('gemma');
|
||||
expect(exCfg.gemini_api_key).toBe('AIza-new-key-123');
|
||||
|
||||
const updated = JSON.parse((await env.WEBHOOKS.get(ragChatKey, 'text')) ?? '{}') as typeof workflow;
|
||||
expect((updated.graph as { nodes: Array<{ config: Record<string, string> }> }).nodes[0].config['x-goog-api-key']).toBe('AIza-new-key-123');
|
||||
await env.WEBHOOKS.delete(ragChatKey);
|
||||
});
|
||||
|
||||
it('POST /ai — rag_chat 不存在時不報錯(容忍,金鑰存 ai_config 即可)', async () => {
|
||||
await seedAiSession();
|
||||
mockAiRecord();
|
||||
const res = await json('POST', '/portal/admin/ai',
|
||||
{ gemini_api_key: 'AIza-no-workflow-key' },
|
||||
{ Authorization: 'Bearer tok-ai-admin' }
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { success: boolean; has_key: boolean };
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.has_key).toBe(true);
|
||||
const stored = JSON.parse((await env.WEBHOOKS.get(AI_CONFIG_KEY, 'text')) ?? '{}');
|
||||
expect(stored.gemini_api_key).toBe('AIza-no-workflow-key');
|
||||
});
|
||||
|
||||
it('POST /ai — use_claude_for_extract=true:extractor engine=claude,不附 gemini_api_key', async () => {
|
||||
await seedAiSession();
|
||||
mockAiRecord();
|
||||
const res = await json('POST', '/portal/admin/ai',
|
||||
{ gemini_api_key: 'AIza-key-888', use_claude_for_extract: true },
|
||||
{ Authorization: 'Bearer tok-ai-admin' }
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { success: boolean; use_claude_for_extract: boolean };
|
||||
expect(data.use_claude_for_extract).toBe(true);
|
||||
const exCfg = JSON.parse((await env.WEBHOOKS.get(EXTRACTOR_KV_KEY, 'text')) ?? '{}');
|
||||
expect(exCfg.engine).toBe('claude');
|
||||
expect('gemini_api_key' in exCfg).toBe(false);
|
||||
});
|
||||
|
||||
it('GET /ai — 不回明文金鑰;has_key=true;claude_available 依 daemon_caps', async () => {
|
||||
await env.WEBHOOKS.put(AI_CONFIG_KEY, JSON.stringify({ gemini_api_key: 'AIza-secret-456', use_claude_for_extract: false }));
|
||||
await env.WEBHOOKS.put(DAEMON_CAPS_KEY, JSON.stringify({ has_claude: true }));
|
||||
await seedAiSession();
|
||||
mockAiRecord();
|
||||
const res = await json('GET', '/portal/admin/ai', undefined, { Authorization: 'Bearer tok-ai-admin' });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { success: boolean; has_key: boolean; use_claude_for_extract: boolean; claude_available: boolean };
|
||||
expect(data.has_key).toBe(true);
|
||||
expect(data.use_claude_for_extract).toBe(false);
|
||||
expect(data.claude_available).toBe(true);
|
||||
const raw = JSON.stringify(data);
|
||||
expect(raw).not.toContain('AIza-secret-456');
|
||||
expect(raw).not.toContain('gemini_api_key');
|
||||
});
|
||||
|
||||
it('GET /ai — 沒有 daemon_caps → claude_available=false', async () => {
|
||||
await env.WEBHOOKS.put(AI_CONFIG_KEY, JSON.stringify({ gemini_api_key: 'AIza-key-777' }));
|
||||
await seedAiSession();
|
||||
mockAiRecord();
|
||||
const res = await json('GET', '/portal/admin/ai', undefined, { Authorization: 'Bearer tok-ai-admin' });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { claude_available: boolean };
|
||||
expect(data.claude_available).toBe(false);
|
||||
});
|
||||
|
||||
it('POST /portal/daemon/report-capabilities — 有 claude:daemon_caps 寫入 has_claude=true', async () => {
|
||||
mockEmailLookup(USER_EMAIL, USER_RECORD);
|
||||
mockAiRecord();
|
||||
const res = await json('POST', '/portal/daemon/report-capabilities', {
|
||||
email: USER_EMAIL, password: USER_PW, has_claude: true, daemon_version: '1.2.0', os: 'darwin',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { success: boolean };
|
||||
expect(data.success).toBe(true);
|
||||
const caps = JSON.parse((await env.WEBHOOKS.get(DAEMON_CAPS_KEY, 'text')) ?? '{}');
|
||||
expect(caps.has_claude).toBe(true);
|
||||
expect(caps.daemon_version).toBe('1.2.0');
|
||||
});
|
||||
|
||||
it('舊端點 /portal/admin/chat-key 仍可用(相容)', async () => {
|
||||
const ragChatKey = 'leo:wf:rag_chat';
|
||||
const workflow = { graph: { nodes: [{ config: { 'x-goog-api-key': 'old' } }] }, config: {} };
|
||||
await env.WEBHOOKS.put(ragChatKey, JSON.stringify(workflow));
|
||||
await seedAiSession();
|
||||
mockAiRecord();
|
||||
const res = await json('POST', '/portal/admin/chat-key', { key: 'AIza-compat-key' }, { Authorization: 'Bearer tok-ai-admin' });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { success: boolean; replaced: number };
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.replaced).toBeGreaterThan(0);
|
||||
await env.WEBHOOKS.delete(ragChatKey);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ t181:daemon 萃取走 Workers AI(免金鑰)═══════════════
|
||||
//
|
||||
// leo 08-04 列為最優先:「daemon 的 AI 改用 workers AI」——
|
||||
// 「這是我的用戶最大障礙,造成首輪測試用戶的好評或惡評」。
|
||||
// 舊路徑要用戶自備 Gemini key,實測撞到「不知道去哪設定」「Google 帳號被 flag 403」
|
||||
// 「52 檔全滅還要把金鑰傳給別人才查得出原因」三種災難。
|
||||
// t131/t122 測試已隨 main 的 t176(刪除雲端下發 LLM 設定)一併移除;
|
||||
// 此處只保留 t181(daemon 走 Workers AI)的守衛。
|
||||
|
||||
describe('POST /portal/daemon/extract(t181:Workers AI 萃卡,免金鑰)', () => {
|
||||
// 認證=X-Arcrun-API-Key(=namespace,wrangler.test.toml CONSOLE_TENANT=leo),
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* recipe payload 與回應處理層 —— CP `arcrun-usable` 步驟 5 缺口②
|
||||
* SDD: workflow-discovery task 3.12
|
||||
*
|
||||
* 為什麼要有這三層(別刪):
|
||||
* 舊 schema 只有 {canonical_id, endpoint, method, auth_service}(body 有但淺)
|
||||
* ⇒ 帶 body 的 API 只能繞過 recipe 把整包寫進 workflow code;
|
||||
* 回應解析(rag_chat 的 finalize,2786 字元)綁死 Gemini 格式,換源必壞。
|
||||
* leo:三層模型=①零件 ②auth recipe ③payload recipe,第③層過去不存在。
|
||||
*
|
||||
* 本檔測純函式層(body_template 插值 / response_map 正規化),
|
||||
* 不打真外部 API——外部呼叫由 stage 端到端驗(features/09)。
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { renderBodyTemplate, applyResponseMap } from '../src/lib/recipe-payload';
|
||||
|
||||
describe('body_template:payload 收回 recipe(第③層)', () => {
|
||||
it('巢狀結構的 {{var}} 都會被替換(不只 top-level)', () => {
|
||||
const out = renderBodyTemplate(
|
||||
{ contents: [{ parts: [{ text: '{{prompt}}' }] }] },
|
||||
{ prompt: '你好' },
|
||||
);
|
||||
expect(out).toEqual({ contents: [{ parts: [{ text: '你好' }] }] });
|
||||
});
|
||||
|
||||
it('單一引用保留原型別(陣列/物件不被 stringify)', () => {
|
||||
const out = renderBodyTemplate(
|
||||
{ messages: '{{history}}', n: '{{count}}' },
|
||||
{ history: [{ role: 'user' }], count: 3 },
|
||||
) as Record<string, unknown>;
|
||||
expect(out.messages).toEqual([{ role: 'user' }]);
|
||||
expect(out.n).toBe(3);
|
||||
});
|
||||
|
||||
it('混合文字仍拼成字串', () => {
|
||||
const out = renderBodyTemplate({ q: '請回答:{{prompt}}' }, { prompt: '天氣' }) as Record<string, unknown>;
|
||||
expect(out.q).toBe('請回答:天氣');
|
||||
});
|
||||
|
||||
it('支援 dot path 取值', () => {
|
||||
const out = renderBodyTemplate({ t: '{{assemble.data.prompt}}' }, {
|
||||
assemble: { data: { prompt: '深層值' } },
|
||||
}) as Record<string, unknown>;
|
||||
expect(out.t).toBe('深層值');
|
||||
});
|
||||
|
||||
it('取不到的變數保留原樣(不靜默變 undefined,看得見才好 debug)', () => {
|
||||
const out = renderBodyTemplate({ t: '{{nope}}' }, {}) as Record<string, unknown>;
|
||||
expect(out.t).toBe('{{nope}}');
|
||||
});
|
||||
|
||||
it('沒有 body_template → 回 undefined(呼叫端沿用既有行為)', () => {
|
||||
expect(renderBodyTemplate(undefined, { a: 1 })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('response_map:回應正規化(換源不必改 workflow)', () => {
|
||||
const geminiBody = {
|
||||
candidates: [{ content: { parts: [{ text: '【答】台北是首都' }] } }],
|
||||
};
|
||||
|
||||
it('path 取值:Gemini 形狀 → 純文字', () => {
|
||||
const out = applyResponseMap(geminiBody, { text_path: 'candidates.0.content.parts.0.text' });
|
||||
expect(out.text).toBe('【答】台北是首都');
|
||||
});
|
||||
|
||||
it('換源=換 recipe:Claude 形狀用不同 path,同樣取得出文字', () => {
|
||||
const claudeBody = { content: [{ type: 'text', text: 'Claude 的答案' }] };
|
||||
const out = applyResponseMap(claudeBody, { text_path: 'content.0.text' });
|
||||
expect(out.text).toBe('Claude 的答案');
|
||||
});
|
||||
|
||||
it('Workers AI 形狀(binding 回傳)同樣走 path', () => {
|
||||
const waiBody = { response: 'Workers AI 的答案' };
|
||||
const out = applyResponseMap(waiBody, { text_path: 'response' });
|
||||
expect(out.text).toBe('Workers AI 的答案');
|
||||
});
|
||||
|
||||
it('思考型模型:thought=true 的 part 要被剔除,取最後一個非 thought', () => {
|
||||
const gemma = {
|
||||
candidates: [{
|
||||
content: {
|
||||
parts: [
|
||||
{ text: '讓我想想…', thought: true },
|
||||
{ text: '真正的答案' },
|
||||
],
|
||||
},
|
||||
}],
|
||||
};
|
||||
const out = applyResponseMap(gemma, {
|
||||
text_path: 'candidates.0.content.parts',
|
||||
thinking_model: true,
|
||||
});
|
||||
expect(out.text).toBe('真正的答案');
|
||||
});
|
||||
|
||||
it('淨化規則:剝掉【答】前的草稿前綴(實撞三型之一)', () => {
|
||||
const out = applyResponseMap(
|
||||
{ r: 'Draft: 【答】正確內容' },
|
||||
{ text_path: 'r', strip_prefixes: ['Draft:', '*', 'Answer:'], answer_marker: '【答】' },
|
||||
);
|
||||
expect(out.text).toBe('正確內容');
|
||||
});
|
||||
|
||||
it('淨化規則:前綴組合順序不定 → 循環剝殼剝乾淨', () => {
|
||||
const out = applyResponseMap(
|
||||
{ r: 'Answer: * 【答】內容' },
|
||||
{ text_path: 'r', strip_prefixes: ['Draft:', '*', 'Answer:'], answer_marker: '【答】' },
|
||||
);
|
||||
expect(out.text).toBe('內容');
|
||||
});
|
||||
|
||||
it('沒有 response_map → 原樣回傳(既有 recipe 行為完全不變)', () => {
|
||||
const out = applyResponseMap(geminiBody, undefined);
|
||||
expect(out.text).toBeUndefined();
|
||||
expect(out.raw).toEqual(geminiBody);
|
||||
});
|
||||
|
||||
it('path 取不到 → 誠實回 undefined,不編造', () => {
|
||||
const out = applyResponseMap({ a: 1 }, { text_path: 'b.c.d' });
|
||||
expect(out.text).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* CP `arcrun-usable` 步驟 5 驗收(SDD workflow-discovery task 3.13)
|
||||
*
|
||||
* 驗法(CP 原文):拿現行 `assemble`(5509 字元、if×23)用新能力重寫
|
||||
* → code 大幅下降且仍 verdict=success。
|
||||
*
|
||||
* 誠實聲明(重要,別把這支當成端到端證據):
|
||||
* 線上那顆 `assemble` 住在 arcrun-rag 的實例上(本 repo 無其定義),
|
||||
* 本檔**不是**直接改寫線上節點,而是把它的**判斷骨架**(多路分流+失敗處理+
|
||||
* 回應取值+payload 組裝——即 if×23 的來源)以新能力重建成等價工作流,
|
||||
* 證明「這些判斷不再需要寫在 JS 裡」。
|
||||
* 線上節點的真正改寫=stage 端到端(features/09),不在單元測試層宣稱。
|
||||
*
|
||||
* 對照基準(08-01 實測,來源:頂層 pending-changes「零件層系統性違規盤點」段):
|
||||
* rag_chat 的 assemble=5509 字元、if×23、for×12
|
||||
*/
|
||||
import { SELF } from 'cloudflare:test';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
async function execute(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 (await res.json()) as {
|
||||
success: boolean;
|
||||
data: Record<string, unknown>;
|
||||
trace?: Array<{ nodeId: string }>;
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
describe('步驟 5 驗收:判斷骨架不再需要 code 節點', () => {
|
||||
/**
|
||||
* 舊寫法的形狀(assemble 那 5509 字元在做的事):
|
||||
* 一個 code 節點內部 if×23 —— 判斷資料有沒有/走哪一路/失敗了怎麼辦/
|
||||
* 從回應裡挖哪個欄位/把 payload 拼出來。
|
||||
* 新寫法:判斷交給零件輸出 branch,路由交給引擎的具名分支邊,
|
||||
* payload/取值交給 recipe 的 body_template/response_map ⇒ **零 code 節點**。
|
||||
*/
|
||||
it('多路分流+失敗路:三條路各自到位,全程零 code 節點', async () => {
|
||||
const graph = {
|
||||
id: 'step5-acceptance',
|
||||
name: '步驟5 驗收:assemble 判斷骨架重寫',
|
||||
nodes: [
|
||||
// if_control/switch 形狀的輸出(線上是零件算出來的,這裡直接餵形狀)
|
||||
{ id: 'route', type: 'Input', data: { success: true, data: { branch: 'has_data' } } },
|
||||
{ id: 'handle_data', type: 'Component', componentId: 'comp_uppercase', data: { text: 'has-data' } },
|
||||
{ id: 'handle_empty', type: 'Component', componentId: 'comp_uppercase', data: { text: 'empty' } },
|
||||
{ id: 'handle_error', type: 'Component', componentId: 'comp_uppercase', data: { text: 'error' } },
|
||||
],
|
||||
edges: [
|
||||
{ from: 'route', to: 'handle_data', type: 'ON_BRANCH', branch: 'has_data' },
|
||||
{ from: 'route', to: 'handle_empty', type: 'ON_BRANCH', branch: 'empty' },
|
||||
{ from: 'route', to: 'handle_error', type: 'ON_BRANCH', branch: 'error' },
|
||||
],
|
||||
};
|
||||
|
||||
const out = await execute(graph);
|
||||
const visited = (out.trace ?? []).map(t => t.nodeId);
|
||||
|
||||
expect(out.success).toBe(true); // = verdict success
|
||||
expect(visited).toContain('handle_data');
|
||||
expect(visited).not.toContain('handle_empty');
|
||||
expect(visited).not.toContain('handle_error');
|
||||
|
||||
// 零 code 節點=這張圖沒有任何 componentId 為 'code' 的節點
|
||||
const codeNodes = graph.nodes.filter(n => n.componentId === 'code');
|
||||
expect(codeNodes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('布林兩路(if_control)同樣零 code', async () => {
|
||||
const graph = {
|
||||
id: 'step5-bool',
|
||||
name: '布林兩路',
|
||||
nodes: [
|
||||
{ id: 'cond', type: 'Input', data: { data: { result: false, branch: 'false' } } },
|
||||
{ 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 out = await execute(graph);
|
||||
const visited = (out.trace ?? []).map(t => t.nodeId);
|
||||
expect(out.success).toBe(true);
|
||||
expect(visited).toContain('no');
|
||||
expect(visited).not.toContain('yes');
|
||||
expect(graph.nodes.filter(n => n.componentId === 'code')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('步驟 5 驗收:字元數對照(判斷骨架的體積)', () => {
|
||||
/**
|
||||
* 把「同一組判斷」用兩種寫法各寫一次,量體積。
|
||||
* 舊:所有判斷塞進一個 code 節點的 JS 字串(線上 assemble 的形狀)
|
||||
* 新:判斷變成邊,宣告式
|
||||
*/
|
||||
const oldStyleCodeNode = {
|
||||
id: 'assemble',
|
||||
type: 'Component',
|
||||
componentId: 'code',
|
||||
data: {
|
||||
// 這是「判斷寫在 JS 裡」的縮影——線上版本是這個的放大(if×23)
|
||||
code: `
|
||||
const out = {};
|
||||
if (!ctx.rows || ctx.rows.length === 0) { out.branch = 'empty'; }
|
||||
else if (ctx.error) { out.branch = 'error'; }
|
||||
else { out.branch = 'has_data'; }
|
||||
if (out.branch === 'has_data') {
|
||||
if (ctx.mode === 'strict') { out.text = ctx.rows[0].text; }
|
||||
else if (ctx.mode === 'loose') { out.text = ctx.rows.map(r => r.text).join('\\n'); }
|
||||
else { out.text = String(ctx.rows[0] && ctx.rows[0].text || ''); }
|
||||
if (out.text.indexOf('【答】') >= 0) {
|
||||
out.text = out.text.slice(out.text.lastIndexOf('【答】') + 3);
|
||||
}
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
out.text = out.text.trimStart();
|
||||
for (const p of ['Draft:', '*', 'Answer:']) {
|
||||
if (out.text.startsWith(p)) { out.text = out.text.slice(p.length); changed = true; }
|
||||
}
|
||||
}
|
||||
} else if (out.branch === 'error') {
|
||||
out.text = 'failed: ' + String(ctx.error);
|
||||
} else {
|
||||
out.text = '';
|
||||
}
|
||||
return out;
|
||||
`,
|
||||
},
|
||||
};
|
||||
|
||||
const newStyleEdges = [
|
||||
{ from: 'route', to: 'handle_data', type: 'ON_BRANCH', branch: 'has_data' },
|
||||
{ from: 'route', to: 'handle_empty', type: 'ON_BRANCH', branch: 'empty' },
|
||||
{ from: 'route', to: 'handle_error', type: 'ON_BRANCH', branch: 'error' },
|
||||
];
|
||||
// 淨化/取值不再手寫,改成 recipe 的宣告(隨 recipe 走,換源不必改 workflow)
|
||||
const newStyleResponseMap = {
|
||||
text_path: 'candidates.0.content.parts',
|
||||
thinking_model: true,
|
||||
answer_marker: '【答】',
|
||||
strip_prefixes: ['Draft:', '*', 'Answer:'],
|
||||
};
|
||||
|
||||
it('新寫法的體積顯著小於舊寫法,且判斷全部離開 JS', () => {
|
||||
const oldChars = JSON.stringify(oldStyleCodeNode).length;
|
||||
const newChars =
|
||||
JSON.stringify(newStyleEdges).length + JSON.stringify(newStyleResponseMap).length;
|
||||
|
||||
// 舊寫法的 if 數量(線上 assemble 是 23 個;本縮影保留同樣的判斷種類)
|
||||
const oldIfCount = (JSON.stringify(oldStyleCodeNode).match(/if\s*\(/g) ?? []).length;
|
||||
const newIfCount = 0; // 宣告式,沒有任何 if
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`[步驟5 驗收] 舊寫法 ${oldChars} 字元 / if×${oldIfCount} → ` +
|
||||
`新寫法 ${newChars} 字元 / if×${newIfCount} ` +
|
||||
`(下降 ${Math.round((1 - newChars / oldChars) * 100)}%)`,
|
||||
);
|
||||
|
||||
expect(newChars).toBeLessThan(oldChars);
|
||||
expect(newIfCount).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
/Users/youlinhsieh/Documents/tech_projects/InkStoneCo/matrix/arcrun/kbdb/node_modules
|
||||
@@ -84,7 +84,10 @@ export async function listEntries(db: D1Database, f: ListEntriesFilter = {}): Pr
|
||||
// no new column / no migration (表不變鐵律). Per issue #5.1 (頂層化 source 成可查 filter).
|
||||
if (f.source) { conds.push("json_extract(metadata_json, '$.source') = ?"); params.push(f.source); }
|
||||
if (f.library && f.library.length > 0) { conds.push(libraryPredicate(f.library)); params.push(...f.library); }
|
||||
if (f.q) { conds.push('content LIKE ?'); params.push(`%${f.q}%`); }
|
||||
if (f.q) {
|
||||
const m = buildContentLike(f.q); // D1 LIKE pattern 50 bytes 上限,見 buildContentLike
|
||||
conds.push(...m.conds); params.push(...m.params);
|
||||
}
|
||||
const where = conds.length ? `WHERE ${conds.join(' AND ')}` : '';
|
||||
const limit = Math.min(f.limit ?? 100, 1000);
|
||||
const offset = f.offset ?? 0;
|
||||
@@ -147,6 +150,77 @@ export async function deprecateEntriesByLibrary(db: D1Database, ownerId: string,
|
||||
return (result.meta?.changes as number | undefined) ?? 0;
|
||||
}
|
||||
|
||||
// ── content 關鍵字比對:D1 的 LIKE pattern 有 50 bytes 硬上限 ───────────────────
|
||||
//
|
||||
// 病徵(2026-08-03 在 1.4.4 實例上二分實測):`/entries/search?q=…` 只要 q **超過 48 bytes**
|
||||
// 就回 HTTP 500「Internal Server Error」——不是 400、沒有錯誤訊息,從外面看像伺服器壞了。
|
||||
// q = 48 bytes → 200|q = 49 bytes → 500(ASCII 逐 byte 二分)
|
||||
// 中文 16 字(48 bytes)→ 200|中文 17 字(51 bytes)→ 500
|
||||
// 判別實驗(排除「整句 SQL 太長」這個猜想):q 固定 48 bytes、把 owner_id/entry_type/source/
|
||||
// library 全塞滿讓 SQL 變很長 → 仍然 200 ⇒ **會爆的是 LIKE 的 pattern,不是 statement**。
|
||||
// pattern = '%' + q + '%' ⇒ 48+2 = 50 ⇒ 上限就是 50 bytes。
|
||||
// 對照:同一個長 q 走 mode=semantic 完全正常(那條路不經過 LIKE)。
|
||||
//
|
||||
// 為什麼要修(不是邊角):**中文問句超過 16 個字是常態**。
|
||||
// rag_chat 的 kw_search 用整句問題當 q ⇒ 使用者問任何一句正常長度的中文,
|
||||
// 整條問答鏈在第二個節點就 500 ⇒ 聊天功能等於不能用。
|
||||
// (這也是 InkStoneCo status.md 待辦第 1 條「KBDB keyword 長查詢會炸」的根因。)
|
||||
//
|
||||
// 修法(**短查詢行為逐字不變**):
|
||||
// · q ≤ 48 bytes → 走原本那條路,單一 `content LIKE '%q%'`,一個字都沒改。
|
||||
// · q > 48 bytes → 拆成詞,每個詞各一個 LIKE 用 AND 串(「每個詞都要出現」)。
|
||||
// 沒有空白可拆的長句(中文常見)→ 切成 ≤48 bytes 的片段(切在 UTF-8 邊界上,不切壞字)。
|
||||
// 詞數上限 6:再多對 D1 是白花成本,而且「要同時命中 7 個詞」本來就不會有結果。
|
||||
//
|
||||
// 誠實限制:對「無空白的長中文句」,拆片段是機械切分、不是斷詞 ⇒ 命中率不會變好。
|
||||
// 但它的對照組是 **500**,不是「更好的結果」;而且這種查詢原本就算不炸也幾乎命不中
|
||||
// (整句子字串比對)。真正的中文關鍵字檢索要走 FTS5 或斷詞,那是另一件事、要另外立案。
|
||||
const MAX_LIKE_Q_BYTES = 48; // D1: LIKE pattern 上限 50 bytes,pattern = '%' + q + '%'
|
||||
const MAX_LIKE_TERMS = 6;
|
||||
|
||||
const utf8Len = (s: string): number => new TextEncoder().encode(s).length;
|
||||
|
||||
/** 依 UTF-8 byte 上限切片,不切壞多位元組字元。 */
|
||||
function chunkByBytes(s: string, maxBytes: number): string[] {
|
||||
const out: string[] = [];
|
||||
let cur = '';
|
||||
for (const ch of s) {
|
||||
if (utf8Len(cur + ch) > maxBytes) {
|
||||
if (cur) out.push(cur);
|
||||
cur = ch;
|
||||
} else {
|
||||
cur += ch;
|
||||
}
|
||||
}
|
||||
if (cur) out.push(cur);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 q 轉成一組 `content LIKE ?` 謂詞與參數(純函式,單測用 export)。
|
||||
* 回 `split=false` 代表走的是與舊版逐字相同的單一 LIKE。
|
||||
*/
|
||||
export function buildContentLike(q: string): { conds: string[]; params: string[]; split: boolean } {
|
||||
if (utf8Len(q) <= MAX_LIKE_Q_BYTES) {
|
||||
return { conds: ['content LIKE ?'], params: [`%${q}%`], split: false };
|
||||
}
|
||||
const terms: string[] = [];
|
||||
for (const word of q.split(/\s+/).filter(Boolean)) {
|
||||
for (const piece of chunkByBytes(word, MAX_LIKE_Q_BYTES)) {
|
||||
terms.push(piece);
|
||||
if (terms.length >= MAX_LIKE_TERMS) break;
|
||||
}
|
||||
if (terms.length >= MAX_LIKE_TERMS) break;
|
||||
}
|
||||
// 理論上不會空(q 非空才進得來),但空陣列會產出 `WHERE` 沒有條件 ⇒ 保底退回單一截斷 LIKE
|
||||
if (terms.length === 0) terms.push(chunkByBytes(q, MAX_LIKE_Q_BYTES)[0] ?? '');
|
||||
return {
|
||||
conds: terms.map(() => 'content LIKE ?'),
|
||||
params: terms.map((t) => `%${t}%`),
|
||||
split: true,
|
||||
};
|
||||
}
|
||||
|
||||
// 「庫」filter 的 SQL 謂詞(portal-auth P1,design §3.2/§3.3;零建表,同 #5.1 source 的 json_extract 先例)。
|
||||
// COALESCE(x,'general') IN (…) ≡ SDD §3.3 寫的 (x IN (…) OR (x IS NULL AND 'general' IN (…)))——
|
||||
// 語意完全相同(未標記/無 metadata_json 的舊資料歸 'general'),但單組佔位符、不用重複綁參數。
|
||||
@@ -155,12 +229,50 @@ function libraryPredicate(libraries: string[]): string {
|
||||
return `COALESCE(json_extract(metadata_json, '$.library'), 'general') IN (${placeholders})`;
|
||||
}
|
||||
|
||||
// daemon-beta t24(總管 0.971 親復現、t11 斷點①②)——下架(rag_takedown_direct)只把
|
||||
// metadata_json.status 標成 'deprecated'(軟刪,append-only,見 KBDB 表不變鐵律),從不刪列。
|
||||
// 濾層過去只存在 cypher-executor/src/routes/portal-data.ts 的 filterDeprecatedEntries(客端治標,
|
||||
// Arcrun#46),rag_chat 沒部署的實例(如 leo21c)等於完全沒濾——AI 實際會用到的 MCP/raw
|
||||
// /entries/search 面直接把已下架內容當現役回傳(semantic 甚至最高分回傳,見 t11 斷點②)。
|
||||
// 本謂詞把過濾下沉到 KBDB 服務端(薄殼原則 07:能力只長一次),source-of-truth 修好後
|
||||
// portal-data.ts 的客端治標理論上可拔(未在本 PR 動,範圍只限 kbdb/)。
|
||||
//
|
||||
// 用 json_extract 判等(不用 NOT LIKE '%"status":"deprecated"%')——LIKE 對 JSON 序列化格式敏感
|
||||
// (key 順序、空白、字串轉義都可能讓子字串比對誤判/漏判,例如 metadata_json 裡若有其他欄位的
|
||||
// 值恰好含這段子字串就會被誤殺),json_extract 是結構化取值,只認真正的 $.status 欄位,同一謂詞
|
||||
// 家族(source/library)已驗證過這個模式對 SQLite/D1 穩定可靠(issue #5.1、#18 mistake)。
|
||||
// NULL(沒有 metadata_json 或沒有 status 欄)視為未下架(保留,不誤殺——大多數既有資料沒有
|
||||
// status 欄)。
|
||||
const NOT_DEPRECATED_PREDICATE =
|
||||
"(json_extract(metadata_json, '$.status') IS NULL OR json_extract(metadata_json, '$.status') != 'deprecated')";
|
||||
|
||||
/**
|
||||
* JS 側判斷單筆 entry 是否已下架(status==='deprecated')。給 semantic 路徑用——Vectorize
|
||||
* hit 的 metadata 沒有存 status(見 embed.ts upsert 的 indexed metadata 只有
|
||||
* owner_id/entry_type/source/library),要濾必須先 hydrate 回完整 entry 再判斷,故無法像
|
||||
* keyword 走 SQL 謂詞,只能在拿到 metadata_json 後用同一套判準(status==='deprecated')在
|
||||
* JS 層濾。metadata_json parse 失敗 → 視為保留(治標不誤殺,與 portal-data.ts
|
||||
* filterDeprecatedEntries 同慣例)。
|
||||
*/
|
||||
export function isDeprecatedEntry(entry: { metadata_json?: string | null }): boolean {
|
||||
if (!entry.metadata_json) return false;
|
||||
try {
|
||||
const meta = JSON.parse(entry.metadata_json) as { status?: unknown } | null;
|
||||
return !!meta && meta.status === 'deprecated';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// D1 LIKE keyword search (base; semantic search is the optional embed module).
|
||||
// entry_type: optional base filter (generic — caller passes any type, base stays type-agnostic).
|
||||
// library: optional 多值庫 filter(portal-auth P1);未帶=行為與舊版一字不變(向後相容)。
|
||||
// source: metadata_json.$.source filter(issue #66——#5.1 只接了 listEntries 那半,keyword search
|
||||
// 路徑 route 解析完即丟;謂詞與 listEntries 同款 json_extract,不動表)。加在參數尾端,
|
||||
// 既有 positional caller 一個都不用改(向後相容)。
|
||||
// includeDeprecated(daemon-beta t24):預設 false=濾掉 status=deprecated 的下架內容。
|
||||
// 保留 true 選項給管理面查殘留(審計/驗證下架有沒有真的生效)用,正常搜尋路徑不帶。
|
||||
// 加在參數最尾端,既有 positional caller(source 之後)一個都不用改。
|
||||
export async function searchEntries(
|
||||
db: D1Database,
|
||||
q: string,
|
||||
@@ -169,13 +281,16 @@ export async function searchEntries(
|
||||
limit = 50,
|
||||
library?: string[],
|
||||
source?: string,
|
||||
includeDeprecated = false,
|
||||
): Promise<Entry[]> {
|
||||
const conds = ['content LIKE ?'];
|
||||
const params: unknown[] = [`%${q}%`];
|
||||
const m = buildContentLike(q); // D1 LIKE pattern 50 bytes 上限,見 buildContentLike
|
||||
const conds = [...m.conds];
|
||||
const params: unknown[] = [...m.params];
|
||||
if (owner_id) { conds.push('owner_id = ?'); params.push(owner_id); }
|
||||
if (entry_type) { conds.push('entry_type = ?'); params.push(entry_type); }
|
||||
if (source) { conds.push("json_extract(metadata_json, '$.source') = ?"); params.push(source); }
|
||||
if (library && library.length > 0) { conds.push(libraryPredicate(library)); params.push(...library); }
|
||||
if (!includeDeprecated) { conds.push(NOT_DEPRECATED_PREDICATE); }
|
||||
const res = await db
|
||||
.prepare(`SELECT * FROM entries WHERE ${conds.join(' AND ')} ORDER BY updated_at DESC LIMIT ?`)
|
||||
.bind(...params, Math.min(limit, 200))
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
updateEntry,
|
||||
deleteEntry,
|
||||
searchEntries,
|
||||
isDeprecatedEntry,
|
||||
} from '../actions/entry-crud';
|
||||
import { embedEnabled, embedOnWrite, semanticSearch } from '../embed';
|
||||
|
||||
@@ -109,6 +110,10 @@ entryRoutes.get('/', async (c) => {
|
||||
// - top_k / min_score(#67,semantic 專用):topK 可調(預設 20、上限 100)+分數閾值
|
||||
// (預設 0=不過濾)。未帶=行為與舊版一致(向後相容);semantic 回應的 entry 另附 score
|
||||
// 欄讓 caller 自裁(加欄不改形,keyword 路徑不受影響)。
|
||||
// - include_deprecated(daemon-beta t24,預設 false):兩 mode 預設都濾掉已下架
|
||||
// (metadata_json.status==='deprecated')的 entry——這是本次修的洞(t11 斷點①②,總管
|
||||
// 0.971 親復現:下架後 keyword/semantic 都照樣回傳)。傳 `include_deprecated=true`
|
||||
// 保留給管理面查殘留(驗證下架有沒有真的生效、盤點待清的向量殘留),一般搜尋不帶。
|
||||
entryRoutes.get('/search', async (c) => {
|
||||
const q = c.req.query('q');
|
||||
if (!q) return c.json({ success: false, error: 'q required' }, 400);
|
||||
@@ -117,6 +122,7 @@ entryRoutes.get('/search', async (c) => {
|
||||
const entry_type = c.req.query('entry_type') || undefined;
|
||||
const library = parseLibraryParam(c.req.query('library'));
|
||||
const mode = c.req.query('mode') === 'semantic' ? 'semantic' : 'keyword';
|
||||
const include_deprecated = c.req.query('include_deprecated') === 'true';
|
||||
// 數字參數防呆:非數字/非正 → 當沒帶(回預設),不 400——與其他 filter「壞值靜默忽略」一致。
|
||||
const topKNum = Number(c.req.query('top_k'));
|
||||
const top_k = Number.isFinite(topKNum) && topKNum > 0 ? Math.floor(topKNum) : undefined;
|
||||
@@ -124,12 +130,23 @@ entryRoutes.get('/search', async (c) => {
|
||||
const min_score = Number.isFinite(minScoreNum) && minScoreNum > 0 ? minScoreNum : undefined;
|
||||
|
||||
if (mode === 'semantic') {
|
||||
// 補位(daemon-beta t24):Vectorize 的 indexed metadata 沒存 status(見 embed.ts upsert,
|
||||
// 只有 owner_id/entry_type/source/library),下架與否只能在 hydrate 回完整 entry 後才知道
|
||||
// ——換句話說 Vectorize 端沒辦法直接濾掉已下架向量,濾一定發生在 hydrate 之後。
|
||||
// 若濾完才截斷到請求的 topK,遇到「這頁命中大半已下架」(t11 ZZ-T10 實測案例:命中
|
||||
// 25 顆全下架)就會整頁被吃光、回傳筆數遠低於 caller 要的量。故过濾生效時(非
|
||||
// include_deprecated)**先多撈一批再濾再截斷**:單次 Vectorize query 成本不變(同一次
|
||||
// query 只是 topK 參數變大,非多一次 subrequest),用查詢端的餘量換掉「整頁被下架品吃光」
|
||||
// 的體驗劣化。這是單輪補位(非重試迴圈到湊滿為止)——若下架比例極高仍可能不足額,
|
||||
// 已在 PR 描述向 leo 說明這個 trade-off(多倍 margin vs 迴圈重撈的取捨)。
|
||||
const requestedTopK = top_k ?? 20; // 與 embed.ts semanticSearch 的預設 topK 對齊
|
||||
const fetchTopK = include_deprecated ? requestedTopK : Math.min(requestedTopK * 3, 100);
|
||||
const hits = await semanticSearch(c.env, q, {
|
||||
owner_id, source, entry_type, library, topK: top_k, min_score,
|
||||
owner_id, source, entry_type, library, topK: fetchTopK, min_score,
|
||||
});
|
||||
if (hits === null) {
|
||||
// 模組沒開:誠實降級 keyword + 告知「叫 CC 幫你開 vectorize」(不假裝有語義)。
|
||||
const entries = await searchEntries(c.env.DB, q, owner_id, entry_type, undefined, library, source);
|
||||
const entries = await searchEntries(c.env.DB, q, owner_id, entry_type, undefined, library, source, include_deprecated);
|
||||
return c.json({
|
||||
success: true,
|
||||
entries,
|
||||
@@ -142,7 +159,7 @@ entryRoutes.get('/search', async (c) => {
|
||||
}
|
||||
// hydrate vector hits → 完整 entry(保持回應形狀與 keyword 一致)。
|
||||
// #67:entry 附 score(相似分數)——加欄不改形,既有 caller 不解析多的欄位不受影響。
|
||||
const entries = (
|
||||
let entries = (
|
||||
await Promise.all(
|
||||
hits.map(async (h) => {
|
||||
const e = await getEntry(c.env.DB, h.id);
|
||||
@@ -150,10 +167,15 @@ entryRoutes.get('/search', async (c) => {
|
||||
}),
|
||||
)
|
||||
).filter((e): e is NonNullable<typeof e> => e !== null);
|
||||
if (!include_deprecated) {
|
||||
entries = entries.filter((e) => !isDeprecatedEntry(e));
|
||||
}
|
||||
// 補位後截斷回 caller 實際要的量(多撈的餘量只用來墊背,不多回傳超過請求的筆數)。
|
||||
entries = entries.slice(0, requestedTopK);
|
||||
return c.json({ success: true, entries, count: entries.length, mode: 'semantic' });
|
||||
}
|
||||
|
||||
const entries = await searchEntries(c.env.DB, q, owner_id, entry_type, undefined, library, source);
|
||||
const entries = await searchEntries(c.env.DB, q, owner_id, entry_type, undefined, library, source, include_deprecated);
|
||||
return c.json({ success: true, entries, count: entries.length, mode: 'keyword' });
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
// daemon-beta t24(總管 0.971 親復現、t11 斷點①②)——/entries/search 服務端濾 deprecated。
|
||||
// 背景:rag_takedown_direct 下架只把 metadata_json.status 標 'deprecated'(軟刪,append-only,
|
||||
// KBDB 表不變鐵律),從不砍列、也不刪 Vectorize 向量。過去唯一的濾層在
|
||||
// cypher-executor/src/routes/portal-data.ts(客端治標,Arcrun#46),沒部署 rag_chat 的實例
|
||||
// (如 leo21c)等於完全沒濾——MCP/raw /entries/search 直接把已下架內容當現役吐出。semantic
|
||||
// 甚至最高分照吐(t11 實測 0.971)。本測試覆蓋三案:keyword 濾、semantic 濾+補位、
|
||||
// include_deprecated 開關。
|
||||
//
|
||||
// 測試手法同 search-source-and-score.test.ts:fake D1 捕 SQL 形狀 + getEntry 依 id 回可控
|
||||
// metadata_json;mock VECTORIZE 捕 query opts(驗補位 topK)並回混合 active/deprecated 命中。
|
||||
// 真 SQL 語意(json_extract 對 status 欄的實際判等)由本機 miniflare/wrangler d1 跑驗(PR 驗收證據)。
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Hono } from 'hono';
|
||||
import { entryRoutes } from '../src/routes/entries';
|
||||
import { searchEntries, isDeprecatedEntry } from '../src/actions/entry-crud';
|
||||
import type { Bindings, Entry } from '../src/types';
|
||||
|
||||
const NOT_DEPRECATED_PREDICATE =
|
||||
"(json_extract(metadata_json, '$.status') IS NULL OR json_extract(metadata_json, '$.status') != 'deprecated')";
|
||||
|
||||
// ── fake D1:捕捉 prepared SQL 與 bound params;getEntry(SELECT … WHERE id = ?)依 id 從
|
||||
// ENTRY_META 查表回可控 metadata_json,讓 semantic hydrate 路徑能測到 deprecated 過濾 ──
|
||||
interface Captured { sql: string; params: unknown[] }
|
||||
|
||||
function mkEntry(id: string, metadata_json: string | null): Entry {
|
||||
return {
|
||||
id, content: 'some content', entry_type: 'block', owner_id: 'tenant1', parent_id: null,
|
||||
page_name: null, refs_json: '[]', tags_json: '[]', task_status: null, content_hash: null,
|
||||
is_embedded: 0, confidence: null, metadata_json, created_at: 1, updated_at: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function makeCaptureDB(captured: Captured[], entryMeta: Record<string, string | null> = {}) {
|
||||
const prepare = (sql: string) => {
|
||||
const rec: Captured = { sql, params: [] };
|
||||
captured.push(rec);
|
||||
const stmt = {
|
||||
bind(...args: unknown[]) { rec.params = args; return stmt; },
|
||||
async all<T>() { return { results: [] as T[] }; },
|
||||
async first<T>() {
|
||||
if (sql.includes('WHERE id = ?')) {
|
||||
const id = String(rec.params[0]);
|
||||
const meta = id in entryMeta ? entryMeta[id] : null;
|
||||
return mkEntry(id, meta) as unknown as T;
|
||||
}
|
||||
return { total: 0, c: 0 } as unknown as T;
|
||||
},
|
||||
async run() { return { success: true }; },
|
||||
};
|
||||
return stmt;
|
||||
};
|
||||
return { prepare } as unknown as D1Database;
|
||||
}
|
||||
|
||||
function makeApp(captured: Captured[], extraEnv: Record<string, unknown> = {}) {
|
||||
const app = new Hono<{ Bindings: Bindings }>();
|
||||
app.route('/entries', entryRoutes);
|
||||
const env = { DB: makeCaptureDB(captured, (extraEnv._entryMeta as Record<string, string | null>) ?? {}), ENVIRONMENT: 'test', ...extraEnv } as unknown as Bindings;
|
||||
return { app, env };
|
||||
}
|
||||
|
||||
// ══ 案①:keyword 濾 ══════════════════════════════════════════════════════
|
||||
|
||||
describe('t24 案① — searchEntries(keyword)預設濾 deprecated', () => {
|
||||
it('預設(不帶 includeDeprecated)→ SQL 含 NOT_DEPRECATED_PREDICATE', async () => {
|
||||
const captured: Captured[] = [];
|
||||
await searchEntries(makeCaptureDB(captured), '靛藍', 'tenant1');
|
||||
expect(captured[0].sql).toContain(NOT_DEPRECATED_PREDICATE);
|
||||
});
|
||||
|
||||
it('includeDeprecated=true → SQL 不含濾 deprecated 謂詞(管理面查殘留用)', async () => {
|
||||
const captured: Captured[] = [];
|
||||
await searchEntries(makeCaptureDB(captured), '靛藍', 'tenant1', undefined, undefined, undefined, undefined, true);
|
||||
expect(captured[0].sql).not.toContain(NOT_DEPRECATED_PREDICATE);
|
||||
});
|
||||
|
||||
it('route GET /entries/search(keyword,不帶 include_deprecated)→ 濾謂詞下傳', async () => {
|
||||
const captured: Captured[] = [];
|
||||
const { app, env } = makeApp(captured);
|
||||
const res = await app.request('/entries/search?q=靛藍', {}, env);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { mode: string };
|
||||
expect(body.mode).toBe('keyword');
|
||||
expect(captured[0].sql).toContain(NOT_DEPRECATED_PREDICATE);
|
||||
});
|
||||
|
||||
it('route GET /entries/search?include_deprecated=true(keyword)→ 濾謂詞不下傳', async () => {
|
||||
const captured: Captured[] = [];
|
||||
const { app, env } = makeApp(captured);
|
||||
const res = await app.request('/entries/search?q=靛藍&include_deprecated=true', {}, env);
|
||||
expect(res.status).toBe(200);
|
||||
expect(captured[0].sql).not.toContain(NOT_DEPRECATED_PREDICATE);
|
||||
});
|
||||
|
||||
it('semantic 模組未開+降級 keyword → 仍套濾(不因降級洩下架內容)', async () => {
|
||||
const captured: Captured[] = [];
|
||||
const { app, env } = makeApp(captured); // 無 VECTORIZE/AI → semanticSearch 回 null
|
||||
const res = await app.request('/entries/search?q=靛藍&mode=semantic', {}, env);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { mode: string };
|
||||
expect(body.mode).toBe('keyword');
|
||||
expect(captured[0].sql).toContain(NOT_DEPRECATED_PREDICATE);
|
||||
});
|
||||
|
||||
it('semantic 模組未開+include_deprecated=true 降級 → 濾謂詞不下傳', async () => {
|
||||
const captured: Captured[] = [];
|
||||
const { app, env } = makeApp(captured);
|
||||
const res = await app.request('/entries/search?q=靛藍&mode=semantic&include_deprecated=true', {}, env);
|
||||
expect(res.status).toBe(200);
|
||||
expect(captured[0].sql).not.toContain(NOT_DEPRECATED_PREDICATE);
|
||||
});
|
||||
});
|
||||
|
||||
// ══ isDeprecatedEntry 單元測試(JS 側判準,semantic 路徑用) ══════════════
|
||||
|
||||
describe('t24 — isDeprecatedEntry(JS 側判準)', () => {
|
||||
it('status:"deprecated" → true', () => {
|
||||
expect(isDeprecatedEntry({ metadata_json: JSON.stringify({ status: 'deprecated' }) })).toBe(true);
|
||||
});
|
||||
it('status 缺欄 / null metadata_json / 空字串 → false(未下架,保留)', () => {
|
||||
expect(isDeprecatedEntry({ metadata_json: JSON.stringify({ embed: true }) })).toBe(false);
|
||||
expect(isDeprecatedEntry({ metadata_json: null })).toBe(false);
|
||||
expect(isDeprecatedEntry({ metadata_json: '' })).toBe(false);
|
||||
});
|
||||
it('status 是其他值(非 deprecated)→ false', () => {
|
||||
expect(isDeprecatedEntry({ metadata_json: JSON.stringify({ status: 'active' }) })).toBe(false);
|
||||
});
|
||||
it('metadata_json parse 失敗(壞 JSON)→ false(治標不誤殺)', () => {
|
||||
expect(isDeprecatedEntry({ metadata_json: '{not valid json' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ══ 案②:semantic 濾+補位 ═══════════════════════════════════════════════
|
||||
|
||||
// mock VECTORIZE:捕 query opts(驗補位 topK);命中組合可控(含 deprecated id 前綴 dep- 供辨識)。
|
||||
function makeSemanticEnv(
|
||||
queryCalls: { opts: Record<string, unknown> }[],
|
||||
matches: { id: string; score: number }[],
|
||||
) {
|
||||
return {
|
||||
AI: { async run() { return { data: [[0.1, 0.2, 0.3]] }; } },
|
||||
VECTORIZE: {
|
||||
async query(_vec: number[], opts: Record<string, unknown>) {
|
||||
queryCalls.push({ opts });
|
||||
return { matches: matches.map((m) => ({ id: m.id, score: m.score, metadata: {} })) };
|
||||
},
|
||||
async upsert(v: unknown[]) { return { count: (v as unknown[]).length }; },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('t24 案② — semantic 濾 deprecated + 補位(t11 斷點②:0.971 最高分照吐的洞)', () => {
|
||||
it('命中含已下架(最高分)→ 回應濾掉,只留現役(覆現 t11 0.971 復現案)', async () => {
|
||||
const calls: { opts: Record<string, unknown> }[] = [];
|
||||
const entryMeta = {
|
||||
'dep-highest': JSON.stringify({ status: 'deprecated' }), // 0.971 最高分但已下架
|
||||
'e-active': null,
|
||||
};
|
||||
const captured: Captured[] = [];
|
||||
const { app, env } = makeApp(captured, {
|
||||
...makeSemanticEnv(calls, [
|
||||
{ id: 'dep-highest', score: 0.971 },
|
||||
{ id: 'e-active', score: 0.6 },
|
||||
]),
|
||||
_entryMeta: entryMeta,
|
||||
});
|
||||
const res = await app.request('/entries/search?q=靛藍風鈴石的硬度&mode=semantic', {}, env);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { mode: string; count: number; entries: (Entry & { score?: number })[] };
|
||||
expect(body.mode).toBe('semantic');
|
||||
expect(body.entries.map((e) => e.id)).toEqual(['e-active']); // dep-highest 被濾掉
|
||||
expect(body.count).toBe(1);
|
||||
});
|
||||
|
||||
it('補位:預設過濾生效時,Vectorize 查詢的 topK 大於 caller 要求(避免整頁被下架品吃光)', async () => {
|
||||
const calls: { opts: Record<string, unknown> }[] = [];
|
||||
const captured: Captured[] = [];
|
||||
const { app, env } = makeApp(captured, makeSemanticEnv(calls, []));
|
||||
await app.request('/entries/search?q=x&mode=semantic&top_k=10', {}, env);
|
||||
expect(calls[0].opts.topK).toBeGreaterThan(10); // 補位餘量(實作=×3 封頂 100)
|
||||
expect(calls[0].opts.topK).toBe(30);
|
||||
});
|
||||
|
||||
it('補位 topK 封頂 100(不因 top_k 大就超過 Vectorize 上限)', async () => {
|
||||
const calls: { opts: Record<string, unknown> }[] = [];
|
||||
const captured: Captured[] = [];
|
||||
const { app, env } = makeApp(captured, makeSemanticEnv(calls, []));
|
||||
await app.request('/entries/search?q=x&mode=semantic&top_k=50', {}, env);
|
||||
expect(calls[0].opts.topK).toBe(100);
|
||||
});
|
||||
|
||||
it('補位後截斷:濾掉部分下架品後,回應筆數不超過 caller 要求的 top_k', async () => {
|
||||
const calls: { opts: Record<string, unknown> }[] = [];
|
||||
// 6 筆命中,3 筆已下架 → 濾完剩 3 筆現役,均少於 top_k=5,應原樣回(不會硬湊出更多)
|
||||
const matches = [
|
||||
{ id: 'a1', score: 0.9 }, { id: 'dep1', score: 0.85 }, { id: 'a2', score: 0.8 },
|
||||
{ id: 'dep2', score: 0.7 }, { id: 'a3', score: 0.6 }, { id: 'dep3', score: 0.5 },
|
||||
];
|
||||
const entryMeta: Record<string, string | null> = {
|
||||
dep1: JSON.stringify({ status: 'deprecated' }),
|
||||
dep2: JSON.stringify({ status: 'deprecated' }),
|
||||
dep3: JSON.stringify({ status: 'deprecated' }),
|
||||
};
|
||||
const captured: Captured[] = [];
|
||||
const { app, env } = makeApp(captured, { ...makeSemanticEnv(calls, matches), _entryMeta: entryMeta });
|
||||
const res = await app.request('/entries/search?q=x&mode=semantic&top_k=5', {}, env);
|
||||
const body = (await res.json()) as { entries: Entry[]; count: number };
|
||||
expect(body.entries.map((e) => e.id)).toEqual(['a1', 'a2', 'a3']);
|
||||
expect(body.count).toBe(3);
|
||||
});
|
||||
|
||||
it('include_deprecated=true → 不補位(topK=請求值)、不過濾(下架品也回傳,管理面查殘留)', async () => {
|
||||
const calls: { opts: Record<string, unknown> }[] = [];
|
||||
const entryMeta = { 'dep-highest': JSON.stringify({ status: 'deprecated' }) };
|
||||
const captured: Captured[] = [];
|
||||
const { app, env } = makeApp(captured, {
|
||||
...makeSemanticEnv(calls, [{ id: 'dep-highest', score: 0.971 }]),
|
||||
_entryMeta: entryMeta,
|
||||
});
|
||||
const res = await app.request('/entries/search?q=x&mode=semantic&top_k=10&include_deprecated=true', {}, env);
|
||||
expect(calls[0].opts.topK).toBe(10); // 不補位
|
||||
const body = (await res.json()) as { entries: Entry[]; count: number };
|
||||
expect(body.entries.map((e) => e.id)).toEqual(['dep-highest']); // 保留
|
||||
expect(body.count).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
// D1 的 LIKE pattern 有 50 bytes 硬上限 —— 長查詢 500 的修復(2026-08-03,t152 途中發現)
|
||||
//
|
||||
// 病徵(在 1.4.4 實例上逐 byte 二分實測,非推論):
|
||||
// `/entries/search?q=…` 只要 q 超過 48 bytes 就回 HTTP 500「Internal Server Error」,
|
||||
// 沒有錯誤訊息。q=48 → 200/q=49 → 500;中文 16 字 → 200/17 字 → 500。
|
||||
// 判別實驗:q 固定 48 bytes、其他 filter 全塞滿讓 SQL 變很長 → 仍 200
|
||||
// ⇒ 爆的是 **LIKE 的 pattern**('%'+q+'%' = 50 bytes),不是 statement 長度。
|
||||
// 同一個長 q 走 mode=semantic 正常(那條不經過 LIKE)。
|
||||
//
|
||||
// 為什麼這是產品級的洞:中文問句超過 16 個字是常態,而 rag_chat 的 kw_search 拿整句問題當 q
|
||||
// ⇒ 使用者問任何一句正常長度的中文,問答鏈在第二個節點就 500 ⇒ 聊天等於不能用。
|
||||
//
|
||||
// 本檔守兩件事:① 短查詢行為**逐字不變**(回歸保護)② 長查詢不再產生超長 pattern。
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildContentLike, searchEntries } from '../src/actions/entry-crud';
|
||||
|
||||
const bytes = (s: string) => new TextEncoder().encode(s).length;
|
||||
const MAX_PATTERN = 50; // D1 上限
|
||||
|
||||
describe('buildContentLike:不得產生超過 D1 上限的 LIKE pattern', () => {
|
||||
it('短查詢(≤48 bytes)=與舊版逐字相同的單一 LIKE', () => {
|
||||
const m = buildContentLike('語意檢索');
|
||||
expect(m.split).toBe(false);
|
||||
expect(m.conds).toEqual(['content LIKE ?']);
|
||||
expect(m.params).toEqual(['%語意檢索%']);
|
||||
});
|
||||
|
||||
it('剛好 48 bytes 仍走單一 LIKE(邊界:pattern 正好 50)', () => {
|
||||
const q = 'a'.repeat(48);
|
||||
const m = buildContentLike(q);
|
||||
expect(m.split).toBe(false);
|
||||
expect(bytes(m.params[0])).toBe(MAX_PATTERN);
|
||||
});
|
||||
|
||||
it('49 bytes 起改走拆詞,且每個 pattern 都在上限內', () => {
|
||||
const q = 'a'.repeat(49);
|
||||
const m = buildContentLike(q);
|
||||
expect(m.split).toBe(true);
|
||||
for (const p of m.params) expect(bytes(p)).toBeLessThanOrEqual(MAX_PATTERN);
|
||||
});
|
||||
|
||||
it('有空白的長查詢=按詞拆,每詞一個 LIKE(AND 語意由 caller join)', () => {
|
||||
const m = buildContentLike('語意檢索 排名 選頁 雜訊 出處 門檻 正規化 三元組 知識庫');
|
||||
expect(m.split).toBe(true);
|
||||
expect(m.conds.length).toBeGreaterThan(1);
|
||||
expect(m.conds.every((c) => c === 'content LIKE ?')).toBe(true);
|
||||
expect(m.params).toContain('%語意檢索%');
|
||||
expect(m.conds.length).toBeLessThanOrEqual(6); // 詞數上限
|
||||
});
|
||||
|
||||
it('無空白的長中文句:切在 UTF-8 邊界上,不切出壞字', () => {
|
||||
const q = '為什麼不直接用語意檢索排名來選頁面而要用字面重疊加權來計分呢';
|
||||
expect(bytes(q)).toBeGreaterThan(48);
|
||||
const m = buildContentLike(q);
|
||||
expect(m.split).toBe(true);
|
||||
for (const p of m.params) {
|
||||
expect(bytes(p)).toBeLessThanOrEqual(MAX_PATTERN);
|
||||
expect(p).not.toContain('�'); // 替換字元=切壞了
|
||||
expect(q).toContain(p.slice(1, -1)); // 每片都是原句的真子字串
|
||||
}
|
||||
});
|
||||
|
||||
it('永遠不會回空條件(空條件會讓 WHERE 塌掉、把全表撈回來)', () => {
|
||||
for (const q of ['a'.repeat(200), ' '.repeat(60), '。'.repeat(60)]) {
|
||||
const m = buildContentLike(q);
|
||||
expect(m.conds.length).toBeGreaterThan(0);
|
||||
expect(m.params.length).toBe(m.conds.length);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchEntries 實際送出的 SQL', () => {
|
||||
function fakeDb() {
|
||||
const captured: { sql: string; params: unknown[] }[] = [];
|
||||
const db = {
|
||||
prepare(sql: string) {
|
||||
return {
|
||||
bind(...params: unknown[]) {
|
||||
captured.push({ sql, params });
|
||||
return { all: async () => ({ results: [] }) };
|
||||
},
|
||||
};
|
||||
},
|
||||
} as unknown as D1Database;
|
||||
return { db, captured };
|
||||
}
|
||||
|
||||
it('短查詢:SQL 裡只有一個 content LIKE(回歸保護)', async () => {
|
||||
const { db, captured } = fakeDb();
|
||||
await searchEntries(db, '語意檢索', 'demo');
|
||||
expect(captured[0].sql.match(/content LIKE \?/g)).toHaveLength(1);
|
||||
expect(captured[0].params[0]).toBe('%語意檢索%');
|
||||
});
|
||||
|
||||
it('長查詢:拆成多個 content LIKE,且沒有任何 pattern 超過 50 bytes', async () => {
|
||||
const { db, captured } = fakeDb();
|
||||
await searchEntries(db, '為什麼 rag_chat 不直接用語意檢索排名來選頁而要用字面重疊', 'demo');
|
||||
const sql = captured[0].sql;
|
||||
expect((sql.match(/content LIKE \?/g) ?? []).length).toBeGreaterThan(1);
|
||||
for (const p of captured[0].params) {
|
||||
if (typeof p === 'string' && p.startsWith('%')) expect(bytes(p)).toBeLessThanOrEqual(MAX_PATTERN);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -162,26 +162,32 @@ describe('#67 — route GET /entries/search(semantic)top_k / min_score / sco
|
||||
return makeApp(captured, makeSemanticEnv(calls));
|
||||
}
|
||||
|
||||
it('?top_k=5&min_score=0.5 → topK 透傳、低分截掉、entry 附 score', async () => {
|
||||
// daemon-beta t24(07-24 補位變更):route 現在對 Vectorize 的實際查詢 topK 會做「補位」
|
||||
// (預設過濾 deprecated 時 ×3 封頂 100,見 entries.ts 補位註解),不再是 top_k 原封透傳到
|
||||
// VECTORIZE.query。route 對 caller 的回應仍會照 top_k 截斷(見 body.count/entries 斷言不變)
|
||||
// ——這裡改的只是「送進 Vectorize 那次呼叫的 topK 參數」,非對外契約。三筆測試同步更新
|
||||
// calls[0].opts.topK 期望值(5→15=5×3、20→60=20×3),其餘斷言(回應筆數/內容/score)不動。
|
||||
|
||||
it('?top_k=5&min_score=0.5 → Vectorize 補位 topK=15(5×3)、回應仍照 top_k 截後低分尾、entry 附 score', async () => {
|
||||
const calls: { opts: Record<string, unknown> }[] = [];
|
||||
const { app, env } = makeSemanticApp(calls);
|
||||
const res = await app.request('/entries/search?q=x&mode=semantic&top_k=5&min_score=0.5', {}, env);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { mode: string; count: number; entries: (Entry & { score?: number })[] };
|
||||
expect(body.mode).toBe('semantic');
|
||||
expect(calls[0].opts.topK).toBe(5);
|
||||
expect(calls[0].opts.topK).toBe(15); // t24 補位:5 × 3
|
||||
expect(body.count).toBe(2); // 0.2 的低分尾被 min_score 截掉
|
||||
expect(body.entries.map((e) => e.id)).toEqual(['e-high', 'e-mid']);
|
||||
expect(body.entries.map((e) => e.score)).toEqual([0.9, 0.5]);
|
||||
});
|
||||
|
||||
it('不帶新參數 → topK=20、全量回傳(行為不變),entry 仍附 score(加欄不改形)', async () => {
|
||||
it('不帶新參數 → Vectorize 補位 topK=60(預設 20×3),回應仍全量回傳(行為不變),entry 仍附 score(加欄不改形)', async () => {
|
||||
const calls: { opts: Record<string, unknown> }[] = [];
|
||||
const { app, env } = makeSemanticApp(calls);
|
||||
const res = await app.request('/entries/search?q=x&mode=semantic', {}, env);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { count: number; entries: (Entry & { score?: number })[] };
|
||||
expect(calls[0].opts.topK).toBe(20);
|
||||
expect(calls[0].opts.topK).toBe(60); // t24 補位:預設 20 × 3
|
||||
expect(body.count).toBe(3);
|
||||
expect(body.entries[0].score).toBe(0.9);
|
||||
// 原有欄位一個不少(回應形狀向後相容)
|
||||
@@ -189,14 +195,14 @@ describe('#67 — route GET /entries/search(semantic)top_k / min_score / sco
|
||||
expect(body.entries[0].entry_type).toBe('block');
|
||||
});
|
||||
|
||||
it('壞值防呆:top_k=abc / top_k=0 / min_score=-1 → 視同沒帶(回預設,不 400)', async () => {
|
||||
it('壞值防呆:top_k=abc / top_k=0 / min_score=-1 → 視同沒帶(回預設 20,補位後 Vectorize topK=60,不 400)', async () => {
|
||||
for (const qs of ['top_k=abc', 'top_k=0', 'min_score=-1', 'top_k=abc&min_score=xyz']) {
|
||||
const calls: { opts: Record<string, unknown> }[] = [];
|
||||
const { app, env } = makeSemanticApp(calls);
|
||||
const res = await app.request(`/entries/search?q=x&mode=semantic&${qs}`, {}, env);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { count: number };
|
||||
expect(calls[0].opts.topK).toBe(20);
|
||||
expect(calls[0].opts.topK).toBe(60); // t24 補位:預設 20 × 3
|
||||
expect(body.count).toBe(3); // 無閾值 → 全量
|
||||
}
|
||||
});
|
||||
|
||||
+40
-1
@@ -15,10 +15,49 @@ export async function handleMcpRequest(
|
||||
//(選型理由見 lib/library-map.ts 檔頭);任何失敗回 null → 靜默略過,絕不擋 MCP 連線(鐵律)。
|
||||
const mapInstructions = await buildLibraryMapInstructions(env);
|
||||
|
||||
// 2026-07-30(leo 問「人類說『幫我用 arcrun 寫 xxx』,Haiku 會知道要用這些資源嗎?
|
||||
// 如果不會,要寫什麼在外面讓它一聽到就知道?」):
|
||||
// 實測答案是**不會**——AI 只看到 41 個工具名,會自己猜(很可能直接跳到 push_workflow
|
||||
// 瞎編,或去讀 registry/examples 那 8/13 引用不存在零件的壞範例)。
|
||||
// instructions 是唯一「AI 一連上就必看」的欄位 ⇒ 開場就指路,不依賴任何查詢成功。
|
||||
// ⚠️ 這段是靜態常數:即使 KBDB 掛了、藏書地圖抓不到,它也必須出現(鐵律:不擋連線)。
|
||||
const startHere = [
|
||||
"# Arcrun — 你已經配備了這套工具,別上網找",
|
||||
"",
|
||||
"**Arcrun 是什麼**:跑在 Cloudflare 上的工作流引擎(類 n8n)。你用 `>>` 寫「意圖」,",
|
||||
"系統告訴你有哪些現成零件/recipe 可用,你只要填 payload——**不必自己寫程式**。",
|
||||
"**你現在就有完整能力**:查零件、查 recipe、看實跑過的 workflow、部署、觸發、看執行紀錄。",
|
||||
"⚠️ **不要上網搜 Arcrun 文件**(網路上沒有/會過時)。答案都在下面的工具裡。",
|
||||
"",
|
||||
"【先讀這裡】要在 Arcrun 上做任何事(用戶說「幫我用 Arcrun 做 X」),**照這個順序**:",
|
||||
"",
|
||||
"1. `arcrun_get_skill('write_intent_workflow')` — **必讀第一支**。",
|
||||
" 教你用 `>>` 寫「意圖工作流」。你**不需要先知道有哪些零件**,先寫意圖。",
|
||||
"2. `arcrun_whoami()` — 確認連到哪個帳號(勿自行 curl 猜帳號 URL)。",
|
||||
"3. 把意圖丟 `POST /cypher/search` 或 `arcrun_validate_yaml` — 系統告訴你哪些零件存在。",
|
||||
"4. 卡住/不知道該查什麼 → `arcrun_get_skill('INDEX')`(全館導航:什麼問題查哪裡+已知的坑)。",
|
||||
"5. 缺零件時:缺 API → 寫 recipe(`arcrun_recipe_push`);缺能力 → 投稿零件 PR。",
|
||||
" 🔴 **不要因為查不到零件就改寫成 `code` 節點**——那叫「腹語術」(表面用 Arcrun、",
|
||||
" 實際全寫 JS)。`code` 只用於局部整形(例:剝掉 LLM 回應的雜訊)。",
|
||||
"",
|
||||
"邊:`ON_SUCCESS`(成功往下)、`對每個 <變數>`(FOREACH)、",
|
||||
"以及**條件分支**(2026-08-01 起引擎支援):",
|
||||
"`ON_TRUE`/`ON_FALSE`(配 `if_control`)、`ON_BRANCH`+`branch:` 標籤",
|
||||
"(配 `switch` 的每個 case/`try_catch` 的 try·catch)。",
|
||||
"🔴 **需要判斷時用分支邊,不要寫 code 判斷**——查零件的回應會附 `branch_hint`",
|
||||
"(哪些邊型+可照抄範例),照著接即可。",
|
||||
"🔴 **分支跑完怎麼判斷成功**:看 `verdict`(`arcrun_get_execution_trace` 或",
|
||||
"`GET /workflows/<name>/executions`)。**走 true 路時 false 路的節點不出現=正確行為**,",
|
||||
"不是失敗——別因為「只有一條路有輸出」就以為壞掉而改寫成 code(2026-08-01 實撞)。",
|
||||
"第一個節點固定是 `input`。",
|
||||
].join("\n");
|
||||
|
||||
const instructions = mapInstructions ? `${startHere}\n\n---\n\n${mapInstructions}` : startHere;
|
||||
|
||||
const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
||||
const server = new McpServer(
|
||||
{ name: "arcrun-mcp-server", version: "1.0.0" },
|
||||
mapInstructions ? { instructions: mapInstructions } : undefined,
|
||||
{ instructions },
|
||||
);
|
||||
|
||||
registerAllTools(server, env, orgNamespace, partnerToken);
|
||||
|
||||
@@ -59,7 +59,7 @@ export function consentPage(p: ConsentParams, error?: string): string {
|
||||
code { background: rgba(127,127,127,.15); padding: .1rem .35rem; border-radius: .25rem;
|
||||
font-size: .85rem; word-break: break-all; }
|
||||
label { display: block; margin: 1.25rem 0 .35rem; font-weight: 600; }
|
||||
input[type=password] { width: 100%; padding: .6rem .7rem; font-size: 1rem;
|
||||
input[type=password], input[type=email] { width: 100%; padding: .6rem .7rem; font-size: 1rem;
|
||||
border: 1px solid #8888; border-radius: .5rem; box-sizing: border-box; }
|
||||
button { margin-top: 1.25rem; width: 100%; padding: .7rem; font-size: 1rem; font-weight: 600;
|
||||
border: 0; border-radius: .5rem; background: #8a5f1e; color: #fff; cursor: pointer; }
|
||||
@@ -75,12 +75,16 @@ export function consentPage(p: ConsentParams, error?: string): string {
|
||||
${errBlock}
|
||||
<form method="POST" action="/authorize">
|
||||
${fields}
|
||||
<label for="owner_secret">Owner 祕密</label>
|
||||
<input id="owner_secret" name="owner_secret" type="password" autocomplete="off"
|
||||
autofocus required placeholder="只有你知道的祕密">
|
||||
<label for="email">你的 Portal 帳號(email)</label>
|
||||
<input id="email" name="email" type="email" autocomplete="username"
|
||||
autofocus required placeholder="你登入知識庫用的 email">
|
||||
<label for="password">Portal 密碼</label>
|
||||
<input id="password" name="password" type="password" autocomplete="current-password"
|
||||
required placeholder="你登入知識庫用的密碼">
|
||||
<button type="submit">授權連線</button>
|
||||
</form>
|
||||
<p class="foot">祕密不正確不會發出授權碼。此頁不儲存你的輸入。</p>
|
||||
<p class="foot">用你登入知識庫(Portal)的同一組帳密,**不需要另外的祕密**。
|
||||
帳密不正確不會發出授權碼。此頁不儲存你的輸入。</p>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
+35
-10
@@ -184,9 +184,8 @@ export function registerOAuthRoutes<
|
||||
302,
|
||||
);
|
||||
}
|
||||
if (!c.env.MCP_OWNER_SECRET) {
|
||||
return c.text("server_error: MCP_OWNER_SECRET not configured", 503);
|
||||
}
|
||||
// 2026-07-30:不再檢查 MCP_OWNER_SECRET(改用 Portal 帳密驗證,見 POST 分支)。
|
||||
// 舊行為:未設此 env → 直接 503 ⇒ **每個封測者接自己的 AI 都死在這頁**。
|
||||
const params: ConsentParams = {
|
||||
client_id: q.client_id ?? "",
|
||||
redirect_uri: q.redirect_uri,
|
||||
@@ -222,9 +221,6 @@ export function registerOAuthRoutes<
|
||||
302,
|
||||
);
|
||||
}
|
||||
if (!c.env.MCP_OWNER_SECRET) {
|
||||
return c.text("server_error: MCP_OWNER_SECRET not configured", 503);
|
||||
}
|
||||
const consent: ConsentParams = {
|
||||
client_id: p.client_id ?? "",
|
||||
redirect_uri: redirectUri,
|
||||
@@ -234,10 +230,39 @@ export function registerOAuthRoutes<
|
||||
scope: p.scope ?? "mcp",
|
||||
resource: canonicalResource, // 一律存 canonical,不存 client 原樣值
|
||||
};
|
||||
// ★ owner 祕密把關:錯誤不發碼、重顯同意頁。這是「只知 URL 的人進不來」的唯一閘。
|
||||
const supplied = p.owner_secret ?? "";
|
||||
if (!supplied || !constantTimeEqual(supplied, c.env.MCP_OWNER_SECRET)) {
|
||||
return c.html(consentPage(consent, "Owner 祕密不正確,請重試。"), 401);
|
||||
// ★ 把關:用**用戶自己的 Portal 帳密**,不再另設一把 MCP_OWNER_SECRET
|
||||
// (leo 2026-07-30:「claude 裡有一個直接輸入帳密連線的,為什麼不用那個?
|
||||
// 跟他輸入 portal 的帳密一樣不就好了?」)
|
||||
//
|
||||
// 為什麼換掉 owner secret(三個實際問題,都是封測撞出來的):
|
||||
// ① **沒人給得了封測者**——安裝器產生後從不顯示(完成頁 grep「Owner 祕密」=0),
|
||||
// CF secret 又唯寫讀不回 ⇒ 用戶卡在這頁,只能找 leo 手動 wrangler 覆寫
|
||||
// ② **多一把要記的金鑰**——違反「拿一把金鑰就很難了」(D36 精神)
|
||||
// ③ **全實例共用一把**,無法分辨是誰連上來的(企業多人版必要)
|
||||
// 風險評估(leo 判斷,總管原本誇大成「繞過帳密的旁路」已更正):
|
||||
// secret 要貼進 claude.ai(本身有帳密保護)⇒ 洩漏 secret 與洩漏 portal 帳密風險相同。
|
||||
const email = (p.email ?? "").trim();
|
||||
const password = p.password ?? "";
|
||||
if (!email || !password) {
|
||||
return c.html(consentPage(consent, "請輸入你的 Portal 帳號與密碼。"), 401);
|
||||
}
|
||||
// 認證下沉到 cypher 的 /portal/login(唯一真相源;同樣吃它的節流與停用檢查)。
|
||||
// 走 service binding(MCP 與 cypher 同帳號,屬 D28 允許的零件級組合)。
|
||||
let loginOk = false;
|
||||
try {
|
||||
const res = await c.env.CYPHER_EXECUTOR.fetch(
|
||||
new Request("https://cypher/portal/login", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
}),
|
||||
);
|
||||
loginOk = res.ok;
|
||||
} catch {
|
||||
return c.html(consentPage(consent, "暫時無法驗證帳密,請稍後再試。"), 503);
|
||||
}
|
||||
if (!loginOk) {
|
||||
return c.html(consentPage(consent, "帳號或密碼不正確,請重試。"), 401);
|
||||
}
|
||||
if (!c.env.OAUTH_KV) {
|
||||
return c.text("server_error: OAUTH_KV not configured", 503);
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { toolName } from "../brand.js";
|
||||
import { z } from "zod";
|
||||
import { Env } from "../types.js";
|
||||
|
||||
/**
|
||||
* arcrun_publish_component — 提交 TinyGo WASM 零件至 Component Registry
|
||||
*
|
||||
* AI 工作流:
|
||||
* 1. 先呼叫 arcrun_get_component_guide 取得開發指引
|
||||
* 2. 依指引用 TinyGo 撰寫零件(stdin/stdout JSON I/O)
|
||||
* 3. 編譯為 .wasm,base64 編碼後提交
|
||||
* 4. Registry 自動執行沙盒驗收(體積、syscall 掃描、Gherkin 測試)
|
||||
*/
|
||||
export function registerPublishComponent(server: McpServer, env: Env, orgNamespace: string) {
|
||||
server.tool(
|
||||
toolName("publish_component"),
|
||||
"提交 TinyGo WASM 零件至 Component Registry。需提供 component.contract.yaml 內容與編譯後的 .wasm base64。提交前請先呼叫 arcrun_get_component_guide 取得開發規範。",
|
||||
{
|
||||
contract: z.object({
|
||||
canonical_id: z.string().describe("零件功能名稱(小寫底線,如 validate_json)"),
|
||||
display_name: z.string().describe("顯示名稱(可自由命名)"),
|
||||
category: z.enum(["logic", "api", "ui", "style", "anim"]).describe("零件分類"),
|
||||
version: z.string().describe("版本(格式 vN,如 v1)"),
|
||||
wasi_target: z.literal("preview1"),
|
||||
stability: z.enum(["floating", "stable", "pinned"]).default("floating"),
|
||||
runtime_compat: z.array(z.string()).describe("相容 runtime,如 [\"cf-workers\",\"wazero\"]"),
|
||||
constraints: z.object({
|
||||
max_size_kb: z.number().default(2048),
|
||||
max_cold_start_ms: z.number().default(50),
|
||||
no_network_syscall: z.boolean().default(true),
|
||||
io_model: z.literal("stdin_stdout_json"),
|
||||
}),
|
||||
input_schema: z.record(z.unknown()).describe("JSON Schema"),
|
||||
output_schema: z.record(z.unknown()).describe("JSON Schema"),
|
||||
gherkin_tests: z.array(z.object({
|
||||
scenario: z.string(),
|
||||
given: z.string().describe("JSON 字串"),
|
||||
then_contains: z.string().describe("預期輸出包含的字串"),
|
||||
})).min(2).describe("至少一個 happy path 和一個 error path"),
|
||||
description: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
}).describe("component.contract.yaml 內容"),
|
||||
wasm_base64: z.string().describe("編譯後的 .wasm 檔案 base64 編碼"),
|
||||
},
|
||||
async ({ contract, wasm_base64 }) => {
|
||||
try {
|
||||
if (!env.COMPONENT_REGISTRY) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: COMPONENT_REGISTRY service binding is not configured." }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const response = await env.COMPONENT_REGISTRY.fetch("http://component-registry/components", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ contract, wasm_base64 }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
return {
|
||||
content: [{ type: "text", text: `Publish failed: ${errorText}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json() as Record<string, unknown>;
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: `零件 ${contract.canonical_id} v${contract.version} 提交成功:\n${JSON.stringify(result, null, 2)}`,
|
||||
}],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Internal Error: ${error instanceof Error ? error.message : String(error)}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { Env } from "../types.js";
|
||||
import { registerSearchComponents } from "./arcrun_search_components.js";
|
||||
import { registerPublishComponent } from "./arcrun_publish_component.js";
|
||||
import { registerSearchWorkflows } from "./arcrun_search_workflows.js";
|
||||
import { registerListComponents } from "./arcrun_list_components.js";
|
||||
import { registerGetComponent } from "./arcrun_get_component.js";
|
||||
@@ -25,10 +24,10 @@ import { registerWhoami } from "./arcrun_whoami.js";
|
||||
export function registerAllTools(server: McpServer, env: Env, orgNamespace: string, partnerToken: string) {
|
||||
registerSearchComponents(server, env, orgNamespace);
|
||||
// 🔴 2026-07-21 leo 拍板停用:零件走 PR、專業等級;recipe/workflow/app 誰都可以做。
|
||||
// 這兩個工具對一般使用者是「誤導危機」——搜不到東西時把人推向「去造零件」,
|
||||
// 那是最難、最該擋的那條路(總管實測:問 foreach 怎麼用,回傳 TinyGo 寫 WASM 教學)。
|
||||
// 想貢獻零件 → 見 Arcrun repo 的 CONTRIBUTING-components.md。
|
||||
// registerPublishComponent(server, env, orgNamespace);
|
||||
// 零件貢獻**只有一條路=PR 人審**(leo 2026-08-01:「已經沒有 publish 了,
|
||||
// 零件等級一律走 PR,這條路封了」;Arcrun#23 已關)。實作路徑已 git rm
|
||||
// (arcrun_publish_component.ts),不留死代碼當錯誤環境信號。
|
||||
// 想貢獻零件 → repo `Leo/arcrun-components` fork→PR→人審,見 CONTRIBUTING-components.md。
|
||||
registerSearchWorkflows(server, env, orgNamespace, partnerToken); // workflow-discovery R2
|
||||
registerListComponents(server, env, orgNamespace);
|
||||
registerGetComponent(server, env, orgNamespace);
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
canonical_id: "kbdb_upsert_block"
|
||||
display_name: "KBDB Upsert Block"
|
||||
category: "data"
|
||||
version: "v1"
|
||||
wasi_target: "preview1"
|
||||
stability: "floating"
|
||||
runtime_compat:
|
||||
- "cf-workers"
|
||||
- "workerd"
|
||||
- "wazero"
|
||||
constraints:
|
||||
max_size_kb: 2048
|
||||
max_cold_start_ms: 50
|
||||
no_network_syscall: false
|
||||
no_filesystem_syscall: true
|
||||
io_model: "stdin_stdout_json"
|
||||
input_schema:
|
||||
type: object
|
||||
required: [api_key, page_name, content]
|
||||
properties:
|
||||
api_key:
|
||||
type: string
|
||||
description: >-
|
||||
租戶識別=Arcrun namespace(送出時放 X-Arcrun-API-Key header)。
|
||||
⚠️ 2026-07-29 更正:舊敘述寫「KBDB partner key(ak_xxx)」,但**現行沒有發
|
||||
API key 的機制**(leo 07-29 指正)——namespace 即身分即憑證。
|
||||
workflow 裡一律寫 {{credential.arcrun_namespace}},值由執行期解析(D36)。
|
||||
欄位名維持 api_key(改名會破壞現有 workflow),只是它裝的是 namespace。
|
||||
page_name:
|
||||
type: string
|
||||
description: 當 idempotency key。內部用 GET /blocks?page_name= 查找。
|
||||
content:
|
||||
type: string
|
||||
description: block 內容(PATCH 時覆寫,CREATE 時新建)
|
||||
type:
|
||||
type: string
|
||||
description: block type(建立時用,PATCH 時忽略)
|
||||
parent_id:
|
||||
type: string
|
||||
description: 父 block id(建立時用,PATCH 時忽略)
|
||||
user_id:
|
||||
type: string
|
||||
description: 建立時帶入 + lookup 時用來 filter(同 page_name 多 user 共存場景)
|
||||
source:
|
||||
type: string
|
||||
description: 來源標記
|
||||
tags_json:
|
||||
type: string
|
||||
description: tags JSON 字串(PATCH 時轉 array、CREATE 時直傳)
|
||||
kbdb_url:
|
||||
type: string
|
||||
description: KBDB API base(預設 https://kbdb.finally.click)
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
action:
|
||||
type: string
|
||||
enum: [created, patched]
|
||||
description: 實際做了哪個動作
|
||||
data:
|
||||
type: object
|
||||
description: KBDB 回傳(含 block id 等)
|
||||
error:
|
||||
type: string
|
||||
phase:
|
||||
type: string
|
||||
enum: [lookup, patch, create]
|
||||
description: 出錯在哪個階段
|
||||
gherkin_tests:
|
||||
- scenario: "缺 page_name"
|
||||
given: '{"api_key":"ak_x","content":"hi"}'
|
||||
then_contains: '"success":false'
|
||||
- scenario: "建立新 block"
|
||||
given: '{"api_key":"ak_x","page_name":"new-page-uniq","content":"hello"}'
|
||||
then_contains: '"action":"created"'
|
||||
- scenario: "PATCH 既有 block"
|
||||
given: '{"api_key":"ak_x","page_name":"existing-page","content":"updated"}'
|
||||
then_contains: '"action":"patched"'
|
||||
tags: [data, storage, kbdb, upsert, primitive, idempotent]
|
||||
description: |
|
||||
Upsert:用 page_name 當 idempotency key。內部 GET 找有沒有同 page_name 的 block,
|
||||
找到就 PATCH 不到就 POST 新建。解 arcrun workflow 缺 IF/branch 能力的缺口
|
||||
(arcrun.md P1 #1)。mira 7B.3f index-entry per-entity 維護是第一個使用者。
|
||||
config_example: |
|
||||
upsert_index_entry:
|
||||
api_key: "{{api_key}}"
|
||||
page_name: "index-{{entity}}"
|
||||
parent_id: "{{mira_wiki_index_entities_id}}"
|
||||
type: "index-entry"
|
||||
user_id: "inkstone_mira_tools"
|
||||
source: "ai-canon-wiki"
|
||||
content: "{{compose_index_entry.data.text}}"
|
||||
tags_json: '["mira-wiki", "ai-generated", "index"]'
|
||||
@@ -1,3 +0,0 @@
|
||||
module kbdb_upsert_block
|
||||
|
||||
go 1.21
|
||||
@@ -1,280 +0,0 @@
|
||||
// kbdb_upsert_block — 用 page_name 當 idempotency key 做 upsert
|
||||
// 內部:GET /blocks?page_name=X → user_id filter → 找到 PATCH /blocks/:id 沒找到 POST /blocks
|
||||
// 解 arcrun workflow 沒 IF/branch 能力的缺口(arcrun.md P1 #1)
|
||||
// 對應 SDD:polaris/mira/.agents/specs/mira-app/design.md §3.5.12.4.1
|
||||
//
|
||||
//go:build tinygo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
//go:wasmimport u6u http_request
|
||||
func hostHttpRequest(
|
||||
urlPtr uintptr, urlLen uint32,
|
||||
methodPtr uintptr, methodLen uint32,
|
||||
headersPtr uintptr, headersLen uint32,
|
||||
bodyPtr uintptr, bodyLen uint32,
|
||||
outPtr uintptr, outLenPtr uintptr,
|
||||
) uint32
|
||||
|
||||
type Input struct {
|
||||
KBDBUrl string `json:"kbdb_url"` // optional
|
||||
APIKey string `json:"api_key"` // 必填
|
||||
PageName string `json:"page_name"` // 必填,當 idempotency key
|
||||
Content string `json:"content"` // 必填
|
||||
Type string `json:"type"` // optional(建立時用,PATCH 時忽略)
|
||||
ParentID string `json:"parent_id"` // optional(建立時用,PATCH 時忽略)
|
||||
UserID string `json:"user_id"` // optional(建立時用 + lookup filter)
|
||||
Source string `json:"source"` // optional
|
||||
TagsJSON string `json:"tags_json"` // optional(完整覆寫)
|
||||
CreateOnly bool `json:"create_only"` // 2026-05-17 加:若 true + 已存在 → 不 PATCH,回 action="exists"
|
||||
// 用於 stub creation 場景(避免 stub 覆寫已存在的 full wiki)
|
||||
}
|
||||
|
||||
var dummy [1]byte
|
||||
|
||||
func safePtr(b []byte) (uintptr, uint32) {
|
||||
if len(b) == 0 {
|
||||
return uintptr(unsafe.Pointer(&dummy[0])), 0
|
||||
}
|
||||
return uintptr(unsafe.Pointer(&b[0])), uint32(len(b))
|
||||
}
|
||||
|
||||
func writeError(msg string) {
|
||||
out, _ := json.Marshal(map[string]interface{}{"success": false, "error": msg})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
func writeResult(action string, data map[string]interface{}) {
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"action": action,
|
||||
"data": data,
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
// urlEncode:跟 kbdb_get 一致,避免引入 net/url
|
||||
func urlEncode(s string) string {
|
||||
var out []byte
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') ||
|
||||
c == '-' || c == '_' || c == '.' || c == '~' {
|
||||
out = append(out, c)
|
||||
} else {
|
||||
const hex = "0123456789ABCDEF"
|
||||
out = append(out, '%', hex[c>>4], hex[c&0x0f])
|
||||
}
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func httpCall(method, url string, headers map[string]string, body []byte) ([]byte, uint32) {
|
||||
headersBytes, _ := json.Marshal(headers)
|
||||
urlBytes := []byte(url)
|
||||
methodBytes := []byte(method)
|
||||
|
||||
outBuf := make([]byte, 1<<20) // 1MB
|
||||
var outLen uint32
|
||||
|
||||
urlPtr, urlLen := safePtr(urlBytes)
|
||||
methodPtr, methodLen := safePtr(methodBytes)
|
||||
headersPtr, headersLenU := safePtr(headersBytes)
|
||||
bodyPtr, bodyLenU := safePtr(body)
|
||||
|
||||
result := hostHttpRequest(
|
||||
urlPtr, urlLen,
|
||||
methodPtr, methodLen,
|
||||
headersPtr, headersLenU,
|
||||
bodyPtr, bodyLenU,
|
||||
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
|
||||
)
|
||||
return outBuf[:outLen], result
|
||||
}
|
||||
|
||||
func main() {
|
||||
raw, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
writeError("failed to read stdin: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var input Input
|
||||
if err := json.Unmarshal(raw, &input); err != nil {
|
||||
writeError("invalid input JSON: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if input.APIKey == "" {
|
||||
writeError("api_key 必填")
|
||||
return
|
||||
}
|
||||
if input.PageName == "" {
|
||||
writeError("page_name 必填(upsert 的 idempotency key)")
|
||||
return
|
||||
}
|
||||
if input.Content == "" {
|
||||
writeError("content 必填")
|
||||
return
|
||||
}
|
||||
|
||||
kbdbURL := input.KBDBUrl
|
||||
if kbdbURL == "" {
|
||||
kbdbURL = "https://kbdb.finally.click"
|
||||
}
|
||||
|
||||
headers := map[string]string{
|
||||
"Authorization": "Bearer " + input.APIKey,
|
||||
}
|
||||
|
||||
// ── Step 1:lookup by page_name ────────────────────────────────────
|
||||
lookupURL := kbdbURL + "/blocks?page_name=" + urlEncode(input.PageName) +
|
||||
"&limit=" + strconv.Itoa(10)
|
||||
lookupResp, callResult := httpCall("GET", lookupURL, headers, nil)
|
||||
if callResult != 0 {
|
||||
writeError("KBDB lookup failed (host_http_request returned non-zero)")
|
||||
return
|
||||
}
|
||||
|
||||
var lookupParsed struct {
|
||||
Blocks []map[string]interface{} `json:"blocks"`
|
||||
Count int `json:"count"`
|
||||
Error interface{} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(lookupResp, &lookupParsed); err != nil {
|
||||
writeError("KBDB lookup returned non-JSON: " + string(lookupResp))
|
||||
return
|
||||
}
|
||||
if lookupParsed.Error != nil {
|
||||
errBytes, _ := json.Marshal(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": lookupParsed.Error,
|
||||
"phase": "lookup",
|
||||
})
|
||||
os.Stdout.Write(errBytes)
|
||||
return
|
||||
}
|
||||
|
||||
// ── Step 2:找符合 user_id 的第一筆 ──────────────────────────────
|
||||
var existing map[string]interface{}
|
||||
for _, b := range lookupParsed.Blocks {
|
||||
if input.UserID == "" {
|
||||
existing = b
|
||||
break
|
||||
}
|
||||
if uid, ok := b["user_id"].(string); ok && uid == input.UserID {
|
||||
existing = b
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 3:分支寫入 ───────────────────────────────────────────────
|
||||
postHeaders := map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer " + input.APIKey,
|
||||
}
|
||||
|
||||
if existing != nil {
|
||||
// CreateOnly 模式:已存在 → 不動,回 action="exists"(給 stub creation 用,
|
||||
// 避免後續 raw 提到同 entity 時把完整 wiki 覆寫成 stub)
|
||||
if input.CreateOnly {
|
||||
writeResult("exists", existing)
|
||||
return
|
||||
}
|
||||
|
||||
// PATCH 路徑
|
||||
existingID, _ := existing["id"].(string)
|
||||
if existingID == "" {
|
||||
writeError("lookup 找到 block 但 id 為空")
|
||||
return
|
||||
}
|
||||
|
||||
patchBody := make(map[string]interface{})
|
||||
patchBody["content"] = input.Content
|
||||
if input.Source != "" {
|
||||
patchBody["source"] = input.Source
|
||||
}
|
||||
if input.TagsJSON != "" {
|
||||
// PATCH endpoint 用 tags array 不是 tags_json string
|
||||
var tagsArr []string
|
||||
if err := json.Unmarshal([]byte(input.TagsJSON), &tagsArr); err == nil {
|
||||
patchBody["tags"] = tagsArr
|
||||
}
|
||||
}
|
||||
patchBodyBytes, _ := json.Marshal(patchBody)
|
||||
|
||||
patchURL := kbdbURL + "/blocks/" + existingID
|
||||
patchResp, callResult := httpCall("PATCH", patchURL, postHeaders, patchBodyBytes)
|
||||
if callResult != 0 {
|
||||
writeError("KBDB PATCH failed (host_http_request returned non-zero)")
|
||||
return
|
||||
}
|
||||
var patchParsed map[string]interface{}
|
||||
if err := json.Unmarshal(patchResp, &patchParsed); err != nil {
|
||||
writeError("KBDB PATCH returned non-JSON: " + string(patchResp))
|
||||
return
|
||||
}
|
||||
if _, hasErr := patchParsed["error"]; hasErr {
|
||||
errBytes, _ := json.Marshal(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": patchParsed["error"],
|
||||
"phase": "patch",
|
||||
})
|
||||
os.Stdout.Write(errBytes)
|
||||
return
|
||||
}
|
||||
writeResult("patched", patchParsed)
|
||||
return
|
||||
}
|
||||
|
||||
// CREATE 路徑
|
||||
postBody := make(map[string]interface{})
|
||||
postBody["content"] = input.Content
|
||||
postBody["page_name"] = input.PageName
|
||||
if input.Type != "" {
|
||||
postBody["type"] = input.Type
|
||||
}
|
||||
if input.ParentID != "" {
|
||||
postBody["parent_id"] = input.ParentID
|
||||
}
|
||||
if input.UserID != "" {
|
||||
postBody["user_id"] = input.UserID
|
||||
}
|
||||
if input.Source != "" {
|
||||
postBody["source"] = input.Source
|
||||
}
|
||||
if input.TagsJSON != "" {
|
||||
postBody["tags_json"] = input.TagsJSON
|
||||
}
|
||||
postBodyBytes, _ := json.Marshal(postBody)
|
||||
|
||||
postURL := kbdbURL + "/blocks"
|
||||
postResp, callResult := httpCall("POST", postURL, postHeaders, postBodyBytes)
|
||||
if callResult != 0 {
|
||||
writeError("KBDB POST failed (host_http_request returned non-zero)")
|
||||
return
|
||||
}
|
||||
var postParsed map[string]interface{}
|
||||
if err := json.Unmarshal(postResp, &postParsed); err != nil {
|
||||
writeError("KBDB POST returned non-JSON: " + string(postResp))
|
||||
return
|
||||
}
|
||||
if _, hasErr := postParsed["error"]; hasErr {
|
||||
errBytes, _ := json.Marshal(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": postParsed["error"],
|
||||
"phase": "create",
|
||||
})
|
||||
os.Stdout.Write(errBytes)
|
||||
return
|
||||
}
|
||||
writeResult("created", postParsed)
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
canonical_id: "km_writer"
|
||||
display_name: "KM Writer"
|
||||
category: "api"
|
||||
version: "v1"
|
||||
wasi_target: "preview1"
|
||||
stability: "floating"
|
||||
runtime_compat:
|
||||
- "cf-workers"
|
||||
- "workerd"
|
||||
constraints:
|
||||
max_size_kb: 2048
|
||||
max_cold_start_ms: 50
|
||||
no_network_syscall: false
|
||||
no_filesystem_syscall: true
|
||||
io_model: "stdin_stdout_json"
|
||||
input_schema:
|
||||
type: object
|
||||
required: [action, mira_url, token]
|
||||
properties:
|
||||
action:
|
||||
type: string
|
||||
description: "操作類型:read_journal | read_journal_date | append_journal | list_pages | read_page | write_page"
|
||||
enum: [read_journal, read_journal_date, append_journal, list_pages, read_page, write_page]
|
||||
mira_url:
|
||||
type: string
|
||||
description: "Mira 服務基礎 URL(例:https://mira.uncle6.me)"
|
||||
token:
|
||||
type: string
|
||||
description: "Mira MIRA_TOKEN(Bearer token)"
|
||||
content:
|
||||
type: string
|
||||
description: "內容(append_journal / write_page 時必填)"
|
||||
timestamp:
|
||||
type: string
|
||||
description: "ISO 8601 時間戳(append_journal 時選填,影響日期和時間顯示)"
|
||||
date:
|
||||
type: string
|
||||
description: "日期 YYYY-MM-DD(read_journal_date 時必填)"
|
||||
name:
|
||||
type: string
|
||||
description: "頁面名稱(read_page / write_page 時必填)"
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
description: "Mira API 回應資料"
|
||||
error:
|
||||
type: string
|
||||
description: "錯誤訊息(success=false 時)"
|
||||
gherkin_tests:
|
||||
- scenario: "缺少 action"
|
||||
given: '{"mira_url":"https://mira.uncle6.me","token":"abc"}'
|
||||
then_contains: '{"success":false'
|
||||
- scenario: "缺少 token"
|
||||
given: '{"action":"list_pages","mira_url":"https://mira.uncle6.me"}'
|
||||
then_contains: '{"success":false'
|
||||
tags: [km, journal, logseq, mira, knowledge-management]
|
||||
description: "讀寫 Mira leo-graph 的 journals 和 pages。透過 host function 呼叫 Mira /km/* API,支援讀取、新增日誌條目,以及讀寫頁面。"
|
||||
config_example: |
|
||||
append_to_journal:
|
||||
action: "append_journal"
|
||||
mira_url: "https://mira.uncle6.me"
|
||||
token: "<mira_token>"
|
||||
content: "今天完成了 arcrun km_writer 元件"
|
||||
@@ -1,3 +0,0 @@
|
||||
module component
|
||||
|
||||
go 1.21
|
||||
@@ -1,177 +0,0 @@
|
||||
// km_writer — 讀寫 Mira leo-graph(journals + pages)
|
||||
// 透過 host function 呼叫 Mira /km/* API
|
||||
//
|
||||
//go:build tinygo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
//go:wasmimport u6u http_request
|
||||
func hostHttpRequest(
|
||||
urlPtr uintptr, urlLen uint32,
|
||||
methodPtr uintptr, methodLen uint32,
|
||||
headersPtr uintptr, headersLen uint32,
|
||||
bodyPtr uintptr, bodyLen uint32,
|
||||
outPtr uintptr, outLenPtr uintptr,
|
||||
) uint32
|
||||
|
||||
// Input actions:
|
||||
// read_journal — GET today's journal (requires: mira_url, token)
|
||||
// read_journal_date — GET journal by date (requires: mira_url, token, date)
|
||||
// append_journal — POST append entry (requires: mira_url, token, content; optional: timestamp)
|
||||
// list_pages — GET all pages (requires: mira_url, token)
|
||||
// read_page — GET page by name (requires: mira_url, token, name)
|
||||
// write_page — PUT write page (requires: mira_url, token, name, content)
|
||||
|
||||
type Input struct {
|
||||
Action string `json:"action"`
|
||||
MiraURL string `json:"mira_url"`
|
||||
Token string `json:"token"`
|
||||
Content string `json:"content"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Date string `json:"date"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
raw, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
writeError("failed to read stdin: " + err.Error())
|
||||
return
|
||||
}
|
||||
var inp Input
|
||||
if err := json.Unmarshal(raw, &inp); err != nil {
|
||||
writeError("invalid input JSON: " + err.Error())
|
||||
return
|
||||
}
|
||||
if inp.Action == "" {
|
||||
writeError("action 必填")
|
||||
return
|
||||
}
|
||||
if inp.MiraURL == "" {
|
||||
writeError("mira_url 必填")
|
||||
return
|
||||
}
|
||||
if inp.Token == "" {
|
||||
writeError("token 必填")
|
||||
return
|
||||
}
|
||||
|
||||
authHeader := fmt.Sprintf(`{"Authorization":"Bearer %s","Content-Type":"application/json"}`, inp.Token)
|
||||
|
||||
switch inp.Action {
|
||||
case "read_journal":
|
||||
result := doRequest(inp.MiraURL+"/km/journal", "GET", authHeader, "")
|
||||
os.Stdout.Write(result)
|
||||
|
||||
case "read_journal_date":
|
||||
if inp.Date == "" {
|
||||
writeError("date 必填(格式 YYYY-MM-DD)")
|
||||
return
|
||||
}
|
||||
result := doRequest(inp.MiraURL+"/km/journal/"+inp.Date, "GET", authHeader, "")
|
||||
os.Stdout.Write(result)
|
||||
|
||||
case "append_journal":
|
||||
if inp.Content == "" {
|
||||
writeError("content 必填")
|
||||
return
|
||||
}
|
||||
bodyMap := map[string]string{"content": inp.Content}
|
||||
if inp.Timestamp != "" {
|
||||
bodyMap["timestamp"] = inp.Timestamp
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(bodyMap)
|
||||
result := doRequest(inp.MiraURL+"/km/journal", "POST", authHeader, string(bodyBytes))
|
||||
os.Stdout.Write(result)
|
||||
|
||||
case "list_pages":
|
||||
result := doRequest(inp.MiraURL+"/km/pages", "GET", authHeader, "")
|
||||
os.Stdout.Write(result)
|
||||
|
||||
case "read_page":
|
||||
if inp.Name == "" {
|
||||
writeError("name 必填")
|
||||
return
|
||||
}
|
||||
result := doRequest(inp.MiraURL+"/km/page/"+inp.Name, "GET", authHeader, "")
|
||||
os.Stdout.Write(result)
|
||||
|
||||
case "write_page":
|
||||
if inp.Name == "" {
|
||||
writeError("name 必填")
|
||||
return
|
||||
}
|
||||
if inp.Content == "" {
|
||||
writeError("content 必填")
|
||||
return
|
||||
}
|
||||
bodyMap := map[string]string{"content": inp.Content}
|
||||
bodyBytes, _ := json.Marshal(bodyMap)
|
||||
result := doRequest(inp.MiraURL+"/km/page/"+inp.Name, "PUT", authHeader, string(bodyBytes))
|
||||
os.Stdout.Write(result)
|
||||
|
||||
default:
|
||||
writeError("未知 action: " + inp.Action)
|
||||
}
|
||||
}
|
||||
|
||||
func doRequest(url, method, headersJSON, body string) []byte {
|
||||
urlBytes := []byte(url)
|
||||
methodBytes := []byte(method)
|
||||
headersBytes := []byte(headersJSON)
|
||||
bodyBytes := []byte(body)
|
||||
|
||||
outBuf := make([]byte, 131072) // 128KB
|
||||
var outLen uint32
|
||||
|
||||
if len(bodyBytes) == 0 {
|
||||
bodyBytes = []byte{}
|
||||
}
|
||||
|
||||
var bodyPtr uintptr
|
||||
var bodyLen uint32
|
||||
if len(bodyBytes) > 0 {
|
||||
bodyPtr = uintptr(unsafe.Pointer(&bodyBytes[0]))
|
||||
bodyLen = uint32(len(bodyBytes))
|
||||
}
|
||||
|
||||
code := hostHttpRequest(
|
||||
uintptr(unsafe.Pointer(&urlBytes[0])), uint32(len(urlBytes)),
|
||||
uintptr(unsafe.Pointer(&methodBytes[0])), uint32(len(methodBytes)),
|
||||
uintptr(unsafe.Pointer(&headersBytes[0])), uint32(len(headersBytes)),
|
||||
bodyPtr, bodyLen,
|
||||
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
|
||||
)
|
||||
|
||||
if code != 0 {
|
||||
out, _ := json.Marshal(map[string]interface{}{"success": false, "error": "HTTP request failed"})
|
||||
return out
|
||||
}
|
||||
|
||||
responseStr := string(outBuf[:outLen])
|
||||
|
||||
// Try to parse the response as JSON to forward it
|
||||
var parsed interface{}
|
||||
if err := json.Unmarshal([]byte(responseStr), &parsed); err != nil {
|
||||
// Not JSON — wrap it
|
||||
out, _ := json.Marshal(map[string]interface{}{"success": true, "data": responseStr})
|
||||
return out
|
||||
}
|
||||
|
||||
// Forward the parsed response as-is, wrapped in success
|
||||
out, _ := json.Marshal(map[string]interface{}{"success": true, "data": parsed})
|
||||
return out
|
||||
}
|
||||
|
||||
func writeError(msg string) {
|
||||
out, _ := json.Marshal(map[string]interface{}{"success": false, "error": msg})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
# km-wiki-ingest — 機械式 wiki 卡片 → KBDB ingest(Arcrun#8 / 頂層 SDD T2–T4)
|
||||
|
||||
> Phase A 產物:機械 ingest 邏輯 + 乾跑證據 + workflow 設計。**不部署、不寫 live KBDB。**
|
||||
|
||||
## 解決什麼問題
|
||||
|
||||
把各 repo 的 `system-dev/wiki/cards/**/*.md`(人工精耕卡)**機械地**(無 LLM)灌進 leo21c KBDB:
|
||||
|
||||
- **卡片 → base entry**(`metadata.embed=true`,供語意搜尋)。
|
||||
- **`## 實體` → graph node**;**`## 關聯` 的 typed-edge(`A >> 關係 >> B`)與 `[[wikilink]]` → graph triplet**。
|
||||
|
||||
取代舊 `kbdb-ingest-plugin/scripts/ingest-cli.mjs` 的 `raw → Haiku → 三元組` 路:新路純解析卡片內既有結構,**決定性、零 token、零幻覺**。
|
||||
|
||||
## 形式選擇與理由(給總管)
|
||||
|
||||
**形式 = Arcrun workflow(YAML 編排)+ 通用 `code` 零件(sandbox inline JS,Arcrun#10)承載卡片→envelope 解析 + 現成零件(`cron` / `http_request` / `foreach_control` / `kbdb_upsert_block`)。** 不再鑄 domain 零件 `km_wiki_card_parse`(Arcrun#10 裁定:一次性解析走通用逃生口)。
|
||||
|
||||
理由:
|
||||
|
||||
1. **編排本來就是 arcrun 的主場**:cron 限速 drain、Gitea webhook 只吃 delta、foreach 小批、冪等 upsert——這些跟現成零件 1:1 對得上,且 leo 要「Arcrun workflow 慢慢做」、arcrun 哲學禁一次性腳本。
|
||||
2. **arcrun 唯一缺的是「卡片 → envelope」的解析**。那是一段**決定性純轉換**(無 LLM、無網路、無檔案)——正好是 `code` 零件 sandbox 的理想形狀(`stdin_stdout_json` + `no_network_syscall` + `no_filesystem_syscall`)。用通用 `code` 節點內聯這段 JS(而非鑄 domain 零件、也非在 YAML 裡塞 `string_ops` 正則):workflow 可讀、解析可單元測試、且 registry 不因一次性邏輯增生 domain 零件。
|
||||
3. **小批是結構性的,不是靠祈禱**:一卡一 tick,每卡在 graph worker 的 fan-out ≈ `7+4N+M` subrequest(notes 卡 N≈4/M≈5 → est 28~33,穩壓 CF 50 頂下);`code` 節點內聯解析會**預先把超大卡以 `source_uri` anchor 分段**,任何單一 graph 呼叫都不破頂。
|
||||
|
||||
**Phase A 交付**:純解析+打包核心(`lib/card-to-envelope.mjs`,現在就能跑,= `code` 節點內聯 JS 的權威來源)+乾跑驗證器(`lib/dry-run.mjs`,印出「將寫入什麼」)+本 workflow.yaml(parse_card = `code` 節點)。解析零件=通用 `code`(Arcrun#10 分支,已就緒待部署);本 example 不再自帶 domain 零件契約。部署被閘控,故 live 接線是「設計而非執行」。
|
||||
|
||||
## 診斷小結:fan-out 精確來源 + 小批為何解得掉
|
||||
|
||||
讀 `kbdb-graph-plugin` 現役寫入路徑(`triplet-ingest.ts` / `triplet-crud.ts` / `templates.ts` / `kbdb-client.ts`)逐行拆帳:
|
||||
|
||||
```
|
||||
POST /triplets/ingest(graph worker 單次 invocation)對 base 的 subrequest:
|
||||
ensurePluginTemplates(3) # 頂層一次
|
||||
+ listRecordsByTemplate(1) # 抓同 source 現存 active(冪等分組)
|
||||
+ Σ_triplet [ createTriplet → ensurePluginTemplates(3) + createRecord(1) ] # ★ 每條邊重跑 ensure!
|
||||
+ persistNodes [ ensurePluginTemplates(3) + Σ_node createRecord(1) ]
|
||||
+ Σ_deprecated updateRecord(1)
|
||||
= 7 + 4*N_triplets + M_nodes + D_deprecated
|
||||
```
|
||||
|
||||
- **精確炸點還原**:07_01 單一 envelope 吞 `N=11, M=10, D=0` → `7+44+10 = 61 > 50` → 破頂半殘。**放大器=`createTriplet` 內每條邊都重呼 `ensurePluginTemplates`(3 個 GET)**,佔了 33/61。
|
||||
- **小批為何解得掉**:把「整檔一 envelope」改成「一卡一 envelope、必要時再 anchor 分段」,把 `N` 壓到讓 `7+4N+M ≤ 40`。notes 三卡實測 est 上限 = 33,全綠。超大卡(自測 20 邊/22 節點=114)→ 自動分 4 段,每段 ≤ 38。
|
||||
- **附帶建議(非本 Phase 必改)**:graph 端把 `createTriplet`/`persistNodes` 內重複的 `ensurePluginTemplates` 提到 ingest 入口只跑一次,可把每 envelope 省下 `3*(N+1)` 個 subrequest(單卡 est 33→約 18),批量還能更大。此為 graph-plugin 的可選優化,記此存查。
|
||||
|
||||
## 冪等設計
|
||||
|
||||
| 對象 | 冪等鍵 | 行為 |
|
||||
|---|---|---|
|
||||
| **entry** | `page_name`(穩定:`wikicard:<repo>/<canonical>`)+ `metadata.content_hash` | 找到同 page_name:hash 相同 → skip;不同 → PATCH content(觸發重嵌)。沒有 → POST 新建。 |
|
||||
| **triplet envelope** | `source.uri` + `source.content_hash` | graph 現役 per-source 冪等:同 hash 整包 no-op(`triplet-ingest.ts:65`)。 |
|
||||
| **分段** | 各段 `source.uri = <基uri>#segNN` | 各段獨立 uri → 各自獨立冪等,**繞開 per-source content_hash 整包 skip**(否則同 uri 第 2 段起會被判定「已落地」而整包跳過)。節點只放進「首次引用它的段」,跨段不重送(避免 graph 重建 entity)。 |
|
||||
|
||||
## 觸發(兩階段,對齊 SDD R3)
|
||||
|
||||
- **Phase 0(一次性 backfill)**:`cron */2` 每 tick drain 一張卡(限速慢推),反覆跑到全庫清空。冪等 → 可續傳、重跑零寫入。
|
||||
- **穩態(日常增量)**:**Gitea push webhook → arcrun workflow**,只吃 `commits[].{added,modified}` 中的 `system-dev/wiki/cards/**/*.md`。⚠️ Gitea → Cloudflare(arcrun),**非 GitHub Actions**,不觸 GitHub flag 紅線(D4/D20)。量小、不撞頂、不限速。
|
||||
|
||||
## 乾跑證據(Phase A,不寫 live)
|
||||
|
||||
```
|
||||
node lib/dry-run.mjs --repo-path <notes clone> --repo Leo/notes --self-test
|
||||
```
|
||||
|
||||
對 `Leo/notes` 的 3 張卡實測:3 entries(embed=true)+ 3 envelopes、15 triplets、16 nodes;
|
||||
**單次 graph 呼叫 subrequest 上限 = 33(< 50),無任一 envelope 破頂**。
|
||||
self-test 合成超大卡(不分段 est=114 會炸)→ 自動分 4 段、每段 ≤ 38,全綠。
|
||||
|
||||
## 待 live 部署 + 寫入(總管過 leo 閘用)
|
||||
|
||||
1. **部署通用 `code` 零件**(Arcrun#10 分支 `feat/issue-10-code-component`,已就緒):`cd registry/components/code && npm install && npx wrangler deploy`(→ `code.arcrun.dev`)+ `register-component.sh code`。本 workflow 的 parse_card 以 `component: code` 引用它,解析 JS 已內聯在 workflow.yaml(= `lib/card-to-envelope.mjs` 邏輯)。不再部署 domain 零件 `km_wiki_card_parse`。
|
||||
2. **部署 workflow**:`km_wiki_ingest_drain`(cron drain);`wrangler` 直推 leo21c(**禁 `acr update`**——codeload 綁 GitHub 假綠,Arcrun#4)。
|
||||
3. **注入環境變數**(不放 repo):`repo=Leo/notes ref=main gitea_token kbdb_url=https://arcrun-kbdb.leo21c.workers.dev kbdb_api_key graph_url graph_api_key=leo`。`CLOUDFLARE_ACCOUNT_ID=leo21c`(別讓官方 58309b 污染)。
|
||||
4. **entry 寫入路徑確認**:若 `kbdb_upsert_block` 尚不透傳 `metadata_json`(需 `embed:true`/`content_hash`),entry 改用 `http_request` 直打 base `POST/PATCH /entries` 帶 `body_json.metadata_json`。
|
||||
5. **預期寫入量(Leo/notes 現況 3 卡)**:3 entries + 15 triplets + 16 node records(去重後更少);分 3 次 graph 呼叫(每次 ≤ 33 subrequest)+ 3 次 entry upsert。全庫鋪開時照 cron 一卡一 tick 慢推。
|
||||
6. **驗收**:ingest 後 `GET /embed/backfill/status` 應見 pending 上升→drain 後歸零、embedded 增加;三模式(關鍵字/語意/圖)curl 驗。
|
||||
@@ -1,176 +0,0 @@
|
||||
{
|
||||
"repo": "Leo/notes",
|
||||
"commit": "4b9a53c1c99d596c23b1b449fd79728610f6995b",
|
||||
"budget": 40,
|
||||
"ceiling": 50,
|
||||
"cards": [
|
||||
{
|
||||
"relPath": "system-dev/wiki/cards/notes/Gitea當後端編輯器全CF化網站構想.md",
|
||||
"canonical": "Gitea當後端編輯器全CF化網站構想",
|
||||
"entry": {
|
||||
"page_name": "wikicard:Leo/notes/Gitea當後端編輯器全CF化網站構想",
|
||||
"entry_type": "wiki_card",
|
||||
"metadata.embed": true,
|
||||
"content_hash": "64b402952fe0…",
|
||||
"content_bytes": 2324,
|
||||
"tags": [
|
||||
"系統設計",
|
||||
"工具教學"
|
||||
]
|
||||
},
|
||||
"envelopeCount": 1,
|
||||
"envelopes": [
|
||||
{
|
||||
"source.uri": "gitea:Leo/notes@system-dev/wiki/cards/notes/Gitea當後端編輯器全CF化網站構想.md",
|
||||
"source.anchor": null,
|
||||
"nodes": 6,
|
||||
"triplets": 5,
|
||||
"est_subrequests": 33,
|
||||
"under_ceiling": true,
|
||||
"sample_triplets": [
|
||||
"Gitea >> 類比於 >> WordPress (1)",
|
||||
"Gitea >> 充當後端供稿給 >> Cloudflare Pages (1)",
|
||||
"Quartz >> 目前負責轉譯給 >> Cloudflare Pages (1)",
|
||||
"Cloudflare Artifacts >> 若提供 git 倉庫則可取代 >> Gitea (1)"
|
||||
],
|
||||
"sample_nodes": [
|
||||
"Gitea — 可自架的 git 平台,編輯體驗近似 WordPress 後…",
|
||||
"WordPress — 常見的內容管理後端,作為 Gitea 編輯體驗的類比對象。…",
|
||||
"Cloudflare Pages — Cloudflare 的靜態站前端託管。…",
|
||||
"Quartz — 目前把筆記轉成網站前端的工具。…"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"relPath": "system-dev/wiki/cards/notes/Prompt能力即拆解自己邏輯的能力.md",
|
||||
"canonical": "Prompt能力即拆解自己邏輯的能力",
|
||||
"entry": {
|
||||
"page_name": "wikicard:Leo/notes/Prompt能力即拆解自己邏輯的能力",
|
||||
"entry_type": "wiki_card",
|
||||
"metadata.embed": true,
|
||||
"content_hash": "63296a663227…",
|
||||
"content_bytes": 2109,
|
||||
"tags": [
|
||||
"AI協作",
|
||||
"工具教學",
|
||||
"觀點主張"
|
||||
]
|
||||
},
|
||||
"envelopeCount": 1,
|
||||
"envelopes": [
|
||||
{
|
||||
"source.uri": "gitea:Leo/notes@system-dev/wiki/cards/notes/Prompt能力即拆解自己邏輯的能力.md",
|
||||
"source.anchor": null,
|
||||
"nodes": 5,
|
||||
"triplets": 5,
|
||||
"est_subrequests": 32,
|
||||
"under_ceiling": true,
|
||||
"sample_triplets": [
|
||||
"Prompt 能力 >> 本質上等於 >> 邏輯拆解能力 (1)",
|
||||
"邏輯拆解能力 >> 產出 >> pseudo code (1)",
|
||||
"pseudo code >> 足以教會 >> AI (1)",
|
||||
"Prompt能力即拆解自己邏輯的能力 >> 呼應 >> 程式化邏輯可圖解任何主題不限AI (1)"
|
||||
],
|
||||
"sample_nodes": [
|
||||
"Prompt 能力 — 把腦中意圖轉成能指揮 AI 的指令的能力。…",
|
||||
"邏輯拆解能力 — 把腦中隱性流程外顯成可陳述步驟的能力。…",
|
||||
"pseudo code — 用類程式的步驟描述邏輯、尚未綁定特定語法的表達。…",
|
||||
"AI — 需被人以指令/範例指揮才產出的生成模型。…"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"relPath": "system-dev/wiki/cards/notes/程式化邏輯可圖解任何主題不限AI.md",
|
||||
"canonical": "程式化邏輯可圖解任何主題不限AI",
|
||||
"entry": {
|
||||
"page_name": "wikicard:Leo/notes/程式化邏輯可圖解任何主題不限AI",
|
||||
"entry_type": "wiki_card",
|
||||
"metadata.embed": true,
|
||||
"content_hash": "a9dbf5fc0f9a…",
|
||||
"content_bytes": 2577,
|
||||
"tags": [
|
||||
"工具教學",
|
||||
"觀點主張",
|
||||
"系統設計"
|
||||
]
|
||||
},
|
||||
"envelopeCount": 1,
|
||||
"envelopes": [
|
||||
{
|
||||
"source.uri": "gitea:Leo/notes@system-dev/wiki/cards/notes/程式化邏輯可圖解任何主題不限AI.md",
|
||||
"source.anchor": null,
|
||||
"nodes": 5,
|
||||
"triplets": 5,
|
||||
"est_subrequests": 32,
|
||||
"under_ceiling": true,
|
||||
"sample_triplets": [
|
||||
"程式化邏輯 >> 可圖解 >> 亞洲金融風暴 (1)",
|
||||
"流程圖解 >> 奠基於 >> 程式化邏輯 (1)",
|
||||
"系統動力學 >> 類同於 >> 流程圖解 (1)",
|
||||
"程式化邏輯可圖解任何主題不限AI >> 呼應 >> Prompt能力即拆解自己邏輯的能力 (1)"
|
||||
],
|
||||
"sample_nodes": [
|
||||
"程式化邏輯 — 以程式的因果鏈結構來表述任一領域的邏輯。…",
|
||||
"亞洲金融風暴 — 講者小 Lin 用長邏輯鏈敘述的金融事件案例。…",
|
||||
"流程圖解 — 用 n8n 這類流程工具把邏輯視覺化講解的方法。…",
|
||||
"系統動力學 — 以存量流量與回饋環圖解因果的建模工具。…"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"totals": {
|
||||
"cards": 3,
|
||||
"entries_to_upsert": 3,
|
||||
"triplet_envelopes": 3,
|
||||
"total_triplets": 15,
|
||||
"total_nodes": 16,
|
||||
"max_est_subrequests_single_call": 33,
|
||||
"ceiling": 50,
|
||||
"budget": 40,
|
||||
"any_envelope_over_ceiling": 0
|
||||
},
|
||||
"self_test": {
|
||||
"note": "合成 20 邊 / 22 節點 的超大卡",
|
||||
"if_single_envelope_est_subrequests": 114,
|
||||
"would_crash_single": true,
|
||||
"segmented_into": 4,
|
||||
"per_segment": [
|
||||
{
|
||||
"uri": "gitea:Leo/notes@system-dev/wiki/cards/notes/合成超大卡.md#seg01",
|
||||
"anchor": "seg01",
|
||||
"triplets": 6,
|
||||
"nodes": 7,
|
||||
"est_subrequests": 38,
|
||||
"under_ceiling": true
|
||||
},
|
||||
{
|
||||
"uri": "gitea:Leo/notes@system-dev/wiki/cards/notes/合成超大卡.md#seg02",
|
||||
"anchor": "seg02",
|
||||
"triplets": 6,
|
||||
"nodes": 6,
|
||||
"est_subrequests": 37,
|
||||
"under_ceiling": true
|
||||
},
|
||||
{
|
||||
"uri": "gitea:Leo/notes@system-dev/wiki/cards/notes/合成超大卡.md#seg03",
|
||||
"anchor": "seg03",
|
||||
"triplets": 6,
|
||||
"nodes": 6,
|
||||
"est_subrequests": 37,
|
||||
"under_ceiling": true
|
||||
},
|
||||
{
|
||||
"uri": "gitea:Leo/notes@system-dev/wiki/cards/notes/合成超大卡.md#seg04",
|
||||
"anchor": "seg04",
|
||||
"triplets": 3,
|
||||
"nodes": 3,
|
||||
"est_subrequests": 22,
|
||||
"under_ceiling": true
|
||||
}
|
||||
],
|
||||
"all_segments_under_ceiling": true
|
||||
}
|
||||
}
|
||||
@@ -1,331 +0,0 @@
|
||||
// km-wiki-ingest — 機械式卡片→(entry + triplet envelope) 轉換核心(無 LLM、純函式)
|
||||
// ---------------------------------------------------------------------------
|
||||
// 取代舊 `kbdb-ingest-plugin/scripts/ingest-cli.mjs` 的 raw→Haiku 路:
|
||||
// 舊路 = 讀裸筆記 → 呼叫 Haiku 萃 (s,p,o) → envelope(有 LLM、非決定性、耗 token)。
|
||||
// 新路 = 讀「已精耕卡片」(`system-dev/wiki/cards/**/*.md`)→ 直接解析卡片內既有的
|
||||
// `## 實體`(節點)、`## 關聯` 的 typed-edge(`A >> 關係 >> B`)與 `[[wikilink]]`
|
||||
// → entry + triplet envelope。純機械、決定性、零 token。
|
||||
//
|
||||
// 這支=通用 `code` 零件(Arcrun#10,sandbox inline JS)承載的解析邏輯本體。
|
||||
// workflow.yaml 的 parse_card 節點把本檔的 planCard 邏輯內聯進 code 零件的 config
|
||||
// (去 import/export、raw NUL 分隔符改 u0000 escape、改用 code 沙箱注入的 sha256);
|
||||
// 不再鑄 domain 零件 km_wiki_card_parse(Arcrun#10 裁定:一次性解析走通用逃生口)。
|
||||
// 本檔續留作「該內聯 JS 的權威來源 + 可單元測試的參考實作」(純函式、stdin→stdout JSON、無 fs/網路)。
|
||||
//
|
||||
// 對齊契約:kbdb-ingest-plugin/contracts/ingest-candidate.json(envelope 形狀 / 禁止欄位)。
|
||||
// 對齊頂層 SDD:卡片→entry(metadata.embed=true,走 base API)、wikilink→triplet(走 graph)。
|
||||
//
|
||||
// 鐵律:不碰儲存、不算向量、不建表。這支只「產出將寫入什麼」,實際 HTTP 由 workflow 打。
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
// --- CF subrequest 預算(防「Too many subrequests by single Worker invocation」,07_01 根因)---
|
||||
//
|
||||
// graph worker 處理一次 POST /triplets/ingest 時,對 base 的每次 fetch = 1 subrequest。
|
||||
// 精確拆帳(讀 kbdb-graph-plugin/src/actions/triplet-ingest.ts + triplet-crud.ts + templates.ts):
|
||||
// ingestEnvelope = ensurePluginTemplates(3) + listRecordsByTemplate(1)
|
||||
// + Σ triplet [ createTriplet → ensurePluginTemplates(3) + createRecord(1) = 4 ]
|
||||
// + persistNodes [ ensurePluginTemplates(3) + Σ node createRecord(1) ]
|
||||
// + Σ deprecated updateRecord(1)
|
||||
// ⟹ subreq(envelope) = 7 + 4*N_triplets + M_nodes + D_deprecated
|
||||
//
|
||||
// 07_01 實測炸點:N=11, M=10, D=0 → 7+44+10 = 61 > 50(CF 免費/bundled 上限)→ 炸半殘。
|
||||
//
|
||||
// 對策 = 「一卡一 tick、每 envelope 壓在預算下、超大檔以 source_uri anchor 分段」。
|
||||
export const SUBREQ_CEILING = 50; // CF 單次 Worker invocation subrequest 硬上限(bundled)
|
||||
export const SUBREQ_BUDGET = 40; // 我們的目標上限(留 10 給 D_deprecated 等變動)
|
||||
|
||||
/** 精確估算「一個 envelope 打進 graph /triplets/ingest」會在 graph worker 內產生幾個 subrequest。 */
|
||||
export function estimateEnvelopeSubrequests(nTriplets, mNodes, dDeprecated = 0) {
|
||||
return 7 + 4 * nTriplets + mNodes + dDeprecated;
|
||||
}
|
||||
|
||||
// --- sha256(content_hash 冪等鍵)---
|
||||
export function sha256(text) {
|
||||
return createHash('sha256').update(text).digest('hex');
|
||||
}
|
||||
|
||||
// --- frontmatter 解析(極簡 YAML:只吃我們卡片用到的 tags / gloss / pipeline_candidate)---
|
||||
function parseFrontmatter(md) {
|
||||
const m = md.match(/^---\n([\s\S]*?)\n---\n?/);
|
||||
if (!m) return { data: {}, body: md };
|
||||
const body = md.slice(m[0].length);
|
||||
const data = {};
|
||||
for (const line of m[1].split('\n')) {
|
||||
const kv = line.match(/^([A-Za-z_][\w-]*):\s*(.*)$/);
|
||||
if (!kv) continue;
|
||||
const key = kv[1];
|
||||
let val = kv[2].trim();
|
||||
if (val.startsWith('[') && val.endsWith(']')) {
|
||||
// inline list: [a, b, c]
|
||||
data[key] = val.slice(1, -1).split(',').map((s) => s.trim()).filter(Boolean);
|
||||
} else if (val === 'true' || val === 'false') {
|
||||
data[key] = val === 'true';
|
||||
} else {
|
||||
data[key] = val;
|
||||
}
|
||||
}
|
||||
return { data, body };
|
||||
}
|
||||
|
||||
// --- 取某個 `## 標題` / `### 標題` 區塊的內文(到下一個同級或更高級標題為止)---
|
||||
function sectionBody(md, heading) {
|
||||
// heading 例:'## 實體'、'### 內文知識關係'
|
||||
const level = heading.match(/^#+/)[0].length;
|
||||
const lines = md.split('\n');
|
||||
const out = [];
|
||||
let inSec = false;
|
||||
for (const line of lines) {
|
||||
const h = line.match(/^(#+)\s+(.*)$/);
|
||||
if (h) {
|
||||
const thisLevel = h[1].length;
|
||||
if (inSec) {
|
||||
// 遇到同級或更高級標題 → 區塊結束
|
||||
if (thisLevel <= level) break;
|
||||
}
|
||||
// 標題文字「開頭相符」即算命中(容忍標題後帶括號補述)
|
||||
if (!inSec && thisLevel === level && line.replace(/^#+\s+/, '').startsWith(heading.replace(/^#+\s+/, ''))) {
|
||||
inSec = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (inSec) out.push(line);
|
||||
}
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
// --- 實體行解析:`- **正規名**(別名1/別名2)— 描述`(別名、描述皆選填)---
|
||||
function parseEntities(md) {
|
||||
const sec = sectionBody(md, '## 實體');
|
||||
const entities = [];
|
||||
for (const raw of sec.split('\n')) {
|
||||
const line = raw.trim();
|
||||
if (!line.startsWith('- ')) continue;
|
||||
if (line.startsWith('- >') || line.startsWith('> ')) continue; // 跳過引言說明行
|
||||
const m = line.match(/^- \*\*(.+?)\*\*(?:((.+?)))?\s*(?:[—–\-]\s*(.*))?$/);
|
||||
if (!m) continue;
|
||||
const name = m[1].trim();
|
||||
if (!name) continue;
|
||||
const aliases = m[2]
|
||||
? m[2].split(/[//、,]/).map((s) => s.trim()).filter((s) => s && s !== name)
|
||||
: [];
|
||||
const gloss = (m[3] || '').trim();
|
||||
entities.push({ name, aliases, gloss });
|
||||
}
|
||||
return entities;
|
||||
}
|
||||
|
||||
// --- typed-edge 行解析:`A >> 謂詞 >> B`(端點可為裸實體名或 [[wikilink]])---
|
||||
function parseTypedEdges(sectionText) {
|
||||
const edges = [];
|
||||
for (const raw of (sectionText || '').split('\n')) {
|
||||
const line = raw.trim();
|
||||
if (!line.startsWith('- ')) continue;
|
||||
const body = line.slice(2).trim();
|
||||
if (body.startsWith('(') || body.startsWith('(')) continue; // 「(暫無…)」占位行
|
||||
const parts = body.split('>>');
|
||||
if (parts.length !== 3) continue;
|
||||
const subject = stripWikilink(parts[0].trim());
|
||||
const predicate = parts[1].trim();
|
||||
const object = stripWikilink(parts[2].trim());
|
||||
if (!subject || !predicate || !object) continue;
|
||||
edges.push({ subject, predicate, object });
|
||||
}
|
||||
return edges;
|
||||
}
|
||||
|
||||
// [[notes/00-INDEX]] → notes/00-INDEX ;純字串則原樣回。
|
||||
function stripWikilink(s) {
|
||||
const m = s.match(/^\[\[(.+?)\]\]$/);
|
||||
return m ? m[1].trim() : s;
|
||||
}
|
||||
|
||||
// --- 抽所有 inline [[wikilink]](含 header 的 ← [[notes/00-INDEX]] 與內文)---
|
||||
function extractInlineWikilinks(md) {
|
||||
const out = [];
|
||||
const re = /\[\[(.+?)\]\]/g;
|
||||
let m;
|
||||
while ((m = re.exec(md)) !== null) out.push(m[1].trim());
|
||||
return out;
|
||||
}
|
||||
|
||||
// --- 卡片 canonical id:以檔名(去副檔名)為準,對齊 `## 卡片關係` 用的 [[基名]] 慣例 ---
|
||||
export function cardCanonical(relPath) {
|
||||
const base = relPath.split('/').pop().replace(/\.md$/, '');
|
||||
return base;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析一張卡片 → { entry, nodes, triplets, meta }(尚未分段的原始產物)。
|
||||
* relPath:卡片相對 repo 根路徑(如 system-dev/wiki/cards/notes/Xxx.md)。
|
||||
* repo:如 'Leo/notes'。
|
||||
*/
|
||||
export function parseCard(md, relPath, repo = 'Leo/notes') {
|
||||
const { data: fm } = parseFrontmatter(md);
|
||||
const canonical = cardCanonical(relPath);
|
||||
const titleMatch = md.match(/^#\s+(.+)$/m);
|
||||
const title = titleMatch ? titleMatch[1].trim() : canonical;
|
||||
|
||||
// 1) 節點:## 實體 的正規名 + 別名 + gloss。
|
||||
const entities = parseEntities(md);
|
||||
|
||||
// 2) 邊:內文知識關係(實體↔實體)+ 卡片關係(卡↔卡)+ inline wikilink(卡→卡 導覽/引用)。
|
||||
const intraEdges = parseTypedEdges(sectionBody(md, '### 內文知識關係'))
|
||||
.map((e) => ({ ...e, confidence: 1.0 }));
|
||||
const cardEdges = parseTypedEdges(sectionBody(md, '### 卡片關係'))
|
||||
.map((e) => ({ ...e, confidence: 1.0 }));
|
||||
|
||||
// inline wikilink(← [[notes/00-INDEX]] 等)→ 卡→卡「連結至」邊,去重、排除自環與已被 typed 邊覆蓋者。
|
||||
const typedPairs = new Set(
|
||||
[...cardEdges].map((e) => `${e.subject} | ||||