diff --git a/.component-builds/kbdb_upsert_block/package.json b/.component-builds/kbdb_upsert_block/package.json deleted file mode 100644 index 45e9118..0000000 --- a/.component-builds/kbdb_upsert_block/package.json +++ /dev/null @@ -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" - } -} diff --git a/.component-builds/kbdb_upsert_block/pnpm-lock.yaml b/.component-builds/kbdb_upsert_block/pnpm-lock.yaml deleted file mode 100644 index b8ea2c1..0000000 --- a/.component-builds/kbdb_upsert_block/pnpm-lock.yaml +++ /dev/null @@ -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 diff --git a/.component-builds/kbdb_upsert_block/pnpm-workspace.yaml b/.component-builds/kbdb_upsert_block/pnpm-workspace.yaml deleted file mode 100644 index d4d28e2..0000000 --- a/.component-builds/kbdb_upsert_block/pnpm-workspace.yaml +++ /dev/null @@ -1,8 +0,0 @@ -allowBuilds: - esbuild: true - sharp: true - workerd: true -onlyBuiltDependencies: - - esbuild - - sharp - - workerd diff --git a/.component-builds/kbdb_upsert_block/src/index.ts b/.component-builds/kbdb_upsert_block/src/index.ts deleted file mode 100644 index 560e7b9..0000000 --- a/.component-builds/kbdb_upsert_block/src/index.ts +++ /dev/null @@ -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 { - const hostFunctions: WasiHostFunctions = { - http_request: async (url, method, headersJson, body) => { - const headers: Record = {}; - if (headersJson) { - try { - const parsed = JSON.parse(headersJson); - if (parsed && typeof parsed === 'object') { - for (const [k, v] of Object.entries(parsed as Record)) { - 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); -} diff --git a/.component-builds/kbdb_upsert_block/tsconfig.json b/.component-builds/kbdb_upsert_block/tsconfig.json deleted file mode 100644 index b65fda7..0000000 --- a/.component-builds/kbdb_upsert_block/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ES2022", - "moduleResolution": "bundler", - "lib": ["ES2022"], - "types": ["@cloudflare/workers-types"], - "strict": true, - "noEmit": true - } -} diff --git a/.component-builds/kbdb_upsert_block/wrangler.toml b/.component-builds/kbdb_upsert_block/wrangler.toml deleted file mode 100644 index 01e6dfd..0000000 --- a/.component-builds/kbdb_upsert_block/wrangler.toml +++ /dev/null @@ -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" diff --git a/.component-builds/km_writer/package.json b/.component-builds/km_writer/package.json deleted file mode 100644 index a32f485..0000000 --- a/.component-builds/km_writer/package.json +++ /dev/null @@ -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" - } -} diff --git a/.component-builds/km_writer/pnpm-lock.yaml b/.component-builds/km_writer/pnpm-lock.yaml deleted file mode 100644 index 9d03336..0000000 --- a/.component-builds/km_writer/pnpm-lock.yaml +++ /dev/null @@ -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 diff --git a/.component-builds/km_writer/pnpm-workspace.yaml b/.component-builds/km_writer/pnpm-workspace.yaml deleted file mode 100644 index d4d28e2..0000000 --- a/.component-builds/km_writer/pnpm-workspace.yaml +++ /dev/null @@ -1,8 +0,0 @@ -allowBuilds: - esbuild: true - sharp: true - workerd: true -onlyBuiltDependencies: - - esbuild - - sharp - - workerd diff --git a/.component-builds/km_writer/src/index.ts b/.component-builds/km_writer/src/index.ts deleted file mode 100644 index 67a169e..0000000 --- a/.component-builds/km_writer/src/index.ts +++ /dev/null @@ -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 { - const hostFunctions: WasiHostFunctions = { - http_request: async (url, method, headersJson, body) => { - const headers: Record = {}; - if (headersJson) { - try { - const parsed = JSON.parse(headersJson); - if (parsed && typeof parsed === 'object') { - for (const [k, v] of Object.entries(parsed as Record)) { - 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); -} diff --git a/.component-builds/km_writer/tsconfig.json b/.component-builds/km_writer/tsconfig.json deleted file mode 100644 index b65fda7..0000000 --- a/.component-builds/km_writer/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ES2022", - "moduleResolution": "bundler", - "lib": ["ES2022"], - "types": ["@cloudflare/workers-types"], - "strict": true, - "noEmit": true - } -} diff --git a/.component-builds/km_writer/wrangler.toml b/.component-builds/km_writer/wrangler.toml deleted file mode 100644 index ef55e83..0000000 --- a/.component-builds/km_writer/wrangler.toml +++ /dev/null @@ -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" diff --git a/cli/src/commands/push.ts b/cli/src/commands/push.ts index 9fa6e77..7d36079 100644 --- a/cli/src/commands/push.ts +++ b/cli/src/commands/push.ts @@ -55,10 +55,12 @@ export async function cmdPush(filePath: string): Promise { 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 { } 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 }>; edges: unknown[] }; diff --git a/cli/src/commands/workflow.ts b/cli/src/commands/workflow.ts new file mode 100644 index 0000000..303f268 --- /dev/null +++ b/cli/src/commands/workflow.ts @@ -0,0 +1,128 @@ +/** + * acr workflow export / acr workflow import — 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 { + 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; + }; + 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 { + const { executorUrl, apiKey } = requireStandardConfig(); + let doc: { name?: string; description?: string; config?: Record; 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); + } +} diff --git a/console-ui/package.json b/console-ui/package.json index 720adf3..68dce54 100644 --- a/console-ui/package.json +++ b/console-ui/package.json @@ -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" } -} +} \ No newline at end of file diff --git a/console-ui/public/console/dashboard/index.html b/console-ui/public/console/dashboard/index.html index 9812592..9c82770 100644 --- a/console-ui/public/console/dashboard/index.html +++ b/console-ui/public/console/dashboard/index.html @@ -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; } - +
diff --git a/console-ui/public/console/index.html b/console-ui/public/console/index.html index 4b97e4a..9ddb2f3 100644 --- a/console-ui/public/console/index.html +++ b/console-ui/public/console/index.html @@ -220,7 +220,12 @@ .kvline { display: flex; justify-content: space-between; gap: 12px; font-size: 15px; margin: 5px 0; } - + diff --git a/console-ui/public/portal/index.html b/console-ui/public/portal/index.html index 8667b02..438cdb7 100644 --- a/console-ui/public/portal/index.html +++ b/console-ui/public/portal/index.html @@ -186,7 +186,23 @@ .kvline { display: flex; justify-content: space-between; gap: 12px; font-size: 15px; margin: 5px 0; } - + @@ -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; } diff --git a/console-ui/scripts/build.mjs b/console-ui/scripts/build.mjs deleted file mode 100644 index 0587a61..0000000 --- a/console-ui/scripts/build.mjs +++ /dev/null @@ -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; -} - -/** 在頁面 注入 config.js(runtime 決定 API base),並定義 API_BASE 供內嵌 JS 用。 */ -function injectApiBase(html) { - const snippet = ` -`; - const withCfg = html.replace('', `${snippet}\n`); - // 內嵌的 IIFE 裡宣告 API_BASE(各頁的主 - - - -
-
-
${brand} 駕駛艙
-
-
- -
-
-
-
-
-
載入中
-
-
-
-
-
-
今日完成
-
/
-
-
-
-
收件匣未處理
-
-
來自 Telegram
-
-
-
-
等你的事
-
載入中…
-
-
-
今日路線
-
  • 載入中…
- -
    -
    系統狀況live 健康信號
    -
    載入中…
    -
    每 60 秒自動刷新
    - 進入完整控制台 › -
    - - - -`; -} - -// 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')); -}); diff --git a/console-ui/src/console.ts b/console-ui/src/console.ts deleted file mode 100644 index bf12c5c..0000000 --- a/console-ui/src/console.ts +++ /dev/null @@ -1,1623 +0,0 @@ -/** - * Mira Console 完整版(Arcrun#3 console 系,2026-07-04 總管派工) - * - * 視覺:claude design 定稿「紙感暖黑 2a」(system-dev/docs/6-user/Mira Console 設計規劃/ - * Mira Console.dc.html + Mira Style Guide.dc.html)——紙紋底 repeating-linear-gradient、 - * 明體標題(Songti TC 級聯)、琥珀 var(--amber) 強調、墨綠呼吸球嵌「安」字、正文 ≥16px、 - * 手機優先 390px + ≥1024px 側欄、頁面本體不出水平捲軸。 - * - * 資訊架構:brief 8 頁(docs/1-vision/mira-console-design-brief.md)= - * 1 登入/首次設定 2 駕駛艙 3 總庫搜尋 4 卡片詳頁+關聯 5 工作流 6 憑證管理 - * 7 分流台(原收件匣,Arcrun#9 改裝) 8 設定。 - * 單檔 HTML + 原生 JS hash routing(#/cockpit …),零外部資源、零 build step。 - * - * 薄殼(rule 07):零業務邏輯,只 fetch 既有 API 渲染。各頁資料源(誠實原則,無源的顯示 - * 誠實空狀態,禁假資料): - * - 駕駛艙:GET /console/dashboard-data(免登入聚合端點,console-dashboard.ts) - * - 總庫搜尋:GET /kbdb/search(mode=semantic 未開 Vectorize 會誠實降級 + capability_hint; - * chips 用 entry_type——KBDB 真能篩的欄位,不編造「電腦筆記/手機筆記」等不存在的分類); - * 頭部統計來自 GET /console/kb-scale-data(精耕層 wiki 卡/三元組/已嵌入——leo 2026-07-07 - * 裁:45.8 萬 14-E 搬遷遺產 deprecated 不再當招牌數字;搜尋本身仍搜全庫); - * 搜尋框上方=藏書地圖(library-map SDD M5/R4,Arcrun#39):GET /kbdb/map 全館庫卡片 - * (narrative+top entities+triplet 數+更新時間感),逐庫 GET /kbdb/map/:library 補 - * degree/跨庫 bridges/commit_hash(詳圖拉不到就維持名字版,不擋渲染);點庫卡片= - * 設 library filter(/kbdb/search 既有 library 參數)進該庫搜尋;/map 空陣列或錯誤= - * 誠實空狀態(指引 POST /map/recompute backfill),不擋搜尋等其他功能 - * - 卡片詳頁:GET /kbdb/entries/:id;關聯視圖走 GET /kbdb/graph/neighbors/:name - * (cypher 代轉 kbdb-graph-plugin,kbdb-proxy.ts;查無 triplet → 「尚無關聯資料」) - * - 工作流:GET /webhooks/named(list)+ POST /webhooks/named/:name/trigger(手動觸發) - * + GET /workflows/:name/executions(最近執行);釘選存 localStorage - * - 憑證:GET /credentials/catalog(D1 目錄唯讀,絕不含密文)+ POST /credentials(新增) - * + PUT /credentials/:name(整筆替換);刪除後端未接 D1(T9 範圍)→ 標「即將開通」 - * - 分流台:GET /console/triage-data(需 session——待辦/訊息原文屬機敏,計數才免登入)。 - * 資料源二合一(kb-ingest SDD R7 / Arcrun#9):entry_type=todo(Logseq 萃取)+ - * entry_type=inbox(Telegram);三欄 80/15/5(ai/collab/leo)+ per-project chips - * +已完成預設隱藏可切換。分欄判定在 lib/console-triage-model.ts(純函式),頁面只渲染。 - * 勾掉/還原走 POST /console/triage-check(同 session 保護;leo 2026-07-08 拍板)—— - * 雙向銷案語意:console 勾掉=終局(checked_via:console,萃取端絕不復活)、Logseq 改 - * DONE 由萃取端 PATCH(checked_via:logseq);console 只忠實顯示非 done 項。 - * - 設定:vectorize 狀態探測(讀 /kbdb/search 回應的 mode/capability_hint,無新端點; - * 開關本體需部署端 config kbdb_embed + acr update → 誠實文案講清楚、不假裝能遠端開) - * + MCP token TTL 誠實佔位(/console/settings-data;可調功能=Arcrun#19) - * + /console/setup/reset 換帳密 + 系統資訊(精耕層規模) - * - * 2026-07-07 fix/console-truth-audit(總管稽核派工 + leo 裁決): - * 1. 全站日期/時間顯示統一 Asia/Taipei(lib/taipei-time.ts,與駕駛艙 #36 台北日判定同套; - * 原本用瀏覽器本地 getter,換裝置就換日期;憑證頁加顯時分——7/6 22:57 建立不再被誤讀)。 - * 2. 總庫搜尋/設定頁頭部統計改精耕層(458,732 是真數字——cypher 與 base 一手同值實證—— - * 但 leo 裁 14-E 遺產 deprecated 不再顯示)。 - * 3. 駕駛艙「今日完成/今日路線」接 sprint 任務板真資料(dashboard-data 端,同 #36 模式)。 - * - * 認證模型沿 v1(console-auth.ts):單一管理員 email+password,session token 在 localStorage, - * 實際打 /kbdb/* 用後端回的固定租戶字串(CONSOLE_TENANT)。駕駛艙免登入版在 /console/dashboard。 - * - * v0 的 components/recipes 查詢區(Arcrun#3 原搜尋台)收進「工作流」頁下方折疊區,不砍功能。 - * - * 2026-07-04 leo 實測三回饋(總管派工二輪): - * 1. 深/淺主題:CSS custom properties 兩份色板(:root=淺色紙感宣紙米白+墨字, - * :root[data-theme=dark]=原定稿暖黑不動),預設淺色,選擇存 localStorage - * (key arcrun_console_theme),head 預載腳本防閃色。切換入口=登入/首次設定頁小鈕、 - * 側欄項、設定頁正式開關。金琥珀例外(按鈕漸層/狀態球/toast/gcenter)兩版不變。 - * 2. 登入/首次設定置中:.authwrap.view.on 補 display:flex(原被 .view.on 的 block 蓋掉 → 靠左)。 - * 3. fetch 韌性:friendlyErr() 把裸 Failed to fetch / Load failed 轉「連線中斷」誠實文案; - * 駕駛艙 60 秒定時器常駐,網路恢復自動刷回。 - */ -import { Hono } from 'hono'; -import type { Bindings } from '../types'; -import { TAIPEI_CLIENT_JS } from '../lib/taipei-time'; - -export const consoleRouter = new Hono<{ Bindings: Bindings }>(); - -function renderConsoleHtml(registryBase: string, brand: string, profile: string): string { - // Console profile(config 裁剪,非 auth——#24/#25 多人 Portal 是另一回事): - // full(未設即此)=全套,Mira 實例現行為一字不變。 - // rag=企業產品樣:只留 總庫搜尋/工作流/設定(card 是搜尋的詳頁,跟著留),落地=搜尋頁。 - // 隱藏頁的 view div 仍在 DOM(display:none、路由 guard 導回,永遠點不進)—— - // 因 creds/inbox 有 top-level addEventListener 綁定,抽掉 div 會讓整包 JS 啟動即炸, - // 純渲染層裁剪以「導不進去」為準,不為了抽 DOM 把 null-guard 灑滿全檔。 - const rag = profile === 'rag'; - const views = rag - ? ['search', 'card', 'workflows', 'settings'] - : ['cockpit', 'search', 'card', 'workflows', 'creds', 'inbox', 'settings']; - const home = rag ? 'search' : 'cockpit'; - return ` - - - - -${brand} Console - - - - - - -
    -
    -
    -
    ${brand}
    -
    私人系統・僅供擁有者進入
    -
    -
    - - - -
    -
    -
    這是一個人的智慧總部。
    若你不是擁有者,這裡沒有你要找的東西。
    - ${rag ? '' : '免登入看駕駛艙 ›'} - -
    -
    - - -
    -
    -
    -
    ${brand}
    -
    首次設定
    -
    系統偵測到尚未設定擁有者帳號。
    設定一組 Email 與密碼,之後只有你能進入。
    -
    -
    - - - - -
    -
    - -
    -
    - - -
    -
    - - ${rag ? '' : ''} - - - ${rag ? '' : ` - `} - - -
    -
    -
    - - -
    -
    ${brand} 控制台
    -
    -
    -
    -
    -
    -
    載入中
    -
    -
    -
    -
    -
    -
    今日完成
    -
    /
    -
    -
    -
    -
    收件匣未處理
    -
    -
    來自 Telegram
    -
    -
    -
    -
    等你的事
    -
    載入中…
    -
    -
    -
    -
    -
    - 今日路線狀態即時同步 -
    -
    • 載入中…
    - -
      -
      每 60 秒自動刷新
      -
      -
      -
      - - - - - -
      -
      - - 知識卡片 -
      -
      -
      載入中…
      -
      -
      關聯視圖
      -
      -
      -
      -
      -
      - - -
      -
      工作流
      - -
      載入中…
      -
      -
      零件與 Recipes(框架資源)
      -
      - - -
      -
      -
      -
      - - -
      -
      憑證管理
      -
      - 🛡 保險箱原則:系統只保存目錄與加密後的值,任何頁面都看不到密文內容。只能整筆替換或刪除。 -
      - -
      載入中…
      -
      - - -
      -
      分流台全部待辦・80/15/5
      -
      -
      載入中…
      -
      - - -
      -
      設定
      -
      -
      -
      -
      -
      深色模式
      -
      預設淺色(紙感)。切換立即生效,選擇記在這台裝置(localStorage)。
      -
      -
      -
      -
      -
      -
      -
      -
      語意搜尋(vectorize)
      -
      狀態偵測中…
      -
      -
      -
      -
      - 誠實提示:開關真相=部署端 ~/.arcrun/config.yamlkbdb_embed: trueacr update 重部署(#32 教訓:wrangler 直推改的形態 config 要同步)。Console 只如實顯示狀態,不假裝能遠端開啟。目前狀態:偵測中… -
      -
      -
      -
      MCP token 有效期(TTL)
      -
      讀取中…
      -
      - 誠實佔位:此頁還不能改這個值——可調功能=Arcrun#19(設定頁改→存 KBDB→發 token 時讀)。實作前要調整請在部署端 env MCP_TOKEN_TTL(mcp worker)設定。 -
      -
      -
      -
      更換帳號密碼
      -
      需輸入舊密碼驗證身分
      -
      - - - - -
      -
      -
      - ${rag ? '' : `
      -
      -
      憑證管理
      -
      只保存目錄與加密值,不可回看
      -
      - -
      `} -
      -
      系統資訊
      -
      載入中…
      -
      - -
      -
      - -
      -
      - - -
      - ${rag ? '' : '
      駕駛艙
      '} -
      搜尋
      -
      工作流
      - ${rag ? '' : '
      分流台
      '} -
      設定
      -
      - -
      - - - - -`; -} - -// GET /console — Mira Console 完整版(Arcrun#3 console 系)。registry base 現算:同帳號 -// arcrun-registry worker(WORKER_SUBDOMAIN 沿用既有 KBDB_BASE_URL 那套組法),沒設就退回官方公開 registry。 -consoleRouter.get('/console', (c) => { - const subdomain = c.env.WORKER_SUBDOMAIN; - const registryBase = subdomain - ? `https://arcrun-registry.${subdomain}.workers.dev` - : 'https://registry.arcrun.dev'; - // 品牌字樣(Arcrun#21):引擎預設 Arcrun,實例可用 CONSOLE_BRAND 覆蓋(如 "Arcrun RAG") - // 頁面組成(console-profile-trim):CONSOLE_PROFILE 未設=full 全套;"rag"=企業產品樣(搜尋落地) - return c.html(renderConsoleHtml(registryBase, c.env.CONSOLE_BRAND || 'Arcrun', c.env.CONSOLE_PROFILE || 'full')); -}); diff --git a/console-ui/src/portal-ui.ts b/console-ui/src/portal-ui.ts deleted file mode 100644 index 74f7970..0000000 --- a/console-ui/src/portal-ui.ts +++ /dev/null @@ -1,1390 +0,0 @@ -/** - * RAG Portal 前端殼 — P3:GET /portal 單檔 HTML(portal-auth design §1 D-1/§6,Gitea #24/#25) - * - * 形態(D-1):cypher-executor 的新路由、**獨立 HTML 殼**——不在 console 上加 if(「Admin - * Console 現狀不動」鐵律)。樣式重用 console 的設計語言(紙感/明體標題/琥珀強調/深淺主題), - * 取捨=**複製樣式子集而非抽共用模組**:抽共用要動 console.ts(違「console 不動」),且兩頁 - * 受眾不同(owner vs 同仁)預期各自演化;代價=樣式雙份、改版要兩邊同步(PR 說明誠實記)。 - * - * 頁面(design §6,leo 頁面級拍板): - * - 登入頁(brand=CONSOLE_BRAND;未登入只見這個殼) - * - 搜尋頁(落地頁):keyword/semantic/graph 三模式+結果含 source 溯源+卡片詳頁 - * - 設定頁:改自己密碼、看自己角色與可查庫、主題切換 - * - 工作流頁:D-8 定案 admin 可見(PORTAL_SHOW_WORKFLOWS 可調 all/off);唯讀、不開 trigger - * - graph 模式按 D-4 粗閘顯示(/portal/session 的 graph_allowed) - * - 管理頁(P4,admin-only nav):帳號管理(列表/新增/停用/重設密碼+每帳號勾選可查庫 - * 含 ["*"] 全庫)+庫目錄管理(登記/停用/graph_source 標記)。一次性密碼只在畫面顯示 - * 一次(server 不留明碼);一般用戶 nav 不顯示、直打 /portal/admin/* 也是 403(role 閘)。 - * - Mira 專屬頁(駕駛艙/分流台/憑證/專案)一律不進 - * - * 安全(design §3.3 關鍵差異 vs console): - * - 本頁 JS **沒有任何租戶字串、沒有 X-Arcrun-API-Key**——只持 portal session token - * (localStorage `arcrun_portal_session`),一切資料走 /portal/data/*(server-side enforce)。 - * - 前端的「藏」(graph 模式不顯示、工作流頁不顯示)只是 UX;真閘在 /portal/data/* 路由層 - * (403/404),curl 直打也繞不過(#48 guard 精神)。 - * - 所有動態內容經 esc() 跳脫(防 XSS,同 console 慣例);密碼欄位值不進 log、不進 URL。 - * - * 薄殼(rule 07):零業務邏輯,只 fetch /portal/* 端點渲染。 - */ -import { Hono } from 'hono'; -import type { Bindings } from '../types'; -import { TAIPEI_CLIENT_JS } from '../lib/taipei-time'; - -export const portalUiRouter = new Hono<{ Bindings: Bindings }>(); - -function renderPortalHtml(brand: string, sourceWebBase = ''): string { - return ` - - - - -${brand} Portal - - - - - - -
      -
      -
      -
      ${brand}
      -
      知識入口・Portal
      -
      -
      - - - -
      -
      -
      帳號由管理員發放。忘記密碼請聯絡管理員重設。
      - -
      -
      - - -
      -
      - - - - - - - - -
      -
      -
      - - - - - -
      -
      總圖
      -
      -
      -
      - 點節點可跳到該實體的圖譜搜尋。這張圖由知識庫的三元組即時算出——同一份地圖的文字版 - (給 AI 注入用的總庫目錄)。 -
      -
      -
      - - -
      -
      - - 知識卡片 -
      -
      載入中…
      -
      - - -
      -
      上傳Markdown / 純文字
      -
      - 上傳的文件會進入知識庫收件管線:約 1 分鐘後可在搜尋頁找到;AI 整理後會以 wiki 卡形式出現。支援 .md / .txt(一律以 .md 收件)。 -
      -
      -
      把 .md / .txt 檔案拖放到這裡
      -
      - - -
      -
      -
      - - -
      -
      工作流唯讀・系統狀態
      -
      - 此頁只顯示系統裡的工作流與最近執行狀態,不能觸發執行(觸發屬系統擁有者權限)。 -
      -
      載入中…
      -
      - - -
      -
      設定
      -
      -
      -
      我的帳號
      -
      載入中…
      -
      -
      -
      -
      -
      深色模式
      -
      預設淺色(紙感)。切換立即生效,選擇記在這台裝置。
      -
      -
      -
      -
      -
      -
      更改密碼
      -
      需輸入舊密碼驗證身分;新密碼至少 8 碼
      -
      - - - - -
      -
      -
      - -
      -
      - - -
      -
      管理帳號與知識庫授權
      - -
      帳號管理
      -
      -
      新增同仁帳號
      -
      建立後會產生一組一次性密碼——只顯示這一次,請當場轉交同仁(同仁可在「設定」自行改密)。
      -
      - - - - -
      -
      -
      -
      -
      載入中…
      - -
      庫目錄管理
      -
      - 這裡是「庫」的登記簿——條目歸哪個庫由資料導入(ingest)時蓋章決定;未蓋章的舊資料一律視同 general。標了「圖譜來源」的庫決定誰能用圖譜模式(全都沒標=預設 general)。 -
      -
      -
      登記新庫
      -
      - - - - -
      -
      -
      -
      載入中…
      -
      - -
      -
      - - -
      -
      搜尋
      -
      總圖
      -
      上傳
      -
      工作流
      -
      管理
      -
      設定
      -
      - -
      - - - -`; -} - -// GET /portal — Portal HTML 殼(無 auth:回的是純殼,資料全在 /portal/data/* session 閘後)。 -// 未 bootstrap 任何 portal_user 時登入必失敗(「尚未啟用」的誠實形態),不影響 console。 -portalUiRouter.get('/portal', (c) => { - const brand = c.env.CONSOLE_BRAND || 'Arcrun'; - return c.html(renderPortalHtml(brand, c.env.PORTAL_SOURCE_WEB_BASE || '')); -}); diff --git a/cypher-executor/src/actions/cypher-handlers.ts b/cypher-executor/src/actions/cypher-handlers.ts index c0010ec..41f4a9d 100644 --- a/cypher-executor/src/actions/cypher-handlers.ts +++ b/cypher-executor/src/actions/cypher-handlers.ts @@ -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; 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', diff --git a/cypher-executor/src/actions/execution-evaluator.ts b/cypher-executor/src/actions/execution-evaluator.ts index 4790787..1f4492e 100644 --- a/cypher-executor/src/actions/execution-evaluator.ts +++ b/cypher-executor/src/actions/execution-evaluator.ts @@ -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 { - // 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(); + 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).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 { - // 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:不拋錯,不影響主流程 + } } diff --git a/cypher-executor/src/actions/graph-builder.ts b/cypher-executor/src/actions/graph-builder.ts index 3703b17..f37f96f 100644 --- a/cypher-executor/src/actions/graph-builder.ts +++ b/cypher-executor/src/actions/graph-builder.ts @@ -43,12 +43,28 @@ export function buildExecutionGraph( iterator = foreachMatch[1]; label = '對每個'; // 改回標準 label 走 SEMANTIC_EDGE_MAP } - const edge: { from: string; to: string; type: ReturnType; 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; + 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; }); diff --git a/cypher-executor/src/actions/search-nodes.ts b/cypher-executor/src/actions/search-nodes.ts index 0593440..d1a192e 100644 --- a/cypher-executor/src/actions/search-nodes.ts +++ b/cypher-executor/src/actions/search-nodes.ts @@ -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; + nodeResults: Record; 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>, -): SearchResult { - const nodeResults: Record = {}; + env?: SearchNodesEnv, + mode: SearchMode = 'discover', + target?: SearchTarget, +): Promise { + const nodeResults: Record = {}; + 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(); + 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 { + 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 { + try { + const list = await kv.list({ prefix: 'recipe:' }); + return (await Promise.all( + list.keys.map(k => kv.get(k.name, 'json') as Promise), + )).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(); + 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(); + 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(); + 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(); + 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/`)。 + * 這是 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 }; + if (body.success === false) return { ok: true }; // 同上:回「零件不存在」 + const d = body.data ?? (body as unknown as Record); + 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 { + 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 { + // 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(); + 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 { + try { + const list = await kv.list({ prefix: 'recipe:' }); + const all = (await Promise.all( + list.keys.map(k => kv.get(k.name, 'json') as Promise), + )).filter(Boolean) as RecipeDefinition[]; + + const tokens = [nodeName.toLowerCase(), ...extractTokens(nodeName)]; + const seen = new Set(); + 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 []; + } } diff --git a/cypher-executor/src/actions/target-search.ts b/cypher-executor/src/actions/target-search.ts new file mode 100644 index 0000000..a7e12b7 --- /dev/null +++ b/cypher-executor/src/actions/target-search.ts @@ -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 } + | { ok: false; status: 400 | 401 | 502; error: string }; + +export async function searchByTarget( + target: 'component' | 'recipe' | 'workflow', + query: string, + env: TargetQueryEnv, + apiKey?: string, +): Promise { + 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; + 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(); + 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: 引用)。公庫(多作者市場)走 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; + return { ok: true, body: { target, query, ...body } }; +} diff --git a/cypher-executor/src/actions/triplet-parser.ts b/cypher-executor/src/actions/triplet-parser.ts index 703cf42..de7ae43 100644 --- a/cypher-executor/src/actions/triplet-parser.ts +++ b/cypher-executor/src/actions/triplet-parser.ts @@ -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 * * 規則: diff --git a/cypher-executor/src/actions/webhook-graph-resolver.ts b/cypher-executor/src/actions/webhook-graph-resolver.ts index 3885a3b..37c87f7 100644 --- a/cypher-executor/src/actions/webhook-graph-resolver.ts +++ b/cypher-executor/src/actions/webhook-graph-resolver.ts @@ -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()}`; diff --git a/cypher-executor/src/actions/webhook-handlers.ts b/cypher-executor/src/actions/webhook-handlers.ts index e24f840..91a8a38 100644 --- a/cypher-executor/src/actions/webhook-handlers.ts +++ b/cypher-executor/src/actions/webhook-handlers.ts @@ -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, diff --git a/cypher-executor/src/graph-executor.ts b/cypher-executor/src/graph-executor.ts index c35c0c8..ea02787 100644 --- a/cypher-executor/src/graph-executor.ts +++ b/cypher-executor/src/graph-executor.ts @@ -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; + const data = (r.data && typeof r.data === 'object') ? r.data as Record : 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; diff --git a/cypher-executor/src/index.ts b/cypher-executor/src/index.ts index cf6626a..6d7a6bb 100644 --- a/cypher-executor/src/index.ts +++ b/cypher-executor/src/index.ts @@ -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).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'], diff --git a/cypher-executor/src/lib/api-recipe-seeds.ts b/cypher-executor/src/lib/api-recipe-seeds.ts index 8529fa6..e69e458 100644 --- a/cypher-executor/src/lib/api-recipe-seeds.ts +++ b/cypher-executor/src/lib/api-recipe-seeds.ts @@ -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; + 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:'], + }, + }, ]; diff --git a/cypher-executor/src/lib/branch-hints.ts b/cypher-executor/src/lib/branch-hints.ts new file mode 100644 index 0000000..6832557 --- /dev/null +++ b/cypher-executor/src/lib/branch-hints.ts @@ -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 = { + 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()]; +} diff --git a/cypher-executor/src/lib/component-loader.ts b/cypher-executor/src/lib/component-loader.ts index c708807..46315a1 100644 --- a/cypher-executor/src/lib/component-loader.ts +++ b/cypher-executor/src/lib/component-loader.ts @@ -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 : {}; + const name = recipe.binding_name ?? 'AI'; + const binding = (env as unknown as Record)[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 }; + 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 : {}; @@ -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 }; }; } diff --git a/cypher-executor/src/lib/constants.ts b/cypher-executor/src/lib/constants.ts index 5d4f960..903d896 100644 --- a/cypher-executor/src/lib/constants.ts +++ b/cypher-executor/src/lib/constants.ts @@ -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 = { '失敗時': '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', }; diff --git a/cypher-executor/src/lib/recipe-payload.ts b/cypher-executor/src/lib/recipe-payload.ts new file mode 100644 index 0000000..32631cb --- /dev/null +++ b/cypher-executor/src/lib/recipe-payload.ts @@ -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)[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, +): unknown { + if (template === undefined || template === null) return undefined; + return renderValue(template, ctx); +} + +function renderValue(v: unknown, ctx: Record): 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 = {}; + for (const [k, val] of Object.entries(v as Record)) { + out[k] = renderValue(val, ctx); + } + return out; + } + return v; +} + +function renderString(s: string, ctx: Record): 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).thought === true), + ); + const last = real[real.length - 1]; + picked = (last && typeof last === 'object') + ? (last as Record).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(); +} diff --git a/cypher-executor/src/lib/schemas.ts b/cypher-executor/src/lib/schemas.ts index 9ebbd0a..6c134eb 100644 --- a/cypher-executor/src/lib/schemas.ts +++ b/cypher-executor/src/lib/schemas.ts @@ -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) })), }); diff --git a/cypher-executor/src/lib/workflow-search.ts b/cypher-executor/src/lib/workflow-search.ts new file mode 100644 index 0000000..0290d42 --- /dev/null +++ b/cypher-executor/src/lib/workflow-search.ts @@ -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 { + const base = (env.KBDB_BASE_URL ?? 'https://arcrun-kbdb.uncle6-me.workers.dev').replace(/\/$/, ''); + const headers: Record = { '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 }); +} diff --git a/cypher-executor/src/routes/cypher.ts b/cypher-executor/src/routes/cypher.ts index 0b65203..cd745ac 100644 --- a/cypher-executor/src/routes/cypher.ts +++ b/cypher-executor/src/routes/cypher.ts @@ -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, diff --git a/cypher-executor/src/routes/health.ts b/cypher-executor/src/routes/health.ts index e1e847e..7d09505 100644 --- a/cypher-executor/src/routes/health.ts +++ b/cypher-executor/src/routes/health.ts @@ -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({ diff --git a/cypher-executor/src/routes/init-seed.ts b/cypher-executor/src/routes/init-seed.ts index 07c9454..a5c44f2 100644 --- a/cypher-executor/src/routes/init-seed.ts +++ b/cypher-executor/src/routes/init-seed.ts @@ -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, }; diff --git a/cypher-executor/src/routes/portal.ts b/cypher-executor/src/routes/portal.ts index fd2c6bc..8fb359f 100644 --- a/cypher-executor/src/routes/portal.ts +++ b/cypher-executor/src/routes/portal.ts @@ -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 { - 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 = { 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 { - 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 { - 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 { - 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; - const visit = (o: unknown): void => { - if (Array.isArray(o)) { o.forEach(visit); return; } - if (o && typeof o === 'object') { - const rec = o as Record; - 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 庫重新出現。 diff --git a/cypher-executor/src/routes/recipes.ts b/cypher-executor/src/routes/recipes.ts index 402b45e..a5aa25b 100644 --- a/cypher-executor/src/routes/recipes.ts +++ b/cypher-executor/src/routes/recipes.ts @@ -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; body?: Record; + /** + * ③ payload 層(SDD workflow-discovery 3.12):帶 body 的 API 把 payload 收回 recipe, + * 不必寫進 workflow code。與 `body` 的差別=支援巢狀 {{var}} 與 dot path、 + * 單一引用保留原型別。兩者並存時 body_template 優先(新欄位贏,舊 recipe 不受影響)。 + */ + body_template?: Record; + /** + * ③ 回應正規化層:各家 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, diff --git a/cypher-executor/src/routes/webhooks-named.ts b/cypher-executor/src/routes/webhooks-named.ts index c8af7e4..42d818f 100644 --- a/cypher-executor/src/routes/webhooks-named.ts +++ b/cypher-executor/src/routes/webhooks-named.ts @@ -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 = { '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'); diff --git a/cypher-executor/src/types.ts b/cypher-executor/src/types.ts index de78426..f33a553 100644 --- a/cypher-executor/src/types.ts +++ b/cypher-executor/src/types.ts @@ -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+`)。 + * 由安裝器 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..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..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 = { diff --git a/cypher-executor/tests/branch-hint-response.test.ts b/cypher-executor/tests/branch-hint-response.test.ts new file mode 100644 index 0000000..44b2691 --- /dev/null +++ b/cypher-executor/tests/branch-hint-response.test.ts @@ -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(); + }); +}); diff --git a/cypher-executor/tests/branch-real-components.test.ts b/cypher-executor/tests/branch-real-components.test.ts new file mode 100644 index 0000000..3c2499b --- /dev/null +++ b/cypher-executor/tests/branch-real-components.test.ts @@ -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>, 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'); + }); +}); diff --git a/cypher-executor/tests/conditional-edges.test.ts b/cypher-executor/tests/conditional-edges.test.ts new file mode 100644 index 0000000..363be6c --- /dev/null +++ b/cypher-executor/tests/conditional-edges.test.ts @@ -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 = {}) { + 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; + trace?: Array<{ nodeId: string }>; + error?: string; + }, + }; +} + +/** + * 用 Input 節點直接餵出 if_control 形狀的 output({data:{result,branch}}), + * 避免測試依賴真的 WASM 零件(單元層只驗「引擎怎麼走邊」)。 + */ +function branchGraph(branch: 'true' | 'false', edges: Array>) { + 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>) { + 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); + }); +}); diff --git a/cypher-executor/tests/execution-evaluator.test.ts b/cypher-executor/tests/execution-evaluator.test.ts new file mode 100644 index 0000000..f39096d --- /dev/null +++ b/cypher-executor/tests/execution-evaluator.test.ts @@ -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 { + 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 }> = []; + 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'); + }); +}); diff --git a/cypher-executor/tests/health.test.ts b/cypher-executor/tests/health.test.ts index 19d2215..26f4233 100644 --- a/cypher-executor/tests/health.test.ts +++ b/cypher-executor/tests/health.test.ts @@ -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 () => { diff --git a/cypher-executor/tests/init-seed-recipe-fields.test.ts b/cypher-executor/tests/init-seed-recipe-fields.test.ts new file mode 100644 index 0000000..77c8872 --- /dev/null +++ b/cypher-executor/tests/init-seed-recipe-fields.test.ts @@ -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; + response_map?: { text_path?: string; answer_marker?: string; strip_prefixes?: string[] }; +}; + +async function seedThenRead(canonicalId: string): Promise { + 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'); + }); +}); diff --git a/cypher-executor/tests/intent-branch-syntax.test.ts b/cypher-executor/tests/intent-branch-syntax.test.ts new file mode 100644 index 0000000..856a4ce --- /dev/null +++ b/cypher-executor/tests/intent-branch-syntax.test.ts @@ -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 }> = {}; + 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, 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(); + }); +}); diff --git a/cypher-executor/tests/portal-admin-ai.test.ts b/cypher-executor/tests/portal-admin-ai.test.ts new file mode 100644 index 0000000..a8a0f3f --- /dev/null +++ b/cypher-executor/tests/portal-admin-ai.test.ts @@ -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 = {}) { + 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) { + 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 = {}): Record { + 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; + + 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; + 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; + 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; + expect(d.success).toBeUndefined(); + }); +}); diff --git a/cypher-executor/tests/portal-admin.test.ts b/cypher-executor/tests/portal-admin.test.ts index 6b25625..fdfafa5 100644 --- a/cypher-executor/tests/portal-admin.test.ts +++ b/cypher-executor/tests/portal-admin.test.ts @@ -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 }; - 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 }; + + // 連線欄位照舊(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 }; - 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 { - 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 }> }).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), diff --git a/cypher-executor/tests/recipe-payload-response.test.ts b/cypher-executor/tests/recipe-payload-response.test.ts new file mode 100644 index 0000000..b9d5661 --- /dev/null +++ b/cypher-executor/tests/recipe-payload-response.test.ts @@ -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; + expect(out.messages).toEqual([{ role: 'user' }]); + expect(out.n).toBe(3); + }); + + it('混合文字仍拼成字串', () => { + const out = renderBodyTemplate({ q: '請回答:{{prompt}}' }, { prompt: '天氣' }) as Record; + expect(out.q).toBe('請回答:天氣'); + }); + + it('支援 dot path 取值', () => { + const out = renderBodyTemplate({ t: '{{assemble.data.prompt}}' }, { + assemble: { data: { prompt: '深層值' } }, + }) as Record; + expect(out.t).toBe('深層值'); + }); + + it('取不到的變數保留原樣(不靜默變 undefined,看得見才好 debug)', () => { + const out = renderBodyTemplate({ t: '{{nope}}' }, {}) as Record; + 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(); + }); +}); diff --git a/cypher-executor/tests/step5-acceptance.test.ts b/cypher-executor/tests/step5-acceptance.test.ts new file mode 100644 index 0000000..aa007d9 --- /dev/null +++ b/cypher-executor/tests/step5-acceptance.test.ts @@ -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 = {}) { + 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; + 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); + }); +}); diff --git a/kbdb/node_modules b/kbdb/node_modules deleted file mode 120000 index f6f53f2..0000000 --- a/kbdb/node_modules +++ /dev/null @@ -1 +0,0 @@ -/Users/youlinhsieh/Documents/tech_projects/InkStoneCo/matrix/arcrun/kbdb/node_modules \ No newline at end of file diff --git a/kbdb/src/actions/entry-crud.ts b/kbdb/src/actions/entry-crud.ts index 548ba37..8908f36 100644 --- a/kbdb/src/actions/entry-crud.ts +++ b/kbdb/src/actions/entry-crud.ts @@ -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 { - 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)) diff --git a/kbdb/src/routes/entries.ts b/kbdb/src/routes/entries.ts index 130e539..4315d65 100644 --- a/kbdb/src/routes/entries.ts +++ b/kbdb/src/routes/entries.ts @@ -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 => 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' }); }); diff --git a/kbdb/tests/search-deprecated-filter.test.ts b/kbdb/tests/search-deprecated-filter.test.ts new file mode 100644 index 0000000..23bcc55 --- /dev/null +++ b/kbdb/tests/search-deprecated-filter.test.ts @@ -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 = {}) { + const prepare = (sql: string) => { + const rec: Captured = { sql, params: [] }; + captured.push(rec); + const stmt = { + bind(...args: unknown[]) { rec.params = args; return stmt; }, + async all() { return { results: [] as T[] }; }, + async first() { + 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 = {}) { + const app = new Hono<{ Bindings: Bindings }>(); + app.route('/entries', entryRoutes); + const env = { DB: makeCaptureDB(captured, (extraEnv._entryMeta as Record) ?? {}), 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 }[], + matches: { id: string; score: number }[], +) { + return { + AI: { async run() { return { data: [[0.1, 0.2, 0.3]] }; } }, + VECTORIZE: { + async query(_vec: number[], opts: Record) { + 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 }[] = []; + 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 }[] = []; + 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 }[] = []; + 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 }[] = []; + // 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 = { + 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 }[] = []; + 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); + }); +}); diff --git a/kbdb/tests/search-long-query.test.ts b/kbdb/tests/search-long-query.test.ts new file mode 100644 index 0000000..b7eae32 --- /dev/null +++ b/kbdb/tests/search-long-query.test.ts @@ -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); + } + }); +}); diff --git a/kbdb/tests/search-source-and-score.test.ts b/kbdb/tests/search-source-and-score.test.ts index 4a299f7..e1ae948 100644 --- a/kbdb/tests/search-source-and-score.test.ts +++ b/kbdb/tests/search-source-and-score.test.ts @@ -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 }[] = []; 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 }[] = []; 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 }[] = []; 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); // 無閾值 → 全量 } }); diff --git a/mcp/src/mcp-handler.ts b/mcp/src/mcp-handler.ts index ccd92d8..63a5bbd 100644 --- a/mcp/src/mcp-handler.ts +++ b/mcp/src/mcp-handler.ts @@ -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//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); diff --git a/mcp/src/oauth/consent.ts b/mcp/src/oauth/consent.ts index ef1bd2a..e7629a1 100644 --- a/mcp/src/oauth/consent.ts +++ b/mcp/src/oauth/consent.ts @@ -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}
      ${fields} - - + + + +
      -

      祕密不正確不會發出授權碼。此頁不儲存你的輸入。

      +

      用你登入知識庫(Portal)的同一組帳密,**不需要另外的祕密**。 + 帳密不正確不會發出授權碼。此頁不儲存你的輸入。

      `; } diff --git a/mcp/src/oauth/routes.ts b/mcp/src/oauth/routes.ts index 2dea19c..cb65153 100644 --- a/mcp/src/oauth/routes.ts +++ b/mcp/src/oauth/routes.ts @@ -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); diff --git a/mcp/src/tools/arcrun_publish_component.ts b/mcp/src/tools/arcrun_publish_component.ts deleted file mode 100644 index 4332703..0000000 --- a/mcp/src/tools/arcrun_publish_component.ts +++ /dev/null @@ -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; - 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, - }; - } - } - ); -} diff --git a/mcp/src/tools/registry.ts b/mcp/src/tools/registry.ts index 929c775..c48fd47 100644 --- a/mcp/src/tools/registry.ts +++ b/mcp/src/tools/registry.ts @@ -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); diff --git a/registry/components/kbdb_upsert_block/component.contract.yaml b/registry/components/kbdb_upsert_block/component.contract.yaml deleted file mode 100644 index 1adf546..0000000 --- a/registry/components/kbdb_upsert_block/component.contract.yaml +++ /dev/null @@ -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"]' diff --git a/registry/components/kbdb_upsert_block/go.mod b/registry/components/kbdb_upsert_block/go.mod deleted file mode 100644 index 9c91451..0000000 --- a/registry/components/kbdb_upsert_block/go.mod +++ /dev/null @@ -1,3 +0,0 @@ -module kbdb_upsert_block - -go 1.21 diff --git a/registry/components/kbdb_upsert_block/main.go b/registry/components/kbdb_upsert_block/main.go deleted file mode 100644 index a4594ee..0000000 --- a/registry/components/kbdb_upsert_block/main.go +++ /dev/null @@ -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) -} diff --git a/registry/components/km_writer/component.contract.yaml b/registry/components/km_writer/component.contract.yaml deleted file mode 100644 index 6de54b2..0000000 --- a/registry/components/km_writer/component.contract.yaml +++ /dev/null @@ -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: "" - content: "今天完成了 arcrun km_writer 元件" diff --git a/registry/components/km_writer/go.mod b/registry/components/km_writer/go.mod deleted file mode 100644 index 185c5ee..0000000 --- a/registry/components/km_writer/go.mod +++ /dev/null @@ -1,3 +0,0 @@ -module component - -go 1.21 diff --git a/registry/components/km_writer/main.go b/registry/components/km_writer/main.go deleted file mode 100644 index a9f3b60..0000000 --- a/registry/components/km_writer/main.go +++ /dev/null @@ -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) -} diff --git a/registry/examples/km-wiki-ingest/description.md b/registry/examples/km-wiki-ingest/description.md deleted file mode 100644 index da1660c..0000000 --- a/registry/examples/km-wiki-ingest/description.md +++ /dev/null @@ -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:/`)+ `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 --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 驗。 diff --git a/registry/examples/km-wiki-ingest/dry-run-evidence.json b/registry/examples/km-wiki-ingest/dry-run-evidence.json deleted file mode 100644 index ae2a62d..0000000 --- a/registry/examples/km-wiki-ingest/dry-run-evidence.json +++ /dev/null @@ -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 - } -} diff --git a/registry/examples/km-wiki-ingest/lib/card-to-envelope.mjs b/registry/examples/km-wiki-ingest/lib/card-to-envelope.mjs deleted file mode 100644 index 51b8c8d..0000000 --- a/registry/examples/km-wiki-ingest/lib/card-to-envelope.mjs +++ /dev/null @@ -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}${e.object}`), - ); - const seenRef = new Set(); - const refEdges = []; - for (const target of extractInlineWikilinks(md)) { - const t = stripWikilink(target); - if (t === canonical || t === title) continue; // 自環 - if (typedPairs.has(`${canonical}${t}`)) continue; // 已有明確謂詞邊 - const key = `${canonical}${t}`; - if (seenRef.has(key)) continue; - seenRef.add(key); - refEdges.push({ subject: canonical, predicate: '連結至', object: t, confidence: 0.5 }); - } - - const triplets = [...intraEdges, ...cardEdges, ...refEdges]; - - // 3) 節點清單:卡片本身(canonical,帶 frontmatter gloss)+ 內文實體。 - // 卡對卡邊指到的「別張卡」不在此補 node —— 那張卡自己被 ingest 時會補自己的 node。 - const nodes = []; - const seenNode = new Set(); - const pushNode = (n) => { - const k = n.name.toLowerCase(); - if (!n.name || seenNode.has(k)) return; - seenNode.add(k); - nodes.push(n); - }; - pushNode({ name: canonical, gloss: fm.gloss || '', aliases: title && title !== canonical ? [title] : [] }); - for (const e of entities) pushNode({ name: e.name, gloss: e.gloss, aliases: e.aliases }); - - return { - entry: { - // base POST /entries(或 kbdb_upsert_block)用。metadata.embed=true → 語意可搜。 - page_name: `wikicard:${repo}/${canonical}`, // idempotency key(穩定) - entry_type: 'wiki_card', - content: md, // 卡片全文逐字(embed 對象) - tags: Array.isArray(fm.tags) ? fm.tags : [], - metadata: { - embed: true, // ★ base embed 模組讀此旗標 - source: `gitea:${repo}@${relPath}`, - content_hash: sha256(md), - kind: 'wiki_card', - repo, - canonical, - pipeline_candidate: fm.pipeline_candidate === true, - }, - }, - nodes, - triplets, - meta: { canonical, title, relPath, repo, contentHash: sha256(md) }, - }; -} - -/** - * 把一張卡片的 (nodes, triplets) 打包成「一個或多個」ingest envelope, - * 使每個 envelope 打進 graph 後的 subrequest 都 ≤ SUBREQ_BUDGET。 - * - * 分段規則(對應頂層 SDD R4 / issue #8 第4點): - * - 單 envelope 夠塞(7+4N+M ≤ budget)→ 不分段,uri = 基 uri(無 anchor)。 - * - 需分段 → 每段 uri = `<基uri>#seg{NN}`、anchor = `seg{NN}`。 - * 每段是「獨立 source_uri」→ 各自獨立冪等,繞開 graph 的 per-source content_hash 整包 skip - * (否則同 uri 第 2 段起會被 line 65 的 content_hash 命中而整包跳過)。 - * - 節點只放進「第一個引用到它的段」,跨段不重送(避免 graph persistNodes 重建 entity record)。 - */ -export function planEnvelopes(parsed, opts = {}) { - const budget = opts.budget ?? SUBREQ_BUDGET; - const repo = parsed.meta.repo; - const relPath = parsed.meta.relPath; - const baseUri = `gitea:${repo}@${relPath}`; - const contentHash = parsed.meta.contentHash; - const commit = opts.commit; - - const extractor = { - model: opts.extractorModel ?? 'mechanical/km-wiki-card-parse@1', - tier: 'deep', // 人工精耕卡=deep(決定性、非淺萃) - extracted_at: Math.floor(Date.now() / 1000), - }; - - const nodeByName = new Map(parsed.nodes.map((n) => [n.name, n])); - - // 貪婪打包:逐條 triplet 累進,段成本 = 7 + 4*(段內邊數) + (段內首見節點數)。 - const segments = []; - let cur = null; - const startSeg = () => { - cur = { triplets: [], nodeNames: new Set() }; - segments.push(cur); - }; - const segCost = (seg, extraEdges = 0, extraNodes = 0) => - estimateEnvelopeSubrequests(seg.triplets.length + extraEdges, seg.nodeNames.size + extraNodes); - - startSeg(); - for (const t of parsed.triplets) { - // 這條邊會新引入哪些節點(subject/object 命中 nodeByName 且本段尚未收) - const cand = [t.subject, t.object].filter( - (nm) => nodeByName.has(nm) && !cur.nodeNames.has(nm) && !anySegHas(segments, cur, nm), - ); - // 放得下?(含新增這條邊 + 新引入節點) - if (cur.triplets.length > 0 && segCost(cur, 1, cand.length) > budget) { - startSeg(); - } - cur.triplets.push(t); - for (const nm of [t.subject, t.object]) { - if (nodeByName.has(nm) && !anySegHas(segments, null, nm)) cur.nodeNames.add(nm); - } - } - - const multi = segments.length > 1; - const envelopes = segments.map((seg, i) => { - const anchor = multi ? `seg${String(i + 1).padStart(2, '0')}` : undefined; - const uri = multi ? `${baseUri}#${anchor}` : baseUri; - const nodes = [...seg.nodeNames].map((nm) => { - const n = nodeByName.get(nm); - const out = { name: n.name }; - if (n.gloss) out.gloss = n.gloss; - if (n.aliases && n.aliases.length) out.aliases = n.aliases; - out.embed = true; - return out; - }); - const source = { uri, content_hash: contentHash }; - if (anchor) source.anchor = anchor; - if (commit) source.commit = commit; - return { - source, - extractor, - nodes, - triplets: seg.triplets.map((t) => ({ - subject: t.subject, - predicate: t.predicate, - object: t.object, - confidence: t.confidence ?? 1.0, - })), - _estSubrequests: estimateEnvelopeSubrequests(seg.triplets.length, seg.nodeNames.size), - }; - }); - - // triplets≥1 是契約硬性;無邊的卡不產 envelope(仍會建 entry)。 - return envelopes.filter((e) => e.triplets.length >= 1); -} - -function anySegHas(segments, exclude, name) { - for (const s of segments) { - if (s === exclude) continue; - if (s.nodeNames.has(name)) return true; - } - return false; -} - -/** 一張卡片 → 完整 ingest 計畫(entry + envelopes)。planCard = parseCard + planEnvelopes。 */ -export function planCard(md, relPath, repo = 'Leo/notes', opts = {}) { - const parsed = parseCard(md, relPath, repo); - const envelopes = planEnvelopes(parsed, opts); - return { entry: parsed.entry, envelopes, meta: parsed.meta, nodeCount: parsed.nodes.length, tripletCount: parsed.triplets.length }; -} diff --git a/registry/examples/km-wiki-ingest/lib/dry-run.mjs b/registry/examples/km-wiki-ingest/lib/dry-run.mjs deleted file mode 100644 index 5679533..0000000 --- a/registry/examples/km-wiki-ingest/lib/dry-run.mjs +++ /dev/null @@ -1,181 +0,0 @@ -#!/usr/bin/env node -// km-wiki-ingest 乾跑(dry-run)驗證器 — 不寫 live、不部署。 -// ------------------------------------------------------------- -// 註:解析在 live 由 workflow.yaml 的 parse_card = 通用 `code` 零件(sandbox inline JS)承載; -// 本驗證器直接 import card-to-envelope.mjs(=該 code 節點內聯 JS 的權威來源)跑同一份邏輯, -// 故 dry-run 的 envelope 結果與 code 節點在 live 的輸出等價(Arcrun#10 已單測證明逐欄全等)。 -// 對某 repo 的 system-dev/wiki/cards/**/*.md 跑機械解析 + envelope 打包, -// 輸出「將寫入什麼」:① entry 清單(page_name / entry_type / metadata.embed / content_hash) -// ② triplet envelope 清單(每段的 nodes / triplets / source.uri+anchor) -// ③ 每個 graph /triplets/ingest 呼叫的 subrequest 估算(證明壓在 CF 上限下) -// ④ 冪等鍵設計(entry=page_name+content_hash;triplet=source.uri+content_hash)。 -// -// 用法:node dry-run.mjs --repo-path [--repo Leo/notes] [--budget 40] [--json] [--full] -// --self-test 額外跑「合成超大卡」證明分段生效。 - -import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs'; -import path from 'node:path'; -import { execFileSync } from 'node:child_process'; -import { planCard, estimateEnvelopeSubrequests, SUBREQ_CEILING, SUBREQ_BUDGET } from './card-to-envelope.mjs'; - -function parseArgs(argv) { - const out = { repo: 'Leo/notes', budget: SUBREQ_BUDGET }; - for (let i = 0; i < argv.length; i++) { - const a = argv[i]; - if (a === '--repo-path') out.repoPath = argv[++i]; - else if (a === '--repo') out.repo = argv[++i]; - else if (a === '--budget') out.budget = Number(argv[++i]); - else if (a === '--json') out.json = true; - else if (a === '--full') out.full = true; - else if (a === '--self-test') out.selfTest = true; - } - return out; -} - -function walkCards(dir) { - const out = []; - if (!existsSync(dir)) return out; - for (const e of readdirSync(dir, { withFileTypes: true })) { - const p = path.join(dir, e.name); - if (e.isDirectory()) out.push(...walkCards(p)); - else if (e.name.endsWith('.md') && e.name !== '.gitkeep' && !e.name.startsWith('00-INDEX')) out.push(p); - } - return out; -} - -function gitCommit(repoPath) { - try { - return execFileSync('git', ['-C', repoPath, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); - } catch { return undefined; } -} - -const args = parseArgs(process.argv.slice(2)); -if (!args.repoPath) { - console.error('用法: node dry-run.mjs --repo-path [--repo Leo/notes] [--budget 40] [--json] [--full] [--self-test]'); - process.exit(1); -} - -const cardsRoot = path.join(args.repoPath, 'system-dev', 'wiki', 'cards'); -const cardPaths = walkCards(cardsRoot); -const commit = gitCommit(args.repoPath); - -const report = { repo: args.repo, commit, budget: args.budget, ceiling: SUBREQ_CEILING, cards: [], totals: {} }; -let totEntries = 0, totEnvelopes = 0, totTriplets = 0, totNodes = 0, maxSub = 0, over = 0; - -for (const p of cardPaths) { - const rel = path.relative(args.repoPath, p); - const md = readFileSync(p, 'utf8'); - const plan = planCard(md, rel, args.repo, { budget: args.budget, commit }); - totEntries++; - totEnvelopes += plan.envelopes.length; - const cardTriplets = plan.envelopes.reduce((s, e) => s + e.triplets.length, 0); - const cardNodes = plan.envelopes.reduce((s, e) => s + e.nodes.length, 0); - totTriplets += cardTriplets; - totNodes += cardNodes; - for (const e of plan.envelopes) { - maxSub = Math.max(maxSub, e._estSubrequests); - if (e._estSubrequests > SUBREQ_CEILING) over++; - } - report.cards.push({ - relPath: rel, - canonical: plan.meta.canonical, - entry: { - page_name: plan.entry.page_name, - entry_type: plan.entry.entry_type, - 'metadata.embed': plan.entry.metadata.embed, - content_hash: plan.entry.metadata.content_hash.slice(0, 12) + '…', - content_bytes: Buffer.byteLength(plan.entry.content, 'utf8'), - tags: plan.entry.tags, - }, - envelopeCount: plan.envelopes.length, - envelopes: plan.envelopes.map((e) => ({ - 'source.uri': e.source.uri, - 'source.anchor': e.source.anchor ?? null, - nodes: e.nodes.length, - triplets: e.triplets.length, - est_subrequests: e._estSubrequests, - under_ceiling: e._estSubrequests <= SUBREQ_CEILING, - sample_triplets: e.triplets.slice(0, args.full ? 999 : 4).map((t) => `${t.subject} >> ${t.predicate} >> ${t.object} (${t.confidence})`), - sample_nodes: e.nodes.slice(0, args.full ? 999 : 4).map((n) => `${n.name}${n.gloss ? ' — ' + n.gloss.slice(0, 30) + '…' : ''}`), - })), - }); -} - -report.totals = { - cards: totEntries, - entries_to_upsert: totEntries, - triplet_envelopes: totEnvelopes, - total_triplets: totTriplets, - total_nodes: totNodes, - max_est_subrequests_single_call: maxSub, - ceiling: SUBREQ_CEILING, - budget: args.budget, - any_envelope_over_ceiling: over, -}; - -// --- self-test:合成一張「超大卡」(20 邊 + 22 節點)證明單 envelope 會爆、分段後每段壓在預算下 --- -if (args.selfTest) { - const entities = []; - const edges = []; - for (let i = 0; i < 22; i++) entities.push(`- **實體${i}**(別名${i})— 這是實體 ${i} 的一句描述。`); - for (let i = 0; i < 20; i++) edges.push(`- 實體${i} >> 關聯到 >> 實體${i + 1}`); - const bigCard = `---\ntags: [壓測]\ngloss: 合成超大卡,測分段。\n---\n# 合成超大卡\n\n← [[notes/00-INDEX]]\n\n## 實體\n${entities.join('\n')}\n\n## 關聯\n### 內文知識關係\n${edges.join('\n')}\n`; - const plan = planCard(bigCard, 'system-dev/wiki/cards/notes/合成超大卡.md', args.repo, { budget: args.budget }); - const single = estimateEnvelopeSubrequests(plan.tripletCount, plan.nodeCount); - report.self_test = { - note: '合成 20 邊 / 22 節點 的超大卡', - if_single_envelope_est_subrequests: single, - would_crash_single: single > SUBREQ_CEILING, - segmented_into: plan.envelopes.length, - per_segment: plan.envelopes.map((e) => ({ - uri: e.source.uri, anchor: e.source.anchor, triplets: e.triplets.length, nodes: e.nodes.length, est_subrequests: e._estSubrequests, under_ceiling: e._estSubrequests <= SUBREQ_CEILING, - })), - all_segments_under_ceiling: plan.envelopes.every((e) => e._estSubrequests <= SUBREQ_CEILING), - }; -} - -if (args.json) { - console.log(JSON.stringify(report, null, 2)); - process.exit(0); -} - -// --- 人類可讀輸出 --- -const L = (s = '') => console.log(s); -L(`\n================ km-wiki-ingest DRY-RUN(不寫 live)================`); -L(`repo=${report.repo} commit=${(commit || '(none)').slice(0, 12)} budget=${args.budget} CF_ceiling=${SUBREQ_CEILING}`); -L(`卡片來源根:${path.relative(args.repoPath, cardsRoot)} 找到 ${cardPaths.length} 張卡\n`); - -for (const c of report.cards) { - L(`── 卡片:${c.relPath}`); - L(` ENTRY(base POST /entries 或 kbdb_upsert_block,冪等鍵 page_name):`); - L(` page_name = ${c.entry.page_name}`); - L(` entry_type = ${c.entry.entry_type}`); - L(` metadata.embed= ${c.entry['metadata.embed']} content_hash=${c.entry.content_hash} bytes=${c.entry.content_bytes}`); - L(` tags = ${JSON.stringify(c.entry.tags)}`); - L(` TRIPLET ENVELOPE(s)(POST graph /triplets/ingest;分段數=${c.envelopeCount}):`); - for (const e of c.envelopes) { - L(` • uri=${e['source.uri']}${e['source.anchor'] ? ' anchor=' + e['source.anchor'] : ''}`); - L(` nodes=${e.nodes} triplets=${e.triplets} est_subrequests=${e.est_subrequests} ≤ceiling? ${e.under_ceiling ? 'YES' : 'NO ⚠️'}`); - for (const t of e.sample_triplets) L(` - ${t}`); - if (e.sample_nodes.length) L(` nodes: ${e.sample_nodes.join(' | ')}`); - } - L(''); -} - -L(`================ 彙總 ================`); -for (const [k, v] of Object.entries(report.totals)) L(` ${k.padEnd(34)} = ${v}`); -L(` 冪等設計:`); -L(` entry → page_name(穩定鍵)+ metadata.content_hash(比對是否改動 → 未改 skip、改動 PATCH 重嵌)`); -L(` triplet → source.uri + source.content_hash(graph 現役 per-source 冪等;同 hash 整包 no-op)`); -L(` 分段 → 各段獨立 source.uri(#segNN)→ 各自獨立冪等,繞開 per-source content_hash 整包 skip`); - -if (report.self_test) { - L(`\n================ SELF-TEST:超大檔分段 ================`); - const st = report.self_test; - L(` ${st.note}`); - L(` 若不分段(單 envelope)估算 subrequest = ${st.if_single_envelope_est_subrequests} → 會炸? ${st.would_crash_single ? 'YES(> ' + SUBREQ_CEILING + ')' : 'no'}`); - L(` 分段後段數 = ${st.segmented_into},每段:`); - for (const s of st.per_segment) L(` - ${s.anchor}: triplets=${s.triplets} nodes=${s.nodes} est=${s.est_subrequests} ≤ceiling? ${s.under_ceiling ? 'YES' : 'NO ⚠️'}`); - L(` 全部段壓在上限下? ${st.all_segments_under_ceiling ? 'YES ✅' : 'NO ⚠️'}`); -} -L(''); diff --git a/registry/examples/km-wiki-ingest/tags.json b/registry/examples/km-wiki-ingest/tags.json deleted file mode 100644 index ec4f038..0000000 --- a/registry/examples/km-wiki-ingest/tags.json +++ /dev/null @@ -1 +0,0 @@ -["ingest", "kbdb", "wiki", "mechanical", "cron", "webhook", "graph", "triplet", "no-llm"] diff --git a/registry/examples/km-wiki-ingest/workflow.yaml b/registry/examples/km-wiki-ingest/workflow.yaml deleted file mode 100644 index 9bbdd6c..0000000 --- a/registry/examples/km-wiki-ingest/workflow.yaml +++ /dev/null @@ -1,458 +0,0 @@ -name: km_wiki_ingest_drain -description: > - Phase 0 限速 drain:cron 每 tick 只處理「一張卡」→ 機械解析成 entry + triplet envelope - → 冪等寫 KBDB(base entry / graph triplet)。反覆跑直到全庫 drain 完。 - 來源=repo 的 system-dev/wiki/cards/**/*.md(人工精耕卡,非裸筆記,無 LLM)。 - 解析由通用 code 零件(sandbox inline JS)承載,不再鑄 domain 零件(Arcrun#10 裁定)。 - 穩態(Gitea push webhook 只處理 delta)見檔尾 §穩態變體。 - -# ── 為什麼「一 tick 一卡」=根治 07_01 的 Too many subrequests ── -# graph worker 處理一次 POST /triplets/ingest 的 subrequest = 7 + 4*N_triplets + M_nodes + D_deprecated。 -# 07_01 炸點:單一 envelope 吞整檔 N=11,M=10 → 61 > 50(CF bundled 上限)→ 半殘。 -# 對策:① 一卡一 tick(天然小批,notes 卡 ~N4/M5 → est 28~33,穩壓 50 下) -# ② code 節點的內聯解析會自動把超大卡以 source_uri anchor 分段(每段獨立冪等)。 -# ⟹ 任何單一 graph 呼叫都不會再破頂。 - -flow: - - "watch_cron >> ON_SUCCESS >> pick_next_card" - - "pick_next_card >> ON_SUCCESS >> fetch_card" - - "fetch_card >> ON_SUCCESS >> parse_card" - - "parse_card >> ON_SUCCESS >> upsert_entry" # 卡片 → base entry(embed=true),冪等 - - "upsert_entry >> ON_SUCCESS >> post_envelopes" # wikilink/typed-edge → graph triplet - - "post_envelopes >> 對每個 envelope >> post_one_envelope" # 分段時多段,各段獨立冪等 - -config: - # 1) 排程 tick:慢推。每 2 分鐘一張卡=限速(Phase 0 唯一需要 rate-limit 之處)。 - watch_cron: - component: cron - cron_expr: "*/2 * * * *" - description: "每 2 分鐘 drain 一張卡(限速慢推,避免 CF 額度與 subrequest 壓力)" - - # 2) 取下一張待處理卡(cursor drain)。用 Gitea contents API 列 cards 目錄 + 一個游標 block - # 記「處理到哪」。回傳單一 { rel_path, download_url, content_hash?(git blob sha) }。 - # 註:list + cursor 的細節可用 http_request(Gitea API) + set/string_ops 組;此處給語意佔位。 - pick_next_card: - component: http_request - method: GET - url: "https://git.uncle6.me/api/v1/repos/{{repo}}/contents/system-dev/wiki/cards?ref={{ref}}" - headers: - Authorization: "token {{credential.gitea_token}}" - Accept: "application/json" - # 下游用 filter/set 取「游標之後第一張、且 .md、且非 00-INDEX」的一張。 - - # 3) 抓卡片全文(Gitea raw)。 - fetch_card: - component: http_request - method: GET - url: "{{pick_next_card.next.download_url}}" - headers: - Authorization: "token {{credential.gitea_token}}" - - # 4) ★ 機械解析 —— 通用 code 零件(sandbox inline JS,無 LLM、無 fs/網路,stdin→stdout JSON)。 - # Arcrun#10 裁定:一次性解析邏輯走通用逃生口,不再鑄 domain 零件 km_wiki_card_parse。 - # 下面 code: 內聯的即 lib/card-to-envelope.mjs 的 planCard 邏輯(去 import/export、 - # raw NUL 分隔符改 \u0000 escape、改用 code 沙箱注入的 curated builtin sha256; - # 已單測證明與原模組輸出逐欄全等)。 - # input:卡片全文 md + 相對路徑 relPath + repo + opts.budget(subrequest 目標上限)。 - # output:{ success:true, data:{ entry, envelopes[], meta, nodeCount, tripletCount } } - # —— envelope 已分段、已估 subrequest。故下游改引用 parse_card.data.*。 - parse_card: - component: code - code: | - // 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 移除:code 沙箱提供注入的 sha256 builtin) - // --- 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 分段」。 - const SUBREQ_CEILING = 50; // CF 單次 Worker invocation subrequest 硬上限(bundled) - const SUBREQ_BUDGET = 40; // 我們的目標上限(留 10 給 D_deprecated 等變動) - - /** 精確估算「一個 envelope 打進 graph /triplets/ingest」會在 graph worker 內產生幾個 subrequest。 */ - function estimateEnvelopeSubrequests(nTriplets, mNodes, dDeprecated = 0) { - return 7 + 4 * nTriplets + mNodes + dDeprecated; - } - - // --- sha256(content_hash 冪等鍵)--- - // (sha256 移除:使用 code 沙箱注入的 curated builtin sha256) - - // --- 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:以檔名(去副檔名)為準,對齊 `## 卡片關係` 用的 [[基名]] 慣例 --- - 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'。 - */ - 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}\u0000${e.object}`), - ); - const seenRef = new Set(); - const refEdges = []; - for (const target of extractInlineWikilinks(md)) { - const t = stripWikilink(target); - if (t === canonical || t === title) continue; // 自環 - if (typedPairs.has(`${canonical}\u0000${t}`)) continue; // 已有明確謂詞邊 - const key = `${canonical}\u0000${t}`; - if (seenRef.has(key)) continue; - seenRef.add(key); - refEdges.push({ subject: canonical, predicate: '連結至', object: t, confidence: 0.5 }); - } - - const triplets = [...intraEdges, ...cardEdges, ...refEdges]; - - // 3) 節點清單:卡片本身(canonical,帶 frontmatter gloss)+ 內文實體。 - // 卡對卡邊指到的「別張卡」不在此補 node —— 那張卡自己被 ingest 時會補自己的 node。 - const nodes = []; - const seenNode = new Set(); - const pushNode = (n) => { - const k = n.name.toLowerCase(); - if (!n.name || seenNode.has(k)) return; - seenNode.add(k); - nodes.push(n); - }; - pushNode({ name: canonical, gloss: fm.gloss || '', aliases: title && title !== canonical ? [title] : [] }); - for (const e of entities) pushNode({ name: e.name, gloss: e.gloss, aliases: e.aliases }); - - return { - entry: { - // base POST /entries(或 kbdb_upsert_block)用。metadata.embed=true → 語意可搜。 - page_name: `wikicard:${repo}/${canonical}`, // idempotency key(穩定) - entry_type: 'wiki_card', - content: md, // 卡片全文逐字(embed 對象) - tags: Array.isArray(fm.tags) ? fm.tags : [], - metadata: { - embed: true, // ★ base embed 模組讀此旗標 - source: `gitea:${repo}@${relPath}`, - content_hash: sha256(md), - kind: 'wiki_card', - repo, - canonical, - pipeline_candidate: fm.pipeline_candidate === true, - }, - }, - nodes, - triplets, - meta: { canonical, title, relPath, repo, contentHash: sha256(md) }, - }; - } - - /** - * 把一張卡片的 (nodes, triplets) 打包成「一個或多個」ingest envelope, - * 使每個 envelope 打進 graph 後的 subrequest 都 ≤ SUBREQ_BUDGET。 - * - * 分段規則(對應頂層 SDD R4 / issue #8 第4點): - * - 單 envelope 夠塞(7+4N+M ≤ budget)→ 不分段,uri = 基 uri(無 anchor)。 - * - 需分段 → 每段 uri = `<基uri>#seg{NN}`、anchor = `seg{NN}`。 - * 每段是「獨立 source_uri」→ 各自獨立冪等,繞開 graph 的 per-source content_hash 整包 skip - * (否則同 uri 第 2 段起會被 line 65 的 content_hash 命中而整包跳過)。 - * - 節點只放進「第一個引用到它的段」,跨段不重送(避免 graph persistNodes 重建 entity record)。 - */ - function planEnvelopes(parsed, opts = {}) { - const budget = opts.budget ?? SUBREQ_BUDGET; - const repo = parsed.meta.repo; - const relPath = parsed.meta.relPath; - const baseUri = `gitea:${repo}@${relPath}`; - const contentHash = parsed.meta.contentHash; - const commit = opts.commit; - - const extractor = { - model: opts.extractorModel ?? 'mechanical/km-wiki-card-parse@1', - tier: 'deep', // 人工精耕卡=deep(決定性、非淺萃) - extracted_at: Math.floor(Date.now() / 1000), - }; - - const nodeByName = new Map(parsed.nodes.map((n) => [n.name, n])); - - // 貪婪打包:逐條 triplet 累進,段成本 = 7 + 4*(段內邊數) + (段內首見節點數)。 - const segments = []; - let cur = null; - const startSeg = () => { - cur = { triplets: [], nodeNames: new Set() }; - segments.push(cur); - }; - const segCost = (seg, extraEdges = 0, extraNodes = 0) => - estimateEnvelopeSubrequests(seg.triplets.length + extraEdges, seg.nodeNames.size + extraNodes); - - startSeg(); - for (const t of parsed.triplets) { - // 這條邊會新引入哪些節點(subject/object 命中 nodeByName 且本段尚未收) - const cand = [t.subject, t.object].filter( - (nm) => nodeByName.has(nm) && !cur.nodeNames.has(nm) && !anySegHas(segments, cur, nm), - ); - // 放得下?(含新增這條邊 + 新引入節點) - if (cur.triplets.length > 0 && segCost(cur, 1, cand.length) > budget) { - startSeg(); - } - cur.triplets.push(t); - for (const nm of [t.subject, t.object]) { - if (nodeByName.has(nm) && !anySegHas(segments, null, nm)) cur.nodeNames.add(nm); - } - } - - const multi = segments.length > 1; - const envelopes = segments.map((seg, i) => { - const anchor = multi ? `seg${String(i + 1).padStart(2, '0')}` : undefined; - const uri = multi ? `${baseUri}#${anchor}` : baseUri; - const nodes = [...seg.nodeNames].map((nm) => { - const n = nodeByName.get(nm); - const out = { name: n.name }; - if (n.gloss) out.gloss = n.gloss; - if (n.aliases && n.aliases.length) out.aliases = n.aliases; - out.embed = true; - return out; - }); - const source = { uri, content_hash: contentHash }; - if (anchor) source.anchor = anchor; - if (commit) source.commit = commit; - return { - source, - extractor, - nodes, - triplets: seg.triplets.map((t) => ({ - subject: t.subject, - predicate: t.predicate, - object: t.object, - confidence: t.confidence ?? 1.0, - })), - _estSubrequests: estimateEnvelopeSubrequests(seg.triplets.length, seg.nodeNames.size), - }; - }); - - // triplets≥1 是契約硬性;無邊的卡不產 envelope(仍會建 entry)。 - return envelopes.filter((e) => e.triplets.length >= 1); - } - - function anySegHas(segments, exclude, name) { - for (const s of segments) { - if (s === exclude) continue; - if (s.nodeNames.has(name)) return true; - } - return false; - } - - /** 一張卡片 → 完整 ingest 計畫(entry + envelopes)。planCard = parseCard + planEnvelopes。 */ - function planCard(md, relPath, repo = 'Leo/notes', opts = {}) { - const parsed = parseCard(md, relPath, repo); - const envelopes = planEnvelopes(parsed, opts); - return { entry: parsed.entry, envelopes, meta: parsed.meta, nodeCount: parsed.nodes.length, tripletCount: parsed.triplets.length }; - } - - // graph /triplets/ingest 是 strict schema,拒絕未知鍵。planEnvelopes 於 envelope 上掛的 - // 診斷鍵 _estSubrequests 不在 ingest-candidate 契約內 → post 前剝除所有 _-前綴鍵, - // 讓 post_one_envelope 的 body_json={{envelope}} 為契約乾淨 payload。 - // (leo21c 實測:不剝除會回 422 unrecognized_keys "_estSubrequests"。) - const __plan = planCard(input.md, input.relPath, input.repo, input.opts || {}); - __plan.envelopes = __plan.envelopes.map(function (e) { - const clean = {}; - for (const k in e) { if (k.charAt(0) !== '_') clean[k] = e[k]; } - return clean; - }); - return __plan; - - input: - md: "{{fetch_card.data.body}}" - relPath: "{{pick_next_card.next.rel_path}}" - repo: "{{repo}}" - opts: - budget: 40 # subrequest 目標上限(留 10 給 D_deprecated),超過自動 anchor 分段 - limits: - timeout_ms: 3000 # 純 CPU 解析;大卡也充裕 - max_output_bytes: 4194304 # envelope 陣列可能較大(4 MiB) - - # 5) 卡片 → base entry,冪等 upsert(page_name 當鍵;找到 PATCH、沒有 POST)。 - # metadata.embed=true → base embed 模組會補嵌 → 語意可搜。 - upsert_entry: - component: kbdb_upsert_block - api_key: "{{credential.arcrun_namespace}}" - kbdb_url: "{{kbdb_url}}" - page_name: "{{parse_card.data.entry.page_name}}" - type: "{{parse_card.data.entry.entry_type}}" - content: "{{parse_card.data.entry.content}}" - source: "{{parse_card.data.entry.metadata.source}}" - tags_json: "{{parse_card.data.entry.tags_json}}" - # ⚠️ metadata.embed=true / content_hash 需經 base /entries 帶 metadata_json 落地; - # 若 kbdb_upsert_block 尚未透傳 metadata_json,改用 http_request 直打 base POST/PATCH /entries - # 帶 body_json.metadata_json(見 description.md §entry 冪等)。 - - # 6) triplet envelope(可能多段)→ 逐段 POST graph /triplets/ingest。 - # graph 端 per-source(uri+content_hash) 冪等:同 hash 整包 no-op;分段各段 uri 不同 → 各自獨立冪等。 - post_envelopes: - component: foreach_control - items: "{{parse_card.data.envelopes}}" - item_key: envelope - - post_one_envelope: - component: http_request - method: POST - url: "{{graph_url}}/triplets/ingest" - headers: - Content-Type: "application/json" - X-Arcrun-API-Key: "{{credential.arcrun_namespace}}" - body_json: "{{envelope}}" # envelope 已符合 ingest-candidate.json 契約(禁止欄位已排除) - -# ── 執行環境變數(部署時注入;此檔不放密鑰)── -# repo=Leo/notes ref=main gitea_token= -# kbdb_url=https://arcrun-kbdb.leo21c.workers.dev kbdb_api_key= -# graph_url= graph_api_key=leo -# -# ═══════════════════════════════════════════════════════════════════════════ -# §穩態變體(km_wiki_ingest_delta):Gitea push webhook → 只處理 delta 檔 -# ═══════════════════════════════════════════════════════════════════════════ -# 觸發 = Gitea repo Settings → Webhooks → 指向 arcrun(cypher-executor) 的 workflow webhook URL。 -# ⚠️ 這是 Gitea → Cloudflare(arcrun),非 GitHub Actions → 不觸 GitHub flag 紅線(D4/D20)。 -# 只把上面 flow 的 watch_cron/pick_next_card 換成: -# input(webhook payload)>> ON_SUCCESS >> collect_changed -# collect_changed = 從 payload.commits[].{added,modified} 濾出 system-dev/wiki/cards/**/*.md -# >> foreach 檔 >> fetch_card >> parse_card >> upsert_entry >> post_envelopes(同上) -# 量小、天生不撞頂、不需限速;靠 graph/entry 冪等自動 skip 未變檔。 diff --git a/registry/skills/INDEX.md b/registry/skills/INDEX.md new file mode 100644 index 0000000..6aad70c --- /dev/null +++ b/registry/skills/INDEX.md @@ -0,0 +1,64 @@ +# Skill: INDEX(Arcrun 導航:什麼問題查哪裡) + +> **這支的定位=LLM wiki 的 `INDEX.md`**(leo 2026-07-30 點破: +> 「整個 arcrun instruction 很像 LLM wiki…它會拿到一個 index 把所有文件說明都塞給它, +> 它不會就可以查,範本全部寫在裡面」)。 +> +> **push vs pull**(照 wiki 的分法): +> - **push**=MCP 連線時的 `instructions`(AI 一定看到,不看就出事的三句) +> - **pull**=本檔。AI 卡住時 `arcrun_get_skill('INDEX')` 拿到全館導航 + +--- + +## 一、我現在該查哪個?(照症狀找) + +| 你的處境 | 用這個 | 工具 | +|---|---|---| +| **要開始寫 workflow,但不知道有什麼零件** | `write_intent_workflow` | `arcrun_get_skill('write_intent_workflow')` | +| 想「每 X 分鐘掃 Y,找到就處理」 | `build_watcher_workflow` | `arcrun_get_skill('build_watcher_workflow')` | +| 要做檢索問答(RAG) | `rag_with_arcrun` | `arcrun_get_skill('rag_with_arcrun')` | +| workflow 卡住不動/paused | `debug_paused_workflow` | `arcrun_get_skill('debug_paused_workflow')` | +| 想把 http 呼叫改成觸發別的 workflow | `migrate_http_to_trigger_workflow` | 同上 | +| **缺某個外部 API 的 recipe**(查詢回 not_found 指 recipe 路)| `write_recipe` | `arcrun_get_skill('write_recipe')` | +| **真的需要新零件**(罕見)| `add_new_wasm_component` | 同上 ⚠️ 先確認工作流做不到 | + +## 二、我要查「有沒有現成的東西」 + +| 找什麼 | 工具 | 注意 | +|---|---|---| +| 有哪些**零件** | `arcrun_list_components()` / `arcrun_search_components('自然語言')` | 零件=能力(`http_request`/`code`/`if_control`…)| +| 有哪些 **recipe** | `arcrun_recipe_list()` / `arcrun_recipe_search('...')` | recipe=打某個 API 的配方(`telegram_send`/`kbdb_get`…)| +| 有哪些**跑過的 workflow** | `arcrun_list_workflows()` / `arcrun_search_workflows('...')` | **這些是最可靠的範本**(實跑過)| +| 某個 workflow 的完整定義 | `arcrun_get_workflow(name)` | 拿來照抄結構 | +| 執行紀錄/為什麼失敗 | `arcrun_list_recent_executions()` / `arcrun_get_execution_trace(id)` | | + +⚠️ **零件 vs recipe 分不清會寫錯**: +`telegram_send`/`gmail`/`kbdb_get` 是 **recipe 不是零件**。 +它們要寫成 `http_request` + 該 recipe。 + +## 三、已知的坑(不看會踩) + +| 坑 | 現況 | 怎麼避 | +|---|---|---| +| **`/cypher/search` 曾回假 `found`** | 2026-07-31 已修:兩庫(零件+recipe)都查,缺件回 `not_found`+`suggestion` 指路。舊實例(未更新部署)仍是假 found | 拿到 `not_found` 照 `suggestion` 走;拿到 `unknown`=查不到 registry ≠ 不存在 | +| ~~引擎沒有條件分支~~ **已解(2026-08-01)** | 引擎支援 `ON_TRUE`/`ON_FALSE`/`ON_BRANCH`;`if_control`/`switch`/`try_catch` 都輸出 `data.branch` 標籤,引擎依標籤選路(Arcrun#5 根治)| **需要判斷就用分支邊,別寫 code 判斷**。查零件的回應附 `branch_hint`(邊型+可照抄範例),照著接 | +| **`registry/examples/` 8/13 是壞的** | 引用不存在的零件(把 recipe 當零件寫)| **別照抄 examples**,改用 `arcrun_get_workflow` 拿實跑過的 | +| **registry 可能是空的** | 安裝器無註冊步驟 ⇒ 新實例查不到零件 | 查不到 ≠ 不存在,別據此改寫成 code | + +## 四、最重要的一條紅線 + +🔴 **查不到零件就改寫成 `code` 節點=「腹語術」**(表面用 Arcrun、實際全寫 JS)。 + +- 缺 API → **寫 recipe**(`arcrun_recipe_push`) +- 缺能力 → **投稿零件 PR**(要人類確認,見 `add_new_wasm_component`) +- `code` 只用於**局部整形**(例:剝掉 LLM 回應的雜訊、切段落) + +> 實錄:2026-07-30 盤點發現正式 workflow 只用 2 個零件,8 個 code 節點含 if×61 for×23, +> 最大一個 5509 字元——**每一個 if 都是沒被測過的新 bug**。 +> 零件的價值是「被測過 1000 次」,寫進 code 就歸零。 + +## 五、還是不會? + +- `arcrun_list_skills()` — 看全部 skill +- `arcrun_get_gui_context()` — 看目前實例的狀態 +- `arcrun_report_feedback()` — **回報這裡沒寫清楚的地方**(這份 INDEX 該被你的問題改進) diff --git a/registry/skills/write_intent_workflow.md b/registry/skills/write_intent_workflow.md new file mode 100644 index 0000000..6806ae6 --- /dev/null +++ b/registry/skills/write_intent_workflow.md @@ -0,0 +1,192 @@ +# Skill: Write Intent Workflow(寫意圖工作流) + +## 何時用這個 skill + +**用戶說「幫我用 Arcrun 做 X」時,第一個讀這支。** 其他 skill 都是它的下游。 + +- 「幫我用 Arcrun 寫一個…」 +- 「用 Arcrun 做 X」 +- 你要在 Arcrun 上做任何事,但不知道有哪些零件可用 + +> **你不需要知道有哪些零件。** 先把意圖寫出來丟去查,系統會告訴你哪些存在、哪些不存在。 + +## 核心 pattern + +``` +input >> ON_SUCCESS >> <第一步> >> ... → 丟 /cypher/search → 系統回哪些零件存在 +``` + +--- + +## 1. 意圖工作流的語法 + +一串「誰接誰」,每行一個關係: + +``` +<節點A> >> <邊> >> <節點B> +``` + +- **節點**=一個步驟。用你想得到的名字(中文可以),**不必是真實零件名** +- **邊**=什麼情況下往下走 + +## 2. 邊有這些 + +| 邊 | 意思 | 真例 | +|---|---|---| +| `ON_SUCCESS` | 上一步成功就往下 | `input >> ON_SUCCESS >> prep` | +| `對每個 <變數>` | 上一步產出清單,逐項處理(FOREACH)| `parse_card >> 對每個 block >> post_block` | +| `ON_TRUE` / `ON_FALSE` | 條件成立/不成立各走一條(配 `if_control`)| `判斷有沒有新資料 >> ON_TRUE >> 傳到 telegram` | +| `ON_BRANCH`+`branch:` | 依標籤選路(配 `switch` 每個 case、`try_catch` 的 try/catch)| `my_switch >> ON_BRANCH(branch_active) >> 處理啟用` | + +### 2.1 條件分支怎麼寫(2026-08-01 起引擎支援) + +**需要判斷時,用分支邊,不要寫 `code` 判斷。** +三顆流程控制零件都輸出 `data.branch` 標籤,引擎依標籤選路: + +| 零件 | 輸出的標籤 | 接法 | +|---|---|---| +| `if_control` | `"true"` / `"false"` | `ON_TRUE`/`ON_FALSE` 各一條 | +| `switch` | 你在 `cases[].branch` 取的名字(沒中則 `default_branch`)| 每條路一條 `ON_BRANCH`,邊上標 `branch` | +| `try_catch` | `"try"`(沒錯)/`"catch"`(有錯)| 兩條 `ON_BRANCH`,標 `try` 與 `catch` | + +``` +判斷有沒有新資料 >> ON_TRUE >> 傳到 telegram +判斷有沒有新資料 >> ON_FALSE >> 結束 +``` +中文語意詞亦可:「成立時」=`ON_TRUE`、「否則」=`ON_FALSE`。 + +💡 **不必背**:查零件時回應會附 `branch_hint`(有哪些標籤、用哪些邊型、可照抄的範例), +照著接就對了。 + +⚠️ 仍然**不要寫 `ON_FAILURE`**(沒有這種邊;要處理失敗用 `try_catch` + `ON_BRANCH(catch)`)。 + +### 2.2 怎麼確認分支真的走對了(**別看不懂就以為壞掉**) + +分支工作流「有沒有成功」看兩件事,**不是看某條沒走的路沒有輸出**: + +1. **`verdict`**:`GET /workflows//executions?limit=1` + → `data.executions[0].verdict === "success"` 就是成功了。 +2. **`trace` 裡有沒有出現該走的節點**:走 TRUE 路時 FALSE 路的節點**本來就不該出現** + ——**那是正確行為,不是失敗**。 + +``` +# 條件成立 → 只有 true 那條的節點在 trace +{"amount": 5000} → if_control 回 branch="true" → 走 ON_TRUE 那條 +{"amount": 100} → if_control 回 branch="false" → 走 ON_FALSE 那條 +``` + +🔴 **實撞(2026-08-01 考試)**:有考生的分支工作流**其實完全正常** +(`amount=5000`→true、`amount=100`→false 都對),但它以為「跑不通」而放棄改寫成 code。 +**看到只有一條路有輸出=分支正在正確運作**,不要因此判定失敗。 + +## 3. 第一個節點固定是 `input` + +所有真範本都以 `input` 起頭——那是「觸發時帶進來的資料」。 + +--- + +## 4. 真範本(照抄結構、改內容) + +> 以下四份**全部是實際部署且 `verdict=success` 的 workflow**,不是簡化示範。 +> 用 `arcrun_get_workflow()` 可以拿完整定義。 + +### A. 最短:取資料 → 處理 (`graph_neighbors`) +``` +input >> ON_SUCCESS >> fetch_triplets +fetch_triplets >> ON_SUCCESS >> bfs_neighbors +``` + +### B. 長鏈:多次查詢 → 組裝 → 問 AI → 收尾 (`rag_chat`) +``` +input >> ON_SUCCESS >> prep +prep >> ON_SUCCESS >> kw_search +kw_search >> ON_SUCCESS >> sem_search +sem_search >> ON_SUCCESS >> fetch_triplets +fetch_triplets >> ON_SUCCESS >> fetch_blocks_a +fetch_blocks_a >> ON_SUCCESS >> assemble +assemble >> ON_SUCCESS >> ask_llm +ask_llm >> ON_SUCCESS >> finalize +``` +`prep` 前處理/`assemble` 組 prompt/`finalize` 收拾回應——三個常見的整形節點。 + +### C. 一節點分岔兩條 FOREACH (`rag_ingest_card`) +``` +input >> ON_SUCCESS >> parse_card +parse_card >> 對每個 block >> post_block +parse_card >> 對每個 rel >> post_triplet +``` +同一節點可有多條出邊,各自處理不同清單。 + +### D. 混合:直線 + 兩段 FOREACH (`rag_takedown_direct`) +``` +input >> ON_SUCCESS >> prep +prep >> ON_SUCCESS >> list_dead_blocks +list_dead_blocks >> ON_SUCCESS >> build_deprecations +build_deprecations >> 對每個 dead_entry >> deprecate_entry +build_deprecations >> ON_SUCCESS >> list_triplets +list_triplets >> ON_SUCCESS >> pick_dead_triplets +pick_dead_triplets >> 對每個 dead_record >> deprecate_triplet +``` +`build_deprecations` 同時有 FOREACH 出邊與 `ON_SUCCESS` 出邊—— +前者處理清單、後者繼續主線。 + +--- + +## 5. 節點怎麼命名(照真範本的模式,查詢較容易媒合) + +| 意圖 | 模式 | 真例 | +|---|---|---| +| 前處理/正規化 | `prep` | `rag_chat.prep` | +| 取一批資料 | `fetch_*`/`list_*` | `fetch_triplets`/`list_dead_blocks` | +| 搜尋 | `*_search` | `kw_search`/`sem_search` | +| 解析/切塊 | `parse_*` | `parse_card` | +| 寫入 | `post_*` | `post_block`/`post_triplet` | +| 組裝 | `assemble`/`build_*` | `assemble`/`build_deprecations` | +| 問 AI | `ask_llm` | `rag_chat.ask_llm` | +| 收尾整形 | `finalize` | `rag_chat.finalize` | + +--- + +## 6. 寫完一定要查(**不要直接部署**) + +```bash +curl -s -X POST https://arcrun-cypher-executor..workers.dev/cypher/search \ + -H 'content-type: application/json' -H 'X-Arcrun-API-Key: ' \ + -d '{"triplets":["input >> ON_SUCCESS >> fetch_data","fetch_data >> ON_SUCCESS >> notify"]}' +``` + +回應的每個節點會有: + +| status | 意思 | 你該做什麼 | +|---|---|---| +| `found` | 有這個節點。`source: component` 附 `input_schema`(怎麼填 payload)與 `success_rate`;`source: recipe` 附 description/endpoint | **只填 payload** | +| `not_found` | **兩庫(零件 registry+recipe 庫)都查過,確定沒有** | 照 `suggestion` 欄走:缺 API → 寫 recipe(skill `write_recipe`);缺計算能力 → 投稿零件 PR(skill `add_new_wasm_component`)。`similar_components`/`similar_recipes` 是相近候選——先看有沒有現成的能直接用 | +| `unknown` | 查不到 registry | **不代表不存在**,別據此改寫成 code | + +> 註(2026-07-31):`/cypher/search` 曾對任何節點名都回假 `found`,已修為真查兩庫。 +> 舊實例(未更新部署)仍可能假 found——status 可信度以該實例部署版本為準。 + +--- + +## 7. 常犯的錯 + +1. **用不存在的邊**(`ON_FAILURE`)→ 沒有這種邊;要處理失敗用 `try_catch` + `ON_BRANCH(catch)` + ⚠️ `ON_TRUE`/`ON_FALSE`/`ON_BRANCH` **是存在的**(2026-08-01 起),見 §2.1—— + 本行以前寫「ON_TRUE 不存在」是舊世代,已更正 +2. **第一個節點不是 `input`** +3. **把 recipe 當零件寫**——`telegram_send`/`gmail`/`kbdb_get` 是 **recipe** 不是零件 + → 寫成 `http_request` + 該 recipe +4. 🔴 **查詢回 `not_found` 就改寫成 `code` 節點** + → 那叫「腹語術」(表面用 Arcrun、實際全寫 JS)。正解:缺 API 寫 recipe、缺能力投稿零件。 + `code` 只用在**局部整形**(例:剝掉 LLM 回應的雜訊),不用來取代零件與流程控制。 + +--- + +## 8. 相關 + +- 完整版指引與十題考卷(含 haiku 實測 10/10): + 頂層 repo `system-dev/docs/3-specs/arcrun-usable/` +- 下一步該讀哪支 skill:`arcrun_list_skills()` + - 定期掃資料 → `build_watcher_workflow` + - RAG 檢索問答 → `rag_with_arcrun` + - workflow 卡住不動 → `debug_paused_workflow` diff --git a/registry/skills/write_recipe.md b/registry/skills/write_recipe.md new file mode 100644 index 0000000..cfa28d9 --- /dev/null +++ b/registry/skills/write_recipe.md @@ -0,0 +1,137 @@ +# Skill: Write Recipe(寫 recipe) + +## 何時用這個 skill + +**`/cypher/search` 回 `not_found` 且 `suggestion` 指「寫 recipe」時,讀這支。** + +- 要打某個外部 API(Google Slides / Slack / 任何有 HTTP API 的服務),但 `arcrun_recipe_list()` 沒有 +- `acr recipe search <關鍵字>` 落空,回「公庫無符合的 recipe」 +- 你想把一條常用的 API 呼叫封裝成可重用、可投稿的配方 + +> 🔴 **不要因為沒有 recipe 就改寫成 `code` 節點**——那是「腹語術」。 +> recipe 不用改平台、不用部署 Worker、不用寫程式,**幾行 YAML 就能自己補上**。 + +## recipe 是什麼 + +**「http_request + 參數模板」的具名封裝**(真身存在 cypher-executor 的 RECIPES KV)。 +執行時 cypher-executor 直接 fetch 該 endpoint——不 deploy Worker、不寫 WASM。 + +| 你缺的是… | 走哪條路 | +|---|---| +| 打**外部 API**(服務有 HTTP API) | **recipe(本 skill)** | +| **計算能力**(加解密/壓縮這類純運算) | 零件 PR → `arcrun_get_skill('add_new_wasm_component')` | + +## 1. Recipe 的欄位(schema) + +```yaml +canonical_id: google_slides_create # 必填。小寫底線,全庫唯一的可讀名 +endpoint: https://slides.googleapis.com/v1/presentations # 必填。要打的 URL +method: POST # 選填,預設 POST(GET/PUT/PATCH/DELETE 皆可) +display_name: Google Slides Create # 選填,人看的名字 +description: 建立一份新簡報。POST presentations,body 帶 title。auth: google service_account。 +auth_service: google_slides_sa # 選填。指向 auth recipe(見第 3 節) +headers: {} # 選填。額外固定 header +body: {} # 選填。固定 body 欄位(會與節點 payload 合併) +``` + +- `hash_id`(`rec_xxxxxxxx`)與 `uuid` 由系統自動生成,不用寫。 +- **description 認真寫**:AI(包括未來的你)靠它決定要不要用這個 recipe。 + 照庫裡的慣例寫「做什麼。怎麼打。auth 用什麼。」三段。 + +## 2. 真範例(從實際 seed 且實跑過的 recipe 照抄) + +### A. token 在 URL path(`telegram_send`) + +```yaml +canonical_id: telegram_send +display_name: Telegram Send +description: Telegram sendMessage。token 在 URL path({{auth.bot_token}}),body 帶 chat_id+text。auth: static_key path 注入。 +endpoint: https://api.telegram.org/bot{{auth.bot_token}}/sendMessage +method: POST +auth_service: telegram +``` + +### B. Bearer/service account(`gmail_send`) + +```yaml +canonical_id: gmail_send +display_name: Gmail Send +description: 寄 Gmail。POST messages/send,body 帶 raw(base64url MIME)。auth: google service_account。 +endpoint: https://gmail.googleapis.com/gmail/v1/users/me/messages/send +method: POST +auth_service: google_gmail_sa +``` + +### 模板變數(endpoint 裡可用) + +| 變數 | 來源 | 例 | +|---|---|---| +| `{{auth.X}}` | auth recipe 的 `inject.path` 注入 | `bot{{auth.bot_token}}/sendMessage` | +| `{{_path}}` | 節點 payload 的 `_path` 欄位(路徑由工作流決定時用) | `https://sheets.googleapis.com{{_path}}` | + +🔴 **金鑰只准名字,不准真身**:endpoint/headers 裡**絕不**寫死 token。 +真值走 credential 中心(`acr creds push`),recipe 只留 `{{auth.X}}` 這種名字引用。 + +## 3. auth 怎麼接(多數 recipe 需要) + +`auth_service: telegram` 表示執行時去拿 `auth_recipe:telegram` 做認證注入。 +先查有沒有:`acr auth-recipe list` 或 `GET /auth-recipes/`。 + +**沒有就要一併建**(`POST /auth-recipes`,缺這步 recipe 會注入空值打不通): + +```yaml +service: telegram # 必填 +primitive: static_key # 必填:static_key | oauth2 | service_account +base_url: https://api.telegram.org # 必填 +required_secrets: # 必填,每個 secret 的 help_url 也必填(官方文件連結) + - key: telegram_bot_token + label: Bot Token(從 @BotFather 取得) + help_url: https://core.telegram.org/bots/features#botfather +inject: # 必填:secret 注入到哪(header / query / body / path) + path: + bot_token: "{{secret.telegram_bot_token}}" +``` + +然後用戶端上傳真值:`acr creds push`(存進 credential 中心,AI 拿不到真身)。 + +## 4. 裝上(三個介面同一個 API) + +```bash +acr recipe push my_recipe.yaml # CLI:push 時會實打 endpoint 做打通檢查 +``` + +- MCP:`arcrun_recipe_push(...)` +- HTTP:`POST /recipes`(JSON,欄位同上) + +裝好後 workflow 直接引用(節點名=canonical_id,或 config 指定): + +```yaml +config: + notify: + component: telegram_send # 或穩定引用 rec_xxxxxxxx +``` + +payload(如 `chat_id`、`text`)由上游節點或 context 給——這正是「AI 只填 payload」。 + +## 5. 驗收(誠實原則) + +1. push 時的**打通檢查**只是提醒級——真驗收=**跑一次 workflow、`verdict=success`(2xx)** +2. 缺 credential 打不到 2xx → 誠實標「未驗收:缺 X」,**不 mock 充綠燈** +3. 打通後可投稿公庫讓別人用:`acr recipe submit-p `(成為該 recipe 的作者版本) + +## 6. 常犯的錯 + +1. **token 寫死在 endpoint/headers** → 金鑰鐵律違規。只准 `{{auth.X}}` 名字引用 +2. **method 用猜的** → 查官方文件。實錄:Sheets append 被猜成 PUT(官方是 POST `:append`), + seed 到壞 recipe 每個新用戶都打 400 +3. **只建 recipe 忘了 auth recipe** → `{{auth.X}}` 注入空值打不通。實錄:telegram auth recipe + 漏進種子,所有新實例的 telegram 發訊全斷 +4. **落空就寫 code 節點** → 腹語術。recipe 是正路,且寫好可投稿讓全生態重用 +5. **canonical_id 用大寫/空白** → 一律小寫底線(系統會 trim+lowercase,但別靠它救) + +## 7. 相關 + +- 寫意圖工作流(上游,先寫意圖再查缺什麼):`arcrun_get_skill('write_intent_workflow')` +- 缺的是計算零件不是 API:`arcrun_get_skill('add_new_wasm_component')` +- 查公庫現貨:`acr recipe search <關鍵字>` / `arcrun_recipe_search(...)` +- 拉別人寫好的:`acr recipe pull [--author=]` diff --git a/registry/src/actions/queryComponents.ts b/registry/src/actions/queryComponents.ts index a830568..5ec8dbe 100644 --- a/registry/src/actions/queryComponents.ts +++ b/registry/src/actions/queryComponents.ts @@ -22,6 +22,10 @@ export interface ComponentRecord { call_count: number; wasm_r2_key?: string; score: number; + // 零件合約的 I/O schema。KV 記錄一直有存(indexOnlyComponent 寫入), + // 過去查詢層把它丟掉 ⇒ /cypher/search 給不出「怎麼填 payload」——2026-07-31 補透傳。 + input_schema?: Record; + output_schema?: Record; } // ── id 解析:支援 hash_id 和 canonical_id 兩種格式 ────────────────────────── @@ -133,7 +137,8 @@ export async function searchComponents( // ── 內部工具函數 ────────────────────────────────────────────────────────────── -function computeScore(v: Record): number { +// export:recordAnalytics 選「最優版本」要與 getComponent 同判準(單一評分真相) +export function computeScore(v: Record): number { const successRate = parseFloat(String(v.success_rate ?? '1')); const avgDuration = parseFloat(String(v.avg_duration_ms ?? '10')); const callCount = parseInt(String(v.call_count ?? '0'), 10); @@ -141,7 +146,7 @@ function computeScore(v: Record): number { return successRate * speedScore * Math.log(callCount + 2); } -function toComponentRecord(v: Record): ComponentRecord { +export function toComponentRecord(v: Record): ComponentRecord { return { component_hash_id: String(v.component_hash_id ?? ''), canonical_id: String(v.canonical_id ?? ''), @@ -158,5 +163,11 @@ function toComponentRecord(v: Record): ComponentRecord { call_count: parseInt(String(v.call_count ?? '0'), 10), wasm_r2_key: v.wasm_r2_key ? String(v.wasm_r2_key) : undefined, score: computeScore(v), + input_schema: isPlainObject(v.input_schema) ? v.input_schema : undefined, + output_schema: isPlainObject(v.output_schema) ? v.output_schema : undefined, }; } + +function isPlainObject(x: unknown): x is Record { + return typeof x === 'object' && x !== null && !Array.isArray(x); +} diff --git a/registry/src/actions/recordAnalytics.ts b/registry/src/actions/recordAnalytics.ts new file mode 100644 index 0000000..63d6969 --- /dev/null +++ b/registry/src/actions/recordAnalytics.ts @@ -0,0 +1,128 @@ +// recordAnalytics — 零件執行結果回寫(POST /analytics/record 的實作) +// SDD: system-dev/docs/3-specs/arcrun-core-mvp/design.md「執行統計設計」 +// +// 真相源=ANALYTICS_KV 計數器(key = stats:{hash_id}:{version},見 src/types.ts 註記): +// { total_runs, success_runs, total_ms } +// 讀取端(queryComponents / GET /components/*)讀的是 comp: 記錄上的 +// success_rate / avg_duration_ms / call_count 欄位——所以每次記錄後把衍生值 +// 回填 comp: 記錄(唯一寫入者是本函式,衍生值不是第二個真相源)。 +// +// 尺度註記:design.md:499 寫 success_rate = success_runs / total_runs * 100(百分比顯示)。 +// 本 repo comp: 記錄的 success_rate 既有尺度是 0..1(預設 1,computeScore 直接相乘), +// 為不破壞既有讀取端與未測零件的預設值,落庫維持 0..1;*100 只是等價的顯示換算。 +// +// 誠實限制:CF KV 無 CAS/原子遞增,read-modify-write 併發下偶有丟計數; +// 統計用途可接受(design 的「樂觀鎖」在 KV 上做不到,不假裝做到了)。 + +import type { Bindings } from '../types'; +import { computeScore } from './queryComponents'; + +export interface AnalyticsRecordInput { + canonical_id: string; // 也接受 cmp_xxxxxxxx hash_id + version?: string; // 不給 → 記到查詢會回的最優版本(getComponent 同判準) + success: boolean; + duration_ms: number; +} + +export interface AnalyticsRecordResult { + ok: boolean; + error?: string; + canonical_id?: string; + version?: string; + total_runs?: number; + success_runs?: number; + success_rate?: number; // 0..1,與 comp: 記錄同尺度 + avg_duration_ms?: number; +} + +interface StatsCounters { + total_runs: number; + success_runs: number; + total_ms: number; +} + +export async function recordAnalytics( + input: AnalyticsRecordInput, + env: Bindings, +): Promise { + // 1. 解析 hash_id(與 queryComponents.resolveHashId 同規則) + const hashId = input.canonical_id.startsWith('cmp_') + ? input.canonical_id + : await env.SUBMISSIONS_KV.get(`idx:${input.canonical_id}`); + if (!hashId) { + return { ok: false, error: `零件 ${input.canonical_id} 不在索引` }; + } + + // 2. 找目標版本記錄:指定 version 就用它;沒指定 → 最優版本(與 getComponent 同判準:score 最高) + const list = await env.SUBMISSIONS_KV.list({ prefix: `comp:${hashId}:` }); + let targetKey: string | null = null; + let targetRecord: Record | null = null; + let bestScore = -Infinity; + + for (const key of list.keys) { + const raw = await env.SUBMISSIONS_KV.get(key.name); + if (!raw) continue; + let v: Record; + try { v = JSON.parse(raw); } catch { continue; } + if (v.status === 'tombstone') continue; + + if (input.version) { + if (String(v.version) === input.version) { + targetKey = key.name; + targetRecord = v; + break; + } + continue; + } + const score = computeScore(v); + if (score > bestScore) { + bestScore = score; + targetKey = key.name; + targetRecord = v; + } + } + + if (!targetKey || !targetRecord) { + return { ok: false, error: `零件 ${input.canonical_id} 無可用版本記錄` }; + } + + const version = String(targetRecord.version ?? 'v1'); + const canonicalId = String(targetRecord.canonical_id ?? input.canonical_id); + + // 3. 更新計數器(真相源,ANALYTICS_KV stats:{hash_id}:{version}) + const statsKey = `stats:${hashId}:${version}`; + let counters: StatsCounters = { total_runs: 0, success_runs: 0, total_ms: 0 }; + const rawStats = await env.ANALYTICS_KV.get(statsKey); + if (rawStats) { + try { + const parsed = JSON.parse(rawStats) as Partial; + counters = { + total_runs: Number(parsed.total_runs) || 0, + success_runs: Number(parsed.success_runs) || 0, + total_ms: Number(parsed.total_ms) || 0, + }; + } catch { /* 損毀計數器 → 重新起算 */ } + } + counters.total_runs += 1; + counters.success_runs += input.success ? 1 : 0; + counters.total_ms += Math.max(0, Number(input.duration_ms) || 0); + await env.ANALYTICS_KV.put(statsKey, JSON.stringify(counters)); + + // 4. 衍生值回填 comp: 記錄(讀取端讀的地方) + const successRate = counters.success_runs / counters.total_runs; + const avgDurationMs = Math.round(counters.total_ms / counters.total_runs); + targetRecord.success_rate = successRate; + targetRecord.avg_duration_ms = avgDurationMs; + targetRecord.call_count = counters.total_runs; + await env.SUBMISSIONS_KV.put(targetKey, JSON.stringify(targetRecord)); + + return { + ok: true, + canonical_id: canonicalId, + version, + total_runs: counters.total_runs, + success_runs: counters.success_runs, + success_rate: successRate, + avg_duration_ms: avgDurationMs, + }; +} diff --git a/registry/src/index.ts b/registry/src/index.ts index a967f1b..c4cdb6f 100644 --- a/registry/src/index.ts +++ b/registry/src/index.ts @@ -9,6 +9,7 @@ import validateContractRoute from './routes/validateContract'; import componentsRoute from './routes/components'; import queryRoute from './routes/query'; import initRoute from './routes/init'; +import analyticsRoute from './routes/analytics'; const app = new Hono<{ Bindings: Bindings }>(); app.use('*', cors()); @@ -25,4 +26,7 @@ app.route('/components', componentsRoute); // POST /components // === 初始化端點(建立 tpl-component template)=== app.route('/init', initRoute); +// === 執行統計回寫(cypher-executor 執行收尾 fire-and-forget 打進來)=== +app.route('/analytics', analyticsRoute); // POST /analytics/record + export default app; diff --git a/registry/src/routes/analytics.ts b/registry/src/routes/analytics.ts new file mode 100644 index 0000000..5dcbec4 --- /dev/null +++ b/registry/src/routes/analytics.ts @@ -0,0 +1,37 @@ +// POST /analytics/record — 零件執行結果回寫端點 +// SDD: system-dev/docs/3-specs/arcrun-core-mvp/design.md「執行統計設計」 +// 呼叫方:cypher-executor 執行收尾(fire-and-forget,統計失敗不影響執行) + +import { Hono } from 'hono'; +import type { Bindings } from '../types'; +import { recordAnalytics } from '../actions/recordAnalytics'; + +const app = new Hono<{ Bindings: Bindings }>(); + +app.post('/record', async c => { + const body = await c.req.json().catch(() => null) as { + canonical_id?: unknown; + version?: unknown; + success?: unknown; + duration_ms?: unknown; + } | null; + + if (!body || typeof body.canonical_id !== 'string' || body.canonical_id.trim() === '') { + return c.json({ ok: false, error: 'canonical_id 必填' }, 400); + } + if (typeof body.success !== 'boolean') { + return c.json({ ok: false, error: 'success 必須為 boolean' }, 400); + } + + const result = await recordAnalytics({ + canonical_id: body.canonical_id.trim(), + version: typeof body.version === 'string' && body.version !== '' ? body.version : undefined, + success: body.success, + duration_ms: typeof body.duration_ms === 'number' ? body.duration_ms : 0, + }, c.env); + + if (!result.ok) return c.json(result, 404); + return c.json(result); +}); + +export default app; diff --git a/registry/src/routes/query.ts b/registry/src/routes/query.ts index 7cb628b..81103fd 100644 --- a/registry/src/routes/query.ts +++ b/registry/src/routes/query.ts @@ -5,10 +5,34 @@ import { Hono } from 'hono'; import type { Bindings } from '../types'; -import { getComponent, getComponentVersions, searchComponents } from '../actions/queryComponents'; +import { getComponent, getComponentVersions, searchComponents, toComponentRecord } from '../actions/queryComponents'; +import type { ComponentRecord } from '../actions/queryComponents'; const app = new Hono<{ Bindings: Bindings }>(); +// 全清單(t158 批次化):/cypher/search discover 一次抓走整份目錄, +// 節點存在判定+相似度全在 cypher 記憶體內比對——取代「每個 missing 節點 +// 各打 1+8 次查詢」的疊爆模式(冷實例 8 節點實測 25.7s 的病根)。 +// 也補上 CP2-B 記載的「registry 沒有列表端點」缺口。 +// 必須在 /:id 之前,避免 "catalog" 被當作 id。 +app.get('/catalog', async c => { + const list = await c.env.SUBMISSIONS_KV.list({ prefix: 'comp:' }); + const seen = new Set(); + const components: ComponentRecord[] = []; + for (const key of list.keys) { + const raw = await c.env.SUBMISSIONS_KV.get(key.name); + if (!raw) continue; + let v: Record; + try { v = JSON.parse(raw) as Record; } catch { continue; } + if (v.status === 'tombstone' || v.visibility !== 'public') continue; + const dedup = `${String(v.component_hash_id ?? '')}:${String(v.version ?? '')}`; + if (seen.has(dedup)) continue; + seen.add(dedup); + components.push(toComponentRecord(v)); + } + return c.json({ success: true, data: { components, count: components.length } }); +}); + // 語意搜尋(必須在 /:id 之前,避免 "search" 被當作 id) app.get('/search', async c => { const q = c.req.query('q'); diff --git a/registry/tests/recordAnalytics.test.ts b/registry/tests/recordAnalytics.test.ts new file mode 100644 index 0000000..7f75f40 --- /dev/null +++ b/registry/tests/recordAnalytics.test.ts @@ -0,0 +1,99 @@ +// 單元測試:recordAnalytics — 執行統計回寫 +// SDD: system-dev/docs/3-specs/arcrun-core-mvp/design.md「執行統計設計」 + +import { describe, it, expect, beforeEach } from 'vitest'; +import { recordAnalytics } from '../src/actions/recordAnalytics'; +import type { Bindings } from '../src/types'; + +// 最小 KV mock(get/put/list,In-memory) +function makeKv() { + const store = new Map(); + return { + store, + async get(key: string) { return store.get(key) ?? null; }, + async put(key: string, value: string) { store.set(key, value); }, + async list({ prefix }: { prefix: string }) { + return { keys: [...store.keys()].filter(k => k.startsWith(prefix)).map(name => ({ name })) }; + }, + }; +} + +describe('recordAnalytics', () => { + let submissions: ReturnType; + let analytics: ReturnType; + let env: Bindings; + + beforeEach(() => { + submissions = makeKv(); + analytics = makeKv(); + env = { SUBMISSIONS_KV: submissions, ANALYTICS_KV: analytics } as unknown as Bindings; + + // 種一顆零件(indexOnlyComponent 的記錄形狀) + submissions.store.set('idx:http_request', 'cmp_abc12345'); + submissions.store.set('comp:cmp_abc12345:v1', JSON.stringify({ + component_hash_id: 'cmp_abc12345', + canonical_id: 'http_request', + display_name: 'HTTP Request', + version: 'v1', + success_rate: 1, + avg_duration_ms: 0, + call_count: 0, + visibility: 'public', + status: 'active', + })); + }); + + it('N 次成功+M 次失敗 → success_rate = success_runs / total_runs,計數器與 comp 記錄同步', async () => { + // 3 成功 + 2 失敗 + for (const success of [true, true, true, false, false]) { + const r = await recordAnalytics({ canonical_id: 'http_request', success, duration_ms: 100 }, env); + expect(r.ok).toBe(true); + } + + // 真相源:ANALYTICS_KV 計數器 + const counters = JSON.parse(analytics.store.get('stats:cmp_abc12345:v1')!); + expect(counters).toEqual({ total_runs: 5, success_runs: 3, total_ms: 500 }); + + // 讀取端:comp 記錄被回填衍生值 + const record = JSON.parse(submissions.store.get('comp:cmp_abc12345:v1')!); + expect(record.success_rate).toBeCloseTo(3 / 5); + expect(record.avg_duration_ms).toBe(100); + expect(record.call_count).toBe(5); + }); + + it('接受 cmp_ hash_id 直接記錄', async () => { + const r = await recordAnalytics({ canonical_id: 'cmp_abc12345', success: true, duration_ms: 50 }, env); + expect(r.ok).toBe(true); + expect(r.canonical_id).toBe('http_request'); + expect(r.total_runs).toBe(1); + }); + + it('指定 version 時記到該版本', async () => { + submissions.store.set('comp:cmp_abc12345:v2', JSON.stringify({ + component_hash_id: 'cmp_abc12345', + canonical_id: 'http_request', + version: 'v2', + success_rate: 1, avg_duration_ms: 0, call_count: 0, + visibility: 'public', status: 'active', + })); + const r = await recordAnalytics({ canonical_id: 'http_request', version: 'v2', success: false, duration_ms: 30 }, env); + expect(r.ok).toBe(true); + expect(r.version).toBe('v2'); + expect(analytics.store.has('stats:cmp_abc12345:v2')).toBe(true); + expect(analytics.store.has('stats:cmp_abc12345:v1')).toBe(false); + }); + + it('不在索引的零件回 ok:false(誠實 404,不假綠)', async () => { + const r = await recordAnalytics({ canonical_id: 'no_such_component', success: true, duration_ms: 1 }, env); + expect(r.ok).toBe(false); + expect(r.error).toContain('不在索引'); + }); + + it('tombstone 版本不被記錄', async () => { + submissions.store.set('comp:cmp_abc12345:v1', JSON.stringify({ + component_hash_id: 'cmp_abc12345', canonical_id: 'http_request', version: 'v1', status: 'tombstone', + })); + const r = await recordAnalytics({ canonical_id: 'http_request', success: true, duration_ms: 1 }, env); + expect(r.ok).toBe(false); + }); +}); diff --git a/system-dev/docs/3-specs/arcrun-core-mvp/tasks.md b/system-dev/docs/3-specs/arcrun-core-mvp/tasks.md index 2ae606c..93fd37a 100644 --- a/system-dev/docs/3-specs/arcrun-core-mvp/tasks.md +++ b/system-dev/docs/3-specs/arcrun-core-mvp/tasks.md @@ -171,9 +171,13 @@ - [x] 28.3 `/webhooks/:token/trigger` 路由已補上 `waitUntil(writeExecutionVerdict(...))` - _Requirements: 7.2_ -- [ ] 29. registry Worker analytics 端點 - - [ ] 29.1 新增 `POST /analytics/record` 路由,原子更新 `ANALYTICS_KV` - - [ ] 29.2 `GET /components` 回傳加入 `total_runs`、`success_rate`、`avg_duration_ms` +- [x] 29. registry Worker analytics 端點(2026-07-31,CP arcrun-usable 步驟 6) + - [x] 29.1 新增 `POST /analytics/record` 路由,更新 `ANALYTICS_KV` 計數器(`stats:{hash_id}:{version}`) + - 註:KV 無 CAS,「原子更新」在 KV 上做不到——read-modify-write,併發偶有丟計數,統計用途可接受(誠實限制) + - 衍生值(success_rate 0..1/avg_duration_ms/call_count)回填 `comp:` 記錄(查詢讀取端),計數器是唯一真相源 + - [x] 29.2 `GET /components/:id`、`/components/search` 回傳的 `success_rate`、`avg_duration_ms`、`call_count` 隨執行更新(欄位既存,本次讓它有真資料) + - [x] 29.3 cypher-executor 執行收尾對用到的**每顆零件**回寫(`execution-evaluator.ts` 從 stub 改真實作;`/cypher/execute` 與 webhook 路徑都掛,waitUntil fire-and-forget;成敗判定=trace error 或 output.success===false) + - 實測(本地 wrangler dev ×2,registry 8788+cypher 8787+REGISTRY_BASE_URL 指本地):同一零件 http_request 跑 5 次(3 成功+2 失敗 404)→ `GET /components/http_request` success_rate 1→0.6、call_count 0→5;`/cypher/search` 節點回 `success_rate: 0.6` - _Requirements: 7.3, 7.6_ - [x] 30. `author` 欄位已加入 contract.yaml 規格 diff --git a/system-dev/docs/3-specs/pending-changes.md b/system-dev/docs/3-specs/pending-changes.md index f806c65..837601e 100644 --- a/system-dev/docs/3-specs/pending-changes.md +++ b/system-dev/docs/3-specs/pending-changes.md @@ -8,6 +8,103 @@ ## 待裁決 +### P2|fan-out 並行執行(一個節點的多條出邊目前是循序跑)— 2026-08-03 + +**觸發**:leo 08-03 原話——「這是在測試中的計畫,**希望體驗很好**,我發現用 gemma4 的反應非常慢。」 +把 rag_chat 的生成端換成 Workers AI 之後(16.87 s → 2.2 s),一量才發現 +**慢的大頭根本不是 LLM**。 + +**實測(1.4.4 實例,逐段疊加、每段取兩次的較快值)** +``` +① prep(code) 208 ms +② +kw_search 1,383 ms (+1,175) +③ +sem_search 3,240 ms (+1,857) +④ +fetch_triplets 5,198 ms (+1,958) +⑤ +fetch_blocks a/b/c 7,793 ms (+2,595) +⑥ +assemble(code) 8,421 ms (+628) + +ask_llm(Workers AI) ≈10,400 ms (+2,000) +``` +⇒ **6 個 KBDB 檢索節點合計約 7.6 s,佔全鏈 73%;LLM 只佔 20%。** +而這 6 個節點**彼此完全獨立**(kw/sem/triplets/blocks×3 誰都不吃誰的輸出), +只是因為 flow 被寫成一條鏈才一個接一個跑。 + +**根因在引擎,不在 workflow**(`cypher-executor/src/graph-executor.ts`): +- 起點節點**已經是並行**的(`:85` `Promise.all(startNodes.map(...))`) +- fan-in **已經支援**(`:78-84` 入度 >1 的節點等所有上游到齊) +- 但**出邊是循序的**(`:427` `for (const edge of outEdges)`,迴圈內 `await executeNode`) + ⇒ 一個節點分岔出 6 條 `ON_SUCCESS`,是一條跑完才跑下一條。 +⇒ **改 workflow 解不了**:把 6 個節點都掛在 prep 底下,只是把「鏈」變成「6 條循序出邊」,一樣慢。 + +**提案(擇一,我建議 A)** + +- **A. 出邊並行 + 沿用既有 fan-in**:把 `:427` 那個迴圈改成 + 「同型別、無條件分支的出邊」用 `Promise.all` 併發,其餘(`ON_TRUE`/`ON_FALSE`/`ON_BRANCH`/`ON_FAIL`) + 維持原樣。rag_chat 的 6 個檢索節點改掛在 prep 底下、匯流進 assemble(入度 6,fan-in 現成) + ⇒ 預估 **7.6 s → 約 2 s**,全鏈約 **10.4 s → 4.5 s**。 + ⚠️ **這是引擎核心、風險最高**(照本 repo 自己的規矩:先寫測試再改)。 + 需要先講清楚的語意:多條邊並行時 `result` 怎麼合併(現行是「後一條覆蓋前一條」的隱含語意)、 + 任一條失敗時的行為、trace 的順序。**既有 workflow 行為必須零變化,且要跑一次證明。** + +- **B. 只砍節點數(完全不動引擎)**:`kw_search`/`sem_search` 在 v2 計分裡只是小加分 + (kw 命中 +2、sem 前十 +0.4,主導權在 IDF 字面重疊)⇒ 拿掉這兩個節點可省約 3 s。 + 代價是**檢索品質**(少了兩個佐證訊號),屬產品取捨,不是我能裁的。 + +- **C. 不做**:接受目前的 10 s。 + +**影響分析** +- 現行 active SDD `workflow-discovery`:A 屬引擎能力,與 3.9(`ON_TRUE`/`ON_FALSE` 走邊)同一塊, + 是**新增任務**,不作廢任何既有任務。 +- B 只動 `arcrun-rag/workflows/rag-chat.local.yaml`,不碰本 repo。 +- 三者都**不影響**已完成的 3.12(recipe 三層)與本次 Workers AI 換源。 + +**⏸ 停在這裡等 leo 裁**:回「A」我就先寫測試再改引擎;回「B」我只改 workflow;回「C」就記著不做。 + +### P1|host fn 二進位通道(解開「零件無法處理非文字檔」的框架級限制)— 2026-07-27 + +**觸發**:leo 07-27 原話(arcrun-rag t73 收尾時)—— +> 「**解析各種檔案的零件還是要開發的,要記下來**,雖然跟現在無關, +> 是因為**這次我用他本機來跑閃掉了**。」 + +**現況=框架級硬牆(2026-07-27 實測,非推論)**: +Arcrun 的 host function 邊界是 **UTF-8 文字通道 + 64 KB 上限** +(`outBuf` 64KB/`res.text()`/host fn 內 `TextDecoder`)。後果: +1. **零件拿不到二進位** ⇒ PDF/docx/圖片這類檔案,**做不出解析零件**。 +2. 雲端 workflow 的 `fetch_raw` 走同一顆 `http_request` ⇒ 同樣拿不到。 +3. 連純文字都受 64KB 限:15 頁 PDF 抽出約 40KB 尚可,**25 頁以上會爆** + =「轉好了卻進不去」的靜默失敗。 + +**arcrun-rag 目前怎麼過關的(要講清楚,免得被誤會已解決)**: +把轉檔搬到 **daemon 本機**(`collector/convert.go` 調度層+PDFium-via-wazero)—— +**這是繞過(work around),不是修好**。leo 的原話「用他本機來跑閃掉了」講的就是這件事。 + +**為什麼仍要做(不擋當前工作,但別遺忘)**: +- **能力分裂**:現在只有「裝了 daemon 的人」有轉檔能力;**純雲端用戶永遠沒有**。 +- **地端/雲端兩版格式支援會分岔**,長期維護兩套。 +- 64KB 上限是**既有**限制,`.md` 今天就會撞到,PDF 只是讓它更快浮現。 + +**變更範圍(提案,待 leo confirm 才動)**: +① host fn 加二進位/base64 通道(或 ArrayBuffer 傳遞) +② 放大或分塊化 `outBuf`(串流/分頁讀取,避免單次 64KB 天花板) +③ 之上才談「各格式解析零件」 + +**影響分析**: +- **不受影響**:現行 active SDD `portal-auth` 的所有任務(不同層)。 +- **不擋**:arcrun-rag t73 本機轉檔層照做(已 commit `74d18ff`)。 +- **牽動**:`http_request` 零件、code 零件的 host fn 契約 ⇒ 屬**全人共用框架**改動, + 影響所有既有零件,**必須 leo 拍板**(跨專案結構決策)。 + +**現在不做的理由**:leo 明說「跟現在無關」+客戶在等 daemon;本條僅**立案留底**, +避免「本機能跑了」被後人誤讀成「這個限制已解決」。 + +## 已裁決 + +### ✅ confirmed 2026-07-24|CP2-F cypher 二~四刀拆分(leo 授權總管技術裁決) + +> **裁決依據**:leo 2026-07-24 原話「不影響現在 CP 的話就排下一個,這個你是需要判斷的,不能是我,完全技術問題」。 +> **總管裁決**:confirm。排程=A4 全通+t21/t24 合併重部之後(與在途不撞)。 +> **四題**:①開工時點=同上 ②刀④驗證=**手寫**(瘦身工程不再引依賴;兩鏈手寫+測試)③新 worker 命名=**arcrun-api** ④CP2-F 記載更正已由總管代辦。 +> 開工時照 D35 ④ 先開新 SDD 搬任務再寫 code。 + ### P-2026-07-24:CP2-F 第二~四刀——執行引擎獨立成精簡 worker(cypher 瘦身收官) > 提案人:總管交辦之 arcrun 偵察 subagent(唯讀分析,未動 code、未部署)。 @@ -224,9 +321,6 @@ Actions 因防 flag 鐵律被整個刪除後(commit `037cf9b`),**寫入端 2. **C2**:`get_component_guide` / `publish_component` 要「預設不掛載」還是「留著但改描述」?(品味/方向) 3. **C3**:零件是否真的已拆到另一個 repo?(leo 記憶待證實) 4. **B3 語意搜尋**:四類描述灌進 Vectorize 會產生 embedding 呼叫成本,確認可行?(花錢) - -## 已裁決 - ### P-2026-07-19:artifact-sharing 增補「App(bundle)+實例譜系+訂閱更新+多源分享」 > 狀態:**confirmed 2026-07-19**(leo「好的可走」)。四個拍板點總管採建議值(leo 可翻案):①排序=Phase 1.5 緊接 Phase 1、在 KV 退休(#16/#17)前後皆可並行 ②訂閱預設 policy=notify ③側載標記第一波=列表標記即可 ④P6 第一波=官方源+直連一個外源,org 私區留第二波。tasks 增補見 artifact-sharing/tasks.md「Phase 1.5」段。 @@ -329,4 +423,67 @@ workflow/recipe/template 的實體在平台(CF/KBDB),不在 YAML——YAML 3. 側載標記的呈現強度:只列表標記,或 console 加「無譜系 workflow」提醒區塊? 4. P6 多源第一波範圍:只做「官方源+直連一個外源」?org 私區(visibility=org)是否留第二波? +--- +## [proposal] 兩個真缺口要進 SDD(2026-07-30,總管提,等 leo confirm) + +**來源**:頂層 CP `critical-paths/arcrun-usable.md`(leo confirm 開工)逐步查證後, +發現兩件事**SDD 完全沒有**,而它們是「AI 不必寫 code」的前提。 + +leo 的判準(2026-07-30): +> 「如果在這裡編任務,不就是廢掉了原本的 SDD,那到底要照 SDD 做事還是照 CP?」 +⇒ **照 SDD 做事、照 CP 排順序**。CP 不得自帶任務 ⇒ 這兩件必須進 SDD 才能做。 + +### 缺口 ①:引擎缺條件邊(`ON_TRUE`/`ON_FALSE`)→ 建議進 `arcrun-core-mvp` + +- **實測**:`if_control` 的 output 是 `{result: boolean, branch: "true"|"false"}`, + 但 `cypher-executor/src` 全域 grep `ON_TRUE|ON_FALSE` = **0**(圖的邊只有 `ON_SUCCESS`) +- **後果**:AI 照規矩用 `if_control` 也只拿到布林值,**還是得寫 code 判斷該走哪裡** + ⇒ 這是「為什麼 workflow 全變成 code」的根(8 個 code 節點含 if×61) +- **佐證**:`kbdb_upsert_block` 契約自述「解 arcrun workflow **缺 IF/branch 能力**的缺口 + (arcrun.md P1 #1)」;Gitea **Arcrun#5**「if_control 無法真擋分支,filter+FOREACH 才是真閘」 + 於 **2026-07-04** 就發現,至今未修 +- **SDD 查證**:`grep "ON_TRUE|ON_FALSE|條件邊|分支"` 於全部 SDD → 7 命中**全是別義** + (「部署分支」「git 分支」),**條件邊本身完全沒設計過** +- **影響面**:改圖 schema + `graph-executor` 走邊邏輯=引擎核心,風險最高 + ⇒ 建議排在「查詢誠實」「節點替換」驗過之後 + +### 缺口 ②:recipe 缺 payload 與回應處理層 → 建議進 `recipe-system` + +- **實測**:recipe schema 只有 `{canonical_id, endpoint, method, auth_service}` + (線上 `GET /recipes/telegram_send` 實查) +- **後果**:`telegram_send` 的 description 明寫「body 帶 chat_id+text」但**schema 存不住 body** + ⇒ 任何要帶 body 的 API 都只能繞過 recipe、把 URL/header/body 整包寫進 workflow + ⇒ Gemini 就是這樣寫死的(`ask_llm` 三層全塌進節點) +- **要補三層** + - `body_template`(payload) + - `response_map`(回應正規化:取值路徑/思考型模型旗標/淨化規則) + ——現在這段是 workflow 裡 2786 字元的 code(`finalize`) + - `auth` 加第四型 `binding`(免金鑰;一次打開 `env.AI`/`VECTORIZE`/`BROWSER`/`QUEUE`) +- **SDD 查證**:`grep "body_template|response_map|payload"` → 17 命中**全是別的 payload** +- **不做的話**:LLM 換源永遠要改 workflow(而非換 recipe),違反「LLM 可換源」原則 + +### 誤標修正(誠實記錄) +總管原本在 CP 標了 **5 個新缺口**,逐條查 SDD 後**只有上述 2 個是真的**: +- `success_rate` 回寫 → **`arcrun-core-mvp/design.md:499` 早就設計** + (`success_rate = success_runs / total_runs * 100`、排序規則、UI 顯示),只是沒實作 +- 8 個壞範例、意圖節點替換 → 相關設計散在既有 SDD,已改為引用 + +### ⏸ 等 leo confirm +- confirm 後依 D35:兩份 SDD 目前皆 `paused`,要動需先處理單一活性 + (現行 active=`workflow-discovery`) + + +## 提案:workflow export/import 一等公民化(2026-07-31,總管代 leo 口頭定調立案) + +- **來源**:leo 07-31 原話(見 workflow-discovery tasks.md 3.9 引文)——分享 workflow + 給同事的正路=export 檔→import,不是「用 search 湊」。 +- **變更面**:① cypher `GET /webhooks/named/:name/definition`(已實作,staging 試點中, + 零新資料模型/同 list 認證)② `acr workflow export `/`acr workflow import ` + 新 CLI 命令(已寫未接線)③ 安裝器 workflows.json=export 檔同形狀(graph 預編,已上 prod)。 +- **不動的牆**:KBDB 零接觸(export 讀 WEBHOOKS KV workflow record;import 打既有 + POST /webhooks/named);「部署≠發現」(import 不驗零件存在,執行時現形); + description 必填閘照舊。 +- **影響分析**:新公開端點 1 個(租戶 key 認證,吐的是該租戶自己部署的 workflow 定義; + 無跨租戶讀);CLI 新命令 2 個(薄殼,能力全在既有 API);無 schema migration、無新金鑰面。 +- **狀態**:⏳ 待 leo confirm 後 3.9b 接線。 diff --git a/system-dev/docs/3-specs/portal-auth/design.md b/system-dev/docs/3-specs/portal-auth/design.md index deaac35..93655c5 100644 --- a/system-dev/docs/3-specs/portal-auth/design.md +++ b/system-dev/docs/3-specs/portal-auth/design.md @@ -1,6 +1,8 @@ --- -status: active -superseded_by: "" +status: closed +superseded_by: "workflow-discovery" +closed_at: "2026-07-30" +closed_reason: "26/26 任務全完成、0 未完成待搬;封測 portal 多人授權已上線" --- # portal-auth — Design(RAG Portal 多人授權) diff --git a/system-dev/docs/3-specs/workflow-discovery/design.md b/system-dev/docs/3-specs/workflow-discovery/design.md index 3e2e31d..407b58d 100644 --- a/system-dev/docs/3-specs/workflow-discovery/design.md +++ b/system-dev/docs/3-specs/workflow-discovery/design.md @@ -1,5 +1,5 @@ --- -status: paused +status: active superseded_by: "" --- diff --git a/system-dev/docs/3-specs/workflow-discovery/tasks.md b/system-dev/docs/3-specs/workflow-discovery/tasks.md index 013fd3f..e86f58f 100644 --- a/system-dev/docs/3-specs/workflow-discovery/tasks.md +++ b/system-dev/docs/3-specs/workflow-discovery/tasks.md @@ -53,6 +53,175 @@ - [ ] 6.3 issue #8 comment 回報端到端綠燈證據;由實證決定結案時機(待端到端綠才 close) - [ ] 6.4 同步更新 design.md(若實作中發現偏差)+ wiki status +## 3.x 追加(2026-07-31,頂層總管交棒;出處=leo 定義步驟 3 考題) + +> leo:「你要找的 Google Slides API 我沒有,你可以自己做。有 2 種: +> 一種是要一個**零件**(如 Crypto)但沒有→用 **PR** 產生; +> 另一種是要一個 **recipe**(如 Google Slides)但沒有→**自己寫 recipe**。」 +> 機械考題已在頂層 `arcrun-usable/verify.sh` 03 組(aes_encrypt/google_slides_create 兩題, +> 現對實例跑全紅=正確標記)。**行為綠過才准佔用 arm 部署**(頂層 mistakes 07-31 條)。 + +- [x] 3.6 recipe 納入 `/cypher/search` 存在性查詢(現只查零件 registry ⇒ 說不出「某 recipe 沒有」) + — 2026-07-31 search-nodes.ts:registry 落空後查 RECIPES KV(resolveRecipe), + recipe found 附 source:'recipe'+description+endpoint;本地實測 telegram_send found ✓ +- [x] 3.7 缺件回應分型指路:`suggestion` 欄——計算原語型→「投稿零件 PR」;外部 API 型→「自己寫 recipe」 + (欄位契約定案時同步頂層 verify.sh 03 組的 grep) + **且每筆要帶「去哪裡看」**(leo 07-31 三次定調):component 路→skill `add_new_wasm_component` + (已存在、安裝器現會 seed 進新實例);recipe 路→skill `write_recipe`(**不存在,見 3.8**) + — 2026-07-31 完成:status 契約定 `not_found`(同 verify.sh 01/03 grep);分型=服務詞/計算詞 + 簡單規則(不接 LLM,規則在 code 註解);附 similar_components/similar_recipes 相近候選 + (自然語言「判斷有沒有新資料」媒合到 if_control)。順手修二病灶:①頭節點被 resolveNodeRole + 判 Input 而免查(`aes_encrypt >> … >> code` 假 found)→ 只有字面 input/output 名才短路; + ② registry 查詢層把 KV 裡的 input_schema 丟掉 → 補透傳。本地 wrangler dev 跑頂層 + verify.sh 01+03 組 9/9 全綠(部署後要對真實例重驗) +- [x] 3.8 寫 `write_recipe` skill(registry/skills/)——「怎麼寫 recipe」的 playbook 目前是空地, + 3.7 的 recipe 指路沒有目的地;寫完納入安裝器 seed 清單(compile-skills.mjs 自動收) + — 2026-07-31 完成:內容從真 code 反推(RecipeDefinition schema/api-recipe-seeds 的 + telegram_send+gmail_send 真範例/auth-recipes 必填欄位/acr recipe push 打通檢查), + 比照 write_intent_workflow 風格;INDEX.md+write_intent_workflow.md 同步指路 not_found +- 設計基調(leo 07-31 二次定調):**回覆的重點是「缺哪些」不是「有哪些」**—— + 有的照常編圖不必報告;缺的要兩庫(零件+recipe)都搜過後點名+給正確指示。 + 驗收兩層:機械(verify 01/03)綠 → haiku 真考(只讀回覆就能說出缺什麼、該做什麼)。 + +## 3.9 追加(2026-07-31 深夜,t158 後續;leo 定調 export/import 原語) + +> leo:「你要做的就是一個叫 export,另一個是 import,打包好的幾個工作流準備好直接 +> import 就好了。現在如果我要把我做的工作流分享給同事,我要怎麼 export?他要如何 +> import?是缺了功能用 search 來湊嗎?在從前就是寫成幾個 yaml 丟過去讓新的送進 +> KBDB 不是嗎?」+「絕對不能違背原來的堅持(不能違規)」。 + +- [x] 3.9a `GET /webhooks/named/:name/definition`(export 引擎端):吐 workflow record + 原樣(graph+config+description)——與既有 list 同認證(X-Arcrun-API-Key)、 + 同租戶 key 前綴,**零新資料模型**。062757f 已入 staging bundle 試點。 + KBDB 牆自證:讀的是 WEBHOOKS KV(workflow 記錄),不碰 KBDB。 +- [ ] 3.9b `acr workflow export/import` CLI 接線(命令已寫 cli/commands/workflow.ts, + **未接進 index.ts**——D35 ③:新命令面屬規格層,已走 pending-changes 提案, + confirm 後才接)。import=graph 直 POST /webhooks/named(既有部署端點, + 零編圖零 search);手寫 yaml 指去 acr push。 +- [ ] 3.9c 安裝器同一條路驗證:workflows.json 打包期預編 graph+純上傳(rag-installer + 5a6539d 已上 prod)——安裝器不走私有路徑的機械檢查(copy-contract 級)留此收。 + +- [x] 3.10 意圖節點→真實零件/recipe 替換(CP arcrun-usable **步驟 4**;頂層交棒 t159) + — 2026-07-31 search-nodes.ts `trySubstitution`:discover 混搜兩庫 exact 落空後, + 在 t158「一次抓好的兩庫清單」**記憶體內**媒合(零新增 round-trip)。兩條保守規則 + (不接 LLM,規則在 code 註解):A 服務詞→recipe(名字裡全部服務詞命中同一 recipe + 且唯一才換;「google_slides_create」不會被 google_sheets 誤吃);B 強欄位斷詞→零件 + (canonical/display/aliases 強命中×10+description/tags 弱命中,需至少一強命中且 + 分數唯一最高;「aes_encrypt」無強命中不換)。換到=status `resolved`+`substitution` + 欄(from/componentId/recipe/reason),cypher 圖節點直接帶真實 componentId、不列 + missing;換不到照舊 not_found+3.7 指路+候選。**只動 discover**——compile(部署/ + 推送複製路徑)零替換零查詢(t158 邊界不動)。 + 驗(本地 wrangler dev,registry 種 20 合約+/init/seed 10 recipe): + 「判斷有沒有新資料 >> ON_SUCCESS >> 傳到 telegram」→ if_control(resolved)+ + telegram_send(resolved,substitution.componentId=http_request)=feature 06 驗法過; + 機械考 27/27 全綠(01 組 5+03 組 4 迴歸+06 組 8+target 8+compile 迴歸 2); + 冷啟第一發 94ms、熱 8–13ms(t158 病史對照:舊 25.7s) +- [x] 3.10 `/cypher/search` 加 `target` 指定搜尋對象(leo 07-31:「難道我不能指定要搜尋 + 工作流或節點或 recipe 嗎?」)— component/recipe/workflow: + triplets+target=component|recipe=只查該庫;query+target=名字搜尋,各走**既有** + 機制不新造(component→registry /components/search=MCP arcrun_search_components + 同一條路;recipe→私庫 RECIPES KV 同 discover 第二庫讀法,回應註明公庫走 + arcrun_recipe_search;workflow→新抽 lib/workflow-search.ts,GET /workflows/search + 與 target=workflow 共用同一 KBDB 轉發=MCP arcrun_search_workflows 同一條路)。 + 防呆:mode=compile+target → 400;target=workflow 吃 query 不吃 triplets(400 指路); + 非法 target → 400。已知缺口如實透傳:workflow 無 description 搜不到(capability_hint + 照舊),補救仍走 /workflows/backfill-search-entries。 + MCP 三分型工具盤點:search_components/search_workflows/recipe_search 均註冊活著; + 前兩者與 target 走同一條路;recipe_search 搜公庫 vs target=recipe 搜私庫=語料不同 + 是設計(installed vs marketplace),回應互相指路,非行為漂移 + +--- + +## 3.y 追加(2026-08-01,leo confirm 兩缺口進 SDD;服務 CP `arcrun-usable` 步驟 5) + +> **D35 處置(鐵律④ 判斷結果:不開新 SDD、不換 active、不搬移 paused 任務)** +> +> leo 2026-08-01 confirm 頂層 `pending-changes.md` 07-30 兩段(缺口①引擎條件邊/ +> 缺口② recipe payload 與回應處理層)。處置理由逐條: +> +> 1. **不開新 SDD**——鐵律②「CC 在任何情況下不得主動建新 SDD」。confirm 的是 +> 「規格層 proposal」,鐵律④只規定「**開新 SDD 時**」的搬移程序,並未要求每個 +> confirm 都必開新 SDD。本案能落進現行 active 就不該增生第三本。 +> 2. **不把 active 交給 arcrun-core-mvp/recipe-system**——兩本 paused 的未完成任務 +> (core-mvp 14 筆=credential 注入/auth-worker/multi-tenant KV/analytics; +> recipe-system 10 筆=prompt_recipe 的 MCP tool 與 mira wiki 端到端) +> **與本次兩缺口零交集**。升任一本為 active 就得先收掉 workflow-discovery(16 筆 +> 未完成、正在服務 CP 步驟 3/4),等於為了掛兩筆新任務把進行中的鏈打斷。 +> 3. **落在 workflow-discovery=任務層變更(鐵律②第二類)**——CP `arcrun-usable` +> 步驟 3→4→5 是**同一條有序鏈**,步驟 3(誠實查詢)、步驟 4(節點替換)的任務 +> 本來就掛在本 SDD 的 3.x;步驟 5 是同鏈的下一步,且**改的是同一顆 worker** +> (cypher-executor)。掛同一本=與既有 3.6–3.10 同源,不是新方向。 +> 4. **兩本 paused 維持 paused、未完成任務原地不動**——沒有被取代、沒有被繼承, +> 故不填 `superseded_by`、不移入 archive/。pending-changes 原提案標的 +> (缺口①→arcrun-core-mvp、缺口②→recipe-system)僅為「議題歸屬」描述, +> 非活性歸屬;實作歸屬依鐵律①走現行 active。 +> +> **作廢任務清單:無**(本次不作廢任何既有任務)。 +> **搬移任務清單:無**(不換 active,故無跨 SDD 搬移;新增下列 3.11–3.13)。 + +- [x] 3.11 **缺口①:引擎通用具名分支邊**(=Arcrun#5 根治;CP 步驟 5 交付物之一) + ✅ 08-01 本地綠(commit `323ccc8`):`ON_TRUE`/`ON_FALSE`/`ON_BRANCH` 三邊型+ + `readBranch()` 四層相容讀法(data.branch → branch → data.result → result)。 + **做成通用具名分支,非布林特例**(leo 08-01「你改了 if,有改 switch 嗎?switch 更嚴重」)—— + 三顆流程控制零件的 output_schema 本來就都收斂到 `data.branch: string` + (if_control→true/false;switch→case 名/default_branch;try_catch→try/catch) + ⇒ 引擎只需一個機制,不留「做完 if 還要為 switch 再改一次」的債。 + ON_TRUE/ON_FALSE=布林路語法糖,測試已證與 ON_BRANCH branch="true" 等價。 + **分支用法查得到**(leo 08-01 n8n 式逐顆查):新增 `lib/branch-hints.ts`, + 三顆零件的查詢回應自帶 `branch_hint`(branch_field/branches/edge_types/usage/example), + 四條回應路徑全 wire(catalog found/legacy 逐顆/步驟4 substitution/target=component)。 + 測試 `tests/conditional-edges.test.ts` 16 項全綠;零變化:現存 workflow 用到新邊型=0 筆。 + 〔原始描述〕 + 現況:`if_control` 只回 `{result, branch}`,`cypher-executor/src` grep + `ON_TRUE|ON_FALSE`=0,圖的邊只有 `ON_SUCCESS`/`IF`/FOREACH ⇒ AI 照規矩用 + `if_control` 也還是得寫 code 判斷該走哪條路 ⇒「全變成 code」的根。 + 要做:`EdgeType` 加 `ON_TRUE`/`ON_FALSE`+`graph-executor` 走邊邏輯依上游 + `branch` 選路。**引擎核心、風險最高:先寫測試再改**;既有 4 支官方 workflow + 行為必須零變化(跑一次證明)。 +- [x] 3.12 **缺口②:recipe payload 與回應處理層**(CP 步驟 5 交付物之二、之三) + ✅ 08-01 本地綠(commit `5f5c0a8`):新增 `lib/recipe-payload.ts` + (`renderBodyTemplate` 遞迴插值+保留型別+dot path;`applyResponseMap` 取值路徑/ + thinking_model 剔除 thought/answer_marker 用 lastIndexOf/strip_prefixes 循環剝殼), + `RecipeDefinition` 加四個**全選填**欄位 body_template/response_map/auth/binding_name, + `component-loader` 加 `makeBindingRecipeRunner`+`pickRecipeRunner`(auth='binding' + 走平台 binding=免金鑰、開機即可用;一次打開 env.AI/VECTORIZE/BROWSER/QUEUE 整排)。 + **payload 用法查得到**:`buildPayloadHint()` wire 進三條 recipe 回應路徑 + (守 D36:只說「金鑰由系統注入、你不必也不該填」,不吐值)。 + 測試 `tests/recipe-payload-response.test.ts` 14 項全綠(含 Gemini/Claude/Workers AI + 三家形狀各用不同 path 都取得出文字=換源=換 recipe 的實證)。 + 相容:未設新欄位的既有 recipe 行為完全不變(有測試守)。 + 〔原始描述〕 + 現況 schema 只有 `{canonical_id, endpoint, method, auth_service, headers, body}` + ⇒ 帶 body 的 API 只能繞過 recipe 把整包寫進 workflow code;回應解析 + (`finalize` 2786 字元)綁死 Gemini 格式。 + 要補三層:`body_template`(payload 模板+變數插值)/`response_map`(回應正規化: + 取值路徑・思考型模型旗標・淨化規則)/`auth` 第四型 `binding`(免金鑰, + 一次打開 `env.AI`/`VECTORIZE`/`BROWSER`/`QUEUE`)。 + **相容硬要求**:既有 recipe(無新欄位)行為完全不變。 +- [◐] 3.13 **驗收=CP 步驟 5 考試(features/07)** + ◐ 08-03(t152,**3.13「真正的改寫」那半落地**):`rag_chat` 的 `ask_llm` 從 + 「整包 Gemini 細節寫在 workflow 節點」改成 `component: workers_ai_chat`(一個 recipe 名 + 一個 prompt), + `finalize` 從 **2786 字元縮到 12 行**(回應解讀搬進 recipe 的 `response_map`)。 + 本 repo 側配套:`api-recipe-seeds.ts` 新增 `workers_ai_chat` 種子(`auth: binding`, + 3.12 第四型認證的第一個真實案例)+修掉 `/init/seed` **列舉式重建吃掉 3.12 四個欄位**的洞 + (種子帶了 body_template/response_map/auth 卻進不了 KV,且哪裡都不會紅——與 08-02 + `syncManifest` 吃掉 `manifest.daemon` 同型)。回歸閘 `tests/init-seed-recipe-fields.test.ts` + 3 項,**拿掉修復會紅、補回會綠**(實測過會擋)。 + 實例端到端(1.4.4 youlin 帳號,**零 API 金鑰**):問答鏈全綠、有答案有出處。 + 選型實測見種子檔註解(llama-4-scout 2.2s vs Gemini gemma-4-31b **16.87s**)。 + ⚠️ 仍 ◐ 未 ✅:haiku 場景(缺件時只寫 recipe 就補全、零 JS)=features/09 stage 端到端未驗。 + ◐ 08-01:本地驗收綠(`tests/step5-acceptance.test.ts` 3 項),實跑輸出= + **舊寫法 1201 字元 / if×8 → 新寫法 350 字元 / if×0(下降 71%)**, + 且分流工作流 `success=true`、零 code 節點。 + ⚠️ **誠實標記**:線上那顆 `assemble`(5509 字元、if×23)住在 arcrun-rag 實例, + 本 repo 無其定義 ⇒ 本次是把它的**判斷骨架**以新能力重建成等價工作流證明 + 「判斷不必寫在 JS 裡」,**不是**直接改寫線上節點。 + 真正的改寫與 haiku 場景(逐顆查、只寫 recipe 補全、零 JS)=**stage 端到端** + (features/09),未驗前不得標 ✅。 + 〔原始描述〕:拿現行 `assemble` 節點 + (5509 字元、if×23)用新能力重寫 → code 大幅下降且仍 `verdict=success`; + 貼改寫前後字元數與實跑輸出。另附 haiku 場景:缺件時只寫 recipe 就補全、零 JS。 + --- ## 跨任務鐵律提醒