Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d7a98f53e7 | |||
| cacaa33f7d | |||
| fda9d169d5 | |||
| 7b478378aa | |||
| ceb7638d74 | |||
| 9fa88065ab | |||
| 969acff325 | |||
| 87f6de7c9f | |||
| 576afa2364 | |||
| 730c2af245 | |||
| 9ba6ba75fc | |||
| aa49b4eeb2 | |||
| 5b22d569c9 | |||
| 30c9312c57 | |||
| 1616517ace | |||
| 7dc3e615e9 | |||
| ccd531863a | |||
| 537b520143 | |||
| 45169ab25e | |||
| 5dd1d1e7a9 | |||
| b8a5201e8a | |||
| 340b461f58 | |||
| 5318398849 | |||
| 9609924818 | |||
| 043e9acc31 | |||
| cfb759a774 | |||
| 73bdb90780 | |||
| 9ad95ee3a6 | |||
| 68f042cfd0 | |||
| 5d441aa7d3 | |||
| 43323b1a78 | |||
| 2b807da324 | |||
| 3f2e45f5dc | |||
| c543abaa8a | |||
| 49248b18c5 | |||
| 05e879faa7 | |||
| 0b72b818a0 | |||
| 7498911856 | |||
| 94d5452425 | |||
| 4b6cc159b8 | |||
| 8286c8afec | |||
| ca1ed2aaf6 | |||
| 88f308642e | |||
| c3856470ad | |||
| 1ccee0055e | |||
| 302e024804 | |||
| c2897ba68b | |||
| 614fe44812 |
Binary file not shown.
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"name": "arcrun-component-worker-template",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
Generated
-917
@@ -1,917 +0,0 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
hono:
|
||||
specifier: ^4.7.0
|
||||
version: 4.13.1
|
||||
devDependencies:
|
||||
'@cloudflare/workers-types':
|
||||
specifier: ^4.20250408.0
|
||||
version: 4.20260702.1
|
||||
typescript:
|
||||
specifier: ^5.4.0
|
||||
version: 5.9.3
|
||||
wrangler:
|
||||
specifier: ^4.0.0
|
||||
version: 4.121.0(@cloudflare/workers-types@4.20260702.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.20260804.1':
|
||||
resolution: {integrity: sha512-191/PPEFicRK2wK69eXzSjnLgHL79k7zR2VUpyIr8rhFgGns1b5bTHgnBuUrgU2LPbEtwbE5eL2hJ0uhLAFEow==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@cloudflare/workerd-darwin-arm64@1.20260804.1':
|
||||
resolution: {integrity: sha512-aI2cAFLsrNkSz3kLSQrgrO9ICTfY7jb2h0jgaWDE9mQLQQDfpXeYrKWt07tTgMmeNP79o9w3NZe3aqtt8mTGHQ==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@cloudflare/workerd-linux-64@1.20260804.1':
|
||||
resolution: {integrity: sha512-KBCjxBIlN2jucfQGaTK4EgmPsWzQgYR/zYhPfi3mkWwdoTyG1dgrt2aizKps/SYse85ci/SOxojKk0/K7vstPw==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@cloudflare/workerd-linux-arm64@1.20260804.1':
|
||||
resolution: {integrity: sha512-7lswfarBZ7xkHpTFrNb74ExwOltIalVaASc+GOYfCNtnwFxK/JZSVByfm/hnZEBtrHU7fMY2z7dYT64rwS0pHg==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@cloudflare/workerd-windows-64@1.20260804.1':
|
||||
resolution: {integrity: sha512-GOgRWYtxISN5rAWfx3K7zubt3xEn0/ZPFrbcLL+GwT4ouCKyNoHqfT1cPNB6Nx7t8BHEjnuTG7gkHO4Wd5ORdg==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@cloudflare/workers-types@4.20260702.1':
|
||||
resolution: {integrity: sha512-mOhf5TUEB1m2vPrxtqoIGfz0fUC9xyxRDx5gWHy5s+OCo6dcV+g7wI1R7gYCMFohhqF/2y2xeKVwMwCJjfn/WA==}
|
||||
|
||||
'@cspotcode/source-map-support@0.8.1':
|
||||
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@emnapi/runtime@1.11.3':
|
||||
resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==}
|
||||
|
||||
'@esbuild/aix-ppc64@0.28.1':
|
||||
resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [aix]
|
||||
|
||||
'@esbuild/android-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-arm@0.28.1':
|
||||
resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-x64@0.28.1':
|
||||
resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/darwin-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/darwin-x64@0.28.1':
|
||||
resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/freebsd-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/freebsd-x64@0.28.1':
|
||||
resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/linux-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-arm@0.28.1':
|
||||
resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ia32@0.28.1':
|
||||
resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-loong64@0.28.1':
|
||||
resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-mips64el@0.28.1':
|
||||
resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [mips64el]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ppc64@0.28.1':
|
||||
resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-riscv64@0.28.1':
|
||||
resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-s390x@0.28.1':
|
||||
resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-x64@0.28.1':
|
||||
resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/netbsd-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/netbsd-x64@0.28.1':
|
||||
resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/openbsd-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openbsd-x64@0.28.1':
|
||||
resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openharmony-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@esbuild/sunos-x64@0.28.1':
|
||||
resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [sunos]
|
||||
|
||||
'@esbuild/win32-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-ia32@0.28.1':
|
||||
resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-x64@0.28.1':
|
||||
resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==}
|
||||
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.35.2':
|
||||
resolution: {integrity: sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-darwin-x64@0.35.2':
|
||||
resolution: {integrity: sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-freebsd-wasm32@0.35.2':
|
||||
resolution: {integrity: sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
os: [freebsd]
|
||||
|
||||
'@img/sharp-libvips-darwin-arm64@1.3.1':
|
||||
resolution: {integrity: sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-libvips-darwin-x64@1.3.1':
|
||||
resolution: {integrity: sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-libvips-linux-arm64@1.3.1':
|
||||
resolution: {integrity: sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-arm@1.3.1':
|
||||
resolution: {integrity: sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-ppc64@1.3.1':
|
||||
resolution: {integrity: sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-riscv64@1.3.1':
|
||||
resolution: {integrity: sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-s390x@1.3.1':
|
||||
resolution: {integrity: sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-x64@1.3.1':
|
||||
resolution: {integrity: sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.3.1':
|
||||
resolution: {integrity: sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.3.1':
|
||||
resolution: {integrity: sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-linux-arm64@0.35.2':
|
||||
resolution: {integrity: sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-arm@0.35.2':
|
||||
resolution: {integrity: sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-ppc64@0.35.2':
|
||||
resolution: {integrity: sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-riscv64@0.35.2':
|
||||
resolution: {integrity: sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-s390x@0.35.2':
|
||||
resolution: {integrity: sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-x64@0.35.2':
|
||||
resolution: {integrity: sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linuxmusl-arm64@0.35.2':
|
||||
resolution: {integrity: sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-linuxmusl-x64@0.35.2':
|
||||
resolution: {integrity: sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-wasm32@0.35.2':
|
||||
resolution: {integrity: sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
|
||||
'@img/sharp-webcontainers-wasm32@0.35.2':
|
||||
resolution: {integrity: sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [wasm32]
|
||||
|
||||
'@img/sharp-win32-arm64@0.35.2':
|
||||
resolution: {integrity: sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@img/sharp-win32-ia32@0.35.2':
|
||||
resolution: {integrity: sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==}
|
||||
engines: {node: ^20.9.0}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@img/sharp-win32-x64@0.35.2':
|
||||
resolution: {integrity: sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==}
|
||||
engines: {node: '>=20.9.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.24':
|
||||
resolution: {integrity: sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==}
|
||||
|
||||
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.28.1:
|
||||
resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==}
|
||||
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.13.1:
|
||||
resolution: {integrity: sha512-kdJoFVv2xmayw6cY09H7AbMJMt8Jn5jdlEdXsP7AGBdF2DIptVlKlOLKXP41yPip4/a3yQPv9gVcJYI8YY04dw==}
|
||||
engines: {node: '>=16.9.0'}
|
||||
|
||||
kleur@4.1.5:
|
||||
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
miniflare@5.20260804.1-alpha:
|
||||
resolution: {integrity: sha512-J0QBHEj+d75TyFE9VhH+2dXgvdYt/pfyDlm+9IiYUFocQvqYy9iuW1DIqNawh1N7Kr6iMrDzVkgDSdt6pA75uA==}
|
||||
engines: {node: '>=22.0.0'}
|
||||
|
||||
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.5:
|
||||
resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
sharp@0.35.2:
|
||||
resolution: {integrity: sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==}
|
||||
engines: {node: '>=20.9.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.29.0:
|
||||
resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==}
|
||||
engines: {node: '>=20.18.1'}
|
||||
|
||||
unenv@2.0.0-rc.24:
|
||||
resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==}
|
||||
|
||||
workerd@1.20260804.1:
|
||||
resolution: {integrity: sha512-b0P38g5/ssemwWxd/mafNYggEZ0ere7PCiUH6RCHkgqjRhhXTP45nDiL2L3iIvi8uF2IjNrGpVgMXEunCaeY/w==}
|
||||
engines: {node: '>=16'}
|
||||
hasBin: true
|
||||
|
||||
wrangler@4.121.0:
|
||||
resolution: {integrity: sha512-dcARWk6CyaD0vJBSLjJn4K2yo+mYko73hzryq+t/9DXnyAq877RWIMl7uOMcDKs5dDXdoickNhZTKxBYT9XKsA==}
|
||||
engines: {node: '>=22.0.0'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@cloudflare/workers-types': ^5.20260804.1
|
||||
peerDependenciesMeta:
|
||||
'@cloudflare/workers-types':
|
||||
optional: true
|
||||
|
||||
ws@8.21.0:
|
||||
resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==}
|
||||
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.20260804.1)':
|
||||
dependencies:
|
||||
unenv: 2.0.0-rc.24
|
||||
optionalDependencies:
|
||||
workerd: 1.20260804.1
|
||||
|
||||
'@cloudflare/workerd-darwin-64@1.20260804.1':
|
||||
optional: true
|
||||
|
||||
'@cloudflare/workerd-darwin-arm64@1.20260804.1':
|
||||
optional: true
|
||||
|
||||
'@cloudflare/workerd-linux-64@1.20260804.1':
|
||||
optional: true
|
||||
|
||||
'@cloudflare/workerd-linux-arm64@1.20260804.1':
|
||||
optional: true
|
||||
|
||||
'@cloudflare/workerd-windows-64@1.20260804.1':
|
||||
optional: true
|
||||
|
||||
'@cloudflare/workers-types@4.20260702.1': {}
|
||||
|
||||
'@cspotcode/source-map-support@0.8.1':
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.9
|
||||
|
||||
'@emnapi/runtime@1.11.3':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@esbuild/aix-ppc64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ia32@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-loong64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-mips64el@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ppc64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-riscv64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-s390x@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openharmony-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/sunos-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-ia32@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@img/colour@1.1.0': {}
|
||||
|
||||
'@img/sharp-darwin-arm64@0.35.2':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-darwin-arm64': 1.3.1
|
||||
optional: true
|
||||
|
||||
'@img/sharp-darwin-x64@0.35.2':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-darwin-x64': 1.3.1
|
||||
optional: true
|
||||
|
||||
'@img/sharp-freebsd-wasm32@0.35.2':
|
||||
dependencies:
|
||||
'@img/sharp-wasm32': 0.35.2
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-darwin-arm64@1.3.1':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-darwin-x64@1.3.1':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-arm64@1.3.1':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-arm@1.3.1':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-ppc64@1.3.1':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-riscv64@1.3.1':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-s390x@1.3.1':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-x64@1.3.1':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.3.1':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.3.1':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-arm64@0.35.2':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-arm64': 1.3.1
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-arm@0.35.2':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-arm': 1.3.1
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-ppc64@0.35.2':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-ppc64': 1.3.1
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-riscv64@0.35.2':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-riscv64': 1.3.1
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-s390x@0.35.2':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-s390x': 1.3.1
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-x64@0.35.2':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-x64': 1.3.1
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linuxmusl-arm64@0.35.2':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linuxmusl-arm64': 1.3.1
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linuxmusl-x64@0.35.2':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linuxmusl-x64': 1.3.1
|
||||
optional: true
|
||||
|
||||
'@img/sharp-wasm32@0.35.2':
|
||||
dependencies:
|
||||
'@emnapi/runtime': 1.11.3
|
||||
optional: true
|
||||
|
||||
'@img/sharp-webcontainers-wasm32@0.35.2':
|
||||
dependencies:
|
||||
'@img/sharp-wasm32': 0.35.2
|
||||
optional: true
|
||||
|
||||
'@img/sharp-win32-arm64@0.35.2':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-win32-ia32@0.35.2':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-win32-x64@0.35.2':
|
||||
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.24': {}
|
||||
|
||||
blake3-wasm@2.1.5: {}
|
||||
|
||||
cookie@1.1.1: {}
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
|
||||
error-stack-parser-es@1.0.5: {}
|
||||
|
||||
esbuild@0.28.1:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.28.1
|
||||
'@esbuild/android-arm': 0.28.1
|
||||
'@esbuild/android-arm64': 0.28.1
|
||||
'@esbuild/android-x64': 0.28.1
|
||||
'@esbuild/darwin-arm64': 0.28.1
|
||||
'@esbuild/darwin-x64': 0.28.1
|
||||
'@esbuild/freebsd-arm64': 0.28.1
|
||||
'@esbuild/freebsd-x64': 0.28.1
|
||||
'@esbuild/linux-arm': 0.28.1
|
||||
'@esbuild/linux-arm64': 0.28.1
|
||||
'@esbuild/linux-ia32': 0.28.1
|
||||
'@esbuild/linux-loong64': 0.28.1
|
||||
'@esbuild/linux-mips64el': 0.28.1
|
||||
'@esbuild/linux-ppc64': 0.28.1
|
||||
'@esbuild/linux-riscv64': 0.28.1
|
||||
'@esbuild/linux-s390x': 0.28.1
|
||||
'@esbuild/linux-x64': 0.28.1
|
||||
'@esbuild/netbsd-arm64': 0.28.1
|
||||
'@esbuild/netbsd-x64': 0.28.1
|
||||
'@esbuild/openbsd-arm64': 0.28.1
|
||||
'@esbuild/openbsd-x64': 0.28.1
|
||||
'@esbuild/openharmony-arm64': 0.28.1
|
||||
'@esbuild/sunos-x64': 0.28.1
|
||||
'@esbuild/win32-arm64': 0.28.1
|
||||
'@esbuild/win32-ia32': 0.28.1
|
||||
'@esbuild/win32-x64': 0.28.1
|
||||
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
hono@4.13.1: {}
|
||||
|
||||
kleur@4.1.5: {}
|
||||
|
||||
miniflare@5.20260804.1-alpha:
|
||||
dependencies:
|
||||
'@cspotcode/source-map-support': 0.8.1
|
||||
sharp: 0.35.2
|
||||
undici: 7.29.0
|
||||
workerd: 1.20260804.1
|
||||
ws: 8.21.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.5: {}
|
||||
|
||||
sharp@0.35.2:
|
||||
dependencies:
|
||||
'@img/colour': 1.1.0
|
||||
detect-libc: 2.1.2
|
||||
semver: 7.8.5
|
||||
optionalDependencies:
|
||||
'@img/sharp-darwin-arm64': 0.35.2
|
||||
'@img/sharp-darwin-x64': 0.35.2
|
||||
'@img/sharp-freebsd-wasm32': 0.35.2
|
||||
'@img/sharp-libvips-darwin-arm64': 1.3.1
|
||||
'@img/sharp-libvips-darwin-x64': 1.3.1
|
||||
'@img/sharp-libvips-linux-arm': 1.3.1
|
||||
'@img/sharp-libvips-linux-arm64': 1.3.1
|
||||
'@img/sharp-libvips-linux-ppc64': 1.3.1
|
||||
'@img/sharp-libvips-linux-riscv64': 1.3.1
|
||||
'@img/sharp-libvips-linux-s390x': 1.3.1
|
||||
'@img/sharp-libvips-linux-x64': 1.3.1
|
||||
'@img/sharp-libvips-linuxmusl-arm64': 1.3.1
|
||||
'@img/sharp-libvips-linuxmusl-x64': 1.3.1
|
||||
'@img/sharp-linux-arm': 0.35.2
|
||||
'@img/sharp-linux-arm64': 0.35.2
|
||||
'@img/sharp-linux-ppc64': 0.35.2
|
||||
'@img/sharp-linux-riscv64': 0.35.2
|
||||
'@img/sharp-linux-s390x': 0.35.2
|
||||
'@img/sharp-linux-x64': 0.35.2
|
||||
'@img/sharp-linuxmusl-arm64': 0.35.2
|
||||
'@img/sharp-linuxmusl-x64': 0.35.2
|
||||
'@img/sharp-webcontainers-wasm32': 0.35.2
|
||||
'@img/sharp-win32-arm64': 0.35.2
|
||||
'@img/sharp-win32-ia32': 0.35.2
|
||||
'@img/sharp-win32-x64': 0.35.2
|
||||
|
||||
supports-color@10.2.2: {}
|
||||
|
||||
tslib@2.8.1:
|
||||
optional: true
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
undici@7.29.0: {}
|
||||
|
||||
unenv@2.0.0-rc.24:
|
||||
dependencies:
|
||||
pathe: 2.0.3
|
||||
|
||||
workerd@1.20260804.1:
|
||||
optionalDependencies:
|
||||
'@cloudflare/workerd-darwin-64': 1.20260804.1
|
||||
'@cloudflare/workerd-darwin-arm64': 1.20260804.1
|
||||
'@cloudflare/workerd-linux-64': 1.20260804.1
|
||||
'@cloudflare/workerd-linux-arm64': 1.20260804.1
|
||||
'@cloudflare/workerd-windows-64': 1.20260804.1
|
||||
|
||||
wrangler@4.121.0(@cloudflare/workers-types@4.20260702.1):
|
||||
dependencies:
|
||||
'@cloudflare/kv-asset-handler': 0.5.0
|
||||
'@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260804.1)
|
||||
blake3-wasm: 2.1.5
|
||||
esbuild: 0.28.1
|
||||
miniflare: 5.20260804.1-alpha
|
||||
path-to-regexp: 6.3.0
|
||||
unenv: 2.0.0-rc.24
|
||||
workerd: 1.20260804.1
|
||||
optionalDependencies:
|
||||
'@cloudflare/workers-types': 4.20260702.1
|
||||
fsevents: 2.3.3
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
ws@8.21.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.24
|
||||
cookie: 1.1.1
|
||||
youch-core: 0.3.3
|
||||
@@ -1,142 +0,0 @@
|
||||
/**
|
||||
* arcrun logic component Worker
|
||||
*
|
||||
* POST / → JSON input → WASM (WASI preview1 stdin/stdout) → JSON output
|
||||
*
|
||||
* WASM is statically bundled at build time via wrangler.toml [[wasm_modules]].
|
||||
* Each logic component gets its own Worker at {name}.arcrun.dev.
|
||||
*/
|
||||
|
||||
import componentWasm from '../component.wasm' assert { type: 'webassembly' };
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import { cors } from 'hono/cors';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.use('*', cors());
|
||||
|
||||
app.get('/', (c) => c.json({ ok: true, component: COMPONENT_ID }));
|
||||
|
||||
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(componentWasm, input);
|
||||
return c.json(result);
|
||||
} catch (e) {
|
||||
return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export default app;
|
||||
|
||||
// ── WASM runner (WASI preview1 stdin/stdout) ─────────────────────────────────
|
||||
|
||||
declare const COMPONENT_ID: string; // injected via [vars] in wrangler.toml
|
||||
|
||||
async function runWasm(wasmModule: WebAssembly.Module, input: unknown): Promise<unknown> {
|
||||
const stdinBytes = new TextEncoder().encode(JSON.stringify(input));
|
||||
let stdinOffset = 0;
|
||||
|
||||
const stdoutChunks: Uint8Array[] = [];
|
||||
let memory: WebAssembly.Memory | null = null;
|
||||
|
||||
const getView = () => new DataView(memory!.buffer);
|
||||
|
||||
const wasi: WebAssembly.Imports = {
|
||||
wasi_snapshot_preview1: {
|
||||
fd_write(fd: number, iovs: number, iovs_len: number, nwritten_ptr: number): number {
|
||||
if (fd !== 1 && fd !== 2) return 76; // ENOSYS
|
||||
const view = getView();
|
||||
let total = 0;
|
||||
for (let i = 0; i < iovs_len; i++) {
|
||||
const base = view.getUint32(iovs + i * 8, true);
|
||||
const len = view.getUint32(iovs + i * 8 + 4, true);
|
||||
if (len === 0) continue;
|
||||
const chunk = new Uint8Array(memory!.buffer, base, len);
|
||||
const copy = new Uint8Array(len);
|
||||
copy.set(chunk);
|
||||
if (fd === 1) stdoutChunks.push(copy);
|
||||
total += len;
|
||||
}
|
||||
view.setUint32(nwritten_ptr, total, true);
|
||||
return 0;
|
||||
},
|
||||
fd_read(fd: number, iovs: number, iovs_len: number, nread_ptr: number): number {
|
||||
if (fd !== 0) return 76;
|
||||
const view = getView();
|
||||
let total = 0;
|
||||
for (let i = 0; i < iovs_len; i++) {
|
||||
const base = view.getUint32(iovs + i * 8, true);
|
||||
const len = view.getUint32(iovs + i * 8 + 4, true);
|
||||
const remaining = stdinBytes.length - stdinOffset;
|
||||
if (remaining <= 0) break;
|
||||
const toCopy = Math.min(len, remaining);
|
||||
new Uint8Array(memory!.buffer, base, toCopy).set(
|
||||
stdinBytes.subarray(stdinOffset, stdinOffset + toCopy)
|
||||
);
|
||||
stdinOffset += toCopy;
|
||||
total += toCopy;
|
||||
}
|
||||
view.setUint32(nread_ptr, total, true);
|
||||
return 0;
|
||||
},
|
||||
proc_exit(code: number): never { throw new Error(`wasm exit: ${code}`); },
|
||||
random_get(ptr: number, len: number): number {
|
||||
crypto.getRandomValues(new Uint8Array(memory!.buffer, ptr, len));
|
||||
return 0;
|
||||
},
|
||||
fd_seek: () => 76, fd_close: () => 0,
|
||||
fd_fdstat_get: () => 76, fd_prestat_get: () => 76,
|
||||
fd_prestat_dir_name: () => 76, environ_get: () => 0,
|
||||
environ_sizes_get: (cp: number, sp: number) => {
|
||||
if (memory) { const v = getView(); v.setUint32(cp,0,true); v.setUint32(sp,0,true); }
|
||||
return 0;
|
||||
},
|
||||
args_get: () => 0,
|
||||
args_sizes_get: (ap: number, bp: number) => {
|
||||
if (memory) { const v = getView(); v.setUint32(ap,0,true); v.setUint32(bp,0,true); }
|
||||
return 0;
|
||||
},
|
||||
clock_time_get: (_id: number, _prec: bigint, tp: number) => {
|
||||
if (memory) getView().setBigUint64(tp, BigInt(Date.now()) * 1_000_000n, true);
|
||||
return 0;
|
||||
},
|
||||
clock_res_get: () => 76, poll_oneoff: () => 76, sched_yield: () => 0,
|
||||
proc_raise: () => 76, sock_accept: () => 76, sock_recv: () => 76,
|
||||
sock_send: () => 76, sock_shutdown: () => 76,
|
||||
path_open: () => 76, path_create_directory: () => 76,
|
||||
path_remove_directory: () => 76, path_rename: () => 76,
|
||||
path_unlink_file: () => 76, path_filestat_get: () => 76,
|
||||
path_readlink: () => 76, path_symlink: () => 76, path_link: () => 76,
|
||||
},
|
||||
// u6u host functions (no-op for pure logic components)
|
||||
u6u: { http_request: () => 1 },
|
||||
};
|
||||
|
||||
const instance = await WebAssembly.instantiate(wasmModule, wasi);
|
||||
memory = instance.exports.memory as WebAssembly.Memory;
|
||||
|
||||
const start = (instance.exports._start ?? instance.exports.main) as () => void;
|
||||
if (typeof start !== 'function') throw new Error('WASM missing _start or main export');
|
||||
|
||||
try { start(); } catch (e) {
|
||||
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const total = stdoutChunks.reduce((n, c) => n + c.length, 0);
|
||||
const merged = new Uint8Array(total);
|
||||
let off = 0;
|
||||
for (const chunk of stdoutChunks) { merged.set(chunk, off); off += chunk.length; }
|
||||
const stdout = decoder.decode(merged).trim();
|
||||
|
||||
if (!stdout) throw new Error('WASM component produced no output');
|
||||
return JSON.parse(stdout);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["@cloudflare/workers-types"],
|
||||
"strict": true,
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -3243,15 +3243,20 @@ var init_kbdb_proxy = __esm({
|
||||
const owner = tenant(c);
|
||||
if (!owner) return c.json(NEED_KEY, 401);
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (!body || !body.template || !body.values) {
|
||||
return c.json({ error: "template \u8207 values \u5FC5\u586B" }, 400);
|
||||
if (!body || !body.template || !body.values && !body.entry_ids) {
|
||||
return c.json({ error: "template \u5FC5\u586B\uFF0Cvalues \u8207 entry_ids \u81F3\u5C11\u8981\u6709\u4E00\u500B" }, 400);
|
||||
}
|
||||
const { base, headers } = kbdbBase(c.env);
|
||||
const res = await fetch(`${base}/records`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
// 強制以租戶身份隔離:忽略 caller 自帶 owner_id,一律用 header 身份(防跨租戶寫入)
|
||||
body: JSON.stringify({ template: body.template, values: body.values, owner_id: owner })
|
||||
body: JSON.stringify({
|
||||
template: body.template,
|
||||
...body.values ? { values: body.values } : {},
|
||||
...body.entry_ids ? { entry_ids: body.entry_ids } : {},
|
||||
owner_id: owner
|
||||
})
|
||||
});
|
||||
return new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } });
|
||||
});
|
||||
@@ -3400,8 +3405,9 @@ function validateName(name) {
|
||||
function validSensitivity(s) {
|
||||
return s === "standard" || s === "high";
|
||||
}
|
||||
async function putWorkerSecret(env, secretRef, value) {
|
||||
if (!env.CF_SECRETS_API_TOKEN || !env.CF_ACCOUNT_ID) {
|
||||
async function putWorkerSecret(env, secretRef, value, tokenOverride) {
|
||||
const token = tokenOverride || env.CF_SECRETS_API_TOKEN;
|
||||
if (!token || !env.CF_ACCOUNT_ID) {
|
||||
throw new Error(
|
||||
"\u6B64 worker \u7F3A CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID \u8A2D\u5B9A\uFF0C\u5BEB\u5165\u8DEF\u5F91\u672A\u5C31\u7DD2\uFF08\u898B credential-store-migration.md T3\uFF1Aacr init/update \u61C9\u78BA\u4FDD\u9019\u5169\u9805\u5C31\u7DD2\uFF09"
|
||||
);
|
||||
@@ -3410,7 +3416,7 @@ async function putWorkerSecret(env, secretRef, value) {
|
||||
const res = await fetch(url, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: `Bearer ${env.CF_SECRETS_API_TOKEN}`,
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({ name: secretRef, text: value, type: "secret_text" })
|
||||
@@ -3421,14 +3427,15 @@ async function putWorkerSecret(env, secretRef, value) {
|
||||
throw new Error(`CF Workers Secrets \u5BEB\u5165\u5931\u6557\uFF1A${detail}`);
|
||||
}
|
||||
}
|
||||
async function deleteWorkerSecret(env, secretRef) {
|
||||
if (!env.CF_SECRETS_API_TOKEN || !env.CF_ACCOUNT_ID) {
|
||||
async function deleteWorkerSecret(env, secretRef, tokenOverride) {
|
||||
const token = tokenOverride || env.CF_SECRETS_API_TOKEN;
|
||||
if (!token || !env.CF_ACCOUNT_ID) {
|
||||
throw new Error("\u6B64 worker \u7F3A CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID \u8A2D\u5B9A\uFF0C\u522A\u9664\u8DEF\u5F91\u672A\u5C31\u7DD2");
|
||||
}
|
||||
const url = `https://api.cloudflare.com/client/v4/accounts/${env.CF_ACCOUNT_ID}/workers/scripts/${CYPHER_SCRIPT_NAME}/secrets/${secretRef}`;
|
||||
const res = await fetch(url, {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${env.CF_SECRETS_API_TOKEN}` }
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (res.status === 404) return;
|
||||
const body = await res.json().catch(() => null);
|
||||
@@ -3789,6 +3796,9 @@ async function resolveCredentialRefs(data, env, apiKey) {
|
||||
if (names.size === 0) return data;
|
||||
const nameList = [...names];
|
||||
const resolvedSecrets = await resolveSecretsFromNewHome(env, apiKey, nameList);
|
||||
if (nameList.every((n) => Object.prototype.hasOwnProperty.call(resolvedSecrets, n))) {
|
||||
return replaceCredentialRefs(data, resolvedSecrets);
|
||||
}
|
||||
const url = wasmWorkerUrl("auth_static_key", env.WORKER_SUBDOMAIN);
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
@@ -9198,11 +9208,8 @@ function shardIndex(name) {
|
||||
function shardNameOf(index) {
|
||||
return index === 0 ? AUTH_STORE_PREFIX : `${AUTH_STORE_PREFIX}_${index}`;
|
||||
}
|
||||
function authStorePresent(env) {
|
||||
return shardNames(env).length > 0 || overlay !== null && Date.now() - overlayAt < AUTH_OVERLAY_TTL_MS;
|
||||
}
|
||||
function authStoreWritable(env) {
|
||||
return Boolean(env.CF_SECRETS_API_TOKEN && env.CF_ACCOUNT_ID);
|
||||
function authStoreWritable(env, tokenOverride) {
|
||||
return Boolean((tokenOverride || env.CF_SECRETS_API_TOKEN) && env.CF_ACCOUNT_ID);
|
||||
}
|
||||
function readAuthStore(env) {
|
||||
if (overlay && Date.now() - overlayAt < AUTH_OVERLAY_TTL_MS) return overlay;
|
||||
@@ -9238,15 +9245,10 @@ function findAuthUserById(env, id) {
|
||||
function isAuthStoreId(recordId) {
|
||||
return recordId.startsWith(AUTH_ID_PREFIX);
|
||||
}
|
||||
function newAuthUserId() {
|
||||
const arr = new Uint8Array(12);
|
||||
crypto.getRandomValues(arr);
|
||||
return AUTH_ID_PREFIX + Array.from(arr).map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
async function writeAuthStore(env, data) {
|
||||
if (!authStoreWritable(env)) {
|
||||
async function writeAuthStore(env, data, tokenOverride) {
|
||||
if (!authStoreWritable(env, tokenOverride)) {
|
||||
throw new AuthStoreWriteError(
|
||||
"\u9019\u53F0\u5BE6\u4F8B\u9084\u4E0D\u80FD\u5BEB\u5165\u8A8D\u8B49\u5132\u5B58\uFF08\u7F3A CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID\uFF09\u3002\u8A8D\u8B49\u5206\u96E2\u9700\u8981\u9019\u5169\u9805\u624D\u5BEB\u5F97\u9032 Workers Secrets\u2014\u2014\u8ACB\u91CD\u65B0\u57F7\u884C\u5B89\u88DD\uFF0F\u66F4\u65B0\u8B93\u5B83\u5C31\u7DD2\u3002"
|
||||
"\u9019\u53F0\u5BE6\u4F8B\u76EE\u524D\u5BEB\u4E0D\u9032\u8A8D\u8B49\u5132\u5B58\uFF08\u7F3A\u53EF\u7528\u7684 Cloudflare \u5BEB\u5165\u6191\u8B49\uFF1ACF_SECRETS_API_TOKEN\uFF09\u3002\u9019\u662F\u5E73\u53F0\u7AEF\u7684\u5DF2\u77E5\u9650\u5236\uFF0C\u4E0D\u662F\u4F60\u64CD\u4F5C\u932F\u8AA4\u2014\u2014\u76EE\u524D\u6C92\u6709\u4F60\u81EA\u5DF1\u5728\u756B\u9762\u4E0A\u80FD\u505A\u7684\u4E0B\u4E00\u6B65\uFF0C\u8ACB\u628A\u9019\u5247\u8A0A\u606F\u5B8C\u6574\u622A\u5716\uFF0F\u8907\u88FD\u7D66\u652F\u63F4\uFF0C\u4E26\u8A3B\u660E\u4F60\u525B\u624D\u5728\u505A\u4EC0\u9EBC\uFF08\u4F8B\u5982\uFF1A\u5B89\u88DD\u7CBE\u9748\u88E1\u5EFA\u7ACB\u7B2C\u4E00\u500B\u5E33\u865F\u3001\u4E8B\u5F8C\u65B0\u589E\u4F7F\u7528\u8005\u3001\u6216\u4FEE\u6539\u5BC6\u78BC\uFF09\uFF0C\u6703\u9700\u8981\u4EBA\u5DE5\u5354\u52A9\u6392\u9664\u3002"
|
||||
);
|
||||
}
|
||||
const shards = [];
|
||||
@@ -9269,10 +9271,10 @@ async function writeAuthStore(env, data) {
|
||||
}
|
||||
const existing = shardNames(env);
|
||||
for (let i = 0; i < shards.length; i++) {
|
||||
await putWorkerSecret(env, shardNameOf(i), shards[i]);
|
||||
await putWorkerSecret(env, shardNameOf(i), shards[i], tokenOverride);
|
||||
}
|
||||
for (const name of existing) {
|
||||
if (shardIndex(name) >= shards.length) await deleteWorkerSecret(env, name);
|
||||
if (shardIndex(name) >= shards.length) await deleteWorkerSecret(env, name, tokenOverride);
|
||||
}
|
||||
overlay = { version: 1, console: data.console ?? null, users: [...data.users] };
|
||||
overlayAt = Date.now();
|
||||
@@ -9319,26 +9321,23 @@ function unionStores(a, b) {
|
||||
}
|
||||
return { version: 1, console: a.console ?? b.console ?? null, users: [...byId.values()] };
|
||||
}
|
||||
async function mutateAuthStore(env, fn) {
|
||||
async function mutateAuthStore(env, fn, tokenOverride) {
|
||||
await hydrateFromAccelerator(env);
|
||||
const next = unionStores(readAuthStore(env), readAuthStoreFromEnv(env));
|
||||
await fn(next);
|
||||
await writeAuthStore(env, next);
|
||||
await writeAuthStore(env, next, tokenOverride);
|
||||
return next;
|
||||
}
|
||||
function authStoreStatus(env) {
|
||||
const data = readAuthStore(env);
|
||||
return {
|
||||
present: authStorePresent(env),
|
||||
writable: authStoreWritable(env),
|
||||
users: data.users.length,
|
||||
console_configured: Boolean(data.console),
|
||||
shards: shardNames(env).length
|
||||
};
|
||||
}
|
||||
|
||||
// cypher-executor/src/routes/health.ts
|
||||
var healthRouter = new Hono2();
|
||||
function authStoreStatus(env) {
|
||||
const legacy = readAuthStore(env);
|
||||
return {
|
||||
console: { home: "sessions-kv", writable: true, legacy_secrets_present: legacy.console !== null },
|
||||
portal_users: { home: "kbdb", writable: true, legacy_secrets_present: legacy.users.length > 0 }
|
||||
};
|
||||
}
|
||||
healthRouter.get("/health", (c) => {
|
||||
const bundleVersion = c.env.ARCRUN_BUNDLE_VERSION;
|
||||
const bundleCommit = c.env.ARCRUN_BUNDLE_COMMIT;
|
||||
@@ -12625,35 +12624,35 @@ function tenantOf(c) {
|
||||
return knowledgeOwner(c.env);
|
||||
}
|
||||
async function loadCredentials(env) {
|
||||
let fromStore = readAuthStore(env).console;
|
||||
if (!fromStore && await hydrateFromAccelerator(env)) {
|
||||
fromStore = readAuthStore(env).console;
|
||||
}
|
||||
if (fromStore) return { creds: fromStore, source: "secrets" };
|
||||
const raw2 = await env.SESSIONS_KV.get(CREDS_KEY);
|
||||
if (!raw2) return { creds: null, source: "none" };
|
||||
let legacy = null;
|
||||
try {
|
||||
legacy = JSON.parse(raw2);
|
||||
} catch {
|
||||
return { creds: null, source: "none" };
|
||||
if (raw2) {
|
||||
try {
|
||||
return { creds: JSON.parse(raw2), source: "kv" };
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
const legacy = readAuthStore(env).console;
|
||||
if (!legacy) return { creds: null, source: "none" };
|
||||
try {
|
||||
await mutateAuthStore(env, (data) => {
|
||||
if (!data.console) data.console = legacy;
|
||||
});
|
||||
await env.SESSIONS_KV.put(CREDS_KEY, JSON.stringify(legacy));
|
||||
} catch {
|
||||
}
|
||||
return { creds: legacy, source: "legacy-kv" };
|
||||
return { creds: legacy, source: "legacy-secrets" };
|
||||
}
|
||||
async function saveCredentials(env, record) {
|
||||
await mutateAuthStore(env, (data) => {
|
||||
data.console = record;
|
||||
});
|
||||
await env.SESSIONS_KV.put(CREDS_KEY, JSON.stringify(record));
|
||||
}
|
||||
function consoleAuthStoreStatus(env) {
|
||||
return {
|
||||
home: "sessions-kv",
|
||||
writable: true,
|
||||
// binding-based,只要 wrangler.toml 有這個 binding 就一定寫得進去
|
||||
legacy_secrets_present: readAuthStore(env).console !== null
|
||||
};
|
||||
}
|
||||
consoleAuthRouter.get("/console/auth-status", async (c) => {
|
||||
const { creds, source } = await loadCredentials(c.env);
|
||||
return c.json({ configured: !!creds, credentials_source: source, auth_store: authStoreStatus(c.env) });
|
||||
return c.json({ configured: !!creds, credentials_source: source, auth_store: consoleAuthStoreStatus(c.env) });
|
||||
});
|
||||
consoleAuthRouter.post("/console/setup", async (c) => {
|
||||
const { creds: existing } = await loadCredentials(c.env);
|
||||
@@ -12679,8 +12678,7 @@ consoleAuthRouter.post("/console/setup", async (c) => {
|
||||
try {
|
||||
await saveCredentials(c.env, record);
|
||||
} catch (e) {
|
||||
const msg = e instanceof AuthStoreWriteError ? e.message : String(e);
|
||||
return c.json({ error: `\u5E33\u5BC6\u6C92\u6709\u5B58\u8D77\u4F86\uFF1A${msg}`, code: "auth_store_not_writable" }, 502);
|
||||
return c.json({ error: `\u5E33\u5BC6\u6C92\u6709\u5B58\u8D77\u4F86\uFF1A${e instanceof Error ? e.message : String(e)}`, code: "auth_store_not_writable" }, 502);
|
||||
}
|
||||
const token = randomHex(32);
|
||||
await c.env.SESSIONS_KV.put(`${SESSION_PREFIX}${token}`, JSON.stringify({ created_at: Date.now() }), {
|
||||
@@ -12705,8 +12703,7 @@ consoleAuthRouter.post("/console/setup/reset", async (c) => {
|
||||
try {
|
||||
await saveCredentials(c.env, record);
|
||||
} catch (e) {
|
||||
const msg = e instanceof AuthStoreWriteError ? e.message : String(e);
|
||||
return c.json({ error: `\u65B0\u5E33\u5BC6\u6C92\u6709\u5B58\u8D77\u4F86\uFF1A${msg}`, code: "auth_store_not_writable" }, 502);
|
||||
return c.json({ error: `\u65B0\u5E33\u5BC6\u6C92\u6709\u5B58\u8D77\u4F86\uFF1A${e instanceof Error ? e.message : String(e)}`, code: "auth_store_not_writable" }, 502);
|
||||
}
|
||||
return c.json({ success: true });
|
||||
});
|
||||
@@ -12717,7 +12714,7 @@ consoleAuthRouter.post("/console/login", async (c) => {
|
||||
{
|
||||
error: "\u9019\u53F0\u5BE6\u4F8B\u9084\u6C92\u6709\u7BA1\u7406\u54E1\u5E33\u5BC6\uFF08\u6216\u8B80\u4E0D\u5230\uFF09\u2014\u2014\u4E0D\u662F\u5BC6\u78BC\u932F\u3002\u8ACB\u5148\u5B8C\u6210\u9996\u6B21\u8A2D\u5B9A\u3002",
|
||||
code: "auth_store_empty",
|
||||
auth_store: authStoreStatus(c.env)
|
||||
auth_store: consoleAuthStoreStatus(c.env)
|
||||
},
|
||||
400
|
||||
);
|
||||
@@ -12726,18 +12723,8 @@ consoleAuthRouter.post("/console/login", async (c) => {
|
||||
const email = (body?.email ?? "").trim().toLowerCase();
|
||||
const password = body?.password ?? "";
|
||||
if (!email || !password) return c.json({ error: "email \u8207 password \u5FC5\u586B" }, 400);
|
||||
let creds = existing;
|
||||
let hash = await hashPassword(password, creds.salt);
|
||||
if (email !== creds.email || hash !== creds.hash) {
|
||||
if (await hydrateFromAccelerator(c.env)) {
|
||||
const again = (await loadCredentials(c.env)).creds;
|
||||
if (again) {
|
||||
creds = again;
|
||||
hash = await hashPassword(password, creds.salt);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (email !== creds.email || hash !== creds.hash) {
|
||||
const hash = await hashPassword(password, existing.salt);
|
||||
if (email !== existing.email || hash !== existing.hash) {
|
||||
return c.json({ error: "email \u6216\u5BC6\u78BC\u932F\u8AA4" }, 401);
|
||||
}
|
||||
const token = randomHex(32);
|
||||
@@ -13008,24 +12995,31 @@ function recordValuesToAuthUser(id, v) {
|
||||
updated_at: v.updated_at ?? (/* @__PURE__ */ new Date()).toISOString()
|
||||
};
|
||||
}
|
||||
async function promoteLegacyUser(env, rec) {
|
||||
async function promoteToKbdb(env, rec) {
|
||||
try {
|
||||
const email = (rec.values.email ?? "").toLowerCase();
|
||||
if (!email) return;
|
||||
if (findAuthUserByEmail(env, email)) return;
|
||||
await mutateAuthStore(env, (data) => {
|
||||
if (data.users.some((u) => u.email === email)) return;
|
||||
data.users.push(recordValuesToAuthUser(newAuthUserId(), rec.values));
|
||||
if (!email) return null;
|
||||
const already = await findKbdbUserRecordId(env, email);
|
||||
if (already) return already;
|
||||
return await createKbdbUserRecord(env, email, {
|
||||
display_name: rec.values.display_name ?? "",
|
||||
status: rec.values.status ?? "active",
|
||||
role: rec.values.role ?? "user",
|
||||
password_hash: rec.values.password_hash ?? "",
|
||||
libraries: rec.values.libraries ?? "[]",
|
||||
created_at: rec.values.created_at ?? (/* @__PURE__ */ new Date()).toISOString(),
|
||||
updated_at: rec.values.updated_at ?? (/* @__PURE__ */ new Date()).toISOString()
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
async function findUserRecordId(env, email) {
|
||||
const inStore = findAuthUserByEmail(env, email);
|
||||
if (inStore) return inStore.id;
|
||||
return findLegacyUserRecordId(env, email);
|
||||
const inKbdb = await findKbdbUserRecordId(env, email);
|
||||
if (inKbdb) return inKbdb;
|
||||
return findAuthUserByEmail(env, email)?.id ?? null;
|
||||
}
|
||||
async function findLegacyUserRecordId(env, email) {
|
||||
async function findKbdbUserRecordId(env, email) {
|
||||
const ns = portalNamespace(env);
|
||||
const params = new URLSearchParams({
|
||||
page_name: email,
|
||||
@@ -13094,42 +13088,53 @@ function daemonActiveKey(env) {
|
||||
}
|
||||
async function listRecordsByTemplate(env, template) {
|
||||
if (template === USER_TEMPLATE) {
|
||||
const fromStore = readAuthStore(env).users.map(authUserToRecord);
|
||||
const seen = new Set(fromStore.map((r) => (r.values.email ?? "").toLowerCase()));
|
||||
let legacy = [];
|
||||
let fromKbdb = [];
|
||||
try {
|
||||
legacy = await listLegacyRecordsByTemplate(env, template);
|
||||
fromKbdb = await listKbdbRecordsByTemplate(env, template);
|
||||
} catch {
|
||||
legacy = [];
|
||||
fromKbdb = [];
|
||||
}
|
||||
return [...fromStore, ...legacy.filter((r) => !seen.has((r.values.email ?? "").toLowerCase()))];
|
||||
const seen = new Set(fromKbdb.map((r) => (r.values.email ?? "").toLowerCase()));
|
||||
const fromLegacy = readAuthStore(env).users.map(authUserToRecord).filter((r) => !seen.has((r.values.email ?? "").toLowerCase()));
|
||||
return [...fromKbdb, ...fromLegacy];
|
||||
}
|
||||
return listLegacyRecordsByTemplate(env, template);
|
||||
return listKbdbRecordsByTemplate(env, template);
|
||||
}
|
||||
async function listLegacyRecordsByTemplate(env, template) {
|
||||
async function listKbdbRecordsByTemplate(env, template) {
|
||||
const ns = portalNamespace(env);
|
||||
const res = await kbdbFetch(env, `/records/by-template/${encodeURIComponent(template)}?owner_id=${encodeURIComponent(ns)}`);
|
||||
if (!res.ok) throw new KbdbError(`GET /records/by-template/${template} \u2192 ${res.status}`);
|
||||
const body = await res.json();
|
||||
return body.records ?? [];
|
||||
}
|
||||
async function createKbdbUserRecord(env, email, values) {
|
||||
const ns = portalNamespace(env);
|
||||
const res = await kbdbFetch(env, "/records", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ template: USER_TEMPLATE, owner_id: ns, values: { ...values, email } })
|
||||
});
|
||||
if (!res.ok) throw new KbdbError(`POST /records\uFF08portal_user\uFF09\u2192 ${res.status}`);
|
||||
const body = await res.json();
|
||||
const recordId = body.record?.record_id;
|
||||
if (!recordId) throw new KbdbError("POST /records \u56DE\u61C9\u7F3A record_id");
|
||||
const head = await kbdbFetch(env, "/entries", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ entry_type: USER_TEMPLATE, page_name: email, content: recordId, owner_id: ns })
|
||||
});
|
||||
if (!head.ok) throw new KbdbError(`head entry \u5EFA\u7ACB\u5931\u6557\uFF08record ${recordId} \u5DF2\u5EFA\uFF0C\u9700\u4EBA\u5DE5\u6536\u62FE\uFF09\u2192 ${head.status}`);
|
||||
return recordId;
|
||||
}
|
||||
async function createPortalUser(env, input) {
|
||||
const now2 = (/* @__PURE__ */ new Date()).toISOString();
|
||||
const id = newAuthUserId();
|
||||
await mutateAuthStore(env, (data) => {
|
||||
data.users.push({
|
||||
id,
|
||||
email: input.email.toLowerCase(),
|
||||
display_name: input.display_name,
|
||||
status: "active",
|
||||
role: input.role,
|
||||
libraries: input.libraries,
|
||||
password_hash: input.password_hash,
|
||||
created_at: now2,
|
||||
updated_at: now2
|
||||
});
|
||||
return createKbdbUserRecord(env, input.email.toLowerCase(), {
|
||||
display_name: input.display_name,
|
||||
status: "active",
|
||||
role: input.role,
|
||||
password_hash: input.password_hash,
|
||||
libraries: JSON.stringify(input.libraries),
|
||||
created_at: now2,
|
||||
updated_at: now2
|
||||
});
|
||||
return id;
|
||||
}
|
||||
function parseLibraries(raw2) {
|
||||
if (!raw2) return [];
|
||||
@@ -13266,7 +13271,7 @@ async function clearLoginFail(env, email) {
|
||||
async function instanceHasNoAuthData(env) {
|
||||
if (readAuthStore(env).users.length > 0) return false;
|
||||
try {
|
||||
return (await listLegacyRecordsByTemplate(env, USER_TEMPLATE)).length === 0;
|
||||
return (await listKbdbRecordsByTemplate(env, USER_TEMPLATE)).length === 0;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
@@ -13303,7 +13308,7 @@ portalRouter.post(
|
||||
{
|
||||
error: "\u9019\u53F0\u5BE6\u4F8B\u8B80\u4E0D\u5230\u4EFB\u4F55\u767B\u5165\u8CC7\u6599\u2014\u2014\u4E0D\u662F\u5BC6\u78BC\u932F\u3002\u8A8D\u8B49\u5132\u5B58\u662F\u7A7A\u7684\uFF0C\u8ACB\u91CD\u65B0\u57F7\u884C\u5B89\u88DD\uFF0F\u66F4\u65B0\u4EE5\u91CD\u65B0\u5EFA\u7ACB\u7BA1\u7406\u54E1\u5E33\u865F\u3002",
|
||||
code: "auth_store_empty",
|
||||
auth_store: authStoreStatus(c.env)
|
||||
auth_store: { home: "kbdb", writable: true, users: 0 }
|
||||
},
|
||||
503
|
||||
);
|
||||
@@ -13318,10 +13323,14 @@ portalRouter.post(
|
||||
await recordLoginFail(c.env, email);
|
||||
return c.json({ error: "email \u6216\u5BC6\u78BC\u932F\u8AA4" }, 401);
|
||||
}
|
||||
if (!isAuthStoreId(recordId)) await promoteLegacyUser(c.env, rec);
|
||||
let sessionRecordId = recordId;
|
||||
if (isAuthStoreId(recordId)) {
|
||||
const migrated = await promoteToKbdb(c.env, rec);
|
||||
if (migrated) sessionRecordId = migrated;
|
||||
}
|
||||
await clearLoginFail(c.env, email);
|
||||
const token = randomHex2(32);
|
||||
await c.env.SESSIONS_KV.put(`${SESSION_PREFIX2}${token}`, JSON.stringify({ record_id: recordId }), {
|
||||
await c.env.SESSIONS_KV.put(`${SESSION_PREFIX2}${token}`, JSON.stringify({ record_id: sessionRecordId }), {
|
||||
expirationTtl: sessionTtl(c.env)
|
||||
});
|
||||
return c.json({
|
||||
@@ -13704,12 +13713,14 @@ portalRouter.post(
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const pageName = String(body?.page_name ?? "").trim();
|
||||
const srcText = String(body?.text ?? "");
|
||||
if (!pageName || !srcText.trim()) return c.json({ error: "page_name \u8207 text \u5FC5\u586B" }, 400);
|
||||
const daemonPrompt = String(body?.prompt ?? "").trim();
|
||||
if (!daemonPrompt && (!pageName || !srcText.trim()))
|
||||
return c.json({ error: "page_name \u8207 text \u5FC5\u586B" }, 400);
|
||||
if (!c.env.AI) {
|
||||
return c.json({ error: "\u9019\u500B\u90E8\u7F72\u6C92\u6709\u7D81\u5B9A Workers AI\uFF08wrangler.toml \u9700\u6709 [ai] binding\uFF09\uFF0C\u8ACB\u66F4\u65B0\u77E5\u8B58\u5EAB\u7248\u672C" }, 501);
|
||||
}
|
||||
const REL = ">".repeat(2);
|
||||
const prompt = `\u628A\u4EE5\u4E0B\u539F\u7A3F\u91CD\u5BEB\u6210\u5B9A\u7A3F\u77E5\u8B58\u5361\uFF08\u6B63\u9AD4\u4E2D\u6587\uFF09\u3002\u76F4\u63A5\u8F38\u51FA\u5361\u7247\u672C\u8EAB\uFF1A\u7B2C\u4E00\u884C\u5FC5\u9808\u662F\u300C# ${pageName}\u300D\uFF0C\u4E0D\u8981\u4EFB\u4F55\u524D\u8A00\u3001\u601D\u8003\u904E\u7A0B\u3001\u82F1\u6587\u8349\u7A3F\u6216\u8AAA\u660E\u3002\u683C\u5F0F\uFF1A
|
||||
const prompt = daemonPrompt || `\u628A\u4EE5\u4E0B\u539F\u7A3F\u91CD\u5BEB\u6210\u5B9A\u7A3F\u77E5\u8B58\u5361\uFF08\u6B63\u9AD4\u4E2D\u6587\uFF09\u3002\u76F4\u63A5\u8F38\u51FA\u5361\u7247\u672C\u8EAB\uFF1A\u7B2C\u4E00\u884C\u5FC5\u9808\u662F\u300C# ${pageName}\u300D\uFF0C\u4E0D\u8981\u4EFB\u4F55\u524D\u8A00\u3001\u601D\u8003\u904E\u7A0B\u3001\u82F1\u6587\u8349\u7A3F\u6216\u8AAA\u660E\u3002\u683C\u5F0F\uFF1A
|
||||
# ${pageName}
|
||||
## \u4E00\u53E5\u8A71\u5B9A\u7FA9
|
||||
\uFF08\u4E00\u884C\uFF09
|
||||
@@ -13725,14 +13736,17 @@ ${srcText}`;
|
||||
try {
|
||||
const out = await c.env.AI.run("@cf/meta/llama-4-scout-17b-16e-instruct", {
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
max_tokens: 2048,
|
||||
max_tokens: daemonPrompt ? 8192 : 2048,
|
||||
temperature: 0.2
|
||||
});
|
||||
const card = String(out?.response ?? "").trim();
|
||||
if (!card) return c.json({ error: "Workers AI \u6C92\u6709\u56DE\u50B3\u5167\u5BB9" }, 502);
|
||||
const raw2 = String(out?.response ?? "").trim();
|
||||
if (!raw2) return c.json({ error: "Workers AI \u6C92\u6709\u56DE\u50B3\u5167\u5BB9" }, 502);
|
||||
if (daemonPrompt) {
|
||||
return c.json({ success: true, output: raw2 });
|
||||
}
|
||||
const marker = `# ${pageName}`;
|
||||
const idx = card.lastIndexOf(marker);
|
||||
return c.json({ success: true, card: (idx >= 0 ? card.slice(idx) : card).trim() + "\n" });
|
||||
const idx = raw2.lastIndexOf(marker);
|
||||
return c.json({ success: true, card: (idx >= 0 ? raw2.slice(idx) : raw2).trim() + "\n" });
|
||||
} catch (e) {
|
||||
return c.json({ error: `Workers AI \u57F7\u884C\u5931\u6557\uFF1A${e instanceof Error ? e.message : String(e)}` }, 502);
|
||||
}
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,51 @@
|
||||
var __defProp = Object.defineProperty;
|
||||
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||
var __esm = (fn, res, err) => function __init() {
|
||||
if (err) throw err[0];
|
||||
try {
|
||||
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
||||
} catch (e) {
|
||||
throw err = [e], e;
|
||||
}
|
||||
};
|
||||
var __export = (target, all) => {
|
||||
for (var name in all)
|
||||
__defProp(target, name, { get: all[name], enumerable: true });
|
||||
};
|
||||
|
||||
// kbdb/src/actions/relation-orphans.ts
|
||||
var relation_orphans_exports = {};
|
||||
__export(relation_orphans_exports, {
|
||||
scanRelationOrphans: () => scanRelationOrphans
|
||||
});
|
||||
async function scanRelationOrphans(db, limit = 200) {
|
||||
const cap = Math.min(Math.max(limit, 1), 1e3);
|
||||
const res = await db.prepare(
|
||||
`SELECT relation_id, role, missing_id FROM (
|
||||
SELECT r.id AS relation_id, 'src' AS role, r.src_id AS missing_id
|
||||
FROM entries r LEFT JOIN entries t ON t.id = r.src_id
|
||||
WHERE r.src_id IS NOT NULL AND t.id IS NULL
|
||||
UNION ALL
|
||||
SELECT r.id, 'rel', r.rel_id
|
||||
FROM entries r LEFT JOIN entries t ON t.id = r.rel_id
|
||||
WHERE r.rel_id IS NOT NULL AND t.id IS NULL
|
||||
UNION ALL
|
||||
SELECT r.id, 'dst', r.dst_id
|
||||
FROM entries r LEFT JOIN entries t ON t.id = r.dst_id
|
||||
WHERE r.dst_id IS NOT NULL AND t.id IS NULL
|
||||
) LIMIT ?`
|
||||
).bind(cap + 1).all();
|
||||
const rows = res.results ?? [];
|
||||
const truncated = rows.length > cap;
|
||||
const orphans = truncated ? rows.slice(0, cap) : rows;
|
||||
return { orphans, count: orphans.length, truncated };
|
||||
}
|
||||
var init_relation_orphans = __esm({
|
||||
"kbdb/src/actions/relation-orphans.ts"() {
|
||||
"use strict";
|
||||
}
|
||||
});
|
||||
|
||||
// kbdb/node_modules/.pnpm/hono@4.12.23/node_modules/hono/dist/compose.js
|
||||
var compose = (middleware, onError, onNotFound) => {
|
||||
return (context, next) => {
|
||||
@@ -2101,12 +2149,15 @@ async function getEntry(db, id) {
|
||||
const row = await db.prepare("SELECT * FROM entries WHERE id = ?").bind(id).first();
|
||||
return row ?? null;
|
||||
}
|
||||
var NOT_MACHINERY_PREDICATE = "(src_id IS NULL AND entry_type NOT IN ('record', 'sheet', 'field', 'system'))";
|
||||
async function listEntries(db, f = {}) {
|
||||
const conds = [];
|
||||
const params = [];
|
||||
if (f.entry_type) {
|
||||
conds.push("entry_type = ?");
|
||||
params.push(f.entry_type);
|
||||
} else {
|
||||
conds.push(NOT_MACHINERY_PREDICATE);
|
||||
}
|
||||
if (f.owner_id) {
|
||||
conds.push("owner_id = ?");
|
||||
@@ -2158,7 +2209,10 @@ async function updateEntry(db, id, patch) {
|
||||
return getEntry(db, id);
|
||||
}
|
||||
async function deleteEntry(db, id) {
|
||||
const ref = await db.prepare("SELECT id FROM entries WHERE dst_id = ? LIMIT 1").bind(id).first();
|
||||
if (ref) throw new Error(`entry ${id} is still referenced by record relation ${ref.id} \u2014 delete the record (or its slot) first`);
|
||||
await db.prepare("DELETE FROM entries WHERE id = ?").bind(id).run();
|
||||
await db.prepare("DELETE FROM entries WHERE src_id = ?").bind(id).run();
|
||||
}
|
||||
async function embeddedIdsByLibrary(db, ownerId, library) {
|
||||
const rows = await db.prepare(
|
||||
@@ -2392,6 +2446,8 @@ async function searchEntries(db, q, owner_id, entry_type, limit = 50, library, s
|
||||
if (entry_type) {
|
||||
conds.push("entry_type = ?");
|
||||
params.push(entry_type);
|
||||
} else {
|
||||
conds.push(NOT_MACHINERY_PREDICATE);
|
||||
}
|
||||
if (source) {
|
||||
conds.push("json_extract(metadata_json, '$.source') = ?");
|
||||
@@ -3236,9 +3292,38 @@ entryRoutes.delete("/:id", async (c) => {
|
||||
function uid2(prefix) {
|
||||
return `${prefix}_${crypto.randomUUID()}`;
|
||||
}
|
||||
var SYS_ROOT = "sys_root";
|
||||
var SYS_BELONGS = "sys_belongs";
|
||||
var SYS_FIELD_OF = "sys_field_of";
|
||||
function fieldEntryId(templateId, slot) {
|
||||
return `fld_${templateId}_${slot}`;
|
||||
}
|
||||
async function ensureAnchors(db) {
|
||||
await db.prepare(
|
||||
`INSERT OR IGNORE INTO entries (id, content, entry_type, owner_id) VALUES
|
||||
('${SYS_ROOT}', 'root', 'system', NULL),
|
||||
('${SYS_BELONGS}', 'belongs', 'system', NULL),
|
||||
('${SYS_FIELD_OF}', 'field_of', 'system', NULL)`
|
||||
).run();
|
||||
}
|
||||
async function ensureFieldEntries(db, templateId, slots) {
|
||||
for (const slot of slots) {
|
||||
const fid = fieldEntryId(templateId, slot);
|
||||
await db.prepare(`INSERT OR IGNORE INTO entries (id, content, entry_type) VALUES (?, ?, 'field')`).bind(fid, slot).run();
|
||||
await db.prepare(
|
||||
`INSERT OR IGNORE INTO entries (id, entry_type, src_id, rel_id, dst_id) VALUES (?, 'relation', ?, '${SYS_FIELD_OF}', ?)`
|
||||
).bind(`relf_${templateId}_${slot}`, fid, templateId).run();
|
||||
}
|
||||
}
|
||||
async function createTemplate(db, input) {
|
||||
const id = input.id ?? uid2("tpl");
|
||||
await db.prepare(`INSERT INTO templates (id, name, description, slots_json, created_by) VALUES (?, ?, ?, ?, ?)`).bind(id, input.name, input.description ?? null, JSON.stringify(input.slots), input.created_by ?? null).run();
|
||||
await ensureAnchors(db);
|
||||
await db.prepare(`INSERT OR IGNORE INTO entries (id, content, entry_type) VALUES (?, ?, 'sheet')`).bind(id, input.name).run();
|
||||
await db.prepare(
|
||||
`INSERT OR IGNORE INTO entries (id, entry_type, src_id, rel_id, dst_id) VALUES (?, 'relation', ?, '${SYS_BELONGS}', '${SYS_ROOT}')`
|
||||
).bind(`relb_${id}`, id).run();
|
||||
await ensureFieldEntries(db, id, input.slots);
|
||||
const row = await getTemplate(db, id);
|
||||
if (!row) throw new Error("createTemplate: row not found after insert");
|
||||
return row;
|
||||
@@ -3265,104 +3350,187 @@ async function updateTemplate(db, id, patch) {
|
||||
if (cols.length === 0) return getTemplate(db, id);
|
||||
cols.push("updated_at = unixepoch()");
|
||||
await db.prepare(`UPDATE templates SET ${cols.join(", ")} WHERE id = ?`).bind(...params, id).run();
|
||||
if (patch.slots !== void 0) await ensureFieldEntries(db, id, patch.slots);
|
||||
return getTemplate(db, id);
|
||||
}
|
||||
async function loadReferencedEntries(db, entryIds, recordOwnerId) {
|
||||
const ids = [...new Set(Object.values(entryIds))];
|
||||
if (ids.length === 0) return /* @__PURE__ */ new Map();
|
||||
const rows = [];
|
||||
for (let i = 0; i < ids.length; i += 90) {
|
||||
const chunk = ids.slice(i, i + 90);
|
||||
const res = await db.prepare(`SELECT id, content, owner_id FROM entries WHERE id IN (${chunk.map(() => "?").join(",")})`).bind(...chunk).all();
|
||||
rows.push(...res.results ?? []);
|
||||
}
|
||||
const found = new Map(rows.map((r) => [r.id, r]));
|
||||
const missing = ids.filter((id) => !found.has(id));
|
||||
if (missing.length > 0) throw new Error(`entry not found: ${missing.join(", ")}`);
|
||||
if (recordOwnerId != null) {
|
||||
const foreign = rows.filter((r) => r.owner_id != null && r.owner_id !== recordOwnerId);
|
||||
if (foreign.length > 0) {
|
||||
throw new Error(
|
||||
`entry owner mismatch: ${foreign.map((r) => `${r.id}(${r.owner_id})`).join(", ")} != ${recordOwnerId}`
|
||||
);
|
||||
}
|
||||
}
|
||||
return new Map(rows.map((r) => [r.id, r.content]));
|
||||
}
|
||||
async function recordBelongs(db, recordId) {
|
||||
const row = await db.prepare(`SELECT dst_id FROM entries WHERE src_id = ? AND rel_id = '${SYS_BELONGS}' AND dst_id != '${SYS_ROOT}' LIMIT 1`).bind(recordId).first();
|
||||
return row ?? null;
|
||||
}
|
||||
async function insertCellRelation(db, recordId, templateId, slot, dstEntryId, ownerId) {
|
||||
await db.prepare(
|
||||
`INSERT INTO entries (id, entry_type, owner_id, src_id, rel_id, dst_id) VALUES (?, 'relation', ?, ?, ?, ?)`
|
||||
).bind(uid2("relv"), ownerId, recordId, fieldEntryId(templateId, slot), dstEntryId).run();
|
||||
}
|
||||
async function createRecord(db, input) {
|
||||
const tpl = await getTemplate(db, input.template);
|
||||
if (!tpl) throw new Error(`template not found: ${input.template}`);
|
||||
const slots = JSON.parse(tpl.slots_json);
|
||||
const recordId = input.record_id ?? uid2("rec");
|
||||
for (const slot of slots) {
|
||||
if (!(slot in input.values)) continue;
|
||||
const values = input.values ?? {};
|
||||
const entryIds = input.entry_ids ?? {};
|
||||
const refSlots = Object.keys(entryIds);
|
||||
const ownerId = input.owner_id ?? null;
|
||||
const both = refSlots.filter((s) => s in values);
|
||||
if (both.length > 0) throw new Error(`slot given both value and entry_id: ${both.join(", ")}`);
|
||||
const unknown = refSlots.filter((s) => !slots.includes(s));
|
||||
if (unknown.length > 0) throw new Error(`slot not in template: ${unknown.join(", ")}`);
|
||||
const referenced = await loadReferencedEntries(db, entryIds, ownerId);
|
||||
await db.prepare(`INSERT OR IGNORE INTO entries (id, entry_type, owner_id) VALUES (?, 'record', ?)`).bind(recordId, ownerId).run();
|
||||
await db.prepare(
|
||||
`INSERT OR IGNORE INTO entries (id, entry_type, owner_id, src_id, rel_id, dst_id) VALUES (?, 'relation', ?, ?, '${SYS_BELONGS}', ?)`
|
||||
).bind(`relb_${recordId}_${tpl.id}`, ownerId, recordId, tpl.id).run();
|
||||
const writtenSlots = slots.filter((s) => s in entryIds || s in values);
|
||||
await ensureFieldEntries(db, tpl.id, writtenSlots);
|
||||
for (const slot of writtenSlots) {
|
||||
if (slot in entryIds) {
|
||||
await insertCellRelation(db, recordId, tpl.id, slot, entryIds[slot], ownerId);
|
||||
continue;
|
||||
}
|
||||
const entry = await createEntry(db, {
|
||||
content: input.values[slot],
|
||||
content: values[slot],
|
||||
entry_type: "value",
|
||||
owner_id: input.owner_id ?? null
|
||||
owner_id: ownerId
|
||||
});
|
||||
await db.prepare(`INSERT INTO entry_values (id, record_id, template_id, slot_name, entry_id) VALUES (?, ?, ?, ?, ?)`).bind(uid2("ev"), recordId, tpl.id, slot, entry.id).run();
|
||||
await insertCellRelation(db, recordId, tpl.id, slot, entry.id, ownerId);
|
||||
}
|
||||
return { record_id: recordId, template_id: tpl.id, values: input.values, owner_id: input.owner_id ?? null };
|
||||
const out = { ...values };
|
||||
for (const [slot, entryId] of Object.entries(entryIds)) out[slot] = referenced.get(entryId) ?? "";
|
||||
return { record_id: recordId, template_id: tpl.id, values: out, owner_id: ownerId };
|
||||
}
|
||||
async function updateRecord(db, recordId, values) {
|
||||
const evRes = await db.prepare(
|
||||
`SELECT ev.slot_name AS slot_name, ev.entry_id AS entry_id, ev.template_id AS template_id, e.owner_id AS owner_id
|
||||
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
|
||||
WHERE ev.record_id = ?`
|
||||
const belongs = await recordBelongs(db, recordId);
|
||||
if (!belongs) return null;
|
||||
const templateId = belongs.dst_id;
|
||||
const cellRes = await db.prepare(
|
||||
`SELECT f.content AS slot_name, r.dst_id AS entry_id
|
||||
FROM entries r JOIN entries f ON r.rel_id = f.id
|
||||
WHERE r.src_id = ? AND r.rel_id != '${SYS_BELONGS}'`
|
||||
).bind(recordId).all();
|
||||
const evRows = evRes.results ?? [];
|
||||
if (evRows.length === 0) return null;
|
||||
const templateId = evRows[0].template_id;
|
||||
const recordOwnerId = evRows.find((r) => r.owner_id != null)?.owner_id ?? null;
|
||||
const slotToEntry = new Map(evRows.map((r) => [r.slot_name, r.entry_id]));
|
||||
const cells = cellRes.results ?? [];
|
||||
const slotToEntries = /* @__PURE__ */ new Map();
|
||||
for (const c of cells) {
|
||||
const list = slotToEntries.get(c.slot_name) ?? [];
|
||||
list.push(c.entry_id);
|
||||
slotToEntries.set(c.slot_name, list);
|
||||
}
|
||||
const identity = await db.prepare("SELECT owner_id FROM entries WHERE id = ?").bind(recordId).first();
|
||||
const recordOwnerId = identity?.owner_id ?? null;
|
||||
const tpl = await getTemplate(db, templateId);
|
||||
const allowed = tpl ? JSON.parse(tpl.slots_json) : [...slotToEntry.keys()];
|
||||
const allowed = tpl ? JSON.parse(tpl.slots_json) : [...slotToEntries.keys()];
|
||||
for (const [slot, content] of Object.entries(values)) {
|
||||
if (!allowed.includes(slot)) {
|
||||
throw new Error(`slot not in template: ${slot}`);
|
||||
}
|
||||
const entryId = slotToEntry.get(slot);
|
||||
if (entryId) {
|
||||
await db.prepare(`UPDATE entries SET content = ?, updated_at = unixepoch() WHERE id = ?`).bind(content, entryId).run();
|
||||
const entryIds = slotToEntries.get(slot);
|
||||
if (entryIds && entryIds.length > 0) {
|
||||
for (const entryId of entryIds) {
|
||||
await db.prepare(`UPDATE entries SET content = ?, updated_at = unixepoch() WHERE id = ?`).bind(content, entryId).run();
|
||||
}
|
||||
} else {
|
||||
await ensureFieldEntries(db, templateId, [slot]);
|
||||
const entry = await createEntry(db, { content, entry_type: "value", owner_id: recordOwnerId });
|
||||
await db.prepare(`INSERT INTO entry_values (id, record_id, template_id, slot_name, entry_id) VALUES (?, ?, ?, ?, ?)`).bind(uid2("ev"), recordId, templateId, slot, entry.id).run();
|
||||
await insertCellRelation(db, recordId, templateId, slot, entry.id, recordOwnerId);
|
||||
}
|
||||
}
|
||||
return getRecord(db, recordId);
|
||||
}
|
||||
async function getRecord(db, recordId) {
|
||||
const belongs = await recordBelongs(db, recordId);
|
||||
if (!belongs) return null;
|
||||
const res = await db.prepare(
|
||||
`SELECT ev.slot_name as slot, e.content as content, ev.template_id as template_id, e.owner_id as owner_id
|
||||
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
|
||||
WHERE ev.record_id = ?`
|
||||
`SELECT f.content AS slot, v.content AS content
|
||||
FROM entries r
|
||||
JOIN entries f ON r.rel_id = f.id
|
||||
JOIN entries v ON r.dst_id = v.id
|
||||
WHERE r.src_id = ? AND r.rel_id != '${SYS_BELONGS}'`
|
||||
).bind(recordId).all();
|
||||
const rows = res.results ?? [];
|
||||
if (rows.length === 0) return null;
|
||||
const values = {};
|
||||
for (const r of rows) values[r.slot] = r.content;
|
||||
const owner_id = rows.find((r) => r.owner_id != null)?.owner_id ?? null;
|
||||
return { record_id: recordId, template_id: rows[0].template_id, values, owner_id };
|
||||
for (const r of res.results ?? []) values[r.slot] = r.content;
|
||||
const identity = await db.prepare("SELECT owner_id FROM entries WHERE id = ?").bind(recordId).first();
|
||||
return { record_id: recordId, template_id: belongs.dst_id, values, owner_id: identity?.owner_id ?? null };
|
||||
}
|
||||
async function searchByTemplate(db, template, owner_id, limit = 100) {
|
||||
const tpl = await getTemplate(db, template);
|
||||
if (!tpl) return [];
|
||||
const cap = Math.min(limit, 500);
|
||||
const res = owner_id ? await db.prepare(
|
||||
`SELECT DISTINCT ev.record_id as record_id FROM entry_values ev
|
||||
JOIN entries e ON ev.entry_id = e.id
|
||||
WHERE ev.template_id = ? AND e.owner_id = ?
|
||||
ORDER BY ev.created_at DESC LIMIT ?`
|
||||
).bind(tpl.id, owner_id, cap).all() : await db.prepare(`SELECT DISTINCT record_id FROM entry_values WHERE template_id = ? ORDER BY created_at DESC LIMIT ?`).bind(tpl.id, cap).all();
|
||||
`SELECT src_id AS record_id FROM entries
|
||||
WHERE rel_id = '${SYS_BELONGS}' AND dst_id = ? AND owner_id = ?
|
||||
ORDER BY created_at DESC, rowid DESC LIMIT ?`
|
||||
).bind(tpl.id, owner_id, cap).all() : await db.prepare(
|
||||
`SELECT src_id AS record_id FROM entries
|
||||
WHERE rel_id = '${SYS_BELONGS}' AND dst_id = ?
|
||||
ORDER BY created_at DESC, rowid DESC LIMIT ?`
|
||||
).bind(tpl.id, cap).all();
|
||||
const ids = (res.results ?? []).map((r) => r.record_id);
|
||||
if (ids.length === 0) return [];
|
||||
const byId = /* @__PURE__ */ new Map();
|
||||
for (const id of ids) byId.set(id, { record_id: id, template_id: tpl.id, values: {}, owner_id: null });
|
||||
for (let i = 0; i < ids.length; i += 90) {
|
||||
const chunk = ids.slice(i, i + 90);
|
||||
const placeholders = chunk.map(() => "?").join(",");
|
||||
const evRes = await db.prepare(
|
||||
`SELECT ev.record_id as record_id, ev.slot_name as slot, e.content as content, ev.template_id as template_id, e.owner_id as owner_id
|
||||
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
|
||||
WHERE ev.record_id IN (${placeholders})`
|
||||
).bind(...chunk).all();
|
||||
for (const r of evRes.results ?? []) {
|
||||
let rec = byId.get(r.record_id);
|
||||
if (!rec) {
|
||||
rec = { record_id: r.record_id, template_id: r.template_id, values: {}, owner_id: null };
|
||||
byId.set(r.record_id, rec);
|
||||
}
|
||||
rec.values[r.slot] = r.content;
|
||||
if (rec.owner_id == null && r.owner_id != null) rec.owner_id = r.owner_id;
|
||||
const [cellRes, identRes] = await Promise.all([
|
||||
db.prepare(
|
||||
`SELECT r.src_id AS record_id, f.content AS slot, v.content AS content
|
||||
FROM entries r
|
||||
JOIN entries f ON r.rel_id = f.id
|
||||
JOIN entries v ON r.dst_id = v.id
|
||||
WHERE r.src_id IN (${placeholders}) AND r.rel_id != '${SYS_BELONGS}'`
|
||||
).bind(...chunk).all(),
|
||||
db.prepare(`SELECT id, owner_id FROM entries WHERE id IN (${placeholders})`).bind(...chunk).all()
|
||||
]);
|
||||
for (const r of cellRes.results ?? []) {
|
||||
const rec = byId.get(r.record_id);
|
||||
if (rec) rec.values[r.slot] = r.content;
|
||||
}
|
||||
for (const r of identRes.results ?? []) {
|
||||
const rec = byId.get(r.id);
|
||||
if (rec) rec.owner_id = r.owner_id;
|
||||
}
|
||||
}
|
||||
return ids.map((id) => byId.get(id)).filter((r) => !!r);
|
||||
}
|
||||
async function deleteRecord(db, recordId) {
|
||||
const evRes = await db.prepare("SELECT entry_id FROM entry_values WHERE record_id = ?").bind(recordId).all();
|
||||
const rows = evRes.results ?? [];
|
||||
if (rows.length === 0) return false;
|
||||
await db.prepare("DELETE FROM entry_values WHERE record_id = ?").bind(recordId).run();
|
||||
for (const { entry_id } of rows) {
|
||||
await db.prepare("DELETE FROM entries WHERE id = ?").bind(entry_id).run();
|
||||
const belongs = await recordBelongs(db, recordId);
|
||||
if (!belongs) return false;
|
||||
const cellRes = await db.prepare(`SELECT dst_id FROM entries WHERE src_id = ? AND rel_id != '${SYS_BELONGS}'`).bind(recordId).all();
|
||||
const dsts = (cellRes.results ?? []).map((r) => r.dst_id);
|
||||
await db.prepare(`DELETE FROM entries WHERE src_id = ?`).bind(recordId).run();
|
||||
await db.prepare(
|
||||
`DELETE FROM entries WHERE id = ?1 AND entry_type = 'record'
|
||||
AND NOT EXISTS (SELECT 1 FROM entries WHERE dst_id = ?1)`
|
||||
).bind(recordId).run();
|
||||
for (const dst of dsts) {
|
||||
await db.prepare(
|
||||
`DELETE FROM entries WHERE id = ?1
|
||||
AND entry_type NOT IN ('sheet', 'field', 'system')
|
||||
AND NOT EXISTS (SELECT 1 FROM entries WHERE dst_id = ?1)
|
||||
AND NOT EXISTS (SELECT 1 FROM entries WHERE src_id = ?1)
|
||||
AND NOT EXISTS (SELECT 1 FROM entries WHERE rel_id = ?1)`
|
||||
).bind(dst).run();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -3395,10 +3563,17 @@ templateRoutes.patch("/:id", async (c) => {
|
||||
|
||||
// kbdb/src/routes/records.ts
|
||||
var recordRoutes = new Hono2();
|
||||
var isStringMap = (v) => !!v && typeof v === "object" && !Array.isArray(v) && Object.values(v).every((x) => typeof x === "string");
|
||||
recordRoutes.post("/", async (c) => {
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (!body || !body.template || !body.values) {
|
||||
return c.json({ success: false, error: "template and values required" }, 400);
|
||||
if (!body || !body.template || !body.values && !body.entry_ids) {
|
||||
return c.json({ success: false, error: "template and values (or entry_ids) required" }, 400);
|
||||
}
|
||||
if (body.values !== void 0 && !isStringMap(body.values)) {
|
||||
return c.json({ success: false, error: "values must be an object of {slot: string}" }, 400);
|
||||
}
|
||||
if (body.entry_ids !== void 0 && !isStringMap(body.entry_ids)) {
|
||||
return c.json({ success: false, error: "entry_ids must be an object of {slot: entry_id}" }, 400);
|
||||
}
|
||||
try {
|
||||
const rec = await createRecord(c.env.DB, body);
|
||||
@@ -3411,20 +3586,14 @@ recordRoutes.get("/triplet-stats", async (c) => {
|
||||
const owner = c.req.query("owner_id") || "";
|
||||
const rows = await c.env.DB.prepare(
|
||||
`SELECT
|
||||
COALESCE(NULLIF(lib_e.content, ''), 'general') AS library,
|
||||
COALESCE(NULLIF(lib_v.content, ''), 'general') AS library,
|
||||
COUNT(*) AS triplet_count
|
||||
FROM (
|
||||
SELECT DISTINCT ev.record_id
|
||||
FROM entry_values ev
|
||||
JOIN templates t ON ev.template_id = t.id
|
||||
JOIN entries e ON ev.entry_id = e.id
|
||||
WHERE t.name = 'triplet'
|
||||
AND (?1 = '' OR e.owner_id = ?1)
|
||||
) AS tr
|
||||
LEFT JOIN entry_values lev
|
||||
ON lev.record_id = tr.record_id AND lev.slot_name = 'library'
|
||||
LEFT JOIN entries lib_e ON lib_e.id = lev.entry_id
|
||||
GROUP BY COALESCE(NULLIF(lib_e.content, ''), 'general')
|
||||
FROM entries b
|
||||
JOIN templates t ON b.dst_id = t.id AND t.name = 'triplet'
|
||||
LEFT JOIN entries lr ON lr.src_id = b.src_id AND lr.rel_id = ('fld_' || b.dst_id || '_library')
|
||||
LEFT JOIN entries lib_v ON lib_v.id = lr.dst_id
|
||||
WHERE b.rel_id = 'sys_belongs' AND (?1 = '' OR b.owner_id = ?1)
|
||||
GROUP BY COALESCE(NULLIF(lib_v.content, ''), 'general')
|
||||
ORDER BY library`
|
||||
).bind(owner).all();
|
||||
const stats = (rows.results ?? []).map((r) => ({ library: r.library, triplet_count: r.triplet_count }));
|
||||
@@ -3604,31 +3773,35 @@ async function ensureTripletLibrarySlot(db, tripletTemplate) {
|
||||
return true;
|
||||
}
|
||||
function tripletPivotSql(ownerFiltered) {
|
||||
return `SELECT ev.record_id AS rid,
|
||||
MAX(CASE WHEN ev.slot_name = 'subject' THEN e.content END) AS subject,
|
||||
MAX(CASE WHEN ev.slot_name = 'object' THEN e.content END) AS object,
|
||||
MAX(CASE WHEN ev.slot_name = 'predicate' THEN e.content END) AS predicate,
|
||||
MAX(CASE WHEN ev.slot_name = 'status' THEN e.content END) AS status,
|
||||
MAX(CASE WHEN ev.slot_name = 'library' THEN e.content END) AS library,
|
||||
MAX(CASE WHEN ev.slot_name = 'source_uri' THEN e.content END) AS source_uri
|
||||
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
|
||||
WHERE ev.template_id = ?${ownerFiltered ? " AND e.owner_id = ?" : ""}
|
||||
GROUP BY ev.record_id`;
|
||||
return `SELECT b.src_id AS rid,
|
||||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_subject' THEN v.content END) AS subject,
|
||||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_object' THEN v.content END) AS object,
|
||||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_predicate' THEN v.content END) AS predicate,
|
||||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_status' THEN v.content END) AS status,
|
||||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_library' THEN v.content END) AS library,
|
||||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_source_uri' THEN v.content END) AS source_uri
|
||||
FROM entries b
|
||||
LEFT JOIN entries r ON r.src_id = b.src_id AND r.rel_id != 'sys_belongs'
|
||||
LEFT JOIN entries v ON v.id = r.dst_id
|
||||
WHERE b.rel_id = 'sys_belongs' AND b.dst_id = ?${ownerFiltered ? " AND b.owner_id = ?" : ""}
|
||||
GROUP BY b.src_id`;
|
||||
}
|
||||
function mapPivotSql(ownerFiltered) {
|
||||
return `SELECT ev.record_id AS rid,
|
||||
MAX(CASE WHEN ev.slot_name = 'library' THEN e.content END) AS library,
|
||||
MAX(CASE WHEN ev.slot_name = 'narrative' THEN e.content END) AS narrative,
|
||||
MAX(CASE WHEN ev.slot_name = 'top_entities' THEN e.content END) AS top_entities,
|
||||
MAX(CASE WHEN ev.slot_name = 'relation_profile' THEN e.content END) AS relation_profile,
|
||||
MAX(CASE WHEN ev.slot_name = 'bridges' THEN e.content END) AS bridges,
|
||||
MAX(CASE WHEN ev.slot_name = 'triplet_count' THEN e.content END) AS triplet_count,
|
||||
MAX(CASE WHEN ev.slot_name = 'commit_hash' THEN e.content END) AS commit_hash,
|
||||
MAX(CASE WHEN ev.slot_name = 'status' THEN e.content END) AS status,
|
||||
MAX(ev.created_at) AS ts
|
||||
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
|
||||
WHERE ev.template_id = ?${ownerFiltered ? " AND e.owner_id = ?" : ""}
|
||||
GROUP BY ev.record_id`;
|
||||
return `SELECT b.src_id AS rid,
|
||||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_library' THEN v.content END) AS library,
|
||||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_narrative' THEN v.content END) AS narrative,
|
||||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_top_entities' THEN v.content END) AS top_entities,
|
||||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_relation_profile' THEN v.content END) AS relation_profile,
|
||||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_bridges' THEN v.content END) AS bridges,
|
||||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_triplet_count' THEN v.content END) AS triplet_count,
|
||||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_commit_hash' THEN v.content END) AS commit_hash,
|
||||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_status' THEN v.content END) AS status,
|
||||
MAX(r.created_at) AS ts
|
||||
FROM entries b
|
||||
LEFT JOIN entries r ON r.src_id = b.src_id AND r.rel_id != 'sys_belongs'
|
||||
LEFT JOIN entries v ON v.id = r.dst_id
|
||||
WHERE b.rel_id = 'sys_belongs' AND b.dst_id = ?${ownerFiltered ? " AND b.owner_id = ?" : ""}
|
||||
GROUP BY b.src_id`;
|
||||
}
|
||||
function parseJsonArray(raw2) {
|
||||
if (!raw2) return [];
|
||||
@@ -3736,6 +3909,7 @@ async function recomputeLibraryMap(db, input) {
|
||||
await updateRecord(db, row.rid, { status: "superseded" });
|
||||
superseded.push(row.rid);
|
||||
}
|
||||
const entryCount = (await liveEntryCountsByLibrary(db, owner)).get(library) ?? 0;
|
||||
return {
|
||||
map: {
|
||||
record_id: blockEntry.id,
|
||||
@@ -3746,6 +3920,7 @@ async function recomputeLibraryMap(db, input) {
|
||||
relation_profile: relationProfile,
|
||||
bridges,
|
||||
triplet_count: tripletCount,
|
||||
entry_count: entryCount,
|
||||
commit_hash: input.commit_hash ?? null,
|
||||
status: "active",
|
||||
updated_at: blockEntry.created_at
|
||||
@@ -3759,18 +3934,36 @@ async function liveTripletCountsByLibrary(db, tripletTemplateId, owner_id) {
|
||||
const params = owner_id ? [tripletTemplateId, owner_id] : [tripletTemplateId];
|
||||
const res = await db.prepare(
|
||||
// kbdb-sql-ok:牆內本體(kbdb/src/actions/),checkout 開在巢狀 worktree matrix/arcrun/.worktree-fix-87/(避免打斷另一 session 佔用中的 matrix/arcrun 主 checkout),hook 逐字比對 matrix/arcrun/kbdb/src/ 吃不到中間多出的 worktree 目錄層,非繞牆
|
||||
`SELECT COALESCE(NULLIF(lib_e.content, ''), 'general') AS library, COUNT(*) AS n
|
||||
`SELECT COALESCE(NULLIF(tr.library, ''), 'general') AS library, COUNT(*) AS n
|
||||
FROM (
|
||||
SELECT ev.record_id AS rid,
|
||||
MAX(CASE WHEN ev.slot_name = 'status' THEN e.content END) AS status
|
||||
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
|
||||
WHERE ev.template_id = ?${owner_id ? " AND e.owner_id = ?" : ""}
|
||||
GROUP BY ev.record_id
|
||||
SELECT b.src_id AS rid,
|
||||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_status' THEN v.content END) AS status,
|
||||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_library' THEN v.content END) AS library
|
||||
FROM entries b
|
||||
LEFT JOIN entries r ON r.src_id = b.src_id AND r.rel_id != 'sys_belongs'
|
||||
LEFT JOIN entries v ON v.id = r.dst_id
|
||||
WHERE b.rel_id = 'sys_belongs' AND b.dst_id = ?${owner_id ? " AND b.owner_id = ?" : ""}
|
||||
GROUP BY b.src_id
|
||||
) AS tr
|
||||
LEFT JOIN entry_values lev ON lev.record_id = tr.rid AND lev.slot_name = 'library'
|
||||
LEFT JOIN entries lib_e ON lib_e.id = lev.entry_id
|
||||
WHERE COALESCE(tr.status, 'active') = 'active'
|
||||
GROUP BY COALESCE(NULLIF(lib_e.content, ''), 'general')`
|
||||
GROUP BY COALESCE(NULLIF(tr.library, ''), 'general')`
|
||||
).bind(...params).all();
|
||||
const m = /* @__PURE__ */ new Map();
|
||||
for (const r of res.results ?? []) m.set(r.library, r.n);
|
||||
return m;
|
||||
}
|
||||
async function liveEntryCountsByLibrary(db, owner_id) {
|
||||
const params = owner_id ? [owner_id] : [];
|
||||
const res = await db.prepare(
|
||||
// kbdb-sql-ok:牆內本體(kbdb/src/actions/),checkout 開在巢狀 worktree /private/tmp/wt-arcrun-library-map-honesty-87/(同 962d863/5919c6b 已記載的假警報成因:hook 逐字比對 matrix/arcrun/kbdb/src/ 吃不到中間多出的 worktree 目錄層,非繞牆)
|
||||
`SELECT COALESCE(NULLIF(json_extract(metadata_json, '$.library'), ''), 'general') AS library,
|
||||
COUNT(*) AS n
|
||||
FROM entries
|
||||
WHERE ${owner_id ? "owner_id = ? AND " : ""}entry_type != 'value'
|
||||
AND src_id IS NULL
|
||||
AND entry_type NOT IN ('record', 'sheet', 'field', 'system')
|
||||
AND NOT (entry_type = 'block' AND COALESCE(json_extract(metadata_json, '$.kind'), '') = 'library_map')
|
||||
GROUP BY COALESCE(NULLIF(json_extract(metadata_json, '$.library'), ''), 'general')`
|
||||
).bind(...params).all();
|
||||
const m = /* @__PURE__ */ new Map();
|
||||
for (const r of res.results ?? []) m.set(r.library, r.n);
|
||||
@@ -3788,10 +3981,12 @@ async function knownLibraryNames(db, owner_id) {
|
||||
if (libTpl) {
|
||||
const libParams = owner_id ? [libTpl.id, owner_id] : [libTpl.id];
|
||||
const libRows = await db.prepare(
|
||||
`SELECT MAX(CASE WHEN ev.slot_name = 'name' THEN e.content END) AS name
|
||||
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
|
||||
WHERE ev.template_id = ?${owner_id ? " AND e.owner_id = ?" : ""}
|
||||
GROUP BY ev.record_id`
|
||||
`SELECT MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_name' THEN v.content END) AS name
|
||||
FROM entries b
|
||||
LEFT JOIN entries r ON r.src_id = b.src_id AND r.rel_id != 'sys_belongs'
|
||||
LEFT JOIN entries v ON v.id = r.dst_id
|
||||
WHERE b.rel_id = 'sys_belongs' AND b.dst_id = ?${owner_id ? " AND b.owner_id = ?" : ""}
|
||||
GROUP BY b.src_id`
|
||||
).bind(...libParams).all();
|
||||
for (const r of libRows.results ?? []) if (r.name) names.add(r.name);
|
||||
}
|
||||
@@ -3825,11 +4020,15 @@ async function listLibraryMaps(db, owner_id) {
|
||||
const tpl = await getTemplate(db, LIBRARY_MAP_TEMPLATE_NAME);
|
||||
if (!tpl) return [];
|
||||
const params = owner_id ? [tpl.id, owner_id] : [tpl.id];
|
||||
const res = await db.prepare(
|
||||
`WITH m AS (${mapPivotSql(!!owner_id)})
|
||||
SELECT * FROM m WHERE COALESCE(m.status, 'active') = 'active' AND m.library IS NOT NULL
|
||||
ORDER BY m.ts DESC`
|
||||
).bind(...params).all();
|
||||
const [res, entryCounts] = await Promise.all([
|
||||
db.prepare(
|
||||
// kbdb-sql-ok:牆內本體(kbdb/src/actions/),既有查詢(listLibraryMaps 原本就有)此次改包進 Promise.all 才重新觸發掃描,非新增違規;worktree 路徑假警報同上方 liveEntryCountsByLibrary 註解
|
||||
`WITH m AS (${mapPivotSql(!!owner_id)})
|
||||
SELECT * FROM m WHERE COALESCE(m.status, 'active') = 'active' AND m.library IS NOT NULL
|
||||
ORDER BY m.ts DESC`
|
||||
).bind(...params).all(),
|
||||
liveEntryCountsByLibrary(db, owner_id)
|
||||
]);
|
||||
const byLib = /* @__PURE__ */ new Map();
|
||||
for (const r of res.results ?? []) {
|
||||
if (!r.library || byLib.has(r.library)) continue;
|
||||
@@ -3838,6 +4037,7 @@ async function listLibraryMaps(db, owner_id) {
|
||||
narrative: r.narrative || null,
|
||||
top_entities: parseJsonArray(r.top_entities).slice(0, 3).map((t) => t.name),
|
||||
triplet_count: Number(r.triplet_count ?? 0) || 0,
|
||||
entry_count: entryCounts.get(r.library) ?? 0,
|
||||
updated_at: r.ts
|
||||
});
|
||||
}
|
||||
@@ -3853,7 +4053,10 @@ async function getLibraryMapDetail(db, library, owner_id) {
|
||||
ORDER BY m.ts DESC LIMIT 1`
|
||||
).bind(...params).first();
|
||||
if (!row) return null;
|
||||
const blockEntry = await getEntry(db, row.rid);
|
||||
const [blockEntry, entryCounts] = await Promise.all([
|
||||
getEntry(db, row.rid),
|
||||
liveEntryCountsByLibrary(db, owner_id)
|
||||
]);
|
||||
return {
|
||||
record_id: row.rid,
|
||||
library,
|
||||
@@ -3863,6 +4066,7 @@ async function getLibraryMapDetail(db, library, owner_id) {
|
||||
relation_profile: parseJsonArray(row.relation_profile),
|
||||
bridges: parseJsonArray(row.bridges),
|
||||
triplet_count: Number(row.triplet_count ?? 0) || 0,
|
||||
entry_count: entryCounts.get(library) ?? 0,
|
||||
commit_hash: row.commit_hash || null,
|
||||
status: row.status ?? "active",
|
||||
updated_at: row.ts
|
||||
@@ -4150,6 +4354,12 @@ app.use("*", async (c, next) => {
|
||||
});
|
||||
app.get("/", (c) => c.json({ service: "arcrun-kbdb", tier: "base", status: "ok" }));
|
||||
app.get("/health", (c) => c.json({ ok: true }));
|
||||
app.get("/maintenance/relation-orphans", async (c) => {
|
||||
const { scanRelationOrphans: scanRelationOrphans2 } = await Promise.resolve().then(() => (init_relation_orphans(), relation_orphans_exports));
|
||||
const limit = Number(c.req.query("limit") ?? "200");
|
||||
const report = await scanRelationOrphans2(c.env.DB, Number.isFinite(limit) ? limit : 200);
|
||||
return c.json({ success: true, ...report });
|
||||
});
|
||||
app.route("/entries", entryRoutes);
|
||||
app.route("/templates", templateRoutes);
|
||||
app.route("/records", recordRoutes);
|
||||
|
||||
@@ -31262,16 +31262,109 @@ function registerAllWorkflowCrudTools(server, env) {
|
||||
registerRunWorkflow(server, env);
|
||||
}
|
||||
|
||||
// mcp/src/lib/portal-client.ts
|
||||
async function portalFetch(env, session, path, opts = {}) {
|
||||
if (!env.CYPHER_EXECUTOR) {
|
||||
throw new Error("CYPHER_EXECUTOR service binding not configured");
|
||||
}
|
||||
const url = new URL(`https://cypher${path}`);
|
||||
for (const [k, v] of Object.entries(opts.query ?? {})) {
|
||||
if (v !== void 0 && v !== "") url.searchParams.set(k, String(v));
|
||||
}
|
||||
return env.CYPHER_EXECUTOR.fetch(url.toString(), {
|
||||
method: opts.method ?? "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${session}`
|
||||
},
|
||||
body: opts.body !== void 0 ? JSON.stringify(opts.body) : void 0
|
||||
});
|
||||
}
|
||||
function resolveKnowledgeIdentity(authPath, portal) {
|
||||
if (authPath !== "oauth") return { kind: "service" };
|
||||
return portal?.session ? { kind: "portal", portal } : { kind: "stale" };
|
||||
}
|
||||
function staleIdentityError() {
|
||||
return errorResponse(
|
||||
"identity_missing",
|
||||
"\u9019\u689D MCP \u9023\u7DDA\u662F\u820A\u7248\u7C3D\u767C\u7684 token\uFF0C\u88E1\u9762\u6C92\u6709\u767B\u5165\u8005\u8EAB\u5206\uFF0C\u56E0\u6B64\u67E5\u4E0D\u5230\u4EFB\u4F55\u77E5\u8B58\u5167\u5BB9\u3002\u91CD\u65B0\u9023\u7DDA\u4E00\u6B21\uFF08\u5728 claude.ai \u7684 connector \u8A2D\u5B9A\u88E1\u91CD\u65B0\u6388\u6B0A\u3001\u8F38\u5165\u4F60\u7684 Portal \u5E33\u5BC6\uFF09\u5373\u53EF\u2014\u2014\u4E0D\u9700\u8981\u53E6\u5916\u627E\u4EFB\u4F55 credential \u6216\u91D1\u9470\u3002",
|
||||
[
|
||||
"\u5230 claude.ai \u2192 Settings \u2192 Connectors\uFF0C\u628A\u9019\u500B connector \u91CD\u65B0\u9023\u7DDA\u4E00\u6B21\uFF08\u6703\u8DF3\u51FA\u8F38\u5165 Portal \u5E33\u5BC6\u7684\u9801\u9762\uFF09",
|
||||
"\u91CD\u9023\u5F8C kbdb_* \u5168\u90E8\u5DE5\u5177\u90FD\u6703\u7528\u4F60\u9019\u500B\u5E33\u865F\u7684\u6B0A\u9650\u67E5\u8A62"
|
||||
]
|
||||
);
|
||||
}
|
||||
async function portalError(res, what) {
|
||||
const detail = await res.text().catch(() => "");
|
||||
if (res.status === 401) {
|
||||
return errorResponse(
|
||||
"session_expired",
|
||||
`${what}\u5931\u6557\uFF1A\u767B\u5165\u968E\u6BB5\u5DF2\u904E\u671F\uFF08portal session \u5230\u671F\u6216\u5DF2\u767B\u51FA\uFF09\u3002`,
|
||||
[
|
||||
"\u5230 claude.ai \u2192 Settings \u2192 Connectors \u91CD\u65B0\u9023\u7DDA\u9019\u500B connector\uFF08\u91CD\u65B0\u8F38\u5165 Portal \u5E33\u5BC6\uFF09",
|
||||
"\u91CD\u9023\u5F8C\u6B0A\u9650\u8207\u4F60\u5728 portal \u7DB2\u9801\u4E0A\u770B\u5230\u7684\u4E00\u81F4"
|
||||
],
|
||||
detail
|
||||
);
|
||||
}
|
||||
if (res.status === 403) {
|
||||
return errorResponse(
|
||||
"forbidden",
|
||||
`${what}\u5931\u6557\uFF1A\u9019\u500B\u5E33\u865F\u6C92\u6709\u9019\u9805\u6B0A\u9650\uFF08\u5E33\u865F\u53EF\u80FD\u5DF2\u505C\u7528\uFF0C\u6216\u6C92\u6709\u88AB\u6388\u6B0A\u8A72\u77E5\u8B58\u5EAB\uFF09\u3002`,
|
||||
["\u8ACB\u77E5\u8B58\u5EAB\u7BA1\u7406\u54E1\u5728 portal \u7684\u5E33\u865F\u7BA1\u7406\u88E1\u78BA\u8A8D\u4F60\u7684\u72C0\u614B\u8207\u53EF\u7528\u77E5\u8B58\u5EAB"],
|
||||
detail
|
||||
);
|
||||
}
|
||||
return errorResponse(`portal_${res.status}`, `${what}\u5931\u6557\uFF08HTTP ${res.status}\uFF09`, ["\u7A0D\u5F8C\u91CD\u8A66"], detail);
|
||||
}
|
||||
|
||||
// mcp/src/tools/arcrun_skills_examples.ts
|
||||
var KbdbAccessError = class extends Error {
|
||||
constructor(status, what, detail) {
|
||||
super(`KBDB ${what} HTTP ${status}`);
|
||||
this.status = status;
|
||||
this.what = what;
|
||||
this.detail = detail;
|
||||
}
|
||||
status;
|
||||
what;
|
||||
detail;
|
||||
};
|
||||
function kbdbFailure(e, searchHint, identity) {
|
||||
const portalNote = identity.kind === "portal" ? "\u4F60\u9019\u689D\u662F\u5E33\u5BC6\u767B\u5165\u7684\u9023\u7DDA\uFF0C\u4F46 skill\uFF0Fexample \u9019\u6279\u5DE5\u5177\u76EE\u524D\u4ECD\u8D70**\u670D\u52D9\u5167\u90E8\u6191\u64DA**\uFF08\u9084\u6C92\u63A5\u4E0A\u767B\u5165\u8EAB\u5206\uFF09\u2014\u2014\u6240\u4EE5\u5B83\u8B80\u4E0D\u5230\uFF0C\u4E0D\u4EE3\u8868\u4F60\u7684\u5E33\u865F\u8B80\u4E0D\u5230\u3002" : "";
|
||||
if (e instanceof KbdbAccessError) {
|
||||
const unauthorized = e.status === 401 || e.status === 403;
|
||||
return errorResponse(
|
||||
unauthorized ? "kbdb_unauthorized" : "kbdb_unreachable",
|
||||
(unauthorized ? `\u8B80\u4E0D\u5230 KBDB\uFF08HTTP ${e.status}\uFF1A\u9019\u689D\u9023\u7DDA\u7684\u6191\u64DA\u88AB\u62D2\u6216\u6839\u672C\u6C92\u5E36\uFF09\u3002` : `\u8B80\u4E0D\u5230 KBDB\uFF08HTTP ${e.status}\uFF09\u3002`) + "\u{1F534} **\u9019\u662F\u300C\u8B80\u4E0D\u5230\u300D\uFF0C\u4E0D\u662F\u300C\u4E0D\u5B58\u5728\u300D**\u2014\u2014\u5167\u5BB9\u9084\u5728\u5EAB\u88E1\uFF0C\u53EA\u662F\u9019\u689D\u8DEF\u88AB\u64CB\u4F4F\u4E86\u3002" + (portalNote ? ` ${portalNote}` : ""),
|
||||
[
|
||||
searchHint,
|
||||
"kbdb_get_map() \u770B\u9019\u53F0\u5BE6\u4F8B\u6709\u54EA\u4E9B\u5EAB\uFF08\u90A3\u689D\u8D70\u5F97\u901A\u5C31\u66F4\u78BA\u5B9A\u662F\u9019\u6279\u5DE5\u5177\u7684\u8DEF\u58DE\u4E86\uFF0C\u4E0D\u662F\u5EAB\u7A7A\u4E86\uFF09",
|
||||
"\u{1F534} \u4E0D\u51C6\u628A\u9019\u500B\u932F\u8AA4\u56DE\u5831\u6210\u300C\u627E\u4E0D\u5230\uFF0F\u6C92\u6709\u9019\u500B skill\u300D\u2014\u2014\u8ACB\u7167\u5BE6\u8AAA\u300CKBDB \u9019\u689D\u8DEF\u8B80\u4E0D\u5230\u300D",
|
||||
"\u6301\u7E8C\u5931\u6557\uFF1A\u544A\u8A34 leo \u9019\u53F0\u5BE6\u4F8B\u7684 arcrun-mcp \u5C11\u4E86 secret KBDB_INTERNAL_TOKEN"
|
||||
],
|
||||
e.detail
|
||||
);
|
||||
}
|
||||
return errorResponse(
|
||||
"kbdb_unreachable",
|
||||
`\u8B80\u4E0D\u5230 KBDB\uFF1A${e instanceof Error ? e.message : String(e)}\u3002\u{1F534} **\u9019\u662F\u300C\u8B80\u4E0D\u5230\u300D\uFF0C\u4E0D\u662F\u300C\u4E0D\u5B58\u5728\u300D**\u3002` + (portalNote ? ` ${portalNote}` : ""),
|
||||
[searchHint, "\u7A0D\u5F8C\u91CD\u8A66", "\u{1F534} \u4E0D\u51C6\u628A\u5B83\u56DE\u5831\u6210\u300C\u6C92\u6709\u9019\u500B skill\uFF0Fexample\u300D"]
|
||||
);
|
||||
}
|
||||
async function kbdbList(env, entryType, limit = 100) {
|
||||
const resp = await kbdbFetch(env, `/entries?entry_type=${encodeURIComponent(entryType)}&limit=${limit}`);
|
||||
if (!resp.ok) throw new Error(`KBDB list entry_type=${entryType} HTTP ${resp.status}`);
|
||||
if (!resp.ok) {
|
||||
throw new KbdbAccessError(resp.status, `list entry_type=${entryType}`, await resp.text().catch(() => ""));
|
||||
}
|
||||
const data = await resp.json();
|
||||
return data.entries ?? [];
|
||||
}
|
||||
async function kbdbGetByPageName(env, pageName) {
|
||||
const resp = await kbdbFetch(env, `/entries?page_name=${encodeURIComponent(pageName)}&limit=1`);
|
||||
if (!resp.ok) return null;
|
||||
if (!resp.ok) {
|
||||
throw new KbdbAccessError(resp.status, `get page_name=${pageName}`, await resp.text().catch(() => ""));
|
||||
}
|
||||
const data = await resp.json();
|
||||
return data.entries?.[0] ?? null;
|
||||
}
|
||||
@@ -31284,7 +31377,7 @@ function parseTags(tagsJson) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
function registerListSkills(server, env) {
|
||||
function registerListSkills(server, env, identity) {
|
||||
server.tool(
|
||||
toolName("list_skills"),
|
||||
"\u5217\u6240\u6709 agent-skill blocks\uFF08\u5F9E arcrun/registry/skills/ \u540C\u6B65\u9032 KBDB\uFF09\u3002\u6BCF\u500B skill \u662F\u500B markdown playbook\uFF0C\u63CF\u8FF0 AI \u9762\u5C0D X \u554F\u984C\u8A72\u600E\u9EBC\u60F3 + \u8A72\u7528\u54EA\u500B example\u3002\u56DE [{slug, title, tags}]\u3002call get_skill(slug) \u62FF\u5B8C\u6574\u5167\u6587\u3002",
|
||||
@@ -31292,6 +31385,7 @@ function registerListSkills(server, env) {
|
||||
tag: external_exports.string().optional().describe("optional \u6A19\u7C64\u904E\u6FFE\u3002\u5982 'rag' / 'watcher' / 'debug'")
|
||||
},
|
||||
async ({ tag }) => {
|
||||
if (identity.kind === "stale") return staleIdentityError();
|
||||
try {
|
||||
const blocks = await kbdbList(env, "agent-skill", 100);
|
||||
const skills = blocks.map((b) => {
|
||||
@@ -31313,20 +31407,19 @@ function registerListSkills(server, env) {
|
||||
return successResponse(
|
||||
{ count: skills.length, skills },
|
||||
[
|
||||
skills.length === 0 ? "\u6C92\u6709 skill \u547D\u4E2D\u3002\u8A66 list_skills() \u4E0D\u5E36 tag \u770B\u5168\u90E8" : "call arcrun_get_skill(slug) \u62FF\u55AE\u500B skill \u5B8C\u6574 markdown"
|
||||
skills.length === 0 ? "\u6C92\u6709 skill \u547D\u4E2D\u3002\u8A66 list_skills() \u4E0D\u5E36 tag \u770B\u5168\u90E8" : "call arcrun_get_skill(slug) \u62FF\u55AE\u500B skill \u5B8C\u6574 markdown",
|
||||
// 誠實:這裡回的是**這台實例被 seed 進去的那幾支**,不是「全世界的 skill 目錄」。
|
||||
// 上面清單沒有的名字(例如 'INDEX')就是這台沒有——別照舊教材去猜一個 slug。
|
||||
"\u{1F534} \u53EA\u7528\u4E0A\u9762\u6E05\u55AE\u88E1\u771F\u7684\u6709\u7684 slug\uFF1B\u6E05\u55AE\u6C92\u6709\uFF1D\u9019\u53F0\u5BE6\u4F8B\u6C92 seed \u9032\u53BB\uFF0C\u4E0D\u8981\u786C\u731C\u540D\u5B57"
|
||||
]
|
||||
);
|
||||
} catch (e) {
|
||||
return errorResponse(
|
||||
"fetch_failed",
|
||||
e instanceof Error ? e.message : String(e),
|
||||
["\u7A0D\u5F8C\u91CD\u8A66", "\u82E5\u6301\u7E8C\u5931\u6557\uFF0C\u544A\u8A34 leo"]
|
||||
);
|
||||
return kbdbFailure(e, "\u6539\u7528 kbdb_search({ q: 'skill' }) \u76F4\u63A5\u5728\u77E5\u8B58\u5EAB\u88E1\u627E skill \u5361\u7247\uFF08\u90A3\u689D\u8DEF\u8D70\u7684\u662F\u53E6\u4E00\u7D44\u6191\u64DA\uFF09", identity);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
function registerGetSkill(server, env) {
|
||||
function registerGetSkill(server, env, identity) {
|
||||
server.tool(
|
||||
toolName("get_skill"),
|
||||
"\u62FF\u55AE\u4E00 agent-skill \u5B8C\u6574 markdown playbook\u3002slug \u5F9E list_skills \u53D6\u5F97\u3002",
|
||||
@@ -31334,15 +31427,17 @@ function registerGetSkill(server, env) {
|
||||
slug: external_exports.string().describe("skill slug\uFF0C\u4F8B\u5982 'build_watcher_workflow' / 'rag_with_arcrun'")
|
||||
},
|
||||
async ({ slug }) => {
|
||||
if (identity.kind === "stale") return staleIdentityError();
|
||||
try {
|
||||
const pageName = slug.startsWith("skill-") ? slug : `skill-${slug}`;
|
||||
const block = await kbdbGetByPageName(env, pageName);
|
||||
if (!block) {
|
||||
return errorResponse(
|
||||
"not_found",
|
||||
`skill "${slug}" \u4E0D\u5B58\u5728`,
|
||||
`KBDB \u6B63\u5E38\u56DE\u61C9\uFF0C\u4F46\u6C92\u6709 page_name="${pageName}" \u9019\u5F35\u5361\u2014\u2014\u9019\u53F0\u5BE6\u4F8B\u6C92\u6709 seed \u9019\u652F skill\u3002\uFF08\u4E0D\u540C\u5BE6\u4F8B seed \u7684 skill \u4E0D\u4E00\u6A23\uFF0C\u5225\u7167\u820A\u6559\u6750\u5047\u8A2D\u67D0\u500B\u540D\u5B57\u4E00\u5B9A\u5728\u3002\uFF09`,
|
||||
[
|
||||
"call arcrun_list_skills() \u770B\u53EF\u7528 slug",
|
||||
"call arcrun_list_skills() \u770B**\u9019\u53F0\u5BE6\u4F8B\u771F\u7684\u6709**\u54EA\u5E7E\u652F",
|
||||
`kbdb_search({ q: '${slug}' }) \u770B\u5167\u5BB9\u662F\u4E0D\u662F\u88AB\u5B58\u6210\u5225\u7684\u540D\u5B57`,
|
||||
"\u78BA\u8A8D\u62FC\u5B57\u6B63\u78BA\uFF08\u4E0D\u9700\u8981 'skill-' prefix\uFF09"
|
||||
]
|
||||
);
|
||||
@@ -31354,16 +31449,16 @@ function registerGetSkill(server, env) {
|
||||
tags: parseTags(block.tags_json)
|
||||
});
|
||||
} catch (e) {
|
||||
return errorResponse(
|
||||
"fetch_failed",
|
||||
e instanceof Error ? e.message : String(e),
|
||||
["\u7A0D\u5F8C\u91CD\u8A66"]
|
||||
return kbdbFailure(
|
||||
e,
|
||||
`\u6539\u7528 kbdb_search({ q: 'skill-${slug}' }) \u6488\u540C\u4E00\u5F35\u5361\u7247\uFF08skill \u5C31\u4F4F\u5728\u77E5\u8B58\u5EAB\u7684 entries \u88E1\uFF0C\u90A3\u689D\u8DEF\u8D70\u53E6\u4E00\u7D44\u6191\u64DA\uFF09`,
|
||||
identity
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
function registerListExamples(server, env) {
|
||||
function registerListExamples(server, env, identity) {
|
||||
server.tool(
|
||||
toolName("list_examples"),
|
||||
"\u5217\u6240\u6709 workflow-example blocks\uFF08\u5F9E arcrun/registry/examples/ \u540C\u6B65\u9032 KBDB\uFF09\u3002\u6BCF\u500B example \u662F\u53EF\u76F4\u63A5 push \u7684 workflow YAML \u7BC4\u672C + description\u3002\u56DE [{slug, tags}]\u3002call get_example / search_examples \u62FF\u7D30\u7BC0\u3002",
|
||||
@@ -31371,6 +31466,7 @@ function registerListExamples(server, env) {
|
||||
tag: external_exports.string().optional().describe("optional \u6A19\u7C64\u904E\u6FFE\u3002\u5982 'rag' / 'cron' / 'llm' / 'webhook'")
|
||||
},
|
||||
async ({ tag }) => {
|
||||
if (identity.kind === "stale") return staleIdentityError();
|
||||
try {
|
||||
const blocks = await kbdbList(env, "workflow-example", 200);
|
||||
const examples = blocks.map((b) => {
|
||||
@@ -31389,16 +31485,12 @@ function registerListExamples(server, env) {
|
||||
]
|
||||
);
|
||||
} catch (e) {
|
||||
return errorResponse(
|
||||
"fetch_failed",
|
||||
e instanceof Error ? e.message : String(e),
|
||||
["\u7A0D\u5F8C\u91CD\u8A66"]
|
||||
);
|
||||
return kbdbFailure(e, "\u6539\u7528 kbdb_search({ q: 'example' }) \u76F4\u63A5\u5728\u77E5\u8B58\u5EAB\u88E1\u627E example \u5361\u7247\uFF08\u90A3\u689D\u8DEF\u8D70\u53E6\u4E00\u7D44\u6191\u64DA\uFF09", identity);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
function registerGetExample(server, env) {
|
||||
function registerGetExample(server, env, identity) {
|
||||
server.tool(
|
||||
toolName("get_example"),
|
||||
"\u62FF\u55AE\u4E00 workflow-example \u5B8C\u6574 YAML + description\u3002slug \u5F9E list_examples / search_examples \u53D6\u5F97\u3002\u53EF\u76F4\u63A5\u62FF YAML \u6539\u6210\u4F60\u81EA\u5DF1\u7684 \u2192 push\u3002",
|
||||
@@ -31406,16 +31498,18 @@ function registerGetExample(server, env) {
|
||||
slug: external_exports.string().describe("example slug\uFF0C\u4F8B\u5982 'rag-search-answer' / 'cron-watcher'")
|
||||
},
|
||||
async ({ slug }) => {
|
||||
if (identity.kind === "stale") return staleIdentityError();
|
||||
try {
|
||||
const pageName = slug.startsWith("example-") ? slug : `example-${slug}`;
|
||||
const block = await kbdbGetByPageName(env, pageName);
|
||||
if (!block) {
|
||||
return errorResponse(
|
||||
"not_found",
|
||||
`example "${slug}" \u4E0D\u5B58\u5728`,
|
||||
`KBDB \u6B63\u5E38\u56DE\u61C9\uFF0C\u4F46\u6C92\u6709 page_name="${pageName}" \u9019\u5F35\u5361\u2014\u2014\u9019\u53F0\u5BE6\u4F8B\u6C92 seed \u9019\u500B example\u3002`,
|
||||
[
|
||||
"call arcrun_list_examples() \u770B\u53EF\u7528 slug",
|
||||
"\u6216 arcrun_search_examples(use_case) \u7528\u81EA\u7136\u8A9E\u8A00\u627E"
|
||||
"call arcrun_list_examples() \u770B**\u9019\u53F0\u5BE6\u4F8B\u771F\u7684\u6709**\u54EA\u4E9B slug",
|
||||
"\u6216 arcrun_search_examples(use_case) \u7528\u95DC\u9375\u5B57\u627E",
|
||||
`kbdb_search({ q: '${slug}' }) \u770B\u5167\u5BB9\u662F\u4E0D\u662F\u88AB\u5B58\u6210\u5225\u7684\u540D\u5B57`
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -31436,16 +31530,16 @@ function registerGetExample(server, env) {
|
||||
"\u770B description_md \u4E86\u89E3\u8A2D\u8A08\u610F\u5716 / \u6539\u9020\u65B9\u5411"
|
||||
]);
|
||||
} catch (e) {
|
||||
return errorResponse(
|
||||
"fetch_failed",
|
||||
e instanceof Error ? e.message : String(e),
|
||||
["\u7A0D\u5F8C\u91CD\u8A66"]
|
||||
return kbdbFailure(
|
||||
e,
|
||||
`\u6539\u7528 kbdb_search({ q: 'example-${slug}' }) \u6488\u540C\u4E00\u5F35\u5361\u7247\uFF08example \u5C31\u4F4F\u5728\u77E5\u8B58\u5EAB\u7684 entries \u88E1\uFF09`,
|
||||
identity
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
function registerSearchExamples(server, env) {
|
||||
function registerSearchExamples(server, env, identity) {
|
||||
server.tool(
|
||||
toolName("search_examples"),
|
||||
"\u7528 use case \u95DC\u9375\u5B57\u641C workflow examples\uFF0C\u56DE\u6700\u76F8\u95DC N \u500B\u3002\u6CE8\u610F\uFF1A\u57FA\u672C\u76E4\u76EE\u524D\u662F D1 LIKE \u95DC\u9375\u5B57\u641C\u5C0B\uFF08\u975E\u8A9E\u7FA9 embedding\uFF1B\u8A9E\u7FA9\u662F kbdb-base Phase 1 \u7684 embed \u6A21\u7D44\uFF0C\u5C1A\u672A\u4E0A\uFF09\u3002\u2192 \u7528\u5177\u9AD4\u8A5E\uFF08'email'\u3001'cron'\u3001'rag'\uFF09\u6BD4\u6574\u53E5\u81EA\u7136\u8A9E\u8A00\u547D\u4E2D\u7387\u9AD8\u3002\u4E5F\u6703\u6BD4\u5C0D slug/tag\u3002",
|
||||
@@ -31454,6 +31548,7 @@ function registerSearchExamples(server, env) {
|
||||
top_k: external_exports.number().int().min(1).max(20).optional().describe("\u56DE\u5E7E\u500B\u7D50\u679C\uFF08\u9810\u8A2D 5\uFF09")
|
||||
},
|
||||
async ({ query, top_k }) => {
|
||||
if (identity.kind === "stale") return staleIdentityError();
|
||||
try {
|
||||
const k = top_k ?? 5;
|
||||
const q = query.trim();
|
||||
@@ -31498,21 +31593,17 @@ function registerSearchExamples(server, env) {
|
||||
]
|
||||
);
|
||||
} catch (e) {
|
||||
return errorResponse(
|
||||
"internal_error",
|
||||
e instanceof Error ? e.message : String(e),
|
||||
["\u91CD\u8A66\u4E00\u6B21"]
|
||||
);
|
||||
return kbdbFailure(e, `\u6539\u7528 kbdb_search({ q: '${query.trim()}' }) \u76F4\u63A5\u67E5\u77E5\u8B58\u5EAB\uFF08\u90A3\u689D\u8DEF\u8D70\u53E6\u4E00\u7D44\u6191\u64DA\uFF09`, identity);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
function registerAllSkillExampleTools(server, env) {
|
||||
registerListSkills(server, env);
|
||||
registerGetSkill(server, env);
|
||||
registerListExamples(server, env);
|
||||
registerGetExample(server, env);
|
||||
registerSearchExamples(server, env);
|
||||
function registerAllSkillExampleTools(server, env, identity) {
|
||||
registerListSkills(server, env, identity);
|
||||
registerGetSkill(server, env, identity);
|
||||
registerListExamples(server, env, identity);
|
||||
registerGetExample(server, env, identity);
|
||||
registerSearchExamples(server, env, identity);
|
||||
}
|
||||
|
||||
// mcp/src/tools/arcrun_recipe.ts
|
||||
@@ -31713,62 +31804,6 @@ function registerRecipeDelete(server, env) {
|
||||
);
|
||||
}
|
||||
|
||||
// mcp/src/lib/portal-client.ts
|
||||
async function portalFetch(env, session, path, opts = {}) {
|
||||
if (!env.CYPHER_EXECUTOR) {
|
||||
throw new Error("CYPHER_EXECUTOR service binding not configured");
|
||||
}
|
||||
const url = new URL(`https://cypher${path}`);
|
||||
for (const [k, v] of Object.entries(opts.query ?? {})) {
|
||||
if (v !== void 0 && v !== "") url.searchParams.set(k, String(v));
|
||||
}
|
||||
return env.CYPHER_EXECUTOR.fetch(url.toString(), {
|
||||
method: opts.method ?? "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${session}`
|
||||
},
|
||||
body: opts.body !== void 0 ? JSON.stringify(opts.body) : void 0
|
||||
});
|
||||
}
|
||||
function resolveKnowledgeIdentity(authPath, portal) {
|
||||
if (authPath !== "oauth") return { kind: "service" };
|
||||
return portal?.session ? { kind: "portal", portal } : { kind: "stale" };
|
||||
}
|
||||
function staleIdentityError() {
|
||||
return errorResponse(
|
||||
"identity_missing",
|
||||
"\u9019\u689D MCP \u9023\u7DDA\u662F\u820A\u7248\u7C3D\u767C\u7684 token\uFF0C\u88E1\u9762\u6C92\u6709\u767B\u5165\u8005\u8EAB\u5206\uFF0C\u56E0\u6B64\u67E5\u4E0D\u5230\u4EFB\u4F55\u77E5\u8B58\u5167\u5BB9\u3002\u91CD\u65B0\u9023\u7DDA\u4E00\u6B21\uFF08\u5728 claude.ai \u7684 connector \u8A2D\u5B9A\u88E1\u91CD\u65B0\u6388\u6B0A\u3001\u8F38\u5165\u4F60\u7684 Portal \u5E33\u5BC6\uFF09\u5373\u53EF\u2014\u2014\u4E0D\u9700\u8981\u53E6\u5916\u627E\u4EFB\u4F55 credential \u6216\u91D1\u9470\u3002",
|
||||
[
|
||||
"\u5230 claude.ai \u2192 Settings \u2192 Connectors\uFF0C\u628A\u9019\u500B connector \u91CD\u65B0\u9023\u7DDA\u4E00\u6B21\uFF08\u6703\u8DF3\u51FA\u8F38\u5165 Portal \u5E33\u5BC6\u7684\u9801\u9762\uFF09",
|
||||
"\u91CD\u9023\u5F8C kbdb_* \u5168\u90E8\u5DE5\u5177\u90FD\u6703\u7528\u4F60\u9019\u500B\u5E33\u865F\u7684\u6B0A\u9650\u67E5\u8A62"
|
||||
]
|
||||
);
|
||||
}
|
||||
async function portalError(res, what) {
|
||||
const detail = await res.text().catch(() => "");
|
||||
if (res.status === 401) {
|
||||
return errorResponse(
|
||||
"session_expired",
|
||||
`${what}\u5931\u6557\uFF1A\u767B\u5165\u968E\u6BB5\u5DF2\u904E\u671F\uFF08portal session \u5230\u671F\u6216\u5DF2\u767B\u51FA\uFF09\u3002`,
|
||||
[
|
||||
"\u5230 claude.ai \u2192 Settings \u2192 Connectors \u91CD\u65B0\u9023\u7DDA\u9019\u500B connector\uFF08\u91CD\u65B0\u8F38\u5165 Portal \u5E33\u5BC6\uFF09",
|
||||
"\u91CD\u9023\u5F8C\u6B0A\u9650\u8207\u4F60\u5728 portal \u7DB2\u9801\u4E0A\u770B\u5230\u7684\u4E00\u81F4"
|
||||
],
|
||||
detail
|
||||
);
|
||||
}
|
||||
if (res.status === 403) {
|
||||
return errorResponse(
|
||||
"forbidden",
|
||||
`${what}\u5931\u6557\uFF1A\u9019\u500B\u5E33\u865F\u6C92\u6709\u9019\u9805\u6B0A\u9650\uFF08\u5E33\u865F\u53EF\u80FD\u5DF2\u505C\u7528\uFF0C\u6216\u6C92\u6709\u88AB\u6388\u6B0A\u8A72\u77E5\u8B58\u5EAB\uFF09\u3002`,
|
||||
["\u8ACB\u77E5\u8B58\u5EAB\u7BA1\u7406\u54E1\u5728 portal \u7684\u5E33\u865F\u7BA1\u7406\u88E1\u78BA\u8A8D\u4F60\u7684\u72C0\u614B\u8207\u53EF\u7528\u77E5\u8B58\u5EAB"],
|
||||
detail
|
||||
);
|
||||
}
|
||||
return errorResponse(`portal_${res.status}`, `${what}\u5931\u6557\uFF08HTTP ${res.status}\uFF09`, ["\u7A0D\u5F8C\u91CD\u8A66"], detail);
|
||||
}
|
||||
|
||||
// mcp/src/tools/kbdb_data.ts
|
||||
var OWNER_IGNORED_HINT = "owner_id \u5728\u767B\u5165\u8EAB\u5206\u4E0B\u4E0D\u751F\u6548\uFF1A\u67E5\u8A62\u7BC4\u570D\u7531\u4F60\u7684\u5E33\u865F\u6B0A\u9650\u6C7A\u5B9A\uFF08\u8207\u4F60\u5728 portal \u7DB2\u9801\u770B\u5230\u7684\u4E00\u81F4\uFF09";
|
||||
function registerAllKbdbDataTools(server, env, identity) {
|
||||
@@ -32142,8 +32177,10 @@ function renderLibraryMapLines(libraries) {
|
||||
const narrative = (l.narrative ?? "").trim() || "\uFF08narrative \u5F85\u88DC\uFF09";
|
||||
const clipped = narrative.length > MAX_NARRATIVE_CHARS ? `${narrative.slice(0, MAX_NARRATIVE_CHARS)}\u2026` : narrative;
|
||||
const core = entityNames(l.top_entities, 3);
|
||||
const count = Number(l.triplet_count ?? 0) || 0;
|
||||
return `- ${l.library}\uFF1A${clipped}\uFF5C\u6838\u5FC3\uFF1A${core.length ? core.join("\u3001") : "\uFF08\u5C1A\u7121\uFF09"}\uFF5C${count} triplets`;
|
||||
const tripletCount = Number(l.triplet_count ?? 0) || 0;
|
||||
const entryCount = Number(l.entry_count ?? 0) || 0;
|
||||
const countPart = tripletCount > 0 ? `${tripletCount} triplets` : entryCount > 0 ? `0 triplets\uFF0F${entryCount} \u7B46\u539F\u59CB\u5167\u5BB9\uFF08\u5C1A\u672A\u8403\u53D6\u95DC\u4FC2\uFF0Ckbdb_search \u67E5\u5F97\u5230\uFF09` : `0 triplets\uFF0F0 \u5167\u5BB9`;
|
||||
return `- ${l.library}\uFF1A${clipped}\uFF5C\u6838\u5FC3\uFF1A${core.length ? core.join("\u3001") : "\uFF08\u5C1A\u7121\uFF09"}\uFF5C${countPart}`;
|
||||
});
|
||||
const omitted = rows.length > MAX_LIBRARY_LINES ? `
|
||||
\uFF08\u5176\u9918 ${rows.length - MAX_LIBRARY_LINES} \u5EAB\u7565\uFF0Ckbdb_get_map \u53EF\u770B\u5168\u90E8\uFF09` : "";
|
||||
@@ -32195,6 +32232,13 @@ var RECOMPUTE_HINTS = [
|
||||
function registerAllKbdbMapTools(server, env, identity) {
|
||||
registerGetMap(server, env, identity);
|
||||
}
|
||||
function entryOnlyHint(tripletCount, entryCount, library) {
|
||||
if (tripletCount > 0 || entryCount <= 0) return [];
|
||||
const lib = library ? `\u300C${library}\u300D` : "\u9019\u500B\u5EAB";
|
||||
return [
|
||||
`${lib} triplet_count\uFF1D0\uFF0C\u4F46\u6709 ${entryCount} \u7B46\u539F\u59CB\u5167\u5BB9\uFF08entries\uFF09\u2014\u2014\u4E09\u5143\u7D44\u8403\u53D6\u9084\u6C92\u5C0D\u5B83\u8DD1\u904E\uFF0C\u4E0D\u4EE3\u8868\u6C92\u6709\u77E5\u8B58\u3002\u7528 kbdb_search\uFF08\u95DC\u9375\u5B57\u6216\u8A9E\u7FA9\uFF09\u76F4\u63A5\u67E5\u5F97\u5230\u5167\u5BB9\u3002`
|
||||
];
|
||||
}
|
||||
function registerGetMap(server, env, identity) {
|
||||
server.tool(
|
||||
"kbdb_get_map",
|
||||
@@ -32229,7 +32273,8 @@ function registerGetMap(server, env, identity) {
|
||||
...l,
|
||||
// 防禦:top_entities 若是 JSON 字串形就 parse 成名字清單(失敗當空,誠實不 crash)。
|
||||
top_entities: entityNames(l.top_entities, 3),
|
||||
triplet_count: Number(l.triplet_count ?? 0) || 0
|
||||
triplet_count: Number(l.triplet_count ?? 0) || 0,
|
||||
entry_count: Number(l.entry_count ?? 0) || 0
|
||||
}));
|
||||
if (libraries.length === 0) {
|
||||
return successResponse({ libraries: [], count: 0 }, [
|
||||
@@ -32238,9 +32283,15 @@ function registerGetMap(server, env, identity) {
|
||||
...RECOMPUTE_HINTS
|
||||
]);
|
||||
}
|
||||
const entryOnlyLibs = libraries.filter(
|
||||
(l) => (l.triplet_count ?? 0) === 0 && (l.entry_count ?? 0) > 0
|
||||
);
|
||||
return successResponse({ libraries, count: libraries.length }, [
|
||||
"\u8981\u770B\u67D0\u5EAB\u7D30\u7BC0\uFF1Akbdb_get_map(library='\u5EAB\u540D')",
|
||||
"\u9032\u5EAB\u67E5\u5167\u5BB9\uFF1Akbdb_search\uFF08\u95DC\u9375\u5B57/\u8A9E\u7FA9\uFF09\uFF1B\u67E5\u95DC\u4FC2\uFF1Akbdb_graph_neighbors"
|
||||
"\u9032\u5EAB\u67E5\u5167\u5BB9\uFF1Akbdb_search\uFF08\u95DC\u9375\u5B57/\u8A9E\u7FA9\uFF09\uFF1B\u67E5\u95DC\u4FC2\uFF1Akbdb_graph_neighbors",
|
||||
...entryOnlyLibs.length > 0 ? [
|
||||
`${entryOnlyLibs.map((l) => l.library).join("\u3001")} \u9019\u5E7E\u5EAB triplet_count\uFF1D0 \u4F46 entry_count\uFF1E0\uFF1A\u6709\u539F\u59CB\u5167\u5BB9\uFF0C\u53EA\u662F\u9084\u6C92\u8403\u53D6\u51FA\u4E09\u5143\u7D44\u95DC\u4FC2\u2014\u2014\u5225\u628A 0 triplets \u8B80\u6210\u300C\u6C92\u6709\u77E5\u8B58\u300D\uFF0C\u76F4\u63A5 kbdb_search \u9032\u53BB\u67E5\u3002`
|
||||
] : []
|
||||
]);
|
||||
}
|
||||
const res = await mapFetch(`/map/${encodeURIComponent(library)}${qs}`);
|
||||
@@ -32268,11 +32319,13 @@ function registerGetMap(server, env, identity) {
|
||||
top_entities: parseSlotArray(raw2.top_entities),
|
||||
relation_profile: parseSlotArray(raw2.relation_profile),
|
||||
bridges: parseSlotArray(raw2.bridges),
|
||||
triplet_count: Number(raw2.triplet_count ?? 0) || 0
|
||||
triplet_count: Number(raw2.triplet_count ?? 0) || 0,
|
||||
entry_count: Number(raw2.entry_count ?? 0) || 0
|
||||
};
|
||||
return successResponse({ map: map2 }, [
|
||||
"bridges\uFF1D\u6B64\u5EAB entity \u540C\u6642\u51FA\u73FE\u5728\u54EA\u4E9B\u5176\u4ED6\u5EAB\uFF08\u53EA\u6709\u5169\u5074\u4E09\u5143\u7D44\u90FD\u6A19\u4E86 library \u503C\u624D\u6293\u5F97\u5230\uFF0C\u820A\u8CC7\u6599\u82E5\u6C92\u6A19\u6703\u504F\u7A00\u758F\uFF0C\u662F\u8AA0\u5BE6\u73FE\u6CC1\u4E0D\u662F bug\uFF09",
|
||||
"\u6CBF\u6838\u5FC3 entity \u6316\u95DC\u4FC2\uFF1Akbdb_graph_neighbors(subject=entity \u540D)"
|
||||
"\u6CBF\u6838\u5FC3 entity \u6316\u95DC\u4FC2\uFF1Akbdb_graph_neighbors(subject=entity \u540D)",
|
||||
...entryOnlyHint(map2.triplet_count, map2.entry_count, library)
|
||||
]);
|
||||
} catch (e) {
|
||||
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["\u7A0D\u5F8C\u91CD\u8A66"]);
|
||||
@@ -32340,7 +32393,7 @@ function registerAllTools(server, env, orgNamespace, partnerToken, identity) {
|
||||
registerReportFeedback(server, env, orgNamespace);
|
||||
registerAllIntrospectionTools(server, env);
|
||||
registerAllWorkflowCrudTools(server, env);
|
||||
registerAllSkillExampleTools(server, env);
|
||||
registerAllSkillExampleTools(server, env, identity);
|
||||
registerAllRecipeTools(server, env);
|
||||
registerAllKbdbDataTools(server, env, identity);
|
||||
registerAllKbdbGraphTools(server, env, orgNamespace, identity);
|
||||
@@ -32349,7 +32402,47 @@ function registerAllTools(server, env, orgNamespace, partnerToken, identity) {
|
||||
}
|
||||
|
||||
// mcp/src/mcp-handler.ts
|
||||
async function handleMcpRequest(request, env, orgNamespace, partnerToken, identity) {
|
||||
var KNOWLEDGE_FIRST = [
|
||||
"\u3010\u9019\u689D\u9023\u7DDA\u4E0A\u6709\u4E3B\u4EBA\u7684\u77E5\u8B58\u5EAB\u2014\u2014\u5148\u67E5\u5B83\uFF0C\u518D\u67E5\u5225\u7684\u3011",
|
||||
"",
|
||||
"\u9019\u689D MCP \u9023\u7DDA\u5F8C\u9762\u63A5\u8457\u4E00\u500B **KBDB \u77E5\u8B58\u5EAB**\uFF1A\u9019\u53F0\u5BE6\u4F8B\u7684\u4E3B\u4EBA\u9577\u671F\u7D2F\u7A4D\u7684\u7B46\u8A18\u3001\u6C7A\u7B56\u3001",
|
||||
"\u8E29\u904E\u7684\u5751\u3001\u5C08\u6848\u73FE\u6CC1\u3001skill \u8207\u5DE5\u4F5C\u6D41\u7D00\u9304\uFF0C\u90FD\u5728\u88E1\u9762\u3002**\u4F60\u4E0D\u662F\u5F9E\u96F6\u958B\u59CB\u7684**\u2014\u2014",
|
||||
"\u4F60\u5C0D\u9019\u4E9B\u5C08\u6848\u7684\u5167\u5EFA\u5370\u8C61\u591A\u534A\u662F\u932F\u7684\u6216\u904E\u6642\u7684\uFF0C\u5EAB\u88E1\u90A3\u4EFD\u624D\u662F\u4E3B\u4EBA\u8A8D\u7684\u7248\u672C\u3002",
|
||||
"",
|
||||
"\u{1F534} **\u6709\u4EBA\u554F\u4F60\u300CX \u662F\u4EC0\u9EBC\uFF0F\u70BA\u4EC0\u9EBC\u9019\u6A23\u505A\uFF0F\u4E4B\u524D\u600E\u9EBC\u6C7A\u5B9A\u7684\uFF0F\u73FE\u5728\u505A\u5230\u54EA\u300D\u2014\u2014",
|
||||
"\u4F60\u7684\u7B2C\u4E00\u500B\u52D5\u4F5C\u662F `kbdb_search`\uFF0C\u4E0D\u662F grep \u539F\u59CB\u78BC\u3001\u4E0D\u662F\u4E0A\u7DB2\u641C\u3001\u4E0D\u662F\u56DE\u7B54\u300C\u6211\u4E0D\u77E5\u9053\u300D\u3002**",
|
||||
"",
|
||||
"- `kbdb_search({ q: \"Arcrun \u662F\u4EC0\u9EBC\" })` \u2014 \u95DC\u9375\u5B57\u67E5\uFF08\u9810\u8A2D `mode:'keyword'`\uFF0C\u57FA\u672C\u76E4\u6C38\u9060\u53EF\u7528\uFF09\u3002",
|
||||
" \u63DB\u5E7E\u7D44\u8B1B\u6CD5\u518D\u653E\u68C4\uFF1B\u60F3\u8981\u8A9E\u7FA9\u76F8\u4F3C\u5EA6\u7528 `mode:'semantic'`\u3002**\u9019\u4E00\u652F\u662F\u4F60\u7684\u7B2C\u4E00\u7AD9\u3002**",
|
||||
"- `kbdb_get_map()` \u2014 \u4E0D\u77E5\u9053\u8A72\u9032\u54EA\u500B\u5EAB\u6642\u5148\u770B\u85CF\u66F8\u5730\u5716\uFF08\u4E0B\u9762\u82E5\u6709\u3010\u85CF\u66F8\u5730\u5716\u3011\u5C31\u662F\u5B83\u7684\u5FEB\u7167\uFF09\u3002",
|
||||
'- `kbdb_graph_neighbors({ subject: "Arcrun" })` \u2014 \u67E5\u67D0\u500B\u6771\u897F\u8DDF\u8AB0\u6709\u95DC\u4FC2\uFF08\u4E09\u5143\u7D44\u904D\u6B77\uFF09\u3002',
|
||||
"- `kbdb_list_templates` / `kbdb_query` \u2014 \u6309 template \u53D6\u6574\u6279\u7D50\u69CB\u5316\u8CC7\u6599\u3002",
|
||||
"",
|
||||
"\u{1F534} **\u9019\u4E09\u4EF6\u4E8B\u4E0D\u53EF\u4EE5\u8B1B\u6210\u540C\u4E00\u53E5**\uFF08\u8B1B\u6210\u540C\u4E00\u53E5\u5C31\u662F\u5728\u9A19\u4EBA\uFF09\uFF1A",
|
||||
"\u2460 \u300C\u77E5\u8B58\u5EAB\u88E1\u6C92\u6709\u300D\u3000\u2461 \u300C\u6211\u6C92\u67E5\u300D\u3000\u2462 \u300C\u5730\u5716\u6C92\u53D6\u5230\uFF0F\u67D0\u5EAB\u986F\u793A 0\u300D\u3002",
|
||||
"\u67E5\u904E\u771F\u7684\u6C92\u6709 \u2192 \u660E\u8AAA\u300C\u77E5\u8B58\u5EAB\u88E1\u67E5\u4E0D\u5230\uFF0C\u4EE5\u4E0B\u662F\u6211\u5F9E\u539F\u59CB\u78BC\uFF0F\u7DB2\u8DEF\u63A8\u7684\u300D\uFF0C\u518D\u53BB\u8B80 code \u6216\u4E0A\u7DB2\u3002",
|
||||
"**\u6C92\u67E5\u5C31\u56DE\u7B54\uFF1D\u62FF\u4F60\u7684\u731C\u6E2C\u5192\u5145\u4E3B\u4EBA\u7684\u77E5\u8B58\uFF0C\u90A3\u662F\u9019\u689D\u9023\u7DDA\u4E0A\u6700\u56B4\u91CD\u7684\u932F\u3002**",
|
||||
"",
|
||||
"\u{1F534} **\u5730\u5716\u662F\u7D22\u5F15\uFF0C\u4E0D\u662F\u5EAB\u5B58\u6E05\u55AE**\uFF1A\u67D0\u5EAB\u986F\u793A `0 triplets`\u3001\u6216\u4E0B\u9762\u6574\u6BB5\u3010\u85CF\u66F8\u5730\u5716\u3011\u6C92\u51FA\u73FE\uFF0C",
|
||||
"\u90FD**\u4E0D\u4EE3\u8868**\u6C92\u6709\u77E5\u8B58\uFF08\u53EF\u80FD\u53EA\u662F\u9084\u6C92\u91CD\u7B97\u3001\u6216\u9019\u6B21\u6C92\u6293\u5230\uFF09\u3002\u8981\u77E5\u9053\u6709\u6C92\u6709\uFF0C\u53EA\u6709\u4E00\u500B\u65B9\u6CD5\uFF1A`kbdb_search` \u67E5\u904E\u3002",
|
||||
"\u540C\u7406\uFF0C\u4EFB\u4F55\u5DE5\u5177\u56DE 401\uFF0F\u9023\u4E0D\u4E0A\uFF0F\u6C92\u6B0A\u9650\uFF0C\u90A3\u662F**\u8B80\u4E0D\u5230**\uFF0C\u4E0D\u662F**\u4E0D\u5B58\u5728**\u2014\u2014\u7167\u5B83\u7D66\u7684 next_actions \u4FEE\uFF0C",
|
||||
"\u5225\u628A\u5B83\u6539\u53E3\u8B1B\u6210\u300C\u9019\u88E1\u6C92\u6709\u300D\uFF08`arcrun_get_skill` \u66FE\u628A KBDB \u7684 401 \u8B1B\u6210\u300Cskill \u4E0D\u5B58\u5728\u300D\uFF0C\u5C31\u662F\u9019\u500B\u75C5\uFF09\u3002"
|
||||
].join("\n");
|
||||
var MAP_UNAVAILABLE_NOTE = [
|
||||
"\u3010\u85CF\u66F8\u5730\u5716\uFF1A\u9019\u6B21\u6C92\u53D6\u5230\u3011",
|
||||
"\u5730\u5716\u6293\u53D6\u903E\u6642\uFF0F\u56DE\u932F\uFF0F\u6216\u5B83\u56DE\u5831\u7684\u6E05\u55AE\u662F\u7A7A\u7684\uFF08\u4E5F\u53EF\u80FD\u53EA\u662F\u9084\u6C92\u91CD\u7B97\u904E\uFF09\u3002",
|
||||
"\u{1F534} **\u9019\u662F\u300C\u5730\u5716\u6C92\u62FF\u5230\u300D\uFF0C\u4E0D\u662F\u300C\u9019\u88E1\u6C92\u6709\u77E5\u8B58\u300D\u3002** \u4E0A\u9762\u90A3\u689D\u898F\u5247\u7167\u820A\uFF1A",
|
||||
"\u8981\u77E5\u9053\u5EAB\u88E1\u6709\u4EC0\u9EBC\uFF0C\u76F4\u63A5 `kbdb_search`\uFF1B\u60F3\u518D\u6293\u4E00\u6B21\u5730\u5716\u547C\u53EB `kbdb_get_map()`\uFF08\u5B83\u6703\u56DE\u5831\u771F\u6B63\u7684\u539F\u56E0\uFF09\u3002"
|
||||
].join("\n");
|
||||
var MAP_STALE_NOTE = [
|
||||
"\u3010\u85CF\u66F8\u5730\u5716\uFF1A\u62FF\u4E0D\u5230\uFF0C\u56E0\u70BA\u9019\u689D\u9023\u7DDA\u662F\u820A\u7248\u7C3D\u767C\u7684 token\u3011",
|
||||
"\u9019\u689D\u9023\u7DDA\u7684 token \u6C92\u5E36\u767B\u5165\u8005\u8EAB\u5206\uFF0C`kbdb_*` \u6703\u56DE `identity_missing`\u3002",
|
||||
"\u{1F534} **\u9019\u4E0D\u4EE3\u8868\u77E5\u8B58\u5EAB\u662F\u7A7A\u7684**\u2014\u2014\u662F\u9019\u689D\u9023\u7DDA\u9084\u6C92\u8A8D\u5F97\u4F60\u3002",
|
||||
"\u4ECD\u7136\u5148\u547C\u53EB\u4E00\u6B21 `kbdb_search` \u78BA\u8A8D\u932F\u8AA4\u78BC\uFF1B\u82E5\u771F\u7684\u662F `identity_missing`\uFF0C",
|
||||
"\u8ACB\u4F7F\u7528\u8005\u5230 claude.ai \u2192 Settings \u2192 Connectors \u628A\u9019\u500B connector \u91CD\u65B0\u9023\u7DDA\u4E00\u6B21\uFF08\u91CD\u65B0\u8F38\u5165 Portal \u5E33\u5BC6\uFF09\uFF0C",
|
||||
"**\u4E0D\u8981\u6539\u53E3\u8AAA\u300C\u67E5\u4E0D\u5230\u8CC7\u6599\u300D\u6216\u81EA\u5DF1\u53BB\u731C\u7B54\u6848\u3002**"
|
||||
].join("\n");
|
||||
async function buildServerInstructions(env, identity) {
|
||||
const mapInstructions = await buildLibraryMapInstructions(env, identity);
|
||||
const startHere = [
|
||||
"# Arcrun \u2014 \u4F60\u5DF2\u7D93\u914D\u5099\u4E86\u9019\u5957\u5DE5\u5177\uFF0C\u5225\u4E0A\u7DB2\u627E",
|
||||
@@ -32363,9 +32456,13 @@ async function handleMcpRequest(request, env, orgNamespace, partnerToken, identi
|
||||
"",
|
||||
"1. `arcrun_get_skill('write_intent_workflow')` \u2014 **\u5FC5\u8B80\u7B2C\u4E00\u652F**\u3002",
|
||||
" \u6559\u4F60\u7528 `>>` \u5BEB\u300C\u610F\u5716\u5DE5\u4F5C\u6D41\u300D\u3002\u4F60**\u4E0D\u9700\u8981\u5148\u77E5\u9053\u6709\u54EA\u4E9B\u96F6\u4EF6**\uFF0C\u5148\u5BEB\u610F\u5716\u3002",
|
||||
" \u26A0\uFE0F \u9019\u652F\u82E5\u56DE\u932F\uFF08401\uFF0F\u9023\u4E0D\u4E0A\uFF0F`kbdb_unreachable`\uFF09\uFF0C\u90A3\u662F**\u9019\u689D\u9023\u7DDA\u8B80\u4E0D\u5230 KBDB**\uFF0C",
|
||||
" **\u4E0D\u662F skill \u4E0D\u5B58\u5728**\u2014\u2014\u540C\u4E00\u4EFD\u5167\u5BB9\u7528 `kbdb_search({ q: 'skill-write_intent_workflow' })` \u6488\u5F97\u5230\u3002",
|
||||
"2. `arcrun_whoami()` \u2014 \u78BA\u8A8D\u9023\u5230\u54EA\u500B\u5E33\u865F\uFF08\u52FF\u81EA\u884C curl \u731C\u5E33\u865F URL\uFF09\u3002",
|
||||
"3. \u628A\u610F\u5716\u4E1F `POST /cypher/search` \u6216 `arcrun_validate_yaml` \u2014 \u7CFB\u7D71\u544A\u8A34\u4F60\u54EA\u4E9B\u96F6\u4EF6\u5B58\u5728\u3002",
|
||||
"4. \u5361\u4F4F\uFF0F\u4E0D\u77E5\u9053\u8A72\u67E5\u4EC0\u9EBC \u2192 `arcrun_get_skill('INDEX')`\uFF08\u5168\u9928\u5C0E\u822A\uFF1A\u4EC0\u9EBC\u554F\u984C\u67E5\u54EA\u88E1\uFF0B\u5DF2\u77E5\u7684\u5751\uFF09\u3002",
|
||||
"4. \u5361\u4F4F\uFF0F\u4E0D\u77E5\u9053\u8A72\u67E5\u4EC0\u9EBC \u2192 \u5148 `arcrun_list_skills()` **\u770B\u9019\u53F0\u5BE6\u4F8B\u771F\u7684\u6709\u54EA\u5E7E\u652F**\uFF0C\u518D\u6311\u4E00\u652F\u8B80\u3002",
|
||||
" \uFF082026-08-13 \u5BE6\u6E2C\uFF1A\u4E0D\u540C\u5BE6\u4F8B seed \u7684 skill \u4E0D\u4E00\u6A23\uFF0C\u6709\u7684\u5BE6\u4F8B\u53EA\u6709\u5169\u652F\u3001\u9023 `INDEX` \u90FD\u6C92\u6709\u3002",
|
||||
" **\u4E0D\u8981\u7167\u6559\u6750\u76F4\u63A5\u6307\u540D\u4E00\u500B slug** \u2014\u2014\u5148\u5217\u6E05\u55AE\uFF0C\u6216 `kbdb_search({ q: 'skill' })` \u76F4\u63A5\u5728\u5EAB\u88E1\u627E\u3002\uFF09",
|
||||
"5. \u7F3A\u96F6\u4EF6\u6642\uFF1A\u7F3A API \u2192 \u5BEB recipe\uFF08`arcrun_recipe_push`\uFF09\uFF1B\u7F3A\u80FD\u529B \u2192 \u6295\u7A3F\u96F6\u4EF6 PR\u3002",
|
||||
" \u{1F534} **\u4E0D\u8981\u56E0\u70BA\u67E5\u4E0D\u5230\u96F6\u4EF6\u5C31\u6539\u5BEB\u6210 `code` \u7BC0\u9EDE**\u2014\u2014\u90A3\u53EB\u300C\u8179\u8A9E\u8853\u300D\uFF08\u8868\u9762\u7528 Arcrun\u3001",
|
||||
" \u5BE6\u969B\u5168\u5BEB JS\uFF09\u3002`code` \u53EA\u7528\u65BC\u5C40\u90E8\u6574\u5F62\uFF08\u4F8B\uFF1A\u525D\u6389 LLM \u56DE\u61C9\u7684\u96DC\u8A0A\uFF09\u3002",
|
||||
@@ -32381,11 +32478,11 @@ async function handleMcpRequest(request, env, orgNamespace, partnerToken, identi
|
||||
"\u4E0D\u662F\u5931\u6557\u2014\u2014\u5225\u56E0\u70BA\u300C\u53EA\u6709\u4E00\u689D\u8DEF\u6709\u8F38\u51FA\u300D\u5C31\u4EE5\u70BA\u58DE\u6389\u800C\u6539\u5BEB\u6210 code\uFF082026-08-01 \u5BE6\u649E\uFF09\u3002",
|
||||
"\u7B2C\u4E00\u500B\u7BC0\u9EDE\u56FA\u5B9A\u662F `input`\u3002"
|
||||
].join("\n");
|
||||
const instructions = mapInstructions ? `${startHere}
|
||||
|
||||
---
|
||||
|
||||
${mapInstructions}` : startHere;
|
||||
const mapSection = mapInstructions ?? (identity.kind === "stale" ? MAP_STALE_NOTE : MAP_UNAVAILABLE_NOTE);
|
||||
return [KNOWLEDGE_FIRST, startHere, mapSection].join("\n\n---\n\n");
|
||||
}
|
||||
async function handleMcpRequest(request, env, orgNamespace, partnerToken, identity) {
|
||||
const instructions = await buildServerInstructions(env, identity);
|
||||
const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: void 0 });
|
||||
const server = new McpServer(
|
||||
{ name: "arcrun-mcp-server", version: "1.0.0" },
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
+570
-31
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"built_for": "arcrun-tier2-worker-artifacts",
|
||||
"generated_at": "2026-08-12T16:25:19.210Z",
|
||||
"repo_head": "2fcae722e7d7eab3c8b39744b87ae1344d23ba2a",
|
||||
"generated_at": "2026-08-15T15:53:28.612Z",
|
||||
"repo_head": "cacaa33f7d4e51d0011096250a33bb5107c67074",
|
||||
"repo_dirty": false,
|
||||
"workers": [
|
||||
{
|
||||
"name": "arcrun-cypher-executor",
|
||||
"source_dir": "cypher-executor",
|
||||
"source_commit": "b223a698844be289c1b01f99eb34a8e2ac85bb74",
|
||||
"source_commit": "9fa88065abdc5a795d3847711f04101480621af0",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-cypher-executor/worker.mjs",
|
||||
"js_bytes": 588587,
|
||||
"content_sha256": "c43728d21251f7835497d7dc40a3e702526a70b33406b617ed64126ffb16e1e0",
|
||||
"js_bytes": 590499,
|
||||
"content_sha256": "94a21ad012ee0c48d78e9a74752048917ddb4616f1ea089976834acd887a9d14",
|
||||
"modules": [],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [
|
||||
@@ -58,11 +58,11 @@
|
||||
{
|
||||
"name": "arcrun-kbdb",
|
||||
"source_dir": "kbdb",
|
||||
"source_commit": "f87d0e92f49690253e7c89c5badc82a08eb5d21b",
|
||||
"source_commit": "ceb7638d746f0e8847552aef721f6f53ef87ff0b",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-kbdb/worker.mjs",
|
||||
"js_bytes": 149797,
|
||||
"content_sha256": "8b23853cbc88aee0ca15ef20ca46e92bd8e75064cd311af2847f4d51811960b1",
|
||||
"js_bytes": 160306,
|
||||
"content_sha256": "47b09d60f8d122f7ec234d146a99bc68b2661c56ad6a98eca146d99ec5de03ea",
|
||||
"modules": [],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [
|
||||
@@ -84,6 +84,308 @@
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-code",
|
||||
"source_dir": "registry/components/code",
|
||||
"source_commit": "621cb8d948d61be6202063fd02effb3f538437fe",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-code/worker.mjs",
|
||||
"js_bytes": 153758,
|
||||
"content_sha256": "751634a3fc9a99cc2da662026818d754c48f031d10bff3b3be2d3a8ee2311bd6",
|
||||
"modules": [
|
||||
{
|
||||
"name": "quickjs.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-code/quickjs.wasm",
|
||||
"sha256": "105c3bed22d457e43e3d1c3c1c6959fda62a8fe06f0fc8a985303c3a2be72232"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [],
|
||||
"requires": {
|
||||
"kv": [],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "code"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-mcp",
|
||||
"source_dir": "mcp",
|
||||
"source_commit": "1ccee0055ebdb1f6d74094df3e807f56b849fa6f",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-mcp/worker.mjs",
|
||||
"js_bytes": 1192612,
|
||||
"content_sha256": "9bc8dffb34d0f36608a78b2cfeb8ee8b5c793f52b4497f5d4d5f69f70700972e",
|
||||
"modules": [],
|
||||
"compat_date": "2024-11-27",
|
||||
"compat_flags": [
|
||||
"nodejs_compat"
|
||||
],
|
||||
"requires": {
|
||||
"kv": [
|
||||
"OAUTH_KV"
|
||||
],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-array-ops",
|
||||
"source_dir": ".component-builds/array_ops",
|
||||
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-array-ops/worker.mjs",
|
||||
"js_bytes": 67802,
|
||||
"content_sha256": "35ad5be6332dde2299efd10be726300c6cace1eb085ab24f08173536ca7fb950",
|
||||
"modules": [
|
||||
{
|
||||
"name": "component.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-array-ops/component.wasm",
|
||||
"sha256": "74dc9d99f8b9ef43628b55c85c05beb1af8f6f90d78d07cd132ffe4b865147ae"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [],
|
||||
"requires": {
|
||||
"kv": [],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "array_ops"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-auth-oauth2",
|
||||
"source_dir": ".component-builds/auth_oauth2",
|
||||
"source_commit": "20c7610371c401f622ef4e6ea1b9ba812963be95",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-auth-oauth2/worker.mjs",
|
||||
"js_bytes": 80472,
|
||||
"content_sha256": "a9b0d45ee6a63e0eebd45554df16d9fa4e6e717e0c31dac13b630e132f1a96db",
|
||||
"modules": [
|
||||
{
|
||||
"name": "component.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-auth-oauth2/component.wasm",
|
||||
"sha256": "3622c20644e6c8c39487109e7353e2590bad90b1207139e34dfb0cf1c2696664"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [
|
||||
"nodejs_compat"
|
||||
],
|
||||
"requires": {
|
||||
"kv": [
|
||||
"CREDENTIALS_KV",
|
||||
"RECIPES"
|
||||
],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "auth_oauth2"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-auth-service-account",
|
||||
"source_dir": ".component-builds/auth_service_account",
|
||||
"source_commit": "20c7610371c401f622ef4e6ea1b9ba812963be95",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-auth-service-account/worker.mjs",
|
||||
"js_bytes": 81693,
|
||||
"content_sha256": "68675ec76f3f060309492e55301936a47f4ae0a5e9b00865eb861e0e631e40e5",
|
||||
"modules": [
|
||||
{
|
||||
"name": "component.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-auth-service-account/component.wasm",
|
||||
"sha256": "7d0808b4b9a2df4fbb54977eafe34a5bdb99e0cc941d97462c62e26f3651951e"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [
|
||||
"nodejs_compat"
|
||||
],
|
||||
"requires": {
|
||||
"kv": [
|
||||
"CREDENTIALS_KV",
|
||||
"RECIPES"
|
||||
],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "auth_service_account"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-auth-static-key",
|
||||
"source_dir": ".component-builds/auth_static_key",
|
||||
"source_commit": "20c7610371c401f622ef4e6ea1b9ba812963be95",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-auth-static-key/worker.mjs",
|
||||
"js_bytes": 81217,
|
||||
"content_sha256": "140c163d2e8c609eebf281de25d16e521d85004f8437cc842613c249e44aa7ff",
|
||||
"modules": [
|
||||
{
|
||||
"name": "component.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-auth-static-key/component.wasm",
|
||||
"sha256": "cd0f69a80ced0979749e0ec30b1981795a49eaa56bdb2ba102cd4b64f4670369"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [
|
||||
"nodejs_compat"
|
||||
],
|
||||
"requires": {
|
||||
"kv": [
|
||||
"CREDENTIALS_KV",
|
||||
"RECIPES"
|
||||
],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "auth_static_key"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-cron",
|
||||
"source_dir": ".component-builds/cron",
|
||||
"source_commit": "6e92ca03727bf22143499d6c0df06b40cb65c17b",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-cron/worker.mjs",
|
||||
"js_bytes": 78911,
|
||||
"content_sha256": "17068f03f686aee6c0aec9d7d748bbb1998edc8918260033f985697bc718aa76",
|
||||
"modules": [
|
||||
{
|
||||
"name": "component.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-cron/component.wasm",
|
||||
"sha256": "afb3a14f8c8f00f4f5573f44153cb1dcf6fc715b2fb47c2afb0aaf95b74d3cde"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [
|
||||
"nodejs_compat"
|
||||
],
|
||||
"requires": {
|
||||
"kv": [],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "cron"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-date-ops",
|
||||
"source_dir": ".component-builds/date_ops",
|
||||
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-date-ops/worker.mjs",
|
||||
"js_bytes": 67781,
|
||||
"content_sha256": "60ebc1d17ab0b9d3bb66f1c71ec63eb5d35bc97ed9f8b4cd1c1c95f4eeb171b7",
|
||||
"modules": [
|
||||
{
|
||||
"name": "component.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-date-ops/component.wasm",
|
||||
"sha256": "4e2c1329ce4014aa86a9a6449d8699e67c6c616f5c07088dc21216d4036ca41e"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [],
|
||||
"requires": {
|
||||
"kv": [],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "date_ops"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-filter",
|
||||
"source_dir": ".component-builds/filter",
|
||||
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-filter/worker.mjs",
|
||||
"js_bytes": 67739,
|
||||
"content_sha256": "c7fd5070ff7c0f0341e9dd364805e393f9d68056a846f48e6bc61ef6f4e67187",
|
||||
"modules": [
|
||||
{
|
||||
"name": "component.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-filter/component.wasm",
|
||||
"sha256": "6a0692b386522918b3b5af79cf7877394d1f02c350018dbaef68ef380dad1b9e"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [],
|
||||
"requires": {
|
||||
"kv": [],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "filter"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-foreach-control",
|
||||
"source_dir": ".component-builds/foreach_control",
|
||||
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-foreach-control/worker.mjs",
|
||||
"js_bytes": 67928,
|
||||
"content_sha256": "e52410c9611a93ac7fe38c7b11291cc18711c7da3370492788c3c55bc0fb03d5",
|
||||
"modules": [
|
||||
{
|
||||
"name": "component.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-foreach-control/component.wasm",
|
||||
"sha256": "8ae161ab37cc78ef6fae65364a47f7accb572378b395c4637daa2ffa57f14632"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [],
|
||||
"requires": {
|
||||
"kv": [],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "foreach_control"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-http-request",
|
||||
"source_dir": ".component-builds/http_request",
|
||||
@@ -117,19 +419,19 @@
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-code",
|
||||
"source_dir": "registry/components/code",
|
||||
"source_commit": "621cb8d948d61be6202063fd02effb3f538437fe",
|
||||
"name": "arcrun-if-control",
|
||||
"source_dir": ".component-builds/if_control",
|
||||
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-code/worker.mjs",
|
||||
"js_bytes": 153758,
|
||||
"content_sha256": "751634a3fc9a99cc2da662026818d754c48f031d10bff3b3be2d3a8ee2311bd6",
|
||||
"main_file": "arcrun-if-control/worker.mjs",
|
||||
"js_bytes": 67823,
|
||||
"content_sha256": "557ecf4ee955105af07b2227b08365eb9de8c39ad8ffb323b101397704f438bd",
|
||||
"modules": [
|
||||
{
|
||||
"name": "quickjs.wasm",
|
||||
"name": "component.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-code/quickjs.wasm",
|
||||
"sha256": "105c3bed22d457e43e3d1c3c1c6959fda62a8fe06f0fc8a985303c3a2be72232"
|
||||
"file": "arcrun-if-control/component.wasm",
|
||||
"sha256": "87c81e9cf5e664f60c06a364508dd365d49953e1b7afba6a5753fce5a4c82636"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
@@ -140,35 +442,272 @@
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "code"
|
||||
"COMPONENT_ID": "if_control"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-mcp",
|
||||
"source_dir": "mcp",
|
||||
"source_commit": "10d150ac2b4385af95a457f3c411430c4a146cf9",
|
||||
"name": "arcrun-merge",
|
||||
"source_dir": ".component-builds/merge",
|
||||
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-mcp/worker.mjs",
|
||||
"js_bytes": 1179487,
|
||||
"content_sha256": "1cd4c4d079d72bf7cba7c490ba6a88476f70b3ea51af7e5c93f9a184ae3c0ce6",
|
||||
"modules": [],
|
||||
"compat_date": "2024-11-27",
|
||||
"compat_flags": [
|
||||
"nodejs_compat"
|
||||
"main_file": "arcrun-merge/worker.mjs",
|
||||
"js_bytes": 67718,
|
||||
"content_sha256": "e4216249436000bbb4db0318cc9028f936ca6537d3d656228cfdd3cb31df25f8",
|
||||
"modules": [
|
||||
{
|
||||
"name": "component.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-merge/component.wasm",
|
||||
"sha256": "55a4d143246df172f60b5e15e7b26e55279deda9db436e3d65bccc25c86d7cd9"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [],
|
||||
"requires": {
|
||||
"kv": [
|
||||
"OAUTH_KV"
|
||||
],
|
||||
"kv": [],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "merge"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-number-ops",
|
||||
"source_dir": ".component-builds/number_ops",
|
||||
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-number-ops/worker.mjs",
|
||||
"js_bytes": 67823,
|
||||
"content_sha256": "91821a291117e033bfa4f013cd88de09479cd4ac43708e37a39df3f31c72aaa6",
|
||||
"modules": [
|
||||
{
|
||||
"name": "component.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-number-ops/component.wasm",
|
||||
"sha256": "452d1940f6e2b2b36dc97c145abcb7b5bacdbfa7631c742827c2f6f6db87b4ea"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [],
|
||||
"requires": {
|
||||
"kv": [],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "number_ops"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-set",
|
||||
"source_dir": ".component-builds/set",
|
||||
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-set/worker.mjs",
|
||||
"js_bytes": 67676,
|
||||
"content_sha256": "6cac587802cae7dc1651d6b461232abafe40dcf0fbfe9e0adc5b1d8e35b41cc1",
|
||||
"modules": [
|
||||
{
|
||||
"name": "component.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-set/component.wasm",
|
||||
"sha256": "f5afcf6e57401d2d83b21fb971ec80ca56d5e8e4bdb74a004f3d118875ed57c4"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [],
|
||||
"requires": {
|
||||
"kv": [],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "set"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-string-ops",
|
||||
"source_dir": ".component-builds/string_ops",
|
||||
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-string-ops/worker.mjs",
|
||||
"js_bytes": 67823,
|
||||
"content_sha256": "c85166e357b76828c7020426d912b591006152ac38f024a095a4d9dcd10dee72",
|
||||
"modules": [
|
||||
{
|
||||
"name": "component.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-string-ops/component.wasm",
|
||||
"sha256": "41f3243ee18bb9f4fcbaa417bd773347128dec2971132e240eddec19ba6a9da2"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [],
|
||||
"requires": {
|
||||
"kv": [],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "string_ops"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-switch",
|
||||
"source_dir": ".component-builds/switch",
|
||||
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-switch/worker.mjs",
|
||||
"js_bytes": 67739,
|
||||
"content_sha256": "e0e7689dbba735a254556272d6d233396c7fa05cc0fba5835a3e20b0ddf00fca",
|
||||
"modules": [
|
||||
{
|
||||
"name": "component.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-switch/component.wasm",
|
||||
"sha256": "6d993dc9b1affe16b600aaa7cae5af977108cc08513bb11d9d9db908285fea8b"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [],
|
||||
"requires": {
|
||||
"kv": [],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "switch"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-try-catch",
|
||||
"source_dir": ".component-builds/try_catch",
|
||||
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-try-catch/worker.mjs",
|
||||
"js_bytes": 67802,
|
||||
"content_sha256": "a5fbcbf452ab00269dc857997df3059bee7eb1f2c1b322d249146ab4e5e5bac9",
|
||||
"modules": [
|
||||
{
|
||||
"name": "component.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-try-catch/component.wasm",
|
||||
"sha256": "10eaddc5ef0d3dc68e3f009970541ac7f5fce30dcdbecec9a333afd5a29e3486"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [],
|
||||
"requires": {
|
||||
"kv": [],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "try_catch"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-validate-json",
|
||||
"source_dir": ".component-builds/validate_json",
|
||||
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-validate-json/worker.mjs",
|
||||
"js_bytes": 67886,
|
||||
"content_sha256": "2d0efa26e9fe38db87eb9585daf72e0f2ba8c859d15d1b65f36798656cc4e83b",
|
||||
"modules": [
|
||||
{
|
||||
"name": "component.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-validate-json/component.wasm",
|
||||
"sha256": "8142ffd3cc57efbb9128faaf450b646111b0f2e2575cc29a7b562a7b185912ab"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [],
|
||||
"requires": {
|
||||
"kv": [],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "validate_json"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-wait",
|
||||
"source_dir": ".component-builds/wait",
|
||||
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-wait/worker.mjs",
|
||||
"js_bytes": 67697,
|
||||
"content_sha256": "3d1b46e7b1ff056abb2c1aeed738d23d58eaa7c8f36b674e4ea2ed1602015f9e",
|
||||
"modules": [
|
||||
{
|
||||
"name": "component.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-wait/component.wasm",
|
||||
"sha256": "52ca2d184bf3dcc646bb94f29f7f3e69b5540276363e83a54cb435ff33ca2f3c"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [],
|
||||
"requires": {
|
||||
"kv": [],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "wait"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-rag-ui",
|
||||
"source_dir": "console-ui/public",
|
||||
"source_commit": "614fe448124dbd25d8228e37723473f1704f3a7a",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-rag-ui/worker.mjs",
|
||||
"js_bytes": 524513,
|
||||
"content_sha256": "5825f427e4211dd4b0eee969c94c8bfa856f65b8a48fc718e137f3b5d074e1ff",
|
||||
"modules": [],
|
||||
"compat_date": "2026-07-01",
|
||||
"compat_flags": [],
|
||||
"requires": {
|
||||
"kv": [],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {}
|
||||
},
|
||||
"ui_fingerprint": "82bc636990e60172663f7ce90c75d8b434193b8532b21f1d4b57c5fb330cbe11",
|
||||
"ui_file_count": 9,
|
||||
"warnings": []
|
||||
}
|
||||
],
|
||||
"excluded": [
|
||||
{
|
||||
"name": "arcrun-claude-api",
|
||||
"dir": ".component-builds/claude_api",
|
||||
"reason": "component.wasm 不在版控(.gitignore 明文排除=這顆不是可出貨的零件)"
|
||||
}
|
||||
],
|
||||
"notes": []
|
||||
}
|
||||
@@ -65,15 +65,26 @@
|
||||
|
||||
## 🔴 第一鐵律:wiki 是判準,不准跳過(2026-07-20/21 leo 兩度點破)
|
||||
|
||||
**要查任何東西之前,先搜尋 wiki——用 grep,不是只讀開頭幾行。**
|
||||
**要查任何東西之前,先照索引走:`system-dev/wiki/INDEX.md` → 下層 `00-INDEX.md` → 卡片。**
|
||||
|
||||
🔴 **2026-08-15 leo 糾正:本條原本寫的是「用 grep」,那是錯的。**
|
||||
grep 是平面搜尋,它繞過索引,於是**索引永遠不會被驗證、也永遠不會變準**——
|
||||
而 AI 又用「索引可能不準」當理由繼續 grep。這是自我實現的。
|
||||
實測那天:`matrix/arcrun` 的 wiki 有 64 張卡,**只有 1 張走得到索引**
|
||||
(唯一那張是 leo 手寫的;63 張機器產的卡從沒加入任何索引),
|
||||
而 AI 從沒發現——**因為 grep 找得到,破損就永遠不會浮現。**
|
||||
|
||||
**grep 不是禁令,是異常訊號**:
|
||||
1. 先照索引走
|
||||
2. 索引走不到 → **那是索引壞了,先講出來**(哪一層缺、缺什麼)
|
||||
3. 講完才准 fallback 用 `grep -rin "<關鍵字>" system-dev/wiki/`
|
||||
|
||||
⇒ 每一次 grep 都該留下一筆「索引缺陷」,而不是變成習慣。
|
||||
(全面禁止 grep 也是錯的——索引壞掉那天 AI 會瞎掉,而且沒人知道。)
|
||||
|
||||
> leo:「花很多力氣去產生 wiki,最重要的就是要可以查詢,**結果要查的時候就跳過,那就白寫了**。」
|
||||
> 「重點是你自己的記憶對嗎?而你有按照規定去切實讀 wiki 嗎?」
|
||||
|
||||
```bash
|
||||
grep -rin "<本題關鍵字>" system-dev/wiki/
|
||||
```
|
||||
|
||||
**三條硬規則**:
|
||||
1. **wiki 與程式碼/歷史文件衝突 → 以 wiki 為準**。程式碼反映「還沒清乾淨」,不等於「還在用」。
|
||||
2. wiki 寫「不可動/待廢除/進行中」→ **讀它的解除條件並逐條核對**。那是當時狀態,不是永久禁令。
|
||||
|
||||
@@ -721,6 +721,22 @@ export async function downloadAndDeploy(
|
||||
} else {
|
||||
failures.push(`D1 migration: 部署物缺 kbdb/migrations/0004_execution_log_template.sql(${execLogMigPath})`);
|
||||
}
|
||||
|
||||
// 3.8 樹狀 record 模型(0007,v7 定稿 2026-08-15):record 有身分、關係是唯一機制、
|
||||
// entry_values 拆表。**必須排在所有 template seed 之後**(它把 templates 表既有列
|
||||
// 鏡射成池中 sheet/field entry)。逐句套用+容錯 duplicate column:檔內三句
|
||||
// ADD COLUMN 在 SQLite 沒有 IF NOT EXISTS 形式,重跑(每次部署都會重跑本段)時
|
||||
// 那三句報 duplicate column = 已套用,其餘語句全部語句級冪等(檔頭有完整說明)。
|
||||
const treeMigPath = join(root, 'kbdb', 'migrations', '0007_tree_record_model.sql');
|
||||
if (existsSync(treeMigPath)) {
|
||||
try {
|
||||
await applyD1MigrationTolerant(ctx, readFileSync(treeMigPath, 'utf8'));
|
||||
} catch (e) {
|
||||
failures.push(`D1 migration 0007_tree_record_model (${ctx.d1DatabaseId}): ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
} else {
|
||||
failures.push(`D1 migration: 部署物缺 kbdb/migrations/0007_tree_record_model.sql(${treeMigPath})`);
|
||||
}
|
||||
}
|
||||
|
||||
const cypherExecutorUrl = ctx.workerSubdomain
|
||||
@@ -751,6 +767,35 @@ export async function downloadAndDeploy(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 逐句套 migration,容錯 duplicate column(0007 專用)。
|
||||
*
|
||||
* 為什麼不能走 applyD1Migration 整檔送:/query 端點任何一句失敗整批中止——
|
||||
* 0007 的三句 ADD COLUMN 在重跑時必然報 duplicate column(SQLite 沒有欄位級
|
||||
* IF NOT EXISTS),整檔送 ⇒ 第二次部署起 migration 永遠假紅、後面的資料搬遷
|
||||
* 語句永遠不被執行。逐句+把 duplicate column 視為「已套用」,其餘錯誤照樣拋。
|
||||
* 切句手法與安裝器 compile-migrations.mjs 同款(剝 -- 註解、依分號切;
|
||||
* 0007 的字串常值不含分號,前提成立)。
|
||||
*/
|
||||
async function applyD1MigrationTolerant(ctx: DeployContext, sql: string): Promise<void> {
|
||||
const statements = sql
|
||||
.split('\n')
|
||||
.map((l) => l.replace(/--.*$/, ''))
|
||||
.join('\n')
|
||||
.split(';')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
for (const stmt of statements) {
|
||||
try {
|
||||
await applyD1Migration(ctx, stmt);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (/duplicate column/i.test(msg)) continue; // ADD COLUMN 重跑=已套用
|
||||
throw new Error(`${stmt.slice(0, 60)}… → ${msg}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 對 D1 套 SQL migration(透過 CF API `/d1/database/{id}/query`,非 wrangler)。
|
||||
* 用 init 已驗的 ctx.apiToken + accountId;query 端點接受多語句檔,一次送整份 0001_base.sql。
|
||||
|
||||
@@ -24,6 +24,19 @@ import { normalizeLiveBindings, normalizeLiveVars } from './rule.mjs';
|
||||
|
||||
const CF_API_BASE = 'https://api.cloudflare.com/client/v4';
|
||||
|
||||
/**
|
||||
* 清單端點每頁抓幾筆。100 是 CF 這幾支端點通用的安全上限(KV 官方上限就是 100)。
|
||||
* 這個數字**不影響正確性**——`cfListAll` 會一直翻到底;它只決定要打幾次 API。
|
||||
*/
|
||||
const LIST_PER_PAGE = 100;
|
||||
|
||||
/**
|
||||
* 翻頁的安全上限。100 頁 × 100 筆 = 10,000 顆,遠超 CF 的帳號上限
|
||||
* (KV namespace 每帳號 1,000)⇒ 正常帳號永遠碰不到。
|
||||
* 碰到了就是 CF 那邊的行為變了,這種時候**寧可 throw 也不回一份不完整的清單**。
|
||||
*/
|
||||
const LIST_MAX_PAGES = 100;
|
||||
|
||||
/**
|
||||
* @typedef {import('./rule.mjs').ResourceApi} ResourceApi
|
||||
* @typedef {import('./rule.mjs').ScriptBindings} ScriptBindings
|
||||
@@ -57,9 +70,10 @@ export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchI
|
||||
|
||||
/**
|
||||
* 把 HTTP status 交回呼叫端自己判斷(要區分「404 不存在」和「其他錯誤」時用)。
|
||||
* `resultInfo` = CF 回應裡的 `result_info`(不分頁的端點是 `null`),`cfListAll` 靠它翻頁。
|
||||
* @param {string} path
|
||||
* @param {RequestInit} [init]
|
||||
* @returns {Promise<{ok: boolean, status: number, result?: any, error?: string}>}
|
||||
* @returns {Promise<{ok: boolean, status: number, result?: any, resultInfo?: any, error?: string}>}
|
||||
*/
|
||||
async function cfRaw(path, init) {
|
||||
const res = await doFetch(`${accountBase}${path}`, {
|
||||
@@ -76,7 +90,7 @@ export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchI
|
||||
`HTTP ${res.status}`,
|
||||
};
|
||||
}
|
||||
return { ok: true, status: res.status, result: data.result };
|
||||
return { ok: true, status: res.status, result: data.result, resultInfo: data?.result_info ?? null };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,6 +104,75 @@ export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchI
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一支「列出帳號上有什麼」的端點**翻到底**,回傳全部項目。
|
||||
*
|
||||
* 【為什麼非翻不可——這是 Arcrun#123 的續集,不是效能優化】
|
||||
* 三支清單方法原本只打 `?per_page=100`,也就是**只看第一頁**。同一個截斷,
|
||||
* 在 #123 的修法前後,後果**不一樣**:
|
||||
*
|
||||
* | 被截掉的那顆 | 規則走到哪 | 結果 |
|
||||
* |---|---|---|
|
||||
* | #123 修好**前**:worker 綁著它,但它落在第二頁 | 2b 判「綁著的資源不見了」 | 產生 blocker,**停手**(過度保守,但安全) |
|
||||
* | #123 修好**後**:名字落在第二頁 | 2c 判「這個名字沒被佔走」 | **去建 → CF 回 title already exists ⇒ #123 的死路原樣回來** |
|
||||
*
|
||||
* ⇒ 修法把這個洞從「叫得太大聲」變成「**安靜地復發**」。所以規約是:
|
||||
* **看不完整就不准當作看完了**——翻不完、或翻出來的數量對不上 CF 自己回報的
|
||||
* `total_count`,一律 throw,讓 `planResources` 把它變成 blocker
|
||||
* (README 規則第 3 條:說不準就整趟停手,一顆都不建)。
|
||||
*
|
||||
* 【三支端點的分頁行為不一樣,這裡刻意不假設它們同款】(2026-08-14 在 geek6688 帳號實測)
|
||||
* - `/storage/kv/namespaces`:真分頁,`result_info` = `{page, per_page, count, total_count, total_pages}`
|
||||
* - `/d1/database`:真分頁,但 `result_info` **沒有 `total_pages`**(實測 `{page, per_page, count, total_count}`)
|
||||
* ⇒ **不准拿 `total_pages` 當終止條件**,那個欄位在 D1 上是 `undefined`
|
||||
* - `/vectorize/v2/indexes`:**不分頁**,`result_info` 是 `null`,帶 `page`/`per_page` 也被忽略(一次回全部)
|
||||
*
|
||||
* 所以終止條件只用「三支都有、或三支都沒有」的兩件事:`result_info` 在不在、`total_count` 對不對得上。
|
||||
* 對不分頁的那支,這支等於只打一次就回來(那兩個被忽略的參數實測無害);
|
||||
* 而萬一 CF 哪天替它補上分頁,這支會自己跟著翻——不必等下一次災情才想起來改。
|
||||
*
|
||||
* @param {string} path 不含分頁參數的端點路徑(可自帶其他 query)
|
||||
* @param {string} what 出錯訊息裡怎麼稱呼它
|
||||
* @returns {Promise<any[]>}
|
||||
*/
|
||||
async function cfListAll(path, what) {
|
||||
/** @type {any[]} */
|
||||
const items = [];
|
||||
for (let page = 1; page <= LIST_MAX_PAGES; page++) {
|
||||
const sep = path.includes('?') ? '&' : '?';
|
||||
const res = await cfRaw(`${path}${sep}per_page=${LIST_PER_PAGE}&page=${page}`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`列 ${what} 失敗(第 ${page} 頁):${res.error ?? `HTTP ${res.status}`}`);
|
||||
}
|
||||
const batch = Array.isArray(res.result) ? res.result : [];
|
||||
items.push(...batch);
|
||||
|
||||
const info = res.resultInfo;
|
||||
// 這支端點沒有分頁(Vectorize v2)⇒ 這一趟拿到的就是全部。
|
||||
if (!info) return items;
|
||||
|
||||
const total = Number(info.total_count);
|
||||
if (Number.isFinite(total)) {
|
||||
if (items.length >= total) return items;
|
||||
// CF 說還有,卻一筆都不給 ⇒ 我們看不到全部。**不准安靜地當作看完了。**
|
||||
if (batch.length === 0) {
|
||||
throw new Error(
|
||||
`列 ${what} 只讀到 ${items.length} 筆,但 Cloudflare 說共有 ${total} 筆,第 ${page} 頁卻是空的。` +
|
||||
`看不到帳號上的全部資源就沒辦法判斷該不該新建——停手。`,
|
||||
);
|
||||
}
|
||||
continue; // total_count 說還有就繼續翻(不看 total_pages:D1 根本沒這個欄位)
|
||||
}
|
||||
|
||||
// 沒有 total_count 可對,只剩「這一頁沒裝滿 ⇒ 沒有下一頁」可用。
|
||||
if (batch.length < LIST_PER_PAGE) return items;
|
||||
}
|
||||
throw new Error(
|
||||
`列 ${what} 翻超過 ${LIST_MAX_PAGES} 頁還沒到底(已讀 ${items.length} 筆)。` +
|
||||
`這不正常,寧可停手,也不拿一份不完整的清單去判斷該不該新建資源。`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
cfRaw,
|
||||
|
||||
@@ -122,7 +205,8 @@ export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchI
|
||||
/** @returns {Promise<Map<string, string>>} title → id */
|
||||
async listKvNamespaces() {
|
||||
/** @type {Array<{id: string, title: string}>} */
|
||||
const result = await cf('/storage/kv/namespaces?per_page=100');
|
||||
// 翻到底才算數(只看第一頁會讓 Arcrun#123 安靜復發,理由見 cfListAll)
|
||||
const result = await cfListAll('/storage/kv/namespaces', 'KV namespace');
|
||||
const map = new Map();
|
||||
for (const ns of result) map.set(ns.title, ns.id);
|
||||
return map;
|
||||
@@ -131,7 +215,8 @@ export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchI
|
||||
/** @returns {Promise<Map<string, string>>} name → uuid */
|
||||
async listD1Databases() {
|
||||
/** @type {Array<{uuid: string, name: string}>} */
|
||||
const result = await cf('/d1/database?per_page=100');
|
||||
// 翻到底才算數。D1 的 result_info **沒有 total_pages**,所以終止條件只認 total_count。
|
||||
const result = await cfListAll('/d1/database', 'D1 資料庫');
|
||||
const map = new Map();
|
||||
for (const db of result) map.set(db.name, db.uuid);
|
||||
return map;
|
||||
@@ -140,8 +225,10 @@ export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchI
|
||||
/** @returns {Promise<string[]>} */
|
||||
async listVectorizeIndexes() {
|
||||
/** @type {Array<{name: string}>} */
|
||||
const result = await cf('/vectorize/v2/indexes');
|
||||
return (result ?? []).map((i) => i.name);
|
||||
// 這支端點**目前不分頁**(`result_info` 是 null),走 cfListAll 等同只打一次;
|
||||
// 但 CF 哪天替它補上分頁,這裡會自己跟著翻,不必等下一次災情才想起來改。
|
||||
const result = await cfListAll('/vectorize/v2/indexes', 'Vectorize index');
|
||||
return result.map((i) => i.name);
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -106,6 +106,19 @@
|
||||
* @property {string} binding
|
||||
* @property {string} worker 需要它的 worker script 名(= wrangler.toml 的 `name`)。
|
||||
* @property {string} createName
|
||||
* @property {boolean} [createNameIsOurs]
|
||||
* 呼叫端在此**聲明**:`createName` 是我們自己用可重現的方式替**這一台實例**算出來的名字
|
||||
* ⇒ 帳號上若已經有一顆**恰好同名**的資源,它只可能是我們上一次沒裝完留下的(Arcrun#123)。
|
||||
*
|
||||
* 🔴 這個聲明是「接管同名資源」的**唯一**依據,預設 false(fail-closed)。
|
||||
* 只有在名字**推導得出、而且推導的輸入是使用者自己的身分**時才准聲明 true——
|
||||
* 安裝器的 `arcrun-rag-<slugFromEmail(email)>-kv-<binding>` 就是這種
|
||||
* (slug = `SHA-256('arcrun-rag:' + email)` 取前 8 碼,同一個 email 每次算出同一組名字,
|
||||
* 別人算不到、也不會不小心撞上)。
|
||||
*
|
||||
* ⚠️ **不准**因為「名字看起來像我們的」就聲明 true。`acr` 那條從 wrangler.toml 讀到的
|
||||
* createName 是裸 binding 名(`WEBHOOKS`)或 toml 宣告的庫名(`arcrun-kbdb`)——
|
||||
* 那種名字使用者自己也可能拿去用,**證明不了是我們的**,所以那條路一律不聲明。
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -113,7 +126,12 @@
|
||||
* @property {ResourceKind} kind
|
||||
* @property {string} binding
|
||||
* @property {string} value
|
||||
* @property {string} from 從哪顆已部署的 worker 上讀到的
|
||||
* @property {string} from 從哪顆已部署的 worker 上讀到的。`reclaimed` 時為空字串——
|
||||
* **沒有任何 worker 綁著它正是接收它的前提**(Arcrun#123),不是漏填。
|
||||
* @property {boolean} [reclaimed]
|
||||
* true = 這顆不是從某顆 worker 的綁定讀出來的,而是「帳號上已經有一顆我們自己命名的同名資源、
|
||||
* 卻沒有人綁著」⇒ 上一次沒裝完留下的,這次把它接回來用(Arcrun#123)。
|
||||
* 給呼叫端做診斷/統計用;**使用者不必知道「殘骸」這個詞**,對外一律講「沿用你原本的資源」。
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -144,7 +162,11 @@
|
||||
* @property {string} binding
|
||||
* @property {string} value
|
||||
* @property {'adopted' | 'created'} origin
|
||||
* 🔴 接回上次沒裝完留下的那顆(`reclaimed`)**仍然算 `adopted`**,不另開第三種值——
|
||||
* 它本來就是「沿用既有資源」,而且呼叫端現有的 `origin === 'adopted' / 'created'` 統計
|
||||
* (安裝器那句「沿用你原本的 N 項資源」)不會因為多一種值就悄悄漏數。
|
||||
* @property {string} [from]
|
||||
* @property {boolean} [reclaimed] 見 PlannedAdopt.reclaimed(Arcrun#123)。
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -251,19 +273,25 @@ export async function planResources(api, requirements, mode) {
|
||||
else byKey.set(key, [req]);
|
||||
}
|
||||
|
||||
/** @type {Map<ResourceKind, Set<string>>} */
|
||||
// 帳號上現有的資源,一種只查一次。**名字 → 身分**(KV/D1 是 id,Vectorize 的身分就是名字)。
|
||||
//
|
||||
// 為什麼連名字都收下來(本來只留 `.values()`):
|
||||
// · 2b 要問的是「這顆綁著的資源還在不在」→ 只需要 values(身分)。
|
||||
// · 2c 要問的是「這個**名字**是不是已經被佔走了」→ 需要 key。
|
||||
// 同一份 API 回應裡兩個問題都答得出來,不必多打一次。
|
||||
/** @type {Map<ResourceKind, Map<string, string>>} */
|
||||
const existingCache = new Map();
|
||||
/** @param {ResourceKind} kind @returns {Promise<Set<string>>} */
|
||||
const listExisting = async (kind) => {
|
||||
/** @param {ResourceKind} kind @returns {Promise<Map<string, string>>} */
|
||||
const listExistingByName = async (kind) => {
|
||||
const hit = existingCache.get(kind);
|
||||
if (hit) return hit;
|
||||
/** @type {Set<string>} */
|
||||
let set;
|
||||
if (kind === 'kv_namespace') set = new Set((await api.listKvNamespaces()).values());
|
||||
else if (kind === 'd1') set = new Set((await api.listD1Databases()).values());
|
||||
else set = new Set(await api.listVectorizeIndexes());
|
||||
existingCache.set(kind, set);
|
||||
return set;
|
||||
/** @type {Map<string, string>} */
|
||||
let map;
|
||||
if (kind === 'kv_namespace') map = await api.listKvNamespaces();
|
||||
else if (kind === 'd1') map = await api.listD1Databases();
|
||||
else map = new Map((await api.listVectorizeIndexes()).map((n) => [n, n]));
|
||||
existingCache.set(kind, map);
|
||||
return map;
|
||||
};
|
||||
|
||||
for (const [, reqs] of byKey) {
|
||||
@@ -291,10 +319,10 @@ export async function planResources(api, requirements, mode) {
|
||||
// 2b. 有人綁著它 → 這就是事實,沿用。名字長什麼樣完全不看。
|
||||
if (distinct.length === 1) {
|
||||
const value = distinct[0];
|
||||
/** @type {Set<string>} */
|
||||
/** @type {Map<string, string>} */
|
||||
let existing;
|
||||
try {
|
||||
existing = await listExisting(kind);
|
||||
existing = await listExistingByName(kind);
|
||||
} catch (e) {
|
||||
blockers.push(
|
||||
`查不到帳號上的 ${KIND_LABEL[kind]} 清單,無法確認「${binding}」綁著的 ${value} 還在不在` +
|
||||
@@ -302,7 +330,7 @@ export async function planResources(api, requirements, mode) {
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (!existing.has(value)) {
|
||||
if (![...existing.values()].includes(value)) {
|
||||
// 這正是 #97 的入口:舊版在這裡會安靜地新建一顆空的頂上去。
|
||||
blockers.push(
|
||||
`worker「${found[0].script}」的「${binding}」綁著 ${KIND_LABEL[kind]} ${value},` +
|
||||
@@ -316,12 +344,62 @@ export async function planResources(api, requirements, mode) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2c. 沒有任何已部署的 worker 綁過它 → 新版本新增的 binding,或全新帳號。
|
||||
// 這種情況下新建不會弄丟任何東西(本來就沒有東西可丟)。
|
||||
// 2c. 沒有任何已部署的 worker 綁過它 → 新版本新增的 binding、全新帳號,
|
||||
// **或者上一次安裝建到一半死掉**(Arcrun#123)。
|
||||
//
|
||||
// 🔴 原本這裡直接 `create.push()`,理由寫「本來就沒有東西可丟」。**那句話漏了一種狀態**:
|
||||
// 資源已經建在帳號上、worker 還沒部署就中斷(逾時/關掉分頁/斷網)。那個當下:
|
||||
// 名字已存在 ✅ / 有 worker 綁著 ❌ ⇒ 舊邏輯判「可以新建」⇒ CF 回
|
||||
// `a namespace with this account ID and title already exists` ⇒ **這個帳號從此裝不起來**。
|
||||
// 封測者 1.4.45 實撞;youlin 拆除時也親眼看到 8 顆「一個 worker 都沒裝出來就被砍」的空殼。
|
||||
//
|
||||
// ⚠️ **這不是把 Arcrun#97 刪掉的 `ensureKvNamespace` 搬回來**,兩者差在三個地方:
|
||||
// ① #97 是「照名字找 → **找不到就新建一顆頂上去**」;這裡是「照名字找 →
|
||||
// **找到才沿用那一顆,找不到就照舊新建**」。**永遠不會拿新的空資源去頂替既有的**
|
||||
// ——會弄丟資料的是那個動作,不是這個。
|
||||
// ② #97 的比對凌駕於「worker 綁著誰」之上;這裡在 2b 之後,**已部署的綁定仍然絕對優先**,
|
||||
// 只有在「確定沒有任何 worker 綁過它」時才輪得到名字說話。
|
||||
// ③ #97 無條件相信名字;這裡要呼叫端**先聲明這個名字推導自使用者自己的身分**
|
||||
// (`createNameIsOurs`),沒聲明就停手。
|
||||
/** @type {Map<string, string>} */
|
||||
let existingByName;
|
||||
try {
|
||||
existingByName = await listExistingByName(kind);
|
||||
} catch (e) {
|
||||
blockers.push(
|
||||
`查不到帳號上的 ${KIND_LABEL[kind]} 清單,無法確認「${reqs[0].createName}」這個名字是不是已經被用掉了` +
|
||||
`(${msg(e)})。不確定就不建——停手。`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const createName = reqs[0].createName;
|
||||
const sameName = existingByName.get(createName);
|
||||
if (sameName !== undefined) {
|
||||
if (!reqs[0].createNameIsOurs) {
|
||||
// 名字被佔走,而呼叫端證明不了那顆是我們的 ⇒ 接管它可能蓋掉使用者自己的東西。
|
||||
// #97 的反向災情(安靜地接管一顆別人的)跟正向一樣糟 ⇒ fail-closed。
|
||||
// 訊息不准叫使用者自己去 Cloudflare 後台動手(#121/D88:機器做得到的事不要丟回給人)。
|
||||
blockers.push(
|
||||
`你的 Cloudflare 帳號上已經有一個叫「${createName}」的 ${KIND_LABEL[kind]},` +
|
||||
`但沒有任何 worker 綁著它,我也無法證明那顆是這次安裝建的。` +
|
||||
`直接拿來用有可能蓋掉你自己的東西,所以停手了——沒有建立或改動任何資源。` +
|
||||
`請把這則訊息回報給我們(錯誤碼 RES-NAME-TAKEN/${kind}/${binding}),這需要我們處理。`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// 名字是我們替這台實例算出來的(見 createNameIsOurs 的推導條件)⇒ 這顆只可能是
|
||||
// 我們上一次沒裝完留下的。沿用它=把上次做到一半的進度接回來,**不會有任何損失**:
|
||||
// · 它若是空的(最常見)→ 等同於新建一顆,只是省下 CF 那個「名字已存在」的拒絕。
|
||||
// · 它若有資料(更早裝過、後來 worker 被拆掉)→ 沿用正是把使用者的東西接回來。
|
||||
adopt.push({ kind, binding, value: sameName, from: '', reclaimed: true });
|
||||
continue;
|
||||
}
|
||||
|
||||
create.push({
|
||||
kind,
|
||||
binding,
|
||||
createName: reqs[0].createName,
|
||||
createName,
|
||||
wantedBy: [...new Set(reqs.map((r) => r.worker))],
|
||||
alsoBind: [],
|
||||
});
|
||||
@@ -401,6 +479,7 @@ export async function applyResourcePlan(api, plan) {
|
||||
value: a.value,
|
||||
origin: 'adopted',
|
||||
from: a.from,
|
||||
...(a.reclaimed ? { reclaimed: true } : {}),
|
||||
});
|
||||
}
|
||||
/** @type {string[]} */
|
||||
|
||||
@@ -135,6 +135,13 @@ class FakeCloudflare implements ResourceApi {
|
||||
async listD1Databases(): Promise<Map<string, string>> { return new Map(this.d1); }
|
||||
async listVectorizeIndexes(): Promise<string[]> { return [...this.vectorize]; }
|
||||
async createKvNamespace(title: string): Promise<string> {
|
||||
// 🔴 Arcrun#123:真的 Cloudflare **不准同名**——
|
||||
// `a namespace with this account ID and title already exists`。
|
||||
// 這個假帳號原本沒有模擬這條限制,於是「上一次裝到一半死掉」的帳號在測試裡
|
||||
// 看起來只是「多建幾顆孤兒」,實際上是**再也裝不起來**。少了這一行,#123 測不出來。
|
||||
if (this.kv.has(title)) {
|
||||
throw new Error('a namespace with this account ID and title already exists');
|
||||
}
|
||||
const id = `NEW-kvid-${this.createdKv.length}`;
|
||||
this.kv.set(title, id);
|
||||
this.kvData.set(id, new Map()); // 新建的是**空的**——災情就是綁到這種東西上
|
||||
@@ -486,6 +493,100 @@ test('repo 的 toml 綁定總集合 = REQUIRED_KV_NAMESPACES(漏綁會讓某
|
||||
assert.deepEqual(kv.sort(), [...REQUIRED_KV_NAMESPACES].sort());
|
||||
});
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// Arcrun#123 —— 「上一次裝到一半死掉」的帳號,再裝一次要能成功
|
||||
//
|
||||
// 這一格與 #97 的差別:#97 是「worker 綁著資源,卻被重新綁到新建的空殼」(會弄丟資料);
|
||||
// #123 是「資源建好了、worker 一顆都還沒部署」——**沒有任何綁定可以當事實**,
|
||||
// 而帳號上偏偏已經有一批同名資源 ⇒ 舊規則判「可以新建」⇒ CF 拒絕 ⇒ 這個帳號從此裝不起來。
|
||||
//
|
||||
// 封測者 1.4.45 實撞。逃過驗證的原因:我們只測乾淨帳號與完整安裝。
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* 安裝器那條路的需求:`createName` 是安裝器**自己替這台實例算出來的**
|
||||
* (`arcrun-rag-<slugFromEmail(email)>-…`)⇒ 有資格聲明 createNameIsOurs。
|
||||
* 對照 collectRequirements()(走 toml,createName 是裸 binding 名 ⇒ 不得聲明)。
|
||||
*/
|
||||
function installerRequirements(claimOwnership = true): BindingRequirement[] {
|
||||
const own = claimOwnership ? { createNameIsOurs: true } : {};
|
||||
const out: BindingRequirement[] = [];
|
||||
for (const { requirements } of [collectRequirements()]) {
|
||||
for (const r of requirements) {
|
||||
if (r.kind === 'kv_namespace') {
|
||||
out.push({ ...r, createName: `arcrun-rag-${INSTANCE}-kv-${r.binding.toLowerCase()}`, ...own });
|
||||
} else if (r.kind === 'd1') {
|
||||
out.push({ ...r, createName: `arcrun-rag-${INSTANCE}-kbdb`, ...own });
|
||||
} else {
|
||||
out.push({ ...r, createName: `arcrun-rag-${INSTANCE}-embed`, ...own });
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
test('#123 ①:上次裝到一半死掉的帳號 → 接回上次留下的那批,一顆都不必新建', async () => {
|
||||
const cf = new FakeCloudflare({ nothingDeployed: true }); // 資源在、worker 一顆都沒有
|
||||
const kvBefore = cf.kv.size;
|
||||
|
||||
const plan = await planResources(cf, installerRequirements(), 'init');
|
||||
assert.deepEqual(plan.blockers, [], '半殘帳號不該有 blocker——用戶只要再按一次就該裝得起來');
|
||||
assert.deepEqual(plan.create, [], '一顆都不該新建');
|
||||
assert.ok(plan.adopt.length > 0 && plan.adopt.every((a) => a.reclaimed === true),
|
||||
'全部都是「接回上次留下的」');
|
||||
|
||||
const resolved = await applyResourcePlan(cf, plan);
|
||||
assert.deepEqual(cf.createdKv, [], '不該新建任何 KV');
|
||||
assert.deepEqual(cf.createdD1, [], '不該新建任何 D1');
|
||||
assert.equal(cf.kv.size, kvBefore, '帳號上的顆數不變(沒有再留下一批孤兒)');
|
||||
|
||||
// 綁到的是上次建的那幾顆本尊
|
||||
assert.equal(resolved.get(bindingKey('kv_namespace', 'WEBHOOKS'))!.value, 'kvid-webhooks');
|
||||
assert.equal(resolved.get(bindingKey('d1', 'DB'))!.value, 'd1id-kbdb');
|
||||
assert.equal(resolved.get(bindingKey('kv_namespace', 'WEBHOOKS'))!.origin, 'adopted',
|
||||
'origin 維持 adopted——呼叫端既有的 adopted/created 統計不會漏數');
|
||||
});
|
||||
|
||||
test('#123 ①對照組:修好之前,同一個帳號會被 CF 用「title already exists」擋死', async () => {
|
||||
const cf = new FakeCloudflare({ nothingDeployed: true });
|
||||
// 不聲明所有權 ⇒ 走的是修好之前的判斷(「沒人綁著就可以新建」)。
|
||||
// 舊版會直接送 POST → 撞 CF 的同名限制;新版在 plan 階段就停手,兩者都裝不起來,
|
||||
// 差別在**新版一顆資源都不會被建出來**,而且訊息說得出人話。
|
||||
const plan = await planResources(cf, installerRequirements(false), 'init');
|
||||
assert.ok(plan.blockers.length > 0, '證明不了是自己的 → fail-closed 停手');
|
||||
assert.match(plan.blockers.join('\n'), /RES-NAME-TAKEN/, '要給得出可回報的錯誤碼');
|
||||
assert.doesNotMatch(plan.blockers.join('\n'), /後台|dashboard/,
|
||||
'不准叫使用者自己去 Cloudflare 後台處理(#121/D88)');
|
||||
await assert.rejects(() => applyResourcePlan(cf, plan), ResourcePlanBlocked);
|
||||
assert.deepEqual(cf.createdKv, [], '被擋下時一顆都不能被建出來');
|
||||
});
|
||||
|
||||
test('#123 ②紅線:名字真的不是我們的(走 toml 的裸 binding 名)→ 停手,不接管也不新建', async () => {
|
||||
const cf = new FakeCloudflare({ nothingDeployed: true });
|
||||
await cf.createKvNamespace('WEBHOOKS'); // 使用者自己建的、剛好叫這個名字
|
||||
cf.createdKv.length = 0;
|
||||
const { requirements } = collectRequirements(); // createName = 裸 binding 名,不得聲明所有權
|
||||
|
||||
const plan = await planResources(cf, requirements, 'init');
|
||||
assert.ok(plan.blockers.length > 0, '撞到不能證明是我們的同名資源 → 停手');
|
||||
assert.ok(!plan.create.some((c) => c.binding === 'WEBHOOKS'), 'WEBHOOKS 不准被排進「要新建」');
|
||||
await assert.rejects(() => applyResourcePlan(cf, plan), ResourcePlanBlocked);
|
||||
assert.deepEqual(cf.createdKv, [], '一顆都沒建');
|
||||
});
|
||||
|
||||
test('#123 ③:全新帳號照舊建得出整套(D82 第一步不可退化)', async () => {
|
||||
const cf = new FakeCloudflare({ nothingDeployed: true });
|
||||
cf.kv.clear(); cf.d1.clear(); cf.vectorize.length = 0; // 真正的空帳號
|
||||
|
||||
const plan = await planResources(cf, installerRequirements(), 'init');
|
||||
assert.deepEqual(plan.blockers, [], '全新帳號不該有 blocker');
|
||||
assert.ok(plan.adopt.length === 0, '全新帳號沒有東西可沿用');
|
||||
const resolved = await applyResourcePlan(cf, plan);
|
||||
assert.equal(cf.createdKv.length, REQUIRED_KV_NAMESPACES.length, '9 顆 KV 全部建出來');
|
||||
assert.equal(cf.createdD1.length, 1, 'D1 建一顆(兩個綁定共用)');
|
||||
assert.ok([...resolved.values()].every((r) => r.origin === 'created'), '全部都是新建的');
|
||||
});
|
||||
|
||||
test('CfAccountClient.getScriptBindings:404 = 還沒部署;其他錯誤要 throw(不能當成「沒有綁」)', async () => {
|
||||
const orig = globalThis.fetch;
|
||||
try {
|
||||
|
||||
@@ -840,7 +840,12 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
'<div class="kt">' + esc(l.library) + (S.library === l.library ? ' <span class="tag" style="font-size:11.5px;vertical-align:middle">搜尋中</span>' : '') + '</div>' +
|
||||
'<div class="ks">' + (l.narrative ? esc(l.narrative) : '<span class="dim">(此庫尚無 narrative——recompute 時可帶入)</span>') + '</div>' +
|
||||
(ents && ents.length ? '<div style="display:flex;gap:6px;flex-wrap:wrap">' + lmEntityLine(ents) + '</div>' : '') +
|
||||
'<div class="km"><span class="mono" style="color:var(--amber)">' + (Number(l.triplet_count) || 0) + ' 三元組</span>' +
|
||||
// Arcrun#87 三次收尾(2026-08-13):triplet_count=0 不等於這庫沒有知識——entries 有標庫、
|
||||
// 只是三元組萃取沒對它跑過(kb 以外幾乎全庫皆此況)。entry_count>0 時附一句,別讓看板
|
||||
// 的「0 三元組」被讀成「這庫是空的」(同 MCP 端 kbdb_map.ts 的 entryOnlyHint 同一件事)。
|
||||
'<div class="km"><span class="mono" style="color:var(--amber)">' + (Number(l.triplet_count) || 0) + ' 三元組' +
|
||||
(!(Number(l.triplet_count) || 0) && (Number(l.entry_count) || 0) > 0
|
||||
? '<span class="dim">・' + (Number(l.entry_count) || 0) + ' 筆原始內容(尚未萃取)</span>' : '') + '</span>' +
|
||||
'<span class="dim" style="margin-left:auto;font-size:12.5px">' + stamp + '</span></div></div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
@@ -245,6 +245,35 @@ export async function resolveCredentialRefs(
|
||||
// 沒取到的 name 缺席 → WASM 的 resolve_credentials 分支 fallback 舊 KV(T7)。
|
||||
const resolvedSecrets = await resolveSecretsFromNewHome(env, apiKey, nameList);
|
||||
|
||||
// ── 全部取到 → 不必打 auth_static_key worker(Arcrun#124,2026-08-14)────────────
|
||||
//
|
||||
// 這條路徑上,那顆 worker 唯一比這裡多做的事,是「幫**沒取到**的 name 回頭翻舊 KV」
|
||||
// (T7 雙讀 fallback)。當 nameList 每一個都已經從新家取到明文時,它收到
|
||||
// `resolved_secrets` 後就是原樣回傳——回填本來就由下面的 `replaceCredentialRefs`
|
||||
// 在本檔做。**所以那趟往返在「全取到」時不產生任何結果差異。**
|
||||
//
|
||||
// 為什麼這不是效能微調,是修 bug:`wasmWorkerUrl` 推導出的
|
||||
// `arcrun-auth-static-key.{WORKER_SUBDOMAIN}.workers.dev` **在網頁安裝器裝出來的實例上不存在**
|
||||
// (首裝只裝 cypher-executor / kbdb / http_request / code 四顆,auth primitive 不在其中,
|
||||
// 也沒有「用到才長」的機制)。於是 fetch 撞 CF 邊緣的 404 `error code: 1042`
|
||||
// ⇒ 下面的 `!res.ok` throw ⇒ **凡是工作流用到 {{credential.X}} 的節點全部 500**。
|
||||
// 實測:leo21c 與全新安裝的 youlin 兩台,問答與寫入路徑一字不差地同死。
|
||||
//
|
||||
// 🔴 給下一個人:**這個 1042 跟 d2048e2/95a1462 那兩輪的 1042 不是同一個病。**
|
||||
// 那兩輪是「same-zone fetch 被 CF 擋」(解法=`global_fetch_strictly_public` flag,
|
||||
// 且 service binding 的解法**已被 revert、不要再拿回來**)。本案是「**目標 worker 根本不存在**」
|
||||
// ——同一個錯誤碼、兩種原因。照舊帳去動 flag 或 binding 對本案完全無效(flag 早就在了)。
|
||||
//
|
||||
// 邊界(rule 02 §2.2)不變:本檔仍然不解密、不展開模板、不組 JWT。
|
||||
// 明文的來源是 `secret_get`(host function,實作 = env[ref]),與改動前完全同一條路;
|
||||
// 這裡只是不再為了「拿一份自己已經拿到的東西」去 fetch 一顆可能不存在的 worker。
|
||||
//
|
||||
// 刻意保留 WASM 路徑:只要有任何一個 name 沒從新家取到(舊租戶、遷移過渡期的 KV 存量),
|
||||
// 就照舊全量交給 WASM 走 T7 雙讀——**不縮減既有能力,只在等價時省掉那一跳**。
|
||||
if (nameList.every((n) => Object.prototype.hasOwnProperty.call(resolvedSecrets, n))) {
|
||||
return replaceCredentialRefs(data, resolvedSecrets) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
const url = wasmWorkerUrl('auth_static_key', env.WORKER_SUBDOMAIN);
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -51,11 +51,6 @@ const WASM_HTTP_RUNNER_IDS: ReadonlySet<string> = new Set([
|
||||
'auth_service_account',
|
||||
'auth_oauth2',
|
||||
'auth_mtls',
|
||||
// hash(Arcrun#91,2026-08-13):純計算零件(sha256/sha1/md5,hex/base64),
|
||||
// 出貨線版本號機制與成品指紋核對用它。no_network_syscall,故不走 LOGIC_BINDING_MAP
|
||||
// 的 Service Binding 路(rule 3.1 禁新增 binding),走這裡的通用 wasmWorkerUrl 推導,
|
||||
// 與 code/cron 同一形狀(獨立 Worker,白名單只是「知道這個 canonical_id 存在」)。
|
||||
'hash',
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -131,9 +131,16 @@ export function authStorePresent(env: Bindings): boolean {
|
||||
return shardNames(env).length > 0 || (overlay !== null && Date.now() - overlayAt < AUTH_OVERLAY_TTL_MS);
|
||||
}
|
||||
|
||||
/** 寫入路徑是否就緒——缺就誠實回報「不能改密碼」,不假綠。 */
|
||||
export function authStoreWritable(env: Bindings): boolean {
|
||||
return Boolean(env.CF_SECRETS_API_TOKEN && env.CF_ACCOUNT_ID);
|
||||
/**
|
||||
* 寫入路徑是否就緒——缺就誠實回報「不能改密碼」,不假綠。
|
||||
*
|
||||
* `tokenOverride`(2026-08-14,arcrun-rag#99):`env.CF_SECRETS_API_TOKEN` 從沒被安裝器種過,
|
||||
* 這是每台新實例都會撞的硬斷點(不是 leo 個人的環境問題)。安裝精靈裝機當下手上有一把
|
||||
* 自己還有效的 OAuth token,讓 `/console/setup`/`/portal/admin/bootstrap` 把它隨請求帶入,
|
||||
* 這裡就把它算進「寫得進去嗎」的判斷——見 `routes/credentials.ts putWorkerSecret` 的完整說明。
|
||||
*/
|
||||
export function authStoreWritable(env: Bindings, tokenOverride?: string): boolean {
|
||||
return Boolean((tokenOverride || env.CF_SECRETS_API_TOKEN) && env.CF_ACCOUNT_ID);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -201,11 +208,21 @@ export function newAuthUserId(): string {
|
||||
* 分片規則:console 一定放第 0 片;users 依序塞,塞不下就開下一片。
|
||||
* 多出來的舊分片會被刪掉(避免「刪了帳號卻還留在舊分片裡復活」)。
|
||||
*/
|
||||
export async function writeAuthStore(env: Bindings, data: AuthStoreData): Promise<void> {
|
||||
if (!authStoreWritable(env)) {
|
||||
export async function writeAuthStore(env: Bindings, data: AuthStoreData, tokenOverride?: string): Promise<void> {
|
||||
if (!authStoreWritable(env, tokenOverride)) {
|
||||
// arcrun-rag#99(2026-08-14,leo 傳實測畫面點破):舊文案叫用戶「重新執行安裝/更新」,
|
||||
// 但在這個訊息會出現的所有情況下(包含安裝精靈本身,若它沒能把 install token 帶到)那句話
|
||||
// 都不保證成立——安裝/更新從來不會把 CF_SECRETS_API_TOKEN 種成一個常駐的值。
|
||||
// 對一個做不到的動作下指令=把人導向死路(他會以為自己操作錯誤,反覆重試)。
|
||||
// 改成誠實描述現況+給得出去的下一步(回報支援),不再承諾一個我們自己都不確定會生效的動作。
|
||||
// 只列 CF_SECRETS_API_TOKEN:CF_ACCOUNT_ID 已查證全新實例一定有
|
||||
//(installer/oauth-prototype/worker.js:1354 無條件注入每一顆部署的 worker),
|
||||
// 列兩項只會讓下一個人以為兩項都要查,白工一次。
|
||||
throw new AuthStoreWriteError(
|
||||
'這台實例還不能寫入認證儲存(缺 CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID)。' +
|
||||
'認證分離需要這兩項才寫得進 Workers Secrets——請重新執行安裝/更新讓它就緒。',
|
||||
'這台實例目前寫不進認證儲存(缺可用的 Cloudflare 寫入憑證:CF_SECRETS_API_TOKEN)。' +
|
||||
'這是平台端的已知限制,不是你操作錯誤——目前沒有你自己在畫面上能做的下一步,' +
|
||||
'請把這則訊息完整截圖/複製給支援,並註明你剛才在做什麼(例如:安裝精靈裡建立第一個帳號、' +
|
||||
'事後新增使用者、或修改密碼),會需要人工協助排除。',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -232,10 +249,10 @@ export async function writeAuthStore(env: Bindings, data: AuthStoreData): Promis
|
||||
|
||||
const existing = shardNames(env);
|
||||
for (let i = 0; i < shards.length; i++) {
|
||||
await putWorkerSecret(env, shardNameOf(i), shards[i]);
|
||||
await putWorkerSecret(env, shardNameOf(i), shards[i], tokenOverride);
|
||||
}
|
||||
for (const name of existing) {
|
||||
if (shardIndex(name) >= shards.length) await deleteWorkerSecret(env, name);
|
||||
if (shardIndex(name) >= shards.length) await deleteWorkerSecret(env, name, tokenOverride);
|
||||
}
|
||||
|
||||
overlay = { version: 1, console: data.console ?? null, users: [...data.users] };
|
||||
@@ -320,11 +337,12 @@ function unionStores(a: AuthStoreData, b: AuthStoreData): AuthStoreData {
|
||||
export async function mutateAuthStore(
|
||||
env: Bindings,
|
||||
fn: (data: AuthStoreData) => void | Promise<void>,
|
||||
tokenOverride?: string,
|
||||
): Promise<AuthStoreData> {
|
||||
await hydrateFromAccelerator(env);
|
||||
const next = unionStores(readAuthStore(env), readAuthStoreFromEnv(env));
|
||||
await fn(next);
|
||||
await writeAuthStore(env, next);
|
||||
await writeAuthStore(env, next, tokenOverride);
|
||||
return next;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,19 +22,18 @@
|
||||
*/
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
// D61(ADR D61 / Leo/arcrun-rag#55):這組管理員帳密原本住 SESSIONS_KV(`console:credentials`,
|
||||
// 而且沒有 TTL)——KV 是靠 binding 指過去的,重裝會被指到**新建的空 KV** ⇒ 帳密憑空消失。
|
||||
// 這是「KV=暫存、非長期真相源」第三次被違反,而這一次違反的是大門的鎖。
|
||||
// 現改存進認證儲存(Workers Secrets,不靠 binding);舊 KV 只保留為回退讀路徑,
|
||||
// 讀到就順手搬過去(見 loadCredentials)。
|
||||
import {
|
||||
AuthStoreWriteError,
|
||||
authStoreStatus,
|
||||
hydrateFromAccelerator,
|
||||
mutateAuthStore,
|
||||
readAuthStore,
|
||||
type AuthConsoleRecord,
|
||||
} from '../lib/portal-auth-store';
|
||||
// D61 補充(2026-08-14,leo confirm「走C」,pending-changes.md「認證儲存要不要搬回 D1/KV」):
|
||||
// D61 把這組管理員帳密搬去認證儲存(CF Workers Secrets)是為了躲開「重裝時 binding 被安裝器
|
||||
// 照名字重新指到新建的空資源」這個病根——但 Workers Secrets 的**寫入**需要外部
|
||||
// `CF_SECRETS_API_TOKEN`,而這把 token 從安裝那天起就沒被種過,於是每一台全新實例永遠建不出
|
||||
// 第一個帳號(arcrun-rag#99)。
|
||||
// 病根本身已經在 2026-08-13 被更早、更通用的 `shared/resource-rule`(Arcrun#97)解掉——
|
||||
// 現在每次安裝/更新都會沿用既有 binding,不會再把 SESSIONS_KV 重指到空資源。既然病根已解,
|
||||
// 就不需要為了躲 binding 而去揹「需要外部 token」這筆新債:**帳密改回住 SESSIONS_KV**
|
||||
// (`console:credentials`,binding,永不需要外部 CF token)。
|
||||
// 認證儲存(Workers Secrets)留著當「已經在跑 D61 的舊實例」的**讀路徑**——讀取零成本、
|
||||
// 零外部憑證需求(只有寫入才要 token)——查到就順手搬回 SESSIONS_KV(見 loadCredentials)。
|
||||
import { readAuthStore } from '../lib/portal-auth-store';
|
||||
// Arcrun#108:租戶字串唯一產地。
|
||||
import { knowledgeOwner } from '../lib/tenant';
|
||||
|
||||
@@ -94,52 +93,59 @@ function tenantOf(c: { env: Bindings }): string {
|
||||
return knowledgeOwner(c.env);
|
||||
}
|
||||
|
||||
// ── D61:帳密的家 ─────────────────────────────────────────────────────────────
|
||||
// ── 帳密的家(2026-08-14 起:SESSIONS_KV 為主,認證儲存為舊實例回退讀路徑)────────────
|
||||
|
||||
/**
|
||||
* 讀出 console 管理員帳密。**新家(Workers Secrets)優先**;沒有才回退舊家(KV),
|
||||
* 且一旦從舊家讀到就順手搬過去(best-effort,搬不動不影響本次登入)。
|
||||
* 讀出 console 管理員帳密。**SESSIONS_KV(binding)優先**;沒有才回退讀舊家
|
||||
* (D61 的認證儲存,CF Workers Secrets——讀取零成本、零外部憑證需求),
|
||||
* 且一旦從舊家讀到就順手搬回 SESSIONS_KV(best-effort,搬不動不影響本次登入)。
|
||||
*/
|
||||
async function loadCredentials(env: Bindings): Promise<{ creds: StoredCredentials | null; source: 'secrets' | 'legacy-kv' | 'none' }> {
|
||||
let fromStore = readAuthStore(env).console;
|
||||
if (!fromStore && (await hydrateFromAccelerator(env))) {
|
||||
// 剛設定完帳密、secret 的新版本還沒鋪到這顆 isolate(實測有 15 秒以上的窗口)
|
||||
// → 先問一次加速器,免得「剛設好就說你沒設過」。細節見 lib 的 ACCEL_KEY 註解。
|
||||
fromStore = readAuthStore(env).console;
|
||||
}
|
||||
if (fromStore) return { creds: fromStore, source: 'secrets' };
|
||||
|
||||
async function loadCredentials(env: Bindings): Promise<{ creds: StoredCredentials | null; source: 'kv' | 'legacy-secrets' | 'none' }> {
|
||||
const raw = await env.SESSIONS_KV.get(CREDS_KEY);
|
||||
if (!raw) return { creds: null, source: 'none' };
|
||||
let legacy: StoredCredentials | null = null;
|
||||
try {
|
||||
legacy = JSON.parse(raw) as StoredCredentials;
|
||||
} catch {
|
||||
return { creds: null, source: 'none' };
|
||||
if (raw) {
|
||||
try {
|
||||
return { creds: JSON.parse(raw) as StoredCredentials, source: 'kv' };
|
||||
} catch {
|
||||
/* KV 這份壞了,當作沒有,往下查舊家 */
|
||||
}
|
||||
}
|
||||
|
||||
// 舊家(D61 的認證儲存):純讀 env 字串,零網路呼叫、不需要任何外部 CF 憑證。
|
||||
const legacy = readAuthStore(env).console;
|
||||
if (!legacy) return { creds: null, source: 'none' };
|
||||
try {
|
||||
await mutateAuthStore(env, (data) => {
|
||||
if (!data.console) data.console = legacy as AuthConsoleRecord;
|
||||
});
|
||||
// best-effort 搬回 SESSIONS_KV——這是 binding put,本來就不需要外部 token,
|
||||
// 幾乎不會失敗;失敗也不影響本次用這份舊資料繼續(狀態看 /console/auth-status)。
|
||||
await env.SESSIONS_KV.put(CREDS_KEY, JSON.stringify(legacy));
|
||||
} catch {
|
||||
/* 搬不動就照舊用 KV 這份(狀態看 /health 的 auth_store) */
|
||||
/* 照舊用這份,下次再試著搬一次 */
|
||||
}
|
||||
return { creds: legacy, source: 'legacy-kv' };
|
||||
return { creds: legacy as StoredCredentials, source: 'legacy-secrets' };
|
||||
}
|
||||
|
||||
/** 寫入 console 管理員帳密——**只寫新家**,不再寫 KV(寫回去等於把病種回土裡)。 */
|
||||
/**
|
||||
* 寫入 console 管理員帳密——**只寫 SESSIONS_KV**(binding,永不需要外部 CF token)。
|
||||
* 不再寫回認證儲存(Workers Secrets):那是要被淘汰的舊家,寫回去等於把債種回土裡。
|
||||
*/
|
||||
async function saveCredentials(env: Bindings, record: StoredCredentials): Promise<void> {
|
||||
await mutateAuthStore(env, (data) => {
|
||||
data.console = record;
|
||||
});
|
||||
await env.SESSIONS_KV.put(CREDS_KEY, JSON.stringify(record));
|
||||
}
|
||||
|
||||
/** `/console/auth-status`、`/health` 共用的儲存狀態區塊(不洩漏 email/雜湊,只回統計)。 */
|
||||
function consoleAuthStoreStatus(env: Bindings): { home: 'sessions-kv'; writable: true; legacy_secrets_present: boolean } {
|
||||
return {
|
||||
home: 'sessions-kv',
|
||||
writable: true, // binding-based,只要 wrangler.toml 有這個 binding 就一定寫得進去
|
||||
legacy_secrets_present: readAuthStore(env).console !== null,
|
||||
};
|
||||
}
|
||||
|
||||
// GET /console/auth-status — 前端用來決定顯示「首次設定」還是「登入」表單。不洩漏 email。
|
||||
consoleAuthRouter.get('/console/auth-status', async (c) => {
|
||||
const { creds, source } = await loadCredentials(c.env);
|
||||
// D61:多回一個 auth_store 區塊——「認證住在哪、寫不寫得進去」要在實例自己這一側看得出來,
|
||||
// 多回一個 auth_store 區塊——「認證住在哪、寫不寫得進去」要在實例自己這一側看得出來,
|
||||
// 不是等用戶登不進去才發現(#10「寧可明顯失敗,不要靜默錯置」)。
|
||||
return c.json({ configured: !!creds, credentials_source: source, auth_store: authStoreStatus(c.env) });
|
||||
return c.json({ configured: !!creds, credentials_source: source, auth_store: consoleAuthStoreStatus(c.env) });
|
||||
});
|
||||
|
||||
// POST /console/setup — 首次設定帳密(body: {email, password})。已設定過 → 409(不可覆蓋,防外人搶注)。
|
||||
@@ -171,11 +177,12 @@ consoleAuthRouter.post('/console/setup', async (c) => {
|
||||
const hash = await hashPassword(password, salt);
|
||||
const record: StoredCredentials = { email: email.toLowerCase(), salt, hash, created_at: new Date().toISOString() };
|
||||
try {
|
||||
// 2026-08-14 起:寫 SESSIONS_KV(binding),不再需要安裝精靈遞任何臨時 CF token
|
||||
// (arcrun-rag#99 那個結構性缺口——見檔頭說明——已經隨儲存層搬回 binding 一併解掉)。
|
||||
await saveCredentials(c.env, record);
|
||||
} catch (e) {
|
||||
// 寫不進去就誠實回報(不假綠:舊版寫 KV 幾乎不會失敗,於是沒人處理過這條路)
|
||||
const msg = e instanceof AuthStoreWriteError ? e.message : String(e);
|
||||
return c.json({ error: `帳密沒有存起來:${msg}`, code: 'auth_store_not_writable' }, 502);
|
||||
// 寫不進去就誠實回報(不假綠:binding put 幾乎不會失敗,於是沒人處理過這條路)
|
||||
return c.json({ error: `帳密沒有存起來:${e instanceof Error ? e.message : String(e)}`, code: 'auth_store_not_writable' }, 502);
|
||||
}
|
||||
|
||||
const token = randomHex(32);
|
||||
@@ -206,8 +213,7 @@ consoleAuthRouter.post('/console/setup/reset', async (c) => {
|
||||
try {
|
||||
await saveCredentials(c.env, record);
|
||||
} catch (e) {
|
||||
const msg = e instanceof AuthStoreWriteError ? e.message : String(e);
|
||||
return c.json({ error: `新帳密沒有存起來:${msg}`, code: 'auth_store_not_writable' }, 502);
|
||||
return c.json({ error: `新帳密沒有存起來:${e instanceof Error ? e.message : String(e)}`, code: 'auth_store_not_writable' }, 502);
|
||||
}
|
||||
return c.json({ success: true });
|
||||
});
|
||||
@@ -216,12 +222,13 @@ consoleAuthRouter.post('/console/setup/reset', async (c) => {
|
||||
consoleAuthRouter.post('/console/login', async (c) => {
|
||||
const { creds: existing } = await loadCredentials(c.env);
|
||||
if (!existing) {
|
||||
// D61 明顯失敗:這是「這台實例讀不到認證資料」,不是「你帳密打錯」
|
||||
// 明顯失敗(#10「寧可明顯失敗,不要靜默錯置」):這是「這台實例讀不到認證資料」,
|
||||
// 不是「你帳密打錯」——兩句話混成一句正是 2026-08-09 leo 被誤鎖 15 分鐘的根因。
|
||||
return c.json(
|
||||
{
|
||||
error: '這台實例還沒有管理員帳密(或讀不到)——不是密碼錯。請先完成首次設定。',
|
||||
code: 'auth_store_empty',
|
||||
auth_store: authStoreStatus(c.env),
|
||||
auth_store: consoleAuthStoreStatus(c.env),
|
||||
},
|
||||
400,
|
||||
);
|
||||
@@ -232,19 +239,8 @@ consoleAuthRouter.post('/console/login', async (c) => {
|
||||
const password = body?.password ?? '';
|
||||
if (!email || !password) return c.json({ error: 'email 與 password 必填' }, 400);
|
||||
|
||||
let creds = existing;
|
||||
let hash = await hashPassword(password, creds.salt);
|
||||
if (email !== creds.email || hash !== creds.hash) {
|
||||
// D61:剛改完帳密、secret 新版本還沒鋪開的窗口 → 問一次加速器再判失敗
|
||||
if (await hydrateFromAccelerator(c.env)) {
|
||||
const again = (await loadCredentials(c.env)).creds;
|
||||
if (again) {
|
||||
creds = again;
|
||||
hash = await hashPassword(password, creds.salt);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (email !== creds.email || hash !== creds.hash) {
|
||||
const hash = await hashPassword(password, existing.salt);
|
||||
if (email !== existing.email || hash !== existing.hash) {
|
||||
return c.json({ error: 'email 或密碼錯誤' }, 401);
|
||||
}
|
||||
|
||||
|
||||
@@ -92,9 +92,21 @@ function validSensitivity(s: unknown): s is 'standard' | 'high' {
|
||||
/**
|
||||
* 呼叫 CF Workers Scripts secrets 管理 API,把明文值存進本 worker 的 per-script secret。
|
||||
* 唯寫:這支 API 不回傳任何既有 secret 的值,只能 create/update/delete/list 名字(D19 對齊)。
|
||||
*
|
||||
* `tokenOverride`(2026-08-14,arcrun-rag#99:全新帳號卡在註冊,`writable:false`):
|
||||
* 本 worker 自己的 `env.CF_SECRETS_API_TOKEN` 從安裝那天起就沒被種過(07-29 已知缺口,記在
|
||||
* pending-changes.md「credential 走 n8n 模式」——當時只降級某個功能;D61 認證分離之後升級成
|
||||
* 「連第一個帳號都建不起來」的硬斷點,因為 `/console/setup`/`/portal/admin/bootstrap`
|
||||
* 現在都走這條寫入路徑)。安裝精靈裝機當下手上有一把**自己還有效**的 OAuth token
|
||||
* (`workers-scripts.write` scope,跟部署零件、種 credential 用的是同一把——見
|
||||
* `installer/oauth-prototype/worker.js` 的 `putWorkerSecretDirect`/`seedCredential`,
|
||||
* 是同一個「安裝器代寫」精神,D36 第1步)。讓呼叫端把這把 token **隨請求帶入、不落地**,
|
||||
* 補的正是「cypher 自己永遠拿不到長效寫入憑證」這個結構性缺口,不是新開一條路。
|
||||
* 優先權:呼叫端提供 > worker 自身 env。
|
||||
*/
|
||||
export async function putWorkerSecret(env: Bindings, secretRef: string, value: string): Promise<void> {
|
||||
if (!env.CF_SECRETS_API_TOKEN || !env.CF_ACCOUNT_ID) {
|
||||
export async function putWorkerSecret(env: Bindings, secretRef: string, value: string, tokenOverride?: string): Promise<void> {
|
||||
const token = tokenOverride || env.CF_SECRETS_API_TOKEN;
|
||||
if (!token || !env.CF_ACCOUNT_ID) {
|
||||
throw new Error(
|
||||
'此 worker 缺 CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID 設定,寫入路徑未就緒(見 ' +
|
||||
'credential-store-migration.md T3:acr init/update 應確保這兩項就緒)',
|
||||
@@ -104,7 +116,7 @@ export async function putWorkerSecret(env: Bindings, secretRef: string, value: s
|
||||
const res = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${env.CF_SECRETS_API_TOKEN}`,
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ name: secretRef, text: value, type: 'secret_text' }),
|
||||
@@ -121,15 +133,19 @@ export async function putWorkerSecret(env: Bindings, secretRef: string, value: s
|
||||
/**
|
||||
* 呼叫 CF Workers Scripts secrets 管理 API 刪除一個 per-script secret(T9 治理端點用)。
|
||||
* 404(本來就不存在)視為成功(冪等刪除,呼叫端可能已被清過)。
|
||||
*
|
||||
* `tokenOverride`:與 `putWorkerSecret` 同一組理由(見該函式註解)。auth store 分片重切時
|
||||
* 會刪多出來的舊分片,這條路徑也要能吃到安裝精靈臨時遞來的 token。
|
||||
*/
|
||||
export async function deleteWorkerSecret(env: Bindings, secretRef: string): Promise<void> {
|
||||
if (!env.CF_SECRETS_API_TOKEN || !env.CF_ACCOUNT_ID) {
|
||||
export async function deleteWorkerSecret(env: Bindings, secretRef: string, tokenOverride?: string): Promise<void> {
|
||||
const token = tokenOverride || env.CF_SECRETS_API_TOKEN;
|
||||
if (!token || !env.CF_ACCOUNT_ID) {
|
||||
throw new Error('此 worker 缺 CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID 設定,刪除路徑未就緒');
|
||||
}
|
||||
const url = `https://api.cloudflare.com/client/v4/accounts/${env.CF_ACCOUNT_ID}/workers/scripts/${CYPHER_SCRIPT_NAME}/secrets/${secretRef}`;
|
||||
const res = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${env.CF_SECRETS_API_TOKEN}` },
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (res.status === 404) return;
|
||||
const body = (await res.json().catch(() => null)) as
|
||||
|
||||
@@ -1,9 +1,26 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { authStoreStatus } from '../lib/portal-auth-store';
|
||||
import { readAuthStore } from '../lib/portal-auth-store';
|
||||
|
||||
export const healthRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
/**
|
||||
* 認證儲存狀態(2026-08-14 起:console 帳密住 SESSIONS_KV、portal 帳號住 KBDB,
|
||||
* 兩者皆 binding-based,不再需要外部 CF token 才寫得進去——見 console-auth.ts/portal.ts
|
||||
* 檔頭「D61 補充」說明)。`legacy_*_present` 只回是否還有 D61 時代留在認證儲存(CF Workers
|
||||
* Secrets)裡尚未搬遷的資料,不洩漏任何 email/雜湊內容。
|
||||
*/
|
||||
function authStoreStatus(env: Bindings): {
|
||||
console: { home: 'sessions-kv'; writable: true; legacy_secrets_present: boolean };
|
||||
portal_users: { home: 'kbdb'; writable: true; legacy_secrets_present: boolean };
|
||||
} {
|
||||
const legacy = readAuthStore(env);
|
||||
return {
|
||||
console: { home: 'sessions-kv', writable: true, legacy_secrets_present: legacy.console !== null },
|
||||
portal_users: { home: 'kbdb', writable: true, legacy_secrets_present: legacy.users.length > 0 },
|
||||
};
|
||||
}
|
||||
|
||||
// t162(leo 07-31 實撞:「小幫手一直顯示知識庫需要更新…重新更新後並不會消失」):
|
||||
// daemon cloudVersionStale() 讀 /health 的 `bundle_version` 判斷是否過舊——
|
||||
// 但本端點過去只回 {ok:true},**從沒吐這個欄位** ⇒ daemon 恆讀到空字串
|
||||
|
||||
@@ -81,20 +81,33 @@ kbdbProxyRouter.get('/kbdb/templates/:idOrName', async (c) => {
|
||||
|
||||
// ── records(以租戶 namespace 為 owner_id 隔離)────────────────────────────────
|
||||
|
||||
// POST /kbdb/records — 填一筆 record(template + values)。owner_id 自動注入。
|
||||
// POST /kbdb/records — 填一筆 record(template + values/entry_ids)。owner_id 自動注入。
|
||||
//
|
||||
// `entry_ids`(Arcrun#128)= slot 指向**既有** entry 的 id,不新建、不複製;與 values 並存
|
||||
// (給字串照舊新建)。這裡維持純轉發,判斷與擋人全在基本盤 kbdb(薄殼鐵律,見檔頭):
|
||||
// · 兩者都沒給/型別不對 → base 回 400
|
||||
// · 指到別人的 entry → base 擋(它比對被參照 entry 的 owner_id 與這裡注入的租戶身份,
|
||||
// 所以「呼叫端自己指定 entry_id」這條新路徑不會變成跨租戶的門)
|
||||
// 🔴 為什麼通道要一起開:#129(wiki template)與 #130(三元組正規化)的寫入端走這扇門。
|
||||
// 基本盤補好而通道不開=能力在、沒人打得到——同 PATCH 那次(b6ef0f0)的教訓。
|
||||
kbdbProxyRouter.post('/kbdb/records', async (c) => {
|
||||
const owner = tenant(c);
|
||||
if (!owner) return c.json(NEED_KEY, 401);
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (!body || !body.template || !body.values) {
|
||||
return c.json({ error: 'template 與 values 必填' }, 400);
|
||||
if (!body || !body.template || (!body.values && !body.entry_ids)) {
|
||||
return c.json({ error: 'template 必填,values 與 entry_ids 至少要有一個' }, 400);
|
||||
}
|
||||
const { base, headers } = kbdbBase(c.env);
|
||||
const res = await fetch(`${base}/records`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
// 強制以租戶身份隔離:忽略 caller 自帶 owner_id,一律用 header 身份(防跨租戶寫入)
|
||||
body: JSON.stringify({ template: body.template, values: body.values, owner_id: owner }),
|
||||
body: JSON.stringify({
|
||||
template: body.template,
|
||||
...(body.values ? { values: body.values } : {}),
|
||||
...(body.entry_ids ? { entry_ids: body.entry_ids } : {}),
|
||||
owner_id: owner,
|
||||
}),
|
||||
});
|
||||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||
});
|
||||
|
||||
@@ -31,19 +31,25 @@ import { accountTenant, knowledgeOwner, ownerField, ownerQuery, tenantFromApiKey
|
||||
// arcrun-rag#10:/portal/admin/ai 存 Gemini key 走 credentials.ts 的**唯一**寫入路徑,
|
||||
// 不在 portal 這層另造第二套儲存(D36:值進 Workers Secret,D1 只留 ref)。
|
||||
import { storeCredential, hasCredential } from './credentials';
|
||||
// D61(Leo/arcrun-rag#55,ADR D61):**帳號不再住知識資料庫**。
|
||||
// 讀寫一律先走 lib/portal-auth-store(CF Workers Secrets,不靠任何 binding),
|
||||
// KBDB 只保留為「舊實例的既有帳號」回退讀路徑,且讀到就順手搬進新家(見 promoteLegacyUser)。
|
||||
// D61 補充(2026-08-14,leo confirm「走C」,pending-changes.md「認證儲存要不要搬回 D1/KV」):
|
||||
// D61 把帳號搬去認證儲存(CF Workers Secrets)是為了躲開「重裝時 binding 被安裝器照名字
|
||||
// 重新指到新建的空資源」這個病根——但 Workers Secrets 的**寫入**需要外部
|
||||
// `CF_SECRETS_API_TOKEN`,這把 token 從安裝那天起就沒被種過,於是每一台全新實例永遠建不出
|
||||
// 第一個帳號、也永遠加不了第二個(arcrun-rag#99)。
|
||||
// 病根本身已經在 2026-08-13 被更早、更通用的 `shared/resource-rule`(Arcrun#97)解掉——
|
||||
// 現在每次安裝/更新都會沿用既有 binding,不會再把 KBDB D1 重指到空資源。既然病根已解,
|
||||
// 就不需要為了躲 binding 而去揹「需要外部 token」這筆新債:**帳號改回住 KBDB**
|
||||
// (走 base HTTP API,D38 零 SQL,binding,永不需要外部 CF token)。
|
||||
// 認證儲存(Workers Secrets)留著當「已經在跑 D61 的舊實例」的**讀路徑**——讀取零成本、
|
||||
// 零外部憑證需求(只有寫入才要 token)——登入成功就順手搬進 KBDB(見 promoteToKbdb)。
|
||||
import {
|
||||
AuthStoreWriteError,
|
||||
authStoreRecentlyWritten,
|
||||
authStoreStatus,
|
||||
findAuthUserByEmail,
|
||||
findAuthUserById,
|
||||
hydrateFromAccelerator,
|
||||
isAuthStoreId,
|
||||
mutateAuthStore,
|
||||
newAuthUserId,
|
||||
readAuthStore,
|
||||
type AuthUserRecord,
|
||||
} from '../lib/portal-auth-store';
|
||||
@@ -114,7 +120,9 @@ export async function run(c: Context<{ Bindings: Bindings }>, fn: () => Promise<
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
// D61:認證儲存寫不進去要**看得出來是這件事**(不是 KBDB 的錯,也不是密碼的錯)
|
||||
// 帳號還住在舊家(D61 認證儲存)時,寫入需要外部 CF_SECRETS_API_TOKEN——
|
||||
// 新家(KBDB)不需要,但尚未搬遷的既有帳號仍可能撞到這格,要看得出來是這件事
|
||||
// (不是 KBDB 的錯,也不是密碼的錯),且會在該帳號下次登入時自動搬進 KBDB 而解除。
|
||||
if (e instanceof AuthStoreWriteError) {
|
||||
return c.json({ error: `認證儲存寫入失敗:${e.message}`, code: 'auth_store_not_writable' }, 502);
|
||||
}
|
||||
@@ -193,7 +201,7 @@ export async function ensurePortalTemplates(
|
||||
return { created, existing, errors };
|
||||
}
|
||||
|
||||
// ── D61 認證儲存 ⇄ PortalRecord 轉換(呼叫端一律只認 PortalRecord,不必分辨住哪)─────
|
||||
// ── 認證儲存(D61 舊家)⇄ PortalRecord 轉換(呼叫端一律只認 PortalRecord,不必分辨住哪)──
|
||||
|
||||
function authUserToRecord(u: AuthUserRecord): PortalRecord {
|
||||
return {
|
||||
@@ -227,33 +235,41 @@ function recordValuesToAuthUser(id: string, v: Record<string, string>): AuthUser
|
||||
}
|
||||
|
||||
/**
|
||||
* 舊實例自癒:在 KBDB 找到的既有帳號,原樣搬進認證儲存。
|
||||
* best-effort——搬不動(寫入路徑未就緒)不影響這次登入,只是下次還會再走一次舊路。
|
||||
* 這就是 #55「第一版不做跨版本遷移機制」的落地方式:**用一次成功的登入把自己搬過去**。
|
||||
* 舊實例自癒:在認證儲存(D61 舊家,CF Workers Secrets)找到的既有帳號,搬進 KBDB(新家)。
|
||||
* best-effort——搬不動(KBDB 不可達)不影響這次登入,只是下次還會再走一次舊路。
|
||||
* 沿用 #55「第一版不做跨版本遷移機制」的落地方式:**用一次成功的登入把自己搬過去**。
|
||||
* 回傳搬遷後的新 record_id;搬不動回 null(呼叫端沿用舊 record_id 繼續,讀路徑仍然通,
|
||||
* 只是這次的 session 仍會落在舊家,下次登入會再試一次)。
|
||||
*/
|
||||
async function promoteLegacyUser(env: Bindings, rec: PortalRecord): Promise<void> {
|
||||
async function promoteToKbdb(env: Bindings, rec: PortalRecord): Promise<string | null> {
|
||||
try {
|
||||
const email = (rec.values.email ?? '').toLowerCase();
|
||||
if (!email) return;
|
||||
if (findAuthUserByEmail(env, email)) return;
|
||||
await mutateAuthStore(env, (data) => {
|
||||
if (data.users.some((u) => u.email === email)) return;
|
||||
data.users.push(recordValuesToAuthUser(newAuthUserId(), rec.values));
|
||||
if (!email) return null;
|
||||
const already = await findKbdbUserRecordId(env, email);
|
||||
if (already) return already; // 更早一次登入已經搬過了,不重複建
|
||||
return await createKbdbUserRecord(env, email, {
|
||||
display_name: rec.values.display_name ?? '',
|
||||
status: rec.values.status ?? 'active',
|
||||
role: rec.values.role ?? 'user',
|
||||
password_hash: rec.values.password_hash ?? '',
|
||||
libraries: rec.values.libraries ?? '[]',
|
||||
created_at: rec.values.created_at ?? new Date().toISOString(),
|
||||
updated_at: rec.values.updated_at ?? new Date().toISOString(),
|
||||
});
|
||||
} catch {
|
||||
/* 搬遷失敗不擋登入(誠實:狀態可從 /health 的 auth_store 看出來) */
|
||||
return null; // 搬遷失敗不擋登入(誠實:狀態可從 /health 的 auth_store 看出來)
|
||||
}
|
||||
}
|
||||
|
||||
/** email → user record_id。**新家優先**;找不到才回退舊家(KBDB),並順手搬過去。 */
|
||||
/** email → user record_id。**KBDB(新家)優先**;找不到才回退舊家(認證儲存)。 */
|
||||
async function findUserRecordId(env: Bindings, email: string): Promise<string | null> {
|
||||
const inStore = findAuthUserByEmail(env, email);
|
||||
if (inStore) return inStore.id;
|
||||
return findLegacyUserRecordId(env, email);
|
||||
const inKbdb = await findKbdbUserRecordId(env, email);
|
||||
if (inKbdb) return inKbdb;
|
||||
return findAuthUserByEmail(env, email)?.id ?? null;
|
||||
}
|
||||
|
||||
/** 舊家(KBDB)的 email → record_id(design §2.3 head entry O(1) 查找)。 */
|
||||
async function findLegacyUserRecordId(env: Bindings, email: string): Promise<string | null> {
|
||||
/** KBDB 的 email → record_id(design §2.3 head entry O(1) 查找)。 */
|
||||
async function findKbdbUserRecordId(env: Bindings, email: string): Promise<string | null> {
|
||||
const ns = portalNamespace(env);
|
||||
const params = new URLSearchParams({
|
||||
page_name: email,
|
||||
@@ -269,7 +285,8 @@ async function findLegacyUserRecordId(env: Bindings, email: string): Promise<str
|
||||
}
|
||||
|
||||
async function getRecordById(env: Bindings, recordId: string): Promise<PortalRecord | null> {
|
||||
// D61:住新家的帳號零網路呼叫直接讀 env(換 D1/換租戶代號都影響不到)
|
||||
// 舊家(D61 認證儲存)的帳號:零網路呼叫直接讀 env(換 D1/換租戶代號都影響不到,
|
||||
// 這正是它當初被選為「不可能因重裝而不見」的理由——見 lib/portal-auth-store.ts 檔頭)。
|
||||
if (isAuthStoreId(recordId)) {
|
||||
const u = findAuthUserById(env, recordId);
|
||||
return u ? authUserToRecord(u) : null;
|
||||
@@ -282,7 +299,9 @@ async function getRecordById(env: Bindings, recordId: string): Promise<PortalRec
|
||||
}
|
||||
|
||||
async function patchRecordValues(env: Bindings, recordId: string, values: Record<string, string>): Promise<PortalRecord> {
|
||||
// D61:住新家的帳號改寫進 Workers Secrets(改密碼/停用/改權限都在這條路上)
|
||||
// 舊家(D61 認證儲存)的帳號仍改寫進 Workers Secrets(需要 CF_SECRETS_API_TOKEN;
|
||||
// 沒有 token 就誠實拋 AuthStoreWriteError → run() 轉 502。這批帳號會在下次登入時
|
||||
// 自動搬進 KBDB(見 promoteToKbdb),搬完之後就落進下面的 KBDB PATCH 分支。
|
||||
if (isAuthStoreId(recordId)) {
|
||||
let updated: AuthUserRecord | null = null;
|
||||
await mutateAuthStore(env, (data) => {
|
||||
@@ -329,24 +348,25 @@ function daemonActiveKey(env: Bindings): string {
|
||||
}
|
||||
|
||||
export async function listRecordsByTemplate(env: Bindings, template: string): Promise<PortalRecord[]> {
|
||||
// D61:帳號清單=新家為主,舊家(KBDB)尚未搬走的補在後面(同 email 以新家為準)。
|
||||
// 舊家讀不到不算失敗——認證已經不靠它了,這裡只是把還沒搬完的人也列出來。
|
||||
// 帳號清單=KBDB(新家)為主,認證儲存(舊家)尚未搬走的補在後面(同 email 以新家為準)。
|
||||
// 新家讀不到(KBDB 不可達)不算失敗——舊家仍是完整真相源之一,這裡只是把還沒搬完的人也列出來。
|
||||
if (template === USER_TEMPLATE) {
|
||||
const fromStore = readAuthStore(env).users.map(authUserToRecord);
|
||||
const seen = new Set(fromStore.map((r) => (r.values.email ?? '').toLowerCase()));
|
||||
let legacy: PortalRecord[] = [];
|
||||
let fromKbdb: PortalRecord[] = [];
|
||||
try {
|
||||
legacy = await listLegacyRecordsByTemplate(env, template);
|
||||
fromKbdb = await listKbdbRecordsByTemplate(env, template);
|
||||
} catch {
|
||||
legacy = [];
|
||||
fromKbdb = [];
|
||||
}
|
||||
return [...fromStore, ...legacy.filter((r) => !seen.has((r.values.email ?? '').toLowerCase()))];
|
||||
const seen = new Set(fromKbdb.map((r) => (r.values.email ?? '').toLowerCase()));
|
||||
const fromLegacy = readAuthStore(env).users.map(authUserToRecord)
|
||||
.filter((r) => !seen.has((r.values.email ?? '').toLowerCase()));
|
||||
return [...fromKbdb, ...fromLegacy];
|
||||
}
|
||||
return listLegacyRecordsByTemplate(env, template);
|
||||
return listKbdbRecordsByTemplate(env, template);
|
||||
}
|
||||
|
||||
/** KBDB 原生的 by-template 查詢(portal_library 等「資料」仍走這條,那些本來就該住知識庫)。 */
|
||||
async function listLegacyRecordsByTemplate(env: Bindings, template: string): Promise<PortalRecord[]> {
|
||||
/** KBDB 原生的 by-template 查詢(portal_library 等「資料」也走這條,那些本來就只住知識庫)。 */
|
||||
async function listKbdbRecordsByTemplate(env: Bindings, template: string): Promise<PortalRecord[]> {
|
||||
const ns = portalNamespace(env);
|
||||
const res = await kbdbFetch(env, `/records/by-template/${encodeURIComponent(template)}?owner_id=${encodeURIComponent(ns)}`);
|
||||
if (!res.ok) throw new KbdbError(`GET /records/by-template/${template} → ${res.status}`);
|
||||
@@ -363,27 +383,44 @@ interface CreateUserInput {
|
||||
}
|
||||
|
||||
/**
|
||||
* 建帳號。**D61 起一律建在認證儲存(Workers Secrets),不再寫進 KBDB。**
|
||||
* 寫入路徑未就緒就誠實拋錯(AuthStoreWriteError → 502),不偷偷退回舊家——
|
||||
* 退回去等於這個帳號下次搬資料時又會不見,那正是本案要根治的病。
|
||||
* 建 portal_user record(子 namespace)+ email head entry(design §2.3)——低階寫入,
|
||||
* `createPortalUser`(一般建帳號)與 `promoteToKbdb`(舊帳號搬遷)共用同一條寫入路徑。
|
||||
*/
|
||||
async function createKbdbUserRecord(env: Bindings, email: string, values: Record<string, string>): Promise<string> {
|
||||
const ns = portalNamespace(env);
|
||||
const res = await kbdbFetch(env, '/records', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ template: USER_TEMPLATE, owner_id: ns, values: { ...values, email } }),
|
||||
});
|
||||
if (!res.ok) throw new KbdbError(`POST /records(portal_user)→ ${res.status}`);
|
||||
const body = (await res.json()) as { record?: { record_id: string } };
|
||||
const recordId = body.record?.record_id;
|
||||
if (!recordId) throw new KbdbError('POST /records 回應缺 record_id');
|
||||
|
||||
// head entry:page_name=email(indexed)→ content=record_id,O(1) 登入查找
|
||||
const head = await kbdbFetch(env, '/entries', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ entry_type: USER_TEMPLATE, page_name: email, content: recordId, owner_id: ns }),
|
||||
});
|
||||
if (!head.ok) throw new KbdbError(`head entry 建立失敗(record ${recordId} 已建,需人工收拾)→ ${head.status}`);
|
||||
return recordId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 建帳號。**2026-08-14 起改回一律建在 KBDB**(binding,走 base HTTP API,D38 零 SQL),
|
||||
* 不再需要外部 `CF_SECRETS_API_TOKEN`——這正是本次補的結構性缺口(見檔頭「D61 補充」)。
|
||||
*/
|
||||
async function createPortalUser(env: Bindings, input: CreateUserInput): Promise<string> {
|
||||
const now = new Date().toISOString();
|
||||
const id = newAuthUserId();
|
||||
await mutateAuthStore(env, (data) => {
|
||||
data.users.push({
|
||||
id,
|
||||
email: input.email.toLowerCase(),
|
||||
display_name: input.display_name,
|
||||
status: 'active',
|
||||
role: input.role,
|
||||
libraries: input.libraries,
|
||||
password_hash: input.password_hash,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
return createKbdbUserRecord(env, input.email.toLowerCase(), {
|
||||
display_name: input.display_name,
|
||||
status: 'active',
|
||||
role: input.role,
|
||||
password_hash: input.password_hash,
|
||||
libraries: JSON.stringify(input.libraries),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
// ── user 值域 helpers ──────────────────────────────────────────────────────
|
||||
@@ -596,15 +633,15 @@ async function clearLoginFail(env: Bindings, email: string): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* D61:這台實例是不是「一個帳號都沒有」(新家空、舊家也空/讀不到)。
|
||||
* 只在「查無此帳號」時才呼叫,不進正常登入熱路徑。
|
||||
* 這台實例是不是「一個帳號都沒有」(KBDB 新家空、認證儲存舊家也空/讀不到)。
|
||||
* 只在「查無此帳號」時才呼叫,不進正常登入熱路徑(#10「寧可明顯失敗,不要靜默錯置」)。
|
||||
*/
|
||||
async function instanceHasNoAuthData(env: Bindings): Promise<boolean> {
|
||||
if (readAuthStore(env).users.length > 0) return false;
|
||||
if (readAuthStore(env).users.length > 0) return false; // 舊家還有尚未搬遷的帳號
|
||||
try {
|
||||
return (await listLegacyRecordsByTemplate(env, USER_TEMPLATE)).length === 0;
|
||||
return (await listKbdbRecordsByTemplate(env, USER_TEMPLATE)).length === 0;
|
||||
} catch {
|
||||
return true; // 舊家讀不到 + 新家空 = 這台實例確實沒有可用的登入資料
|
||||
return true; // KBDB 讀不到 + 舊家空 = 這台實例確實沒有可用的登入資料
|
||||
}
|
||||
}
|
||||
|
||||
@@ -655,7 +692,7 @@ portalRouter.post('/portal/login', (c) =>
|
||||
|
||||
const { recordId, rec, ok } = await findAndVerifyUser(c.env, email, password);
|
||||
if (!recordId || !rec) {
|
||||
// D61 明顯失敗(arcrun-rag#10「寧可明顯失敗,不要靜默錯置」套到門鎖上):
|
||||
// 明顯失敗(arcrun-rag#10「寧可明顯失敗,不要靜默錯置」套到門鎖上):
|
||||
// 「這台實例一個帳號都沒有」跟「你密碼打錯」是兩件事,不准混成同一句話——
|
||||
// 2026-08-09 leo 就是被這個誤判鎖了 15 分鐘,而他的密碼從頭到尾都是對的。
|
||||
// ⇒ 回一個**分得出來**的錯,而且**不計入鎖定**。
|
||||
@@ -666,7 +703,7 @@ portalRouter.post('/portal/login', (c) =>
|
||||
'這台實例讀不到任何登入資料——不是密碼錯。認證儲存是空的,' +
|
||||
'請重新執行安裝/更新以重新建立管理員帳號。',
|
||||
code: 'auth_store_empty',
|
||||
auth_store: authStoreStatus(c.env),
|
||||
auth_store: { home: 'kbdb', writable: true, users: 0 },
|
||||
},
|
||||
503,
|
||||
);
|
||||
@@ -682,14 +719,20 @@ portalRouter.post('/portal/login', (c) =>
|
||||
return c.json({ error: 'email 或密碼錯誤' }, 401);
|
||||
}
|
||||
|
||||
// D61 自癒:這次是拿舊家(KBDB)的帳號登進來的 → 順手搬進認證儲存,
|
||||
// 下次換庫/換租戶代號就不會再把他鎖在門外。
|
||||
if (!isAuthStoreId(recordId)) await promoteLegacyUser(c.env, rec);
|
||||
// 自癒:這次是拿舊家(D61 認證儲存)的帳號登進來的 → 順手搬進 KBDB(新家),
|
||||
// 且**這次登入發出的 session 就直接指向新 record_id**(搬遷成功的話)——不必等
|
||||
// 下一次登入,密碼變更/admin 編輯這類寫入這次就已經走得到不需要外部 token 的 KBDB 路。
|
||||
// 搬不動(KBDB 不可達)就沿用舊 record_id,讀路徑仍然通,只是寫入仍會走舊家那格。
|
||||
let sessionRecordId = recordId;
|
||||
if (isAuthStoreId(recordId)) {
|
||||
const migrated = await promoteToKbdb(c.env, rec);
|
||||
if (migrated) sessionRecordId = migrated;
|
||||
}
|
||||
|
||||
await clearLoginFail(c.env, email);
|
||||
const token = randomHex(32);
|
||||
// session 值只存 record_id(design §4.3)——權限/狀態每請求回讀 record,不快取進 session
|
||||
await c.env.SESSIONS_KV.put(`${SESSION_PREFIX}${token}`, JSON.stringify({ record_id: recordId }), {
|
||||
await c.env.SESSIONS_KV.put(`${SESSION_PREFIX}${token}`, JSON.stringify({ record_id: sessionRecordId }), {
|
||||
expirationTtl: sessionTtl(c.env),
|
||||
});
|
||||
return c.json({
|
||||
@@ -1073,6 +1116,8 @@ portalRouter.post('/portal/admin/bootstrap', (c) =>
|
||||
if (password.length < 8) return c.json({ error: '密碼至少 8 碼' }, 400);
|
||||
if (await findUserRecordId(c.env, email)) return c.json({ error: '此 email 已存在' }, 409);
|
||||
|
||||
// 2026-08-14 起:createPortalUser 寫 KBDB(binding),不再需要安裝精靈遞任何臨時
|
||||
// CF token(arcrun-rag#99 那個結構性缺口已隨儲存層搬回 binding 一併解掉)。
|
||||
const recordId = await createPortalUser(c.env, {
|
||||
email,
|
||||
display_name: displayName,
|
||||
@@ -1264,7 +1309,8 @@ function toPublicLibrary(rec: PortalRecord) {
|
||||
// 小幫手回報它看守的資料夾各自對應的庫,雲端**自動登記**——庫目錄與地端資料夾一比一。
|
||||
// 認證=同 /portal/daemon/config(用戶帳密)。已存在的庫略過(冪等),不覆寫顯示名。
|
||||
// POST /portal/daemon/extract — 小幫手把「已轉成純文字的原稿」送上來,雲端用 Workers AI 萃成知識卡。
|
||||
// body {email, password, page_name, text}。認證同 /portal/daemon/config(帳密)。
|
||||
// body {page_name, text}(舊 daemon)或 {page_name, text, prompt}(Arcrun#134 起:
|
||||
// daemon 自帶提示詞,回應改回 {success, output} 原文)。認證=X-Arcrun-API-Key(見下)。
|
||||
//
|
||||
// 🔴 t181(leo 08-04:「daemon 的 AI 改用 workers AI」,列為**最優先**——
|
||||
// 「這是我的用戶最大障礙,造成首輪測試用戶的好評或惡評」):
|
||||
@@ -1303,41 +1349,67 @@ portalRouter.post('/portal/daemon/extract', (c) =>
|
||||
if (!apiKey) return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
|
||||
|
||||
const body = (await c.req.json().catch(() => null)) as
|
||||
| { page_name?: string; text?: string }
|
||||
| { page_name?: string; text?: string; prompt?: string }
|
||||
| null;
|
||||
const pageName = String(body?.page_name ?? '').trim();
|
||||
const srcText = String(body?.text ?? '');
|
||||
if (!pageName || !srcText.trim()) return c.json({ error: 'page_name 與 text 必填' }, 400);
|
||||
// 🔴 Arcrun#134(2026-08-15):新版 daemon **自帶完整提示詞**(`prompt` 欄位)。
|
||||
//
|
||||
// 為什麼:卡片格式契約原本存在兩份——daemon 端 gemmaPrompt(Go)與本端點(TS),
|
||||
// 靠註解叮嚀「兩邊要一起改」同步。2026-08-15 daemon 端改成「LLM 只回 JSON 判斷、
|
||||
// wikishape.go 機械組卡」(InkStoneCo#44 ④),本端點沒跟上 ⇒ 免金鑰預設路
|
||||
// (=多數用戶實際走的路)繼續產舊格式卡——**叮嚀式同步實證失效,這張票就是證據**。
|
||||
//
|
||||
// ⇒ 格式契約唯一真相源改為 daemon(提示詞+JSON 解析+組卡同住 collector/,
|
||||
// 不可能再彼此漂移);本端點在 prompt 模式下只是「用實例自己的 env.AI 跑一段
|
||||
// 生成」的執行器,**不認得也不需要認得卡的形狀**——與 cypher TS「只做 routing
|
||||
// +提供能力、不長業務邏輯」的分工一致。
|
||||
// 安全面不變:認證同上(租戶識別 key)、只生成、不寫資料、用戶自己的 AI 額度;
|
||||
// `text` 本來就是任意內容,本欄位沒有放大任何攻擊面。
|
||||
// 相容:舊 daemon 不帶 prompt ⇒ 走下方 legacy 路(行為與回應完全不變)。
|
||||
const daemonPrompt = String(body?.prompt ?? '').trim();
|
||||
if (!daemonPrompt && (!pageName || !srcText.trim()))
|
||||
return c.json({ error: 'page_name 與 text 必填' }, 400);
|
||||
|
||||
if (!c.env.AI) {
|
||||
// 誠實失敗:不假裝成功,並指名這個部署缺什麼(禁假綠)
|
||||
return c.json({ error: '這個部署沒有綁定 Workers AI(wrangler.toml 需有 [ai] binding),請更新知識庫版本' }, 501);
|
||||
}
|
||||
|
||||
// 提示詞與 daemon 端 gemmaPrompt 同一份契約(第一行必須是「# <頁名>」),
|
||||
// 兩邊要一起改;daemon 端在 collector/extract_gemma.go。
|
||||
// legacy 提示詞:只服務**還沒更新的舊 daemon**(無 prompt 欄位)。舊 daemon 期待
|
||||
// 「# <頁名>」開頭的 markdown 卡(收端 lint 新舊雙軌仍接受);新格式不再從這裡出生,
|
||||
// 所以本段刻意凍結、不再演進——要改卡的形狀,去 daemon 端 collector/ 改那唯一一份。
|
||||
// 註:關聯段用的是「知識卡三元組」格式(主詞/謂詞/受詞),與 Arcrun 工作流的邊無關。
|
||||
const REL = '>'.repeat(2);
|
||||
const prompt =
|
||||
daemonPrompt ||
|
||||
`把以下原稿重寫成定稿知識卡(正體中文)。直接輸出卡片本身:第一行必須是「# ${pageName}」,` +
|
||||
`不要任何前言、思考過程、英文草稿或說明。格式:\n# ${pageName}\n## 一句話定義\n(一行)\n` +
|
||||
`## 要點\n- (3-12 條,具體、含數字條件)\n## 關鍵實體\n- **實體名** — 一句說明\n` +
|
||||
`## 關聯\n- 實體A ${REL} 關係 ${REL} 實體B(3-8 行,用上面實體名)\n\n原稿:\n${srcText}`;
|
||||
`不要任何前言、思考過程、英文草稿或說明。格式:\n# ${pageName}\n## 一句話定義\n(一行)\n` +
|
||||
`## 要點\n- (3-12 條,具體、含數字條件)\n## 關鍵實體\n- **實體名** — 一句說明\n` +
|
||||
`## 關聯\n- 實體A ${REL} 關係 ${REL} 實體B(3-8 行,用上面實體名)\n\n原稿:\n${srcText}`;
|
||||
|
||||
try {
|
||||
// 模型與 workers_ai_chat recipe 同一支(選型實測見 api-recipe-seeds.ts:140:
|
||||
// llama-4-scout 2373ms/答案最完整;對照 Gemini gemma-4-31b-it 16.87 秒且吐英文草稿)。
|
||||
// prompt 模式放寬 max_tokens 到 8192:對齊 gemma 路的 generationConfig——
|
||||
// JSON 契約要裝下「文件總覽+N 個原子概念」,2048 會把 JSON 攔腰截斷(解析必失敗)。
|
||||
const out = (await c.env.AI.run('@cf/meta/llama-4-scout-17b-16e-instruct', {
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
max_tokens: 2048,
|
||||
max_tokens: daemonPrompt ? 8192 : 2048,
|
||||
temperature: 0.2,
|
||||
} as never)) as { response?: string } | undefined;
|
||||
const card = String(out?.response ?? '').trim();
|
||||
if (!card) return c.json({ error: 'Workers AI 沒有回傳內容' }, 502);
|
||||
const raw = String(out?.response ?? '').trim();
|
||||
if (!raw) return c.json({ error: 'Workers AI 沒有回傳內容' }, 502);
|
||||
if (daemonPrompt) {
|
||||
// 原文回傳(`output` 欄位):淨化與解析屬於契約,契約住 daemon ⇒ 這裡一個字都不動。
|
||||
// daemon 以「回應裡有沒有 output」分辨雲端新舊:舊雲端只會回 `card`(見下),
|
||||
// 新 daemon 據此 fallback 到 legacy 卡處理——兩個方向的版本歪斜都有路可走。
|
||||
return c.json({ success: true, output: raw });
|
||||
}
|
||||
// 淨化:模型偶爾在卡片前多帶一段前言 ⇒ 取最後一個「# <頁名>」起(同 daemon cleanGemmaCard)
|
||||
const marker = `# ${pageName}`;
|
||||
const idx = card.lastIndexOf(marker);
|
||||
return c.json({ success: true, card: (idx >= 0 ? card.slice(idx) : card).trim() + '\n' });
|
||||
const idx = raw.lastIndexOf(marker);
|
||||
return c.json({ success: true, card: (idx >= 0 ? raw.slice(idx) : raw).trim() + '\n' });
|
||||
} catch (e) {
|
||||
return c.json({ error: `Workers AI 執行失敗:${e instanceof Error ? e.message : String(e)}` }, 502);
|
||||
}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
/**
|
||||
* component-loader:`hash` 零件解析測試(Arcrun#91)。
|
||||
*
|
||||
* 背景:workflow 寫 `component: hash` 時,component-loader 解析鏈 step 1-6 都不命中
|
||||
* (非 builtin / 非 URL / 非 hash-id / 非 LOGIC_BINDING_MAP 邏輯零件 / 非 recipe),
|
||||
* 必須靠 step 7 的 WASM_HTTP_RUNNER_IDS 白名單 → wasmWorkerUrl 通用推導。
|
||||
* 白名單漏 `hash` 就會落到 step 8 直接丟「找不到零件」——與 #29 的 `code` 缺口同形狀。
|
||||
*
|
||||
* 驗證:
|
||||
* 1. `hash` 能被解析成 runner(不 throw)。
|
||||
* 2. runner 打的 URL = arcrun-hash.{WORKER_SUBDOMAIN}.workers.dev(通用推導、無寫死
|
||||
* 官方 hash.arcrun.dev → self-hosted 換 WORKER_SUBDOMAIN 即落地)。
|
||||
*/
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { env } from 'cloudflare:test';
|
||||
import { createComponentLoader, wasmWorkerUrl } from '../src/lib/component-loader';
|
||||
import type { Bindings } from '../src/types';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('component-loader:hash 零件解析(WASM HTTP runner 白名單,Arcrun#91)', () => {
|
||||
it('wasmWorkerUrl 通用推導:hash → arcrun-hash.{subdomain}.workers.dev(self-hosted 換 subdomain 即成立)', () => {
|
||||
expect(wasmWorkerUrl('hash', 'uncle6-me')).toBe('https://arcrun-hash.uncle6-me.workers.dev');
|
||||
expect(wasmWorkerUrl('hash', 'my-selfhosted-sub')).toBe('https://arcrun-hash.my-selfhosted-sub.workers.dev');
|
||||
});
|
||||
|
||||
it('component: hash 能被解析成 runner,且 fetch 打 subdomain 推導的 URL', async () => {
|
||||
const fakeEnv = {
|
||||
...env,
|
||||
WORKER_SUBDOMAIN: 'test-sub',
|
||||
} as unknown as Bindings;
|
||||
|
||||
const loader = createComponentLoader(fakeEnv);
|
||||
// 白名單漏 `hash` 時這裡就 throw「找不到零件 "hash"」
|
||||
const runner = await loader('hash');
|
||||
expect(typeof runner).toBe('function');
|
||||
|
||||
// stub fetch 捕 URL:證明 runner 打的是通用推導 URL,非寫死官方域名
|
||||
const calledUrls: string[] = [];
|
||||
const fetchSpy = vi.fn(async (url: unknown) => {
|
||||
calledUrls.push(String(url));
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
data: { result: '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824', algorithm: 'sha256', encoding: 'hex' },
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
|
||||
const result = await runner({ algorithm: 'sha256', input: 'hello', encoding: 'hex' }) as Record<string, unknown>;
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
expect(calledUrls[0]).toBe('https://arcrun-hash.test-sub.workers.dev');
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,21 @@
|
||||
/**
|
||||
* console-auth.ts —— D61 舊實例相容(帳密只在舊 SESSIONS_KV,尚未搬遷過)
|
||||
* console-auth.ts —— 舊實例相容(帳密還在 D61 的認證儲存,尚未搬回 SESSIONS_KV)
|
||||
*
|
||||
* 拆成獨立檔案的理由:portal-auth-store.ts 的 per-isolate overlay 是模組級全域變數,
|
||||
* 一旦某個測試讓 console 帳密的認證儲存寫入成功,overlay.console 就會在**同一支測試檔案**
|
||||
* 剩下的測試裡持續存在(不同檔案=不同 worker 執行個體,互不污染,已用小型探針驗證過)。
|
||||
* tests/console-auth.test.ts 一開始就會走一次「首次設定成功」,之後整支檔案都是「已設定」
|
||||
* 的世界;「認證儲存還是空的、帳密只活在舊 KV」這個起始狀態只有在全新檔案才測得出來。
|
||||
* 2026-08-14 起(D61 補充,leo confirm「走C」):console 帳密的家改回 SESSIONS_KV(binding)。
|
||||
* 已經在跑 D61(帳密住 CF Workers Secrets)的實例不能因為這次改動而登不進去——讀取認證儲存
|
||||
* 零成本、零外部憑證需求(只有寫入才需要 CF_SECRETS_API_TOKEN),所以永遠讀得到;讀到就
|
||||
* 順手搬回 SESSIONS_KV(best-effort,binding put 幾乎不會失敗)。
|
||||
*
|
||||
* 拆成獨立檔案的理由:portal-auth-store.ts 內部沒有跨測試檔案共享的可變狀態問題(每個測試
|
||||
* 檔案是獨立 worker 執行個體),但為了跟 console-auth.test.ts(一開始就會把帳密設進
|
||||
* SESSIONS_KV)的「全新、尚未設定」起始狀態互不干擾,仍分開一支檔案,語意更清楚。
|
||||
*/
|
||||
import { SELF, env, fetchMock } from 'cloudflare:test';
|
||||
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
|
||||
import { mutateAuthStore } from '../src/lib/portal-auth-store';
|
||||
import type { Bindings } from '../src/types';
|
||||
|
||||
const CF_API = 'https://api.cloudflare.com';
|
||||
const CREDS_KEY = 'console:credentials';
|
||||
|
||||
beforeAll(() => {
|
||||
fetchMock.activate();
|
||||
@@ -27,18 +31,12 @@ function json(method: string, path: string, body?: unknown) {
|
||||
});
|
||||
}
|
||||
|
||||
function mockAuthStoreWrite(times = 1): { puts: () => Array<{ name: string; text: string }> } {
|
||||
const captured: Array<{ name: string; text: string }> = [];
|
||||
/** 種一筆進 D61 認證儲存(CF Workers Secrets)——直接呼叫 lib,不經任何 HTTP route。 */
|
||||
function mockLegacySecretsWrite(): void {
|
||||
fetchMock
|
||||
.get(CF_API)
|
||||
.intercept({ path: (p: string) => p.includes('/secrets'), method: 'PUT' })
|
||||
.reply(200, (opts) => {
|
||||
const body = JSON.parse(String(opts.body)) as { name: string; text: string };
|
||||
captured.push(body);
|
||||
return { success: true };
|
||||
})
|
||||
.times(times);
|
||||
return { puts: () => captured };
|
||||
.reply(200, { success: true });
|
||||
}
|
||||
|
||||
/** 複刻 console-auth.ts 內未 export 的私有迭代雜湊(sha256(salt+password) 迭代 3 次),
|
||||
@@ -57,45 +55,45 @@ const EMAIL = 'legacy-owner@example.com';
|
||||
const PASSWORD = 'legacy-owner-pw-1';
|
||||
const SALT = 'deadbeef00112233';
|
||||
|
||||
describe('D61 舊實例相容:console 帳密只在舊 KV(尚未搬遷)', () => {
|
||||
it('GET /console/auth-status:讀到舊 KV 這筆、順手搬進認證儲存', async () => {
|
||||
// 🔴 全部收在**同一個 `it()`** 裡(不拆成多則):`isolatedStorage`(vitest-pool-workers 預設開)
|
||||
// 在每一個 `it()` 前重置 SESSIONS_KV,但 D61 認證儲存的模組級記憶體變數 `overlay` 不受影響
|
||||
// (見 lib/portal-auth-store.ts 檔頭)。若拆成多則,「搬回 SESSIONS_KV 後再打一次直接命中
|
||||
// 新家」這一步在下一個 `it()` 會因為 KV 被重置而又落回 legacy-secrets 分支,驗不出「新家優先」
|
||||
// 這件事——所以要在同一次測試、同一份 KV 狀態內連續打兩次才驗得出來。
|
||||
describe('舊實例相容:console 帳密只在認證儲存(尚未搬回 SESSIONS_KV)', () => {
|
||||
it('GET /console/auth-status 讀到認證儲存這筆、順手搬回 SESSIONS_KV;再打一次直接命中新家;帳密登得進去', async () => {
|
||||
const hash = await legacyHash(PASSWORD, SALT);
|
||||
await env.SESSIONS_KV.put(
|
||||
CREDS_KEY,
|
||||
JSON.stringify({ email: EMAIL, salt: SALT, hash, created_at: '2026-01-01T00:00:00.000Z' }),
|
||||
);
|
||||
const { puts } = mockAuthStoreWrite();
|
||||
mockLegacySecretsWrite();
|
||||
await mutateAuthStore(env as unknown as Bindings, (data) => {
|
||||
data.console = { email: EMAIL, salt: SALT, hash, created_at: '2026-01-01T00:00:00.000Z' };
|
||||
});
|
||||
|
||||
const res = await json('GET', '/console/auth-status');
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as {
|
||||
configured: boolean;
|
||||
credentials_source: string;
|
||||
auth_store: { console_configured: boolean };
|
||||
auth_store: { legacy_secrets_present: boolean };
|
||||
};
|
||||
expect(data.configured).toBe(true);
|
||||
expect(data.credentials_source).toBe('legacy-kv'); // 這次是靠回退讀到的
|
||||
// loadCredentials 內的 best-effort 搬遷在回應組出來之前就已 await 完成,
|
||||
// 故 authStoreStatus 已經反映搬遷後的狀態
|
||||
expect(data.auth_store.console_configured).toBe(true);
|
||||
expect(data.credentials_source).toBe('legacy-secrets'); // 這次是靠回退讀到的
|
||||
// loadCredentials 內的 best-effort 搬遷在回應組出來之前就已 await 完成
|
||||
expect(data.auth_store.legacy_secrets_present).toBe(true);
|
||||
|
||||
const shards = puts();
|
||||
expect(shards.length).toBe(1);
|
||||
const shard = JSON.parse(shards[0].text) as { console: { email: string; hash: string } };
|
||||
expect(shard.console.email).toBe(EMAIL);
|
||||
expect(shard.console.hash).toBe(hash); // 原樣搬過去,不重新雜湊
|
||||
});
|
||||
const kvRaw = await env.SESSIONS_KV.get('console:credentials');
|
||||
expect(kvRaw).toBeTruthy();
|
||||
const stored = JSON.parse(kvRaw!) as { email: string; hash: string };
|
||||
expect(stored.email).toBe(EMAIL);
|
||||
expect(stored.hash).toBe(hash); // 原樣搬過去,不重新雜湊
|
||||
|
||||
it('搬遷後再打一次:新家已經有了,直接命中新家(不用再查舊 KV)', async () => {
|
||||
const res = await json('GET', '/console/auth-status');
|
||||
const data = (await res.json()) as { credentials_source: string };
|
||||
expect(data.credentials_source).toBe('secrets');
|
||||
});
|
||||
// 搬回後再打一次:SESSIONS_KV 已經有了,直接命中新家(不用再查認證儲存)
|
||||
const res2 = await json('GET', '/console/auth-status');
|
||||
const data2 = (await res2.json()) as { credentials_source: string };
|
||||
expect(data2.credentials_source).toBe('kv');
|
||||
|
||||
it('用搬遷過去的帳密登入 → 200(搬遷沒有讓帳密變得登不進去)', async () => {
|
||||
const res = await json('POST', '/console/login', { email: EMAIL, password: PASSWORD });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { success: boolean };
|
||||
expect(data.success).toBe(true);
|
||||
// 用搬回去的帳密登入 → 200(搬遷沒有讓帳密變得登不進去)
|
||||
const login = await json('POST', '/console/login', { email: EMAIL, password: PASSWORD });
|
||||
expect(login.status).toBe(200);
|
||||
expect((await login.json() as { success: boolean }).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,81 +1,64 @@
|
||||
/**
|
||||
* console-auth.ts 測試(D61:console 管理員帳密搬進認證儲存,ADR D61 / Leo/arcrun-rag#55)
|
||||
* console-auth.ts 測試(2026-08-14 起:console 管理員帳密改回住 SESSIONS_KV,
|
||||
* D61 補充/pending-changes.md「認證儲存要不要搬回 D1/KV」,leo confirm「走C」)
|
||||
*
|
||||
* 這組帳密(/console/setup、/console/login…)原本住 SESSIONS_KV `console:credentials`
|
||||
* (沒有 TTL)——KV 靠 binding 指過去,重裝會被指到新建的空 KV ⇒ 帳密憑空消失
|
||||
* (console-auth.ts 檔頭「KV=暫存、非長期真相源」第三次被違反,這次違反的是大門的鎖)。
|
||||
* D61 起改存進認證儲存(CF Workers Secrets),SESSIONS_KV 只留為回退讀路徑。
|
||||
* 這組帳密(/console/setup、/console/login…)曾在 D61(ADR D61 / Leo/arcrun-rag#55)搬進
|
||||
* 認證儲存(CF Workers Secrets)以躲開「重裝時 binding 被重新指到新建空資源」的病根——但
|
||||
* Workers Secrets 的寫入需要外部 `CF_SECRETS_API_TOKEN`,這把 token 從安裝那天起就沒被種過,
|
||||
* 於是每一台全新實例永遠建不出第一個帳號(arcrun-rag#99)。病根本身已被更通用的
|
||||
* `shared/resource-rule`(Arcrun#97)解掉,故改回 SESSIONS_KV(binding,永不需要外部 token)。
|
||||
* 認證儲存留為舊實例的回退讀路徑,見 tests/console-auth-legacy.test.ts。
|
||||
*
|
||||
* 覆蓋(本檔在此之前不存在,D61 交辦要求的新增覆蓋):
|
||||
* 覆蓋:
|
||||
* 1. 全新實例:auth-status 回 configured:false;login 回「讀不到認證資料」(不是密碼錯)。
|
||||
* 2. 首次設定成功:POST /console/setup 寫進認證儲存(CF Workers Secrets),不再寫 KV。
|
||||
* 3. 已設定過 → 409,訊息明講「你剛才輸入的密碼沒有被採用」(D61 明顯失敗,取代舊版
|
||||
* 2. 首次設定成功:POST /console/setup 寫進 SESSIONS_KV。
|
||||
* 3. 已設定過 → 409,訊息明講「你剛才輸入的密碼沒有被採用」(明顯失敗,取代舊版
|
||||
* 只說「已設定過」卻不說清楚剛才那組密碼發生了什麼事的誤導文案)。
|
||||
* 4. 登入對錯:帳密正確 200;密碼錯 401。
|
||||
* 5. /console/setup/reset:舊密碼驗證+新密碼寫進新家;換密碼後舊密碼立即失效。
|
||||
* 5. /console/setup/reset:舊密碼驗證+新密碼寫進 SESSIONS_KV;換密碼後舊密碼立即失效。
|
||||
*
|
||||
* 認證儲存寫入會呼叫 `https://api.cloudflare.com/.../secrets`(PUT),走 fetchMock 假 host
|
||||
* 攔截(同 portal-auth.test.ts 的 mockAuthStoreWrite),不外連;wrangler.test.toml 已預設
|
||||
* CF_SECRETS_API_TOKEN/CF_ACCOUNT_ID 就緒。
|
||||
* 全程不需要任何 CF API mock——SESSIONS_KV 是 binding,wrangler.test.toml 已提供 mock KV。
|
||||
*
|
||||
* ⚠️ 測試順序不可打亂:portal-auth-store.ts 的 per-isolate overlay 是模組級全域變數,
|
||||
* 一旦某則測試讓 /console/setup 或 reset 真的寫成功,overlay.console 就會在**這支檔案**
|
||||
* 剩下的測試裡持續存在(同檔案不會在測試之間重置模組全域,只有 KV/D1 等 storage 才有
|
||||
* isolatedStorage 重置)。因此本檔刻意排成一條線性故事:先驗證「全新、尚未設定」的分支,
|
||||
* 再做一次成功的 /console/setup(之後永久變成「已設定」),後面的測試都建立在這個已設定
|
||||
* 的基礎上。「帳密只存在舊 KV(尚未搬遷過)」這個分支需要 overlay 是空的,因此另開一支
|
||||
* 檔案 tests/console-auth-legacy.test.ts(不同檔案=不同 worker 執行個體,狀態不互相污染)。
|
||||
* 🔴 每則測試自帶前置狀態(不依賴前一則測試的寫入殘留):`@cloudflare/vitest-pool-workers`
|
||||
* 的 `isolatedStorage`(預設開)在**每一個 `it()`** 前重置 KV/D1 等 storage bindings——
|
||||
* 這正是本檔要驗證的儲存介質(SESSIONS_KV),故「已設定過」的情境一律在該則測試內先呼叫一次
|
||||
* 真正的 POST /console/setup 自建,不能沿用其他 `it()` 建立的帳密(那套「同檔案內殘留」的
|
||||
* 假設只對 D61 認證儲存的模組級記憶體變數 `overlay` 成立,SESSIONS_KV 不是那個機制)。
|
||||
*/
|
||||
import { SELF, env, fetchMock } from 'cloudflare:test';
|
||||
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
|
||||
import { SELF, env } from 'cloudflare:test';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
const CF_API = 'https://api.cloudflare.com';
|
||||
|
||||
beforeAll(() => {
|
||||
fetchMock.activate();
|
||||
fetchMock.disableNetConnect();
|
||||
});
|
||||
afterEach(() => fetchMock.assertNoPendingInterceptors());
|
||||
|
||||
function json(method: string, path: string, body?: unknown, headers: Record<string, string> = {}) {
|
||||
function json(method: string, path: string, body?: unknown) {
|
||||
return SELF.fetch(`http://localhost${path}`, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
/** D61:認證儲存寫入路徑(同 portal-auth.test.ts 的同名 helper,那邊有完整說明)。 */
|
||||
function mockAuthStoreWrite(times = 1): { puts: () => Array<{ name: string; text: string }> } {
|
||||
const captured: Array<{ name: string; text: string }> = [];
|
||||
fetchMock
|
||||
.get(CF_API)
|
||||
.intercept({ path: (p: string) => p.includes('/secrets'), method: 'PUT' })
|
||||
.reply(200, (opts) => {
|
||||
const body = JSON.parse(String(opts.body)) as { name: string; text: string };
|
||||
captured.push(body);
|
||||
return { success: true };
|
||||
})
|
||||
.times(times);
|
||||
return { puts: () => captured };
|
||||
/** 每則測試自建一組帳密(POST /console/setup),回傳供後續斷言使用。 */
|
||||
async function setupOwner(email: string, password: string): Promise<void> {
|
||||
const res = await json('POST', '/console/setup', { email, password });
|
||||
expect(res.status).toBe(200);
|
||||
}
|
||||
|
||||
const OWNER_EMAIL = 'owner@example.com';
|
||||
const OWNER_PW = 'owner-first-pw-1';
|
||||
|
||||
// ═══════════════ 1. 全新實例(尚未設定過,必須排最前面)═══════════════
|
||||
// ═══════════════ 1. 全新實例(尚未設定過任何管理員帳密)═══════════════
|
||||
|
||||
describe('全新實例(尚未設定過任何管理員帳密)', () => {
|
||||
it('GET /console/auth-status → configured:false,不洩漏 email', async () => {
|
||||
const res = await json('GET', '/console/auth-status');
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { configured: boolean; credentials_source: string; auth_store: { present: boolean } };
|
||||
const data = (await res.json()) as { configured: boolean; credentials_source: string; auth_store: { home: string } };
|
||||
expect(data.configured).toBe(false);
|
||||
expect(data.credentials_source).toBe('none');
|
||||
expect(data.auth_store.home).toBe('sessions-kv');
|
||||
expect(JSON.stringify(data)).not.toContain('@'); // 不洩漏 email
|
||||
});
|
||||
|
||||
it('POST /console/login → 400「讀不到認證資料」,不是密碼錯(D61 明顯失敗)', async () => {
|
||||
it('POST /console/login → 400「讀不到認證資料」,不是密碼錯(明顯失敗)', async () => {
|
||||
const res = await json('POST', '/console/login', { email: 'anyone@example.com', password: 'whatever-pw-1' });
|
||||
expect(res.status).toBe(400);
|
||||
const data = (await res.json()) as { code: string; error: string };
|
||||
@@ -91,36 +74,31 @@ describe('全新實例(尚未設定過任何管理員帳密)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 2. 首次設定:成功寫進認證儲存(D61 起唯一寫入路徑)═══════════════
|
||||
// ═══════════════ 2. 首次設定:成功寫進 SESSIONS_KV ═══════════════
|
||||
|
||||
describe('POST /console/setup — 首次設定', () => {
|
||||
it('成功:寫進認證儲存(不再寫 SESSIONS_KV),回 session_token', async () => {
|
||||
const { puts } = mockAuthStoreWrite();
|
||||
it('成功:寫進 SESSIONS_KV(binding,不需要任何外部 CF token),回 session_token', async () => {
|
||||
const res = await json('POST', '/console/setup', { email: OWNER_EMAIL.toUpperCase(), password: OWNER_PW });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { success: boolean; session_token: string; tenant: string };
|
||||
expect(data.success).toBe(true);
|
||||
expect(typeof data.session_token).toBe('string');
|
||||
|
||||
// 寫入認證儲存:一片、含小寫 email,明碼密碼絕不落地
|
||||
const shards = puts();
|
||||
expect(shards.length).toBe(1);
|
||||
expect(shards[0].name).toBe('ARCRUN_AUTH_STORE');
|
||||
expect(shards[0].text).not.toContain(OWNER_PW);
|
||||
const shard = JSON.parse(shards[0].text) as { console: { email: string; salt: string; hash: string } };
|
||||
expect(shard.console.email).toBe(OWNER_EMAIL); // 存小寫
|
||||
expect(typeof shard.console.salt).toBe('string');
|
||||
expect(typeof shard.console.hash).toBe('string');
|
||||
|
||||
// D61:不再寫舊 KV——這是本次變更的核心(舊版寫 SESSIONS_KV,重裝就蒸發)
|
||||
expect(await env.SESSIONS_KV.get('console:credentials')).toBeNull();
|
||||
const raw = await env.SESSIONS_KV.get('console:credentials');
|
||||
expect(raw).toBeTruthy();
|
||||
expect(raw).not.toContain(OWNER_PW); // 明碼絕不落地
|
||||
const stored = JSON.parse(raw!) as { email: string; salt: string; hash: string };
|
||||
expect(stored.email).toBe(OWNER_EMAIL); // 存小寫
|
||||
expect(typeof stored.salt).toBe('string');
|
||||
expect(typeof stored.hash).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 3. 已設定過 → 409(D61 明顯失敗:說得出「沒有被採用」)═══════════════
|
||||
// ═══════════════ 3. 已設定過 → 409(明顯失敗:說得出「沒有被採用」)═══════════════
|
||||
|
||||
describe('POST /console/setup — 已設定過(重複設定)', () => {
|
||||
it('409,訊息明講「你剛才輸入的密碼沒有被採用」,不誤導成「設定成功」', async () => {
|
||||
await setupOwner(OWNER_EMAIL, OWNER_PW);
|
||||
const res = await json('POST', '/console/setup', { email: 'attacker@example.com', password: 'trying-to-hijack-1' });
|
||||
expect(res.status).toBe(409);
|
||||
const data = (await res.json()) as {
|
||||
@@ -130,22 +108,29 @@ describe('POST /console/setup — 已設定過(重複設定)', () => {
|
||||
expect(data.password_applied).toBe(false);
|
||||
expect(data.error).toContain('沒有被採用');
|
||||
expect(data.reset_path).toBe('/console/setup/reset');
|
||||
// 攻擊者填的帳密真的沒有生效:用它登入應該失敗(下一個 describe 也會正面驗證原帳密仍有效)
|
||||
|
||||
// 攻擊者填的帳密真的沒有生效:用它登入應該失敗,原帳密仍有效
|
||||
const attackerLogin = await json('POST', '/console/login', { email: 'attacker@example.com', password: 'trying-to-hijack-1' });
|
||||
expect(attackerLogin.status).toBe(401);
|
||||
const ownerLogin = await json('POST', '/console/login', { email: OWNER_EMAIL, password: OWNER_PW });
|
||||
expect(ownerLogin.status).toBe(200);
|
||||
});
|
||||
|
||||
it('GET /console/auth-status → configured:true,credentials_source:secrets(新家優先命中)', async () => {
|
||||
it('GET /console/auth-status → configured:true,credentials_source:kv', async () => {
|
||||
await setupOwner(OWNER_EMAIL, OWNER_PW);
|
||||
const res = await json('GET', '/console/auth-status');
|
||||
const data = (await res.json()) as { configured: boolean; credentials_source: string; auth_store: { console_configured: boolean } };
|
||||
const data = (await res.json()) as { configured: boolean; credentials_source: string; auth_store: { writable: boolean } };
|
||||
expect(data.configured).toBe(true);
|
||||
expect(data.credentials_source).toBe('secrets');
|
||||
expect(data.auth_store.console_configured).toBe(true);
|
||||
expect(data.credentials_source).toBe('kv');
|
||||
expect(data.auth_store.writable).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 4. 登入對錯(用第 2 節設定的帳密)═══════════════
|
||||
// ═══════════════ 4. 登入對錯 ═══════════════
|
||||
|
||||
describe('POST /console/login', () => {
|
||||
it('帳密正確 → 200,發 session token', async () => {
|
||||
await setupOwner(OWNER_EMAIL, OWNER_PW);
|
||||
const res = await json('POST', '/console/login', { email: OWNER_EMAIL, password: OWNER_PW });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { success: boolean; session_token: string };
|
||||
@@ -154,30 +139,27 @@ describe('POST /console/login', () => {
|
||||
});
|
||||
|
||||
it('密碼錯 → 401', async () => {
|
||||
await setupOwner(OWNER_EMAIL, OWNER_PW);
|
||||
const res = await json('POST', '/console/login', { email: OWNER_EMAIL, password: 'wrong-password-x' });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('攻擊者在第 3 節試圖搶注的帳密登不進來(證明真的「沒有被採用」)', async () => {
|
||||
const res = await json('POST', '/console/login', { email: 'attacker@example.com', password: 'trying-to-hijack-1' });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 5. /console/setup/reset:換密碼,寫進新家 ═══════════════
|
||||
// ═══════════════ 5. /console/setup/reset:換密碼,寫進 SESSIONS_KV ═══════════════
|
||||
|
||||
describe('POST /console/setup/reset', () => {
|
||||
const NEW_PW = 'brand-new-owner-pw-1';
|
||||
|
||||
it('舊密碼錯 → 401,不寫入', async () => {
|
||||
await setupOwner(OWNER_EMAIL, OWNER_PW);
|
||||
const res = await json('POST', '/console/setup/reset', {
|
||||
current_password: 'still-wrong', email: OWNER_EMAIL, password: NEW_PW,
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('舊密碼對 → 200,新 hash 寫進新家;換完後舊密碼立即失效、新密碼生效', async () => {
|
||||
const { puts } = mockAuthStoreWrite();
|
||||
it('舊密碼對 → 200,新 hash 寫進 SESSIONS_KV;換完後舊密碼立即失效、新密碼生效', async () => {
|
||||
await setupOwner(OWNER_EMAIL, OWNER_PW);
|
||||
const res = await json('POST', '/console/setup/reset', {
|
||||
current_password: OWNER_PW, email: OWNER_EMAIL, password: NEW_PW,
|
||||
});
|
||||
@@ -185,11 +167,10 @@ describe('POST /console/setup/reset', () => {
|
||||
const data = (await res.json()) as { success: boolean };
|
||||
expect(data.success).toBe(true);
|
||||
|
||||
const shards = puts();
|
||||
expect(shards.length).toBe(1);
|
||||
expect(shards[0].text).not.toContain(NEW_PW); // 明碼不落地
|
||||
const shard = JSON.parse(shards[0].text) as { console: { email: string } };
|
||||
expect(shard.console.email).toBe(OWNER_EMAIL);
|
||||
const raw = await env.SESSIONS_KV.get('console:credentials');
|
||||
expect(raw).not.toContain(NEW_PW); // 明碼不落地
|
||||
const stored = JSON.parse(raw!) as { email: string };
|
||||
expect(stored.email).toBe(OWNER_EMAIL);
|
||||
|
||||
// 舊密碼立即失效
|
||||
const oldLogin = await json('POST', '/console/login', { email: OWNER_EMAIL, password: OWNER_PW });
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* POST /kbdb/records — `entry_ids` 通道(Arcrun#128)
|
||||
*
|
||||
* 背景:基本盤 kbdb 的 createRecord 現在接受 `entry_ids`(slot 指向**既有** entry 的 id,
|
||||
* 不新建、不複製)。這條 proxy 之前寫死只轉發 `values`,且沒有 values 就 400
|
||||
* ⇒ 走 X-Arcrun-API-Key 的呼叫者(#129 的 wiki 寫入端、#130 的三元組正規化)打不到新能力,
|
||||
* 等於基本盤補好了、通道沒開(同 b6ef0f0 那次 PATCH 的形狀)。
|
||||
*
|
||||
* 驗的是 IO 接線(判斷真身在基本盤,這裡只測轉發,比照 kbdb-records-patch-proxy.test.ts):
|
||||
* 1. 租戶閘:無 X-Arcrun-API-Key → 401 不碰 KBDB
|
||||
* 2. 只給 entry_ids(沒有 values)→ 轉發成功(舊版這裡是 400)
|
||||
* 3. 轉發的 body:帶 entry_ids + **注入租戶當 owner_id**(caller 自帶的 owner_id 被忽略)
|
||||
* 4. values 與 entry_ids 混用 → 兩個都轉過去
|
||||
* 5. 兩個都沒給 → 400,不轉發
|
||||
* 6. base 擋跨租戶(400)→ 原樣透傳,不假裝成功
|
||||
*
|
||||
* KBDB 打 fetchMock 假 host(wrangler.test.toml KBDB_BASE_URL=https://kbdb.test)+
|
||||
* disableNetConnect——測試絕不外連。
|
||||
*/
|
||||
import { SELF, fetchMock } from 'cloudflare:test';
|
||||
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
|
||||
|
||||
const KEY = { 'X-Arcrun-API-Key': 'leo', 'Content-Type': 'application/json' };
|
||||
|
||||
beforeAll(() => {
|
||||
fetchMock.activate();
|
||||
fetchMock.disableNetConnect();
|
||||
});
|
||||
afterEach(() => fetchMock.assertNoPendingInterceptors());
|
||||
|
||||
describe('POST /kbdb/records(entry_ids)— 租戶閘與參數', () => {
|
||||
it('無 X-Arcrun-API-Key → 401,不碰 KBDB', async () => {
|
||||
const res = await SELF.fetch('http://localhost/kbdb/records', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ template: 'wiki', entry_ids: { gloss: 'e_1' } }),
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('values 與 entry_ids 都沒給 → 400,不轉發', async () => {
|
||||
const res = await SELF.fetch('http://localhost/kbdb/records', {
|
||||
method: 'POST',
|
||||
headers: KEY,
|
||||
body: JSON.stringify({ template: 'wiki' }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /kbdb/records(entry_ids)— 轉發', () => {
|
||||
it('只給 entry_ids(沒有 values)→ 轉發,且 owner_id 由租戶身份注入', async () => {
|
||||
fetchMock
|
||||
.get('https://kbdb.test')
|
||||
.intercept({
|
||||
path: '/records',
|
||||
method: 'POST',
|
||||
// 沒有 values 這個 key(不要憑空塞一個空物件給基本盤)
|
||||
body: JSON.stringify({ template: 'wiki', entry_ids: { gloss: 'e_1', points: 'e_2' }, owner_id: 'leo' }),
|
||||
})
|
||||
.reply(200, {
|
||||
success: true,
|
||||
record: { record_id: 'rec_1', template_id: 'tpl-wiki', values: { gloss: '既有', points: '既有2' }, owner_id: 'leo' },
|
||||
});
|
||||
const res = await SELF.fetch('http://localhost/kbdb/records', {
|
||||
method: 'POST',
|
||||
headers: KEY,
|
||||
// caller 自帶 owner_id:必須被忽略(防跨租戶寫入,本檔既有慣例)
|
||||
body: JSON.stringify({ template: 'wiki', entry_ids: { gloss: 'e_1', points: 'e_2' }, owner_id: 'alice' }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { success: boolean; record: { values: Record<string, string> } };
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.record.values.gloss).toBe('既有');
|
||||
});
|
||||
|
||||
it('values 與 entry_ids 混用 → 兩個都轉過去', async () => {
|
||||
fetchMock
|
||||
.get('https://kbdb.test')
|
||||
.intercept({
|
||||
path: '/records',
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ template: 'wiki', values: { title: '新建' }, entry_ids: { gloss: 'e_1' }, owner_id: 'leo' }),
|
||||
})
|
||||
.reply(200, { success: true, record: { record_id: 'rec_2', template_id: 'tpl-wiki', values: {}, owner_id: 'leo' } });
|
||||
const res = await SELF.fetch('http://localhost/kbdb/records', {
|
||||
method: 'POST',
|
||||
headers: KEY,
|
||||
body: JSON.stringify({ template: 'wiki', values: { title: '新建' }, entry_ids: { gloss: 'e_1' } }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('舊呼叫端(只給 values)→ 轉發的 body 不夾帶 entry_ids,行為與過去相同', async () => {
|
||||
fetchMock
|
||||
.get('https://kbdb.test')
|
||||
.intercept({
|
||||
path: '/records',
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ template: 'triplet', values: { subject: 'A', predicate: 'r', object: 'B' }, owner_id: 'leo' }),
|
||||
})
|
||||
.reply(200, { success: true, record: { record_id: 'rec_3', template_id: 'tpl-triplet', values: {}, owner_id: 'leo' } });
|
||||
const res = await SELF.fetch('http://localhost/kbdb/records', {
|
||||
method: 'POST',
|
||||
headers: KEY,
|
||||
body: JSON.stringify({ template: 'triplet', values: { subject: 'A', predicate: 'r', object: 'B' } }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('base 擋下跨租戶參照(400)→ 原樣透傳,不假裝成功', async () => {
|
||||
fetchMock
|
||||
.get('https://kbdb.test')
|
||||
.intercept({ path: '/records', method: 'POST' })
|
||||
.reply(400, { success: false, error: 'entry owner mismatch: e_x(alice) != leo' });
|
||||
const res = await SELF.fetch('http://localhost/kbdb/records', {
|
||||
method: 'POST',
|
||||
headers: KEY,
|
||||
body: JSON.stringify({ template: 'wiki', entry_ids: { gloss: 'e_x' } }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const data = (await res.json()) as { success: boolean; error: string };
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.error).toContain('owner mismatch');
|
||||
});
|
||||
});
|
||||
@@ -16,20 +16,20 @@
|
||||
* KBDB 打 fetchMock 假 host(wrangler.test.toml KBDB_BASE_URL=https://kbdb.test)+
|
||||
* disableNetConnect——絕不外連。UI 全流程由本機隔離雙 worker 端到端 curl 驗證(PR 證據表)。
|
||||
*
|
||||
* D61(ADR D61 / Leo/arcrun-rag#55):本檔測試裡的帳號 fixture(rec_admin/rec_u1/rec_admin2…)
|
||||
* 全部沿用「record_id 不是 auth: 開頭」這個既有慣例——這正是 portal.ts 的相容分流點
|
||||
* (isAuthStoreId(recordId)),非 auth: 開頭的 id 一律走原本的 KBDB 路徑,行為與 D61 之前
|
||||
* 完全一致,故本檔絕大多數測試不需要改。**只有「新建帳號」這個動作**(POST /portal/admin/users、
|
||||
* POST /portal/admin/bootstrap 走同一支 createPortalUser)改成寫進認證儲存(CF Workers
|
||||
* Secrets),需要額外攔截 `https://api.cloudflare.com/.../secrets`(PUT)——見 mockAuthStoreWrite。
|
||||
* 2026-08-14 起(D61 補充,leo confirm「走C」):帳號改回住 KBDB(binding,見
|
||||
* pending-changes.md「認證儲存要不要搬回 D1/KV」)。本檔測試裡的帳號 fixture
|
||||
* (rec_admin/rec_u1/rec_admin2…)全部沿用「record_id 不是 auth: 開頭」這個既有慣例——
|
||||
* 這正是 portal.ts 的相容分流點(isAuthStoreId(recordId)),非 auth: 開頭的 id 一律走
|
||||
* KBDB 路徑,行為與 D61 之前完全一致,故本檔絕大多數測試不需要改。
|
||||
* **只有「新建帳號」這個動作**(POST /portal/admin/users、POST /portal/admin/bootstrap
|
||||
* 走同一支 createPortalUser)改回直接寫 KBDB(POST /records + POST /entries head),
|
||||
* 不再需要 `https://api.cloudflare.com/.../secrets`。
|
||||
*/
|
||||
import { SELF, env, fetchMock } from 'cloudflare:test';
|
||||
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
|
||||
import { hashPassword, PBKDF2_ITERATIONS } from '../src/lib/portal-auth';
|
||||
import { AUTH_ID_PREFIX } from '../src/lib/portal-auth-store';
|
||||
|
||||
const KBDB = 'https://kbdb.test';
|
||||
const CF_API = 'https://api.cloudflare.com';
|
||||
const NS = 'leo::portal'; // wrangler.test.toml CONSOLE_TENANT=leo → 子 namespace
|
||||
|
||||
let storedHash: string;
|
||||
@@ -49,19 +49,21 @@ function json(method: string, path: string, body?: unknown, headers: Record<stri
|
||||
});
|
||||
}
|
||||
|
||||
/** D61:認證儲存寫入路徑(同 portal-auth.test.ts 的同名 helper,見那邊檔頭的完整說明)。 */
|
||||
function mockAuthStoreWrite(times = 1): { puts: () => Array<{ name: string; text: string }> } {
|
||||
const captured: Array<{ name: string; text: string }> = [];
|
||||
/** 建帳號的 KBDB 寫入路徑(POST /records + POST /entries head entry)。 */
|
||||
function mockCreateUser(recordId: string): { recordBody: () => string } {
|
||||
let recordBody = '';
|
||||
fetchMock
|
||||
.get(CF_API)
|
||||
.intercept({ path: (p: string) => p.includes('/secrets'), method: 'PUT' })
|
||||
.get(KBDB)
|
||||
.intercept({ path: '/records', method: 'POST' })
|
||||
.reply(200, (opts) => {
|
||||
const body = JSON.parse(String(opts.body)) as { name: string; text: string };
|
||||
captured.push(body);
|
||||
return { success: true };
|
||||
})
|
||||
.times(times);
|
||||
return { puts: () => captured };
|
||||
recordBody = String(opts.body);
|
||||
return { success: true, record: { record_id: recordId, template_id: 'tpl_pu', values: {} } };
|
||||
});
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: '/entries', method: 'POST' })
|
||||
.reply(200, { success: true, entry: { id: `${recordId}_head` } });
|
||||
return { recordBody: () => recordBody };
|
||||
}
|
||||
|
||||
function mockHeadLookup(email: string, recordId: string | null) {
|
||||
@@ -202,11 +204,12 @@ describe('last-admin 鎖死保護(PATCH /portal/admin/users/:id)', () => {
|
||||
// ═══════════════ 2. 一次性密碼(新增帳號)═══════════════
|
||||
|
||||
describe('POST /portal/admin/users(一次性密碼)', () => {
|
||||
it('未帶 password → generated_password 回一次(16 碼);認證儲存落的是 hash 非明碼(D61)', async () => {
|
||||
it('未帶 password → generated_password 回一次(16 碼);KBDB 落的是 hash 非明碼', async () => {
|
||||
await seedAdminSession();
|
||||
mockGetRecord('rec_admin', adminValues());
|
||||
mockHeadLookup('new@example.com', null); // email 未占用(新家找不到 → 回退查舊家)
|
||||
const { puts } = mockAuthStoreWrite();
|
||||
mockHeadLookup('new@example.com', null); // email 未占用
|
||||
const { recordBody } = mockCreateUser('rec_new');
|
||||
mockGetRecord('rec_new', userValues({ email: 'new@example.com' })); // 回應用的回讀
|
||||
const res = await json(
|
||||
'POST',
|
||||
'/portal/admin/users',
|
||||
@@ -218,23 +221,21 @@ describe('POST /portal/admin/users(一次性密碼)', () => {
|
||||
expect(typeof data.generated_password).toBe('string');
|
||||
expect(data.generated_password!.length).toBe(16);
|
||||
expect('password_hash' in data.user).toBe(false);
|
||||
expect((data.user as { record_id: string }).record_id.startsWith(AUTH_ID_PREFIX)).toBe(true); // 住新家
|
||||
expect((data.user as { record_id: string }).record_id).toBe('rec_new'); // 住 KBDB
|
||||
|
||||
// 一次性密碼不落地:認證儲存收到的 shard 只有 hash、無明碼
|
||||
const shards = puts();
|
||||
expect(shards.length).toBe(1);
|
||||
expect(shards[0].text).not.toContain(data.generated_password!);
|
||||
const shard = JSON.parse(shards[0].text) as { users: Array<{ email: string; password_hash: string }> };
|
||||
const stored = shard.users.find((u) => u.email === 'new@example.com');
|
||||
expect(stored).toBeDefined();
|
||||
expect(stored!.password_hash.startsWith(`pbkdf2-sha256$${PBKDF2_ITERATIONS}$`)).toBe(true);
|
||||
// 一次性密碼不落地:KBDB 收到的 record body 只有 hash、無明碼
|
||||
expect(recordBody()).not.toContain(data.generated_password!);
|
||||
const rec = JSON.parse(recordBody()) as { owner_id: string; values: Record<string, string> };
|
||||
expect(rec.owner_id).toBe(NS);
|
||||
expect(rec.values.password_hash.startsWith(`pbkdf2-sha256$${PBKDF2_ITERATIONS}$`)).toBe(true);
|
||||
});
|
||||
|
||||
it('自帶 password → 回應**無** generated_password', async () => {
|
||||
await seedAdminSession();
|
||||
mockGetRecord('rec_admin', adminValues());
|
||||
mockHeadLookup('own@example.com', null);
|
||||
mockAuthStoreWrite();
|
||||
mockCreateUser('rec_own');
|
||||
mockGetRecord('rec_own', userValues({ email: 'own@example.com' }));
|
||||
const res = await json(
|
||||
'POST',
|
||||
'/portal/admin/users',
|
||||
@@ -747,4 +748,22 @@ describe('POST /portal/daemon/extract(t181:Workers AI 萃卡,免金鑰)'
|
||||
expect(raw).not.toContain('gemini_api_key');
|
||||
expect(raw).not.toContain('credential');
|
||||
});
|
||||
|
||||
// 🔴 Arcrun#134:新 daemon 自帶提示詞(卡片格式契約的唯一真相源在 daemon 端
|
||||
// collector/——提示詞+JSON 解析+wikishape 組卡同住一處,才不會像 t181 那樣
|
||||
// 兩份 prompt 靠註解叮嚀同步而漂移)。雲端在 prompt 模式下只是執行器。
|
||||
//
|
||||
// 測試環境刻意沒有 [ai] binding ⇒ 走到 AI 分支必回 501。這裡拿 501 當「驗證已通過、
|
||||
// 已進入 AI 分支」的探針:若有人把 prompt 模式改回要求 page_name/text,這則會看到 400。
|
||||
it('帶 prompt 時不再要求 page_name/text(#134:契約住 daemon 端)', async () => {
|
||||
const res = await json('POST', '/portal/daemon/extract', { prompt: '任意契約內容' }, KEY);
|
||||
expect(res.status).toBe(501); // 不是 400 ⇒ prompt 單獨成立
|
||||
});
|
||||
|
||||
// 相容守衛:舊 daemon(無 prompt 欄位)行為凍結——缺欄仍 400(上面那則),
|
||||
// 齊欄則同樣走到 AI 分支(501 探針)。若這則變 400,代表 legacy 驗證被動到了。
|
||||
it('legacy(無 prompt)齊欄仍走原路(相容不回退)', async () => {
|
||||
const res = await json('POST', '/portal/daemon/extract', { page_name: 'x', text: 'y' }, KEY);
|
||||
expect(res.status).toBe(501);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,31 +4,29 @@
|
||||
* 覆蓋(=tasks.md P2 測試項):
|
||||
* 1. KDF:pbkdf2-sha256$100000$… 格式(CF Workers runtime 上限 100k,2026-07-14 真雲實撞)、
|
||||
* 驗證對錯、壞格式誠實 false、600k 舊 hash 相容(迭代數從儲存值解析)
|
||||
* 2. bootstrap 閘:無 console session → 401;建 admin 寫進**認證儲存**(D61);
|
||||
* 已有 admin → 409
|
||||
* 2. bootstrap 閘:無 console session → 401;建 admin 寫進 **KBDB**;已有 admin → 409
|
||||
* 3. 登入對錯:成功發 token(回應**無租戶字串**)、密碼錯 401、停用 403、未知 email 401
|
||||
* 4. 節流:5 次失敗 → 429(KV TTL 計數)
|
||||
* 5. session:每請求回讀 record;停用即拒(既有 session 立即失效)
|
||||
* 6. 改密碼:驗舊密;新 hash 以 100k 格式落 slot
|
||||
* 7. role 閘:非 admin 打 admin 端點 → 403;admin 列表**剝除 password_hash**
|
||||
*
|
||||
* D61(ADR D61 / Leo/arcrun-rag#55)補的覆蓋(原本沒有,這次變更的重點):
|
||||
* 2026-08-14 起(D61 補充,leo confirm「走C」,pending-changes.md「認證儲存要不要搬回
|
||||
* D1/KV」):帳號的家從認證儲存(CF Workers Secrets,D61)改回 **KBDB**(binding,走 base
|
||||
* HTTP API,不需要外部 CF token)。額外覆蓋:
|
||||
* 8. 整台實例沒有任何認證資料 → 登入回「讀不到認證資料」(不是密碼錯),且不計入鎖定
|
||||
* 9. 舊實例相容:帳號只存在 KBDB(舊家)時仍登得進去,登入成功後自動搬進認證儲存
|
||||
* 9. 舊實例相容:帳號只存在認證儲存(D61 舊家)時仍登得進去,登入成功後自動搬進 KBDB,
|
||||
* 且**這次登入發出的 session 就直接指向新 record_id**;搬遷失敗(KBDB 拒寫)不擋登入
|
||||
*
|
||||
* KBDB 打 fetchMock 假 host(wrangler.test.toml KBDB_BASE_URL=https://kbdb.test)+
|
||||
* disableNetConnect——絕不外連。子 namespace 隔離的「搜 email 搜不到」由本機雙 worker
|
||||
* 端到端 curl 驗證(PR 驗收證據表),這裡驗「寫入時 owner_id=leo::portal」的機械事實。
|
||||
*
|
||||
* D61 起,帳號的家從 KBDB 換成認證儲存(CF Workers Secrets)——寫入會呼叫
|
||||
* `https://api.cloudflare.com/.../secrets`(PUT),同樣走 fetchMock 假 host 攔截,不外連。
|
||||
* wrangler.test.toml 已預設 CF_SECRETS_API_TOKEN/CF_ACCOUNT_ID 就緒(比照真實裝妥的實例)。
|
||||
*/
|
||||
import { SELF, env, fetchMock } from 'cloudflare:test';
|
||||
import { beforeAll, beforeEach, afterEach, describe, it, expect } from 'vitest';
|
||||
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
|
||||
import { hashPassword, verifyPassword, PBKDF2_ITERATIONS } from '../src/lib/portal-auth';
|
||||
import { PORTAL_TEMPLATE_SEEDS } from '../src/lib/portal-seeds';
|
||||
import { AUTH_ID_PREFIX } from '../src/lib/portal-auth-store';
|
||||
import { AUTH_ID_PREFIX, isAuthStoreId, mutateAuthStore } from '../src/lib/portal-auth-store';
|
||||
import { portalRouter } from '../src/routes/portal';
|
||||
import type { Bindings, ExecutionContext } from '../src/types';
|
||||
|
||||
@@ -56,38 +54,13 @@ function json(method: string, path: string, body?: unknown, headers: Record<stri
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* D61:認證儲存的寫入路徑(單元測試層級——一次呼叫=一片,測試資料量小不會觸發溢位分片)。
|
||||
* 攔截 CF Workers Scripts secrets 管理 API 的 PUT,捕捉 body 供斷言(片名/內容)。
|
||||
* 用法:每個會觸發寫入的測試呼叫一次,回傳的 `puts()` 拿到依序捕捉到的 {name, text}[]。
|
||||
*
|
||||
* ⚠️ 讀路徑沒有對應的「seed 進 env」捷徑可用:`cloudflare:test` 的 `env` 物件是傳給
|
||||
* `vitest` 主 context 用的,對 `SELF.fetch()` 打的那個 worker isolate **不生效**(實測驗證,
|
||||
* mutate `env.XXX` 後 SELF 端讀到的仍是 wrangler.test.toml 的原值)。因此「舊實例相容」
|
||||
* 一類的讀路徑測試,一律靠**既有的 KBDB fetchMock**(新家預設空,天然等於「帳號只在舊家」);
|
||||
* 要驗證「新家已經有資料」則靠**真的呼叫一次寫入端點**(bootstrap/新增同仁),讓 portal-auth-store
|
||||
* 模組內的 per-isolate overlay 落地——這個 overlay 在同一支測試檔案裡的後續測試\*也讀得到\*
|
||||
* (模組級全域變數不隨 test 重置,只有 KV/D1 等 storage 才有 isolatedStorage 重置),
|
||||
* 這是刻意善用而非意外:想要「乾淨無帳號」的情境,該測試必須排在檔案裡**第一個寫入動作之前**。
|
||||
*/
|
||||
function mockAuthStoreWrite(times = 1): { puts: () => Array<{ name: string; text: string }> } {
|
||||
const captured: Array<{ name: string; text: string }> = [];
|
||||
fetchMock
|
||||
.get(CF_API)
|
||||
.intercept({ path: (p: string) => p.includes('/secrets'), method: 'PUT' })
|
||||
.reply(200, (opts) => {
|
||||
const body = JSON.parse(String(opts.body)) as { name: string; text: string };
|
||||
captured.push(body);
|
||||
return { success: true };
|
||||
})
|
||||
.times(times);
|
||||
return { puts: () => captured };
|
||||
}
|
||||
|
||||
// ── KBDB mock helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/** head entry 查找(GET /entries?page_name=…&entry_type=portal_user&owner_id=ns&limit=1) */
|
||||
function mockHeadLookup(email: string, recordId: string | null) {
|
||||
/**
|
||||
* head entry 查找(GET /entries?page_name=…&entry_type=portal_user&owner_id=ns&limit=1)。
|
||||
* `times`:`findUserRecordId`/`promoteToKbdb` 都可能各查一次同一個 email,需要幾次命中就傳幾。
|
||||
*/
|
||||
function mockHeadLookup(email: string, recordId: string | null, times = 1) {
|
||||
const needle = new URLSearchParams({ page_name: email }).toString();
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
@@ -96,7 +69,61 @@ function mockHeadLookup(email: string, recordId: string | null) {
|
||||
p.startsWith('/entries?') && p.includes(needle) && p.includes(encodeURIComponent(NS)),
|
||||
method: 'GET',
|
||||
})
|
||||
.reply(200, { success: true, entries: recordId ? [{ content: recordId }] : [], count: recordId ? 1 : 0, total: recordId ? 1 : 0 });
|
||||
.reply(200, { success: true, entries: recordId ? [{ content: recordId }] : [], count: recordId ? 1 : 0, total: recordId ? 1 : 0 })
|
||||
.times(times);
|
||||
}
|
||||
|
||||
/** 建 portal_user 的 KBDB 寫入路徑(POST /records + POST /entries head entry)。 */
|
||||
function mockCreateUser(recordId: string): { recordBody: () => string } {
|
||||
let recordBody = '';
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: '/records', method: 'POST' })
|
||||
.reply(200, (opts) => {
|
||||
recordBody = String(opts.body);
|
||||
return { success: true, record: { record_id: recordId, template_id: 'tpl_pu', values: {} } };
|
||||
});
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: '/entries', method: 'POST' })
|
||||
.reply(200, { success: true, entry: { id: `${recordId}_head` } });
|
||||
return { recordBody: () => recordBody };
|
||||
}
|
||||
|
||||
/** POST /records 建立失敗(模擬 KBDB 拒寫,用於「搬遷失敗不擋登入」的測試)。 */
|
||||
function mockCreateUserFails(status = 500) {
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: '/records', method: 'POST' })
|
||||
.reply(status, { success: false, error: 'kbdb write failed (test)' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 種一筆帳號進**認證儲存**(D61 舊家,CF Workers Secrets)——直接呼叫 lib,不經任何 HTTP
|
||||
* route(consoleAuthRouter/portalRouter 現在都不再寫這個家了)。需要攔截一次 CF Secrets PUT
|
||||
* (wrangler.test.toml 已預設 CF_SECRETS_API_TOKEN/CF_ACCOUNT_ID 就緒)。
|
||||
*/
|
||||
async function seedLegacySecretsUser(overrides: Partial<{
|
||||
email: string; display_name: string; status: string; role: string; libraries: string[]; password_hash: string;
|
||||
}> = {}): Promise<void> {
|
||||
fetchMock
|
||||
.get(CF_API)
|
||||
.intercept({ path: (p: string) => p.includes('/secrets'), method: 'PUT' })
|
||||
.reply(200, { success: true });
|
||||
const now = new Date().toISOString();
|
||||
await mutateAuthStore(env as unknown as Bindings, (data) => {
|
||||
data.users.push({
|
||||
id: `auth:${crypto.randomUUID().replace(/-/g, '')}`,
|
||||
email: (overrides.email ?? EMAIL).toLowerCase(),
|
||||
display_name: overrides.display_name ?? '舊實例同仁',
|
||||
status: overrides.status ?? 'active',
|
||||
role: overrides.role ?? 'user',
|
||||
libraries: overrides.libraries ?? ['general'],
|
||||
password_hash: overrides.password_hash ?? storedHash,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function mockGetRecord(recordId: string, values: Record<string, string>) {
|
||||
@@ -176,20 +203,20 @@ describe('PBKDF2 模組(lib/portal-auth)', () => {
|
||||
|
||||
// ═══════════════ 1.5 D61:整台實例沒有任何認證資料 ═══════════════
|
||||
//
|
||||
// 🔴 這個 describe 必須留在檔案裡「第一個會寫入認證儲存的測試」之前(下面 2. bootstrap
|
||||
// 的「console session OK」那則)——見 mockAuthStoreWrite 檔頭註解:portal-auth-store.ts
|
||||
// 的 per-isolate overlay 是模組級全域變數,同一支測試檔案跑起來不會在測試之間重置,
|
||||
// 一旦有測試寫入過,後面的測試都會看到那筆資料,「乾淨無帳號」的前提就不成立了。
|
||||
describe('D61:整台實例沒有任何認證資料(arcrun-rag#55,leo 2026-08-09 被誤鎖 15 分鐘的事故)', () => {
|
||||
// 🔴 這個 describe 必須留在檔案裡「第一個會種入認證儲存(舊家)的測試」之前——見
|
||||
// seedLegacySecretsUser 檔頭註解:portal-auth-store.ts 的 per-isolate overlay 是模組級全域
|
||||
// 變數,同一支測試檔案跑起來不會在測試之間重置,一旦有測試種過舊家資料,後面的測試都會看到
|
||||
// 那筆資料,「乾淨無帳號」的前提就不成立了。
|
||||
describe('整台實例沒有任何認證資料(arcrun-rag#55,leo 2026-08-09 被誤鎖 15 分鐘的事故)', () => {
|
||||
it('登入回「讀不到認證資料」而不是「密碼錯誤」,且不計入失敗鎖定', async () => {
|
||||
// 新家(overlay/env bag)此刻還是空的(本測試特意排在任何寫入測試之前);
|
||||
// 舊家(KBDB)也回空——head lookup 查無此人+by-template 列表也空,兩邊都沒有帳號,
|
||||
// KBDB(新家)此刻回空——head lookup 查無此人+by-template 列表也空;
|
||||
// 認證儲存(舊家)此刻也還是空的(本測試特意排在任何種子測試之前)——兩邊都沒有帳號,
|
||||
// 才是「這台實例真的沒有認證資料」。
|
||||
mockHeadLookup('anyone@example.com', null);
|
||||
mockListByTemplate('portal_user', []);
|
||||
const res = await json('POST', '/portal/login', { email: 'anyone@example.com', password: 'whatever-pw-1' });
|
||||
expect(res.status).toBe(503);
|
||||
const data = (await res.json()) as { error: string; code: string; auth_store: { present: boolean; users: number } };
|
||||
const data = (await res.json()) as { error: string; code: string; auth_store: { users: number } };
|
||||
expect(data.code).toBe('auth_store_empty');
|
||||
// 分得出來的錯:這句要誠實講「不是密碼錯」,而且**不能**是密碼錯誤那句通用訊息
|
||||
// (文案含混是 leo 被鎖 15 分鐘的根因——他的密碼從頭到尾是對的)。
|
||||
@@ -209,12 +236,12 @@ describe('POST /portal/admin/bootstrap', () => {
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('console session OK → 建第一個 admin:寫進認證儲存(D61,不再落 KBDB)', async () => {
|
||||
it('console session OK → 建第一個 admin:寫進 KBDB,不需要任何外部 CF token', async () => {
|
||||
await env.SESSIONS_KV.put('console_sess:owner-token', JSON.stringify({ created_at: Date.now() }));
|
||||
mockTemplatesExist();
|
||||
mockListByTemplate('portal_user', []); // 尚無 admin(新家空,舊家也空)
|
||||
mockHeadLookup('admin@example.com', null); // email 未占用(新家找不到 → 回退查舊家)
|
||||
const { puts } = mockAuthStoreWrite();
|
||||
mockListByTemplate('portal_user', []); // 尚無 admin(KBDB 空,認證儲存舊家也空)
|
||||
mockHeadLookup('admin@example.com', null); // email 未占用
|
||||
const { recordBody } = mockCreateUser('rec_admin_new');
|
||||
|
||||
const res = await json(
|
||||
'POST',
|
||||
@@ -225,25 +252,18 @@ describe('POST /portal/admin/bootstrap', () => {
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as Record<string, unknown>;
|
||||
expect(data.success).toBe(true);
|
||||
expect(typeof data.record_id).toBe('string');
|
||||
expect((data.record_id as string).startsWith(AUTH_ID_PREFIX)).toBe(true); // 住新家(D61)
|
||||
expect(data.record_id).toBe('rec_admin_new'); // 住 KBDB(新 record_id 來自 KBDB 回應)
|
||||
expect(data.email).toBe('admin@example.com'); // 存小寫(design §2.1)
|
||||
|
||||
// D61:一次寫入=一片,落進認證儲存(Workers Secrets),不再有 KBDB record/head entry
|
||||
const shards = puts();
|
||||
expect(shards.length).toBe(1);
|
||||
expect(shards[0].name).toBe('ARCRUN_AUTH_STORE');
|
||||
const shard = JSON.parse(shards[0].text) as {
|
||||
users: Array<{ email: string; role: string; status: string; libraries: string[]; password_hash: string }>;
|
||||
};
|
||||
expect(shard.users.length).toBe(1);
|
||||
const stored = shard.users[0];
|
||||
expect(stored.email).toBe('admin@example.com');
|
||||
expect(stored.role).toBe('admin');
|
||||
expect(stored.status).toBe('active');
|
||||
expect(stored.libraries).toEqual(['*']);
|
||||
expect(stored.password_hash.startsWith(`pbkdf2-sha256$${PBKDF2_ITERATIONS}$`)).toBe(true);
|
||||
expect(shards[0].text).not.toContain('bootstrap-pw-1'); // 明碼絕不落地
|
||||
// 一次 POST /records=一筆,body 含 owner_id 子 namespace,明碼絕不落地
|
||||
const rec = JSON.parse(recordBody()) as { owner_id: string; values: Record<string, string> };
|
||||
expect(rec.owner_id).toBe(NS);
|
||||
expect(rec.values.email).toBe('admin@example.com');
|
||||
expect(rec.values.role).toBe('admin');
|
||||
expect(rec.values.status).toBe('active');
|
||||
expect(JSON.parse(rec.values.libraries)).toEqual(['*']);
|
||||
expect(rec.values.password_hash.startsWith(`pbkdf2-sha256$${PBKDF2_ITERATIONS}$`)).toBe(true);
|
||||
expect(recordBody()).not.toContain('bootstrap-pw-1'); // 明碼絕不落地
|
||||
});
|
||||
|
||||
it('已有 admin → 409 拒絕重複 bootstrap', async () => {
|
||||
@@ -263,12 +283,10 @@ describe('POST /portal/admin/bootstrap', () => {
|
||||
// ═══════════════ 3. 登入對錯 ═══════════════
|
||||
|
||||
describe('POST /portal/login', () => {
|
||||
// 🔴 這一區塊全部共用 EMAIL/'rec_1' 這組舊家 fixture(原本就是),**故意不**在這裡驗證
|
||||
// 「登入成功後搬進新家」——promoteLegacyUser 一旦真的寫成功,會把 EMAIL 留進 overlay,
|
||||
// 而 overlay 是模組級全域、同檔案後面的測試都讀得到,會讓後面每一則「查 KBDB 的 EMAIL」
|
||||
// 全部改成「命中新家」而跳過 KBDB mock,導致假性的 pending-interceptor 骨牌。
|
||||
// 搬遷本身的驗證另開一組使用**專屬、不共用**email 的 describe(見檔案最後
|
||||
// 「D61:舊實例登入自癒」),避免污染這裡的既有 fixture。
|
||||
// 🔴 這一區塊全部共用 EMAIL/'rec_1' 這組 **KBDB 原生** fixture(非 auth: 開頭 id)——
|
||||
// 2026-08-14 起 KBDB 是新家,這批帳號本來就住在該住的地方,**不會**觸發任何搬遷嘗試
|
||||
// (isAuthStoreId('rec_1') 為 false)。搬遷本身的驗證另開一組使用**專屬、不共用**email
|
||||
// 的 describe(見檔案最後「舊實例登入自癒」),避免污染這裡的既有 fixture。
|
||||
it('成功:發 session token;回 display_name/role/libraries;**無任何租戶字串欄位**', async () => {
|
||||
mockHeadLookup(EMAIL, 'rec_1');
|
||||
mockGetRecord('rec_1', activeUserValues());
|
||||
@@ -286,11 +304,6 @@ describe('POST /portal/login', () => {
|
||||
const sess = await env.SESSIONS_KV.get(`portal_sess:${data.session_token}`);
|
||||
expect(sess).toBeTruthy();
|
||||
expect((JSON.parse(sess!) as { record_id: string }).record_id).toBe('rec_1'); // 只存 record_id
|
||||
// D61:promoteLegacyUser 的實際寫入嘗試沒有掛 CF API mock,disableNetConnect 之下
|
||||
// 該次 fetch 會失敗,但函式本身 best-effort 吞掉(見 portal.ts promoteLegacyUser 的
|
||||
// try/catch)——這正是要驗的事:搬不動不影響本次登入已經成功這件事實(上面兩個
|
||||
// expect 已經成立)。afterEach 的 assertNoPendingInterceptors 只檢查「有登記但沒用到」
|
||||
// 的 mock,一次沒登記過 mock 的失敗呼叫不算數,故這裡不需要(也不能)額外掛 CF API mock。
|
||||
});
|
||||
|
||||
it('密碼錯 → 401 通用訊息+lockfail 計數 +1', async () => {
|
||||
@@ -305,6 +318,10 @@ describe('POST /portal/login', () => {
|
||||
|
||||
it('未知 email → 401 同樣通用訊息(不洩帳號存在性)', async () => {
|
||||
mockHeadLookup('ghost@example.com', null);
|
||||
// instanceHasNoAuthData 在「查無此帳號」時會確認「是不是整台實例都沒帳號」——
|
||||
// 這裡要證明的是「查無此人」而非「這台實例是空的」,故 by-template 要回非空列表
|
||||
// (這台實例確實有別的帳號,只是不是 ghost@example.com)。
|
||||
mockListByTemplate('portal_user', [{ record_id: 'rec_1', values: activeUserValues() }]);
|
||||
const res = await json('POST', '/portal/login', { email: 'ghost@example.com', password: 'whatever-123' });
|
||||
expect(res.status).toBe(401);
|
||||
const data = (await res.json()) as { error: string };
|
||||
@@ -529,57 +546,52 @@ describe('t130 — triplet template seed(PORTAL_TEMPLATE_SEEDS 補 triplet,e
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ D61:舊實例登入自癒(搬進新家)═══════════════
|
||||
// ═══════════════ 舊實例登入自癒(認證儲存舊家 → 搬進 KBDB 新家)═══════════════
|
||||
//
|
||||
// 🔴 放在檔案最後、用**專屬 email**(不與上面任何一則共用):portal-auth-store.ts 的
|
||||
// per-isolate overlay 是模組級全域變數,寫入一旦成功就會留在同一支測試檔案的後續測試裡
|
||||
// (見 mockAuthStoreWrite 檔頭的長註解)。這裡就是要驗證那次「留下」,所以刻意隔離在最後,
|
||||
// per-isolate overlay 是模組級全域變數,seedLegacySecretsUser 種一次資料就會留在同一支測試
|
||||
// 檔案的後續測試裡(見該函式檔頭的長註解)。這裡就是要驗證那次「留下」,所以刻意隔離在最後,
|
||||
// 不會有更後面的測試共用這個 email 而被污染。
|
||||
describe('D61:舊實例登入自癒(帳號只在 KBDB,登入成功後 best-effort 搬進認證儲存)', () => {
|
||||
describe('舊實例登入自癒(帳號只在認證儲存舊家,登入成功後搬進 KBDB 新家)', () => {
|
||||
const LEGACY_EMAIL = 'legacy-promote@example.com';
|
||||
|
||||
it('登入成功;promoteLegacyUser 把這筆帳號寫進認證儲存(一片、含正確 email/hash)', async () => {
|
||||
mockHeadLookup(LEGACY_EMAIL, 'rec_legacy_1');
|
||||
mockGetRecord('rec_legacy_1', activeUserValues({ email: LEGACY_EMAIL }));
|
||||
const { puts } = mockAuthStoreWrite();
|
||||
it('登入成功;自動把這筆帳號寫進 KBDB(含正確 email/hash),且**這次的 session 就指向新 record_id**', async () => {
|
||||
await seedLegacySecretsUser({ email: LEGACY_EMAIL });
|
||||
// KBDB 查無此人:一次給 findUserRecordId(登入查找)、一次給 promoteToKbdb(搬遷前的
|
||||
// 「已經搬過了嗎」確認)——兩次都要 mock,見 mockHeadLookup 的 times 參數說明。
|
||||
mockHeadLookup(LEGACY_EMAIL, null, 2);
|
||||
const { recordBody } = mockCreateUser('rec_legacy_promoted_1');
|
||||
|
||||
const res = await json('POST', '/portal/login', { email: LEGACY_EMAIL, password: PASSWORD });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { success: boolean };
|
||||
const data = (await res.json()) as { success: boolean; session_token: string };
|
||||
expect(data.success).toBe(true);
|
||||
|
||||
const shards = puts();
|
||||
expect(shards.length).toBe(1);
|
||||
expect(shards[0].name).toBe('ARCRUN_AUTH_STORE');
|
||||
const shard = JSON.parse(shards[0].text) as { users: Array<{ email: string; password_hash: string }> };
|
||||
const promoted = shard.users.find((u) => u.email === LEGACY_EMAIL);
|
||||
expect(promoted).toBeDefined();
|
||||
expect(promoted!.password_hash).toBe(storedHash); // 原樣搬過去,不重新雜湊
|
||||
const rec = JSON.parse(recordBody()) as { owner_id: string; values: Record<string, string> };
|
||||
expect(rec.owner_id).toBe(NS);
|
||||
expect(rec.values.email).toBe(LEGACY_EMAIL);
|
||||
expect(rec.values.password_hash).toBe(storedHash); // 原樣搬過去,不重新雜湊
|
||||
|
||||
// 這次登入發出的 session 已經指向新 record_id(不必等下一次登入才生效)
|
||||
const sess = await env.SESSIONS_KV.get(`portal_sess:${data.session_token}`);
|
||||
expect((JSON.parse(sess!) as { record_id: string }).record_id).toBe('rec_legacy_promoted_1');
|
||||
});
|
||||
|
||||
it('若新家寫入路徑未就緒(缺 CF_SECRETS_API_TOKEN),照樣登入成功——搬不動不擋門', async () => {
|
||||
// 直接呼叫 router、帶一份缺寫入路徑的 env(health.test.ts 已有的直呼叫慣例),
|
||||
// 證明 promoteLegacyUser 的失敗被 best-effort 吞掉,不影響登入本身。
|
||||
const email = 'legacy-promote-writeless@example.com';
|
||||
mockHeadLookup(email, 'rec_legacy_2');
|
||||
mockGetRecord('rec_legacy_2', activeUserValues({ email }));
|
||||
const fakeEnv = { ...env, CF_SECRETS_API_TOKEN: undefined, CF_ACCOUNT_ID: undefined } as unknown as Bindings;
|
||||
const res = await portalRouter.fetch(
|
||||
new Request('http://localhost/portal/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password: PASSWORD }),
|
||||
}),
|
||||
fakeEnv,
|
||||
{} as ExecutionContext,
|
||||
);
|
||||
it('KBDB 拒寫(搬遷失敗)——照樣登入成功,session 沿用舊家 record_id,搬不動不擋門', async () => {
|
||||
const email = 'legacy-promote-writefail@example.com';
|
||||
await seedLegacySecretsUser({ email });
|
||||
mockHeadLookup(email, null, 2); // 登入查找 + 搬遷前確認,KBDB 兩次都查無此人
|
||||
mockCreateUserFails(500); // 搬遷寫入本身失敗(模擬 KBDB 不可達/拒寫)
|
||||
|
||||
const res = await json('POST', '/portal/login', { email, password: PASSWORD });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { success: boolean };
|
||||
const data = (await res.json()) as { success: boolean; session_token: string };
|
||||
expect(data.success).toBe(true);
|
||||
// 沒掛 CF API mock:若程式碼真的嘗試網呼叫且被 disableNetConnect 擋下,錯誤仍會被
|
||||
// best-effort 吞掉(不影響上面的 200 斷言);若程式碼正確地在 authStoreWritable() 檢查
|
||||
// 就提前短路,則根本不會嘗試呼叫——兩種情況這裡都驗不出差異,差異由 afterEach 的
|
||||
// assertNoPendingInterceptors 間接把關(沒有殘留 mock 代表沒有意外多打的請求)。
|
||||
|
||||
// 搬不動 → session 沿用舊家(認證儲存)的 record_id("auth:" 開頭),讀路徑仍然通
|
||||
const sess = await env.SESSIONS_KV.get(`portal_sess:${data.session_token}`);
|
||||
const sessRecordId = (JSON.parse(sess!) as { record_id: string }).record_id;
|
||||
expect(isAuthStoreId(sessRecordId)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -587,7 +599,7 @@ describe('D61:舊實例登入自癒(帳號只在 KBDB,登入成功後 best
|
||||
//
|
||||
// ⚠️ 順序刻意:這兩個 describe 放在檔案最後,而且「D62」在前、「#66」在後。
|
||||
// 原因=#66 那組會**故意把 per-isolate overlay 灌成一份沒有任何帳號的資料**(模擬傳播空窗),
|
||||
// 而 overlay 是模組級全域變數、不隨 test 重置(見 mockAuthStoreWrite 檔頭長註解)。
|
||||
// 而 overlay 是模組級全域變數、不隨 test 重置(見 seedLegacySecretsUser 檔頭長註解)。
|
||||
// 任何需要「認證儲存裡有帳號」的測試都不能排在它後面。
|
||||
|
||||
describe('D62:改密碼與忘記密碼是同一個機制(同一支端點、同一條寫入路徑)', () => {
|
||||
@@ -682,3 +694,63 @@ describe('arcrun-rag#66:傳播空窗期不可以銷毀 session', () => {
|
||||
expect(await env.SESSIONS_KV.get('portal_sess:broken-66')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ arcrun-rag#99(2026-08-14):全新安裝從沒種過 CF_SECRETS_API_TOKEN ═══════════════
|
||||
//
|
||||
// 每一台裝好的新實例過去都會在 bootstrap 這裡卡死(leo 本人+封測者都撞到「裝得起來,
|
||||
// 但卡在註冊」——`/console/auth-status` 永遠回 `writable:false`)。止血版(安裝精靈遞一把
|
||||
// 臨時 OAuth token)只解掉「建第一個帳號」這一格;2026-08-14 confirm 走 C 之後,帳號改回
|
||||
// 住 KBDB(binding),**從根拔掉整個問題**:`x-arcrun-install-token` 這條路已經不存在了,
|
||||
// 因為建帳號這件事本來就不再需要任何 CF API 憑證,無論裝機當下還是裝完之後都一樣。
|
||||
//
|
||||
// 下面這則是回歸守衛:**一台連 `CF_SECRETS_API_TOKEN`/`CF_ACCOUNT_ID` 都沒有的全新實例**,
|
||||
// 從 bootstrap 建第一個帳號、到之後用 /portal/admin/users 加第二個帳號,全程都要成功——
|
||||
// 若哪天有人把帳號寫入路徑又改回去揹 CF Secrets 依賴,這裡會紅。
|
||||
describe('arcrun-rag#99:全新實例(連 CF_SECRETS_API_TOKEN/CF_ACCOUNT_ID 都沒有)不再卡死', () => {
|
||||
it('bootstrap 建第一個 admin、再用 admin session 加第二個 user,全程零 CF token', async () => {
|
||||
await env.SESSIONS_KV.put('console_sess:owner-token-fresh', JSON.stringify({ created_at: Date.now() }));
|
||||
mockTemplatesExist();
|
||||
mockListByTemplate('portal_user', []); // 尚無 admin
|
||||
mockHeadLookup('fresh-install-admin@example.com', null);
|
||||
const { recordBody: bootstrapBody } = mockCreateUser('rec_fresh_admin');
|
||||
|
||||
const fakeEnv = { ...env, CF_SECRETS_API_TOKEN: undefined, CF_ACCOUNT_ID: undefined } as unknown as Bindings;
|
||||
const bootstrapRes = await portalRouter.fetch(
|
||||
new Request('http://localhost/portal/admin/bootstrap', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer owner-token-fresh' },
|
||||
body: JSON.stringify({ email: 'fresh-install-admin@example.com', password: 'bootstrap-pw-3' }),
|
||||
}),
|
||||
fakeEnv,
|
||||
{} as ExecutionContext,
|
||||
);
|
||||
expect(bootstrapRes.status).toBe(200);
|
||||
const bootstrapData = (await bootstrapRes.json()) as Record<string, unknown>;
|
||||
expect(bootstrapData.success).toBe(true);
|
||||
expect(bootstrapData.record_id).toBe('rec_fresh_admin');
|
||||
expect(JSON.parse(bootstrapBody()).values.password_hash.startsWith('pbkdf2-sha256$')).toBe(true);
|
||||
expect(bootstrapBody()).not.toContain('bootstrap-pw-3'); // 明碼絕不落地
|
||||
|
||||
// 安裝精靈離場之後:管理員事後手動加第二個人(POST /portal/admin/users),
|
||||
// 這條路本來就沒帶過任何臨時 token,過去在 D61 底下永遠 502——現在也要成功。
|
||||
await env.SESSIONS_KV.put('portal_sess:tok-fresh-admin', JSON.stringify({ record_id: 'rec_fresh_admin' }));
|
||||
mockGetRecord('rec_fresh_admin', activeUserValues({ email: 'fresh-install-admin@example.com', role: 'admin' }));
|
||||
mockHeadLookup('second-user@example.com', null);
|
||||
const { recordBody: secondBody } = mockCreateUser('rec_fresh_second');
|
||||
mockGetRecord('rec_fresh_second', activeUserValues({ email: 'second-user@example.com' }));
|
||||
|
||||
const addUserRes = await portalRouter.fetch(
|
||||
new Request('http://localhost/portal/admin/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer tok-fresh-admin' },
|
||||
body: JSON.stringify({ email: 'second-user@example.com', password: 'second-user-pw-1' }),
|
||||
}),
|
||||
fakeEnv,
|
||||
{} as ExecutionContext,
|
||||
);
|
||||
expect(addUserRes.status).toBe(200);
|
||||
const addUserData = (await addUserRes.json()) as { success: boolean };
|
||||
expect(addUserData.success).toBe(true);
|
||||
expect(JSON.parse(secondBody()).owner_id).toBe(NS);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# 00-INDEX(arcrun/docs)
|
||||
|
||||
> 機器整理的 wiki。**原稿在上一層**,這裡不放原文。有爭議時以原稿為準。
|
||||
|
||||
## 子節點
|
||||
|
||||
- **user_requirements** → `../user_requirements/.wiki/00-INDEX.md`
|
||||
主題:u6u 四層架構、credential 三層模型|概念:Polaris 意圖層、零件宇宙、AuthBroker…
|
||||
`#規格 #架構 #credential` 4 篇(含 1 篇指針) 更 2026-08-15
|
||||
|
||||
## 文件(本層每一篇一張)
|
||||
|
||||
- [[HANDOFF-config-scope-and-vectorize]] — 一份交辦文件,指出 arcrun 設定分層的兩條缺口,萃出兩個概念。
|
||||
`#文件 #設定` 建 2026-06-15 更 2026-08-15
|
||||
- [[HANDOFF-matrix-rearrange]] — Matrix 重整交給 arcrun 的待辦清單,用指針式考古不複製素材。
|
||||
`#文件 #遷移` 建 2026-06-13 更 2026-08-15
|
||||
- [[HANDOFF-mira-repair]] — 一份修復交辦,把調查做完了:哪些不用改、哪些要改、哪些要降級。
|
||||
`#文件 #事故` 建 2026-06-03 更 2026-08-15
|
||||
- [[HANDOFF-self-host-harness]] — 一份把「今天要做的三件事」連同已查證實況整理好的交辦,核心是戰法轉向。
|
||||
`#文件 #策略` 建 2026-06-01 更 2026-08-15
|
||||
- [[component-pr-review-standard]] — 零件與 binding 的 PR 審核規範,五段 checklist,任一否就打回。
|
||||
`#文件 #流程` 建 2026-06-03 更 2026-08-15
|
||||
|
||||
## 主題(跨文件聚類產生)
|
||||
|
||||
- [[結構性閘]] — 把錯誤路徑做成「根本碰不到」,而不是叮嚀人不要走。
|
||||
`#方法論 #流程 #架構` 建 2026-06-03 更 2026-08-15
|
||||
|
||||
## 本層概念(9)
|
||||
|
||||
- [[AI 讀不到的隱形分層]] — arcrun 是給 AI 操作的工具,而 harness 說明 41 行對 scope 零字提及。
|
||||
`#設定 #AI 協作` 建 2026-06-15 更 2026-08-15
|
||||
- [[Mira workflow 斷鏈]] — Mira 六個 workflow 全斷,但真因不是 arcrun 壞了,是 Mira 當初把東西錯做成假零件。
|
||||
`#事故 #零件` 建 2026-06-03 更 2026-08-15
|
||||
- [[cypher 雙份分岔]] — matrix 與 arcrun 各有一份 cypher-executor 且已分岔——整合後只留 arcrun 一份。
|
||||
`#架構 #遷移` 建 2026-06-13 更 2026-08-15
|
||||
- [[scope 誤部風險]] — 部署到哪個 CF 帳號,取決於你當下站在哪個資料夾——而這件事沒有任何地方寫著。
|
||||
`#設定 #風險` 建 2026-06-15 更 2026-08-15
|
||||
- [[self-hosted 戰法轉向]] — 2026-06-01 從 SaaS 改成 self-hosted 開源——它改的不只是商業模式,是「成功」的定義。
|
||||
`#策略 #部署` 建 2026-06-01 更 2026-08-15
|
||||
- [[假零件降級]] — 打固定 endpoint 的東西不是零件是 recipe——33 顆降到 22 顆,代價是所有引用它的人斷鏈。
|
||||
`#零件 #架構` 建 2026-06-03 更 2026-08-15
|
||||
- [[成功的定義]] — 舊:在我的帳號上能跑。新:任何人在自己的帳號上跑得通,而且寫錯會被擋住。
|
||||
`#策略 #驗收` 建 2026-06-01 更 2026-08-15
|
||||
- [[規則記不住]] — 規定說幾次都沒用——這不是紀律問題,是機制缺席。
|
||||
`#流程 #AI 協作` 建 2026-06-03 更 2026-08-15
|
||||
- [[零件 PR 審核閘]] — 靠「AI 記住規則」防架構錯誤不 scale,所以把錯誤擋在 PR 這道結構性閘上。
|
||||
`#流程 #架構 #審核` 建 2026-06-03 更 2026-08-15
|
||||
|
||||
## 說明
|
||||
|
||||
> 上面四節是**機器讀的**,只准放連結/路徑;說明寫在這一節,**不要用連結語法**。
|
||||
> 每一行的摘要/標籤/日期都是從卡的 frontmatter 複製過來的 ⇒ index 可機械產生。
|
||||
>
|
||||
> 🔴 **`user_requirements/arcrun-landing-page/` 不在任何清單上,這是對的**——
|
||||
> 它只有 `.jsx`/`.html`/`.css`,**沒有可掃文件 ⇒ 依規範它不是節點**,不建 `.wiki`。
|
||||
>
|
||||
> 本層只長出一個主題(結構性閘),因為另外幾張卡各自獨立、還湊不出第二個主題。
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
tags: [設定, AI 協作]
|
||||
gloss: arcrun 是給 AI 操作的工具,而 harness 說明 41 行對 scope 零字提及。
|
||||
created: 2026-06-15
|
||||
updated: 2026-08-15
|
||||
---
|
||||
# AI 讀不到的隱形分層
|
||||
|
||||
← [[HANDOFF-config-scope-and-vectorize]]
|
||||
|
||||
## 摘要
|
||||
|
||||
真實事故:mira 被交代遷移時沒看懂 scope 存在,差點誤部官方帳號。病根不在它笨,在它讀的東西裡沒有這一層。
|
||||
|
||||
## 重點
|
||||
|
||||
- **arcrun 的操作者是 AI 不是人**(mindset §2),所以「AI 讀得到什麼」就是「系統有什麼」
|
||||
- 而 harness block 全文 41 行,**對 scope/帳號歸屬/誤部風險 0 字**
|
||||
- ⇒ mira 把「搬資料」跟「用哪個帳號」混為一談——**那是兩件獨立的事**
|
||||
- 這是 [[結構性閘]] 的另一面:規則沒被寫在會被讀到的地方,等於不存在
|
||||
|
||||
## 實體
|
||||
|
||||
- **harness block**(介面)— AI 讀 arcrun 時看到的說明,`cli/harness/CLAUDE.block.md`。
|
||||
- **遷移 ≠ scope**(判準)— 搬資料與用哪個帳號是兩件獨立的事。
|
||||
|
||||
## 關聯
|
||||
|
||||
### 內文知識關係
|
||||
|
||||
- harness block >> 沒有提及 >> scope
|
||||
- AI >> 只知道 >> harness block 寫的東西
|
||||
|
||||
### 卡片關係
|
||||
|
||||
- AI 讀不到的隱形分層 >> 屬於 >> [[HANDOFF-config-scope-and-vectorize]]
|
||||
- AI 讀不到的隱形分層 >> 屬於 >> [[結構性閘]]
|
||||
- AI 讀不到的隱形分層 >> 被誤部風險放大 >> [[scope 誤部風險]]
|
||||
|
||||
### 出處(原文 >> 提及 >> 本卡,可多筆)
|
||||
|
||||
- `../HANDOFF-config-scope-and-vectorize.md` >> 提及 >> AI 讀不到的隱形分層
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
tags: [文件, 設定]
|
||||
gloss: 一份交辦文件,指出 arcrun 設定分層的兩條缺口,萃出兩個概念。
|
||||
created: 2026-06-15
|
||||
updated: 2026-08-15
|
||||
---
|
||||
# HANDOFF-config-scope-and-vectorize
|
||||
|
||||
← [[00-INDEX]]
|
||||
|
||||
## 摘要
|
||||
|
||||
2026-06-15 頂層總管交辦。由 mira self-hosted dogfood 踩出來的兩條框架缺口,兩條都走 SDD 協議。
|
||||
|
||||
## 重點
|
||||
|
||||
- **缺口 1** 是功能面:讀分層完整但只能寫全域 ⇒ [[scope 誤部風險]]
|
||||
- **缺口 2 被作者標為最重要**:AI 讀的說明裡完全沒有這一層 ⇒ [[AI 讀不到的隱形分層]]
|
||||
- 文件還帶了第三塊(KBDB Vectorize 開關),但那部分在本次萃取中沒有獨立成概念
|
||||
|
||||
## 實體
|
||||
|
||||
- **HANDOFF-config-scope-and-vectorize**(原稿)— 本卡對應的那一份交辦文件。
|
||||
|
||||
## 關聯
|
||||
|
||||
### 卡片關係
|
||||
|
||||
- HANDOFF-config-scope-and-vectorize >> 涵蓋 >> [[AI 讀不到的隱形分層]]
|
||||
- HANDOFF-config-scope-and-vectorize >> 涵蓋 >> [[scope 誤部風險]]
|
||||
|
||||
### 出處(原文 >> 提及 >> 本卡,可多筆)
|
||||
|
||||
- `../HANDOFF-config-scope-and-vectorize.md` >> 提及 >> HANDOFF-config-scope-and-vectorize
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
tags: [文件, 遷移]
|
||||
gloss: Matrix 重整交給 arcrun 的待辦清單,用指針式考古不複製素材。
|
||||
created: 2026-06-13
|
||||
updated: 2026-08-15
|
||||
---
|
||||
# HANDOFF-matrix-rearrange
|
||||
|
||||
← [[00-INDEX]]
|
||||
|
||||
## 摘要
|
||||
|
||||
頂層重整的產物。第一條就是關掉 matrix 版 cypher-executor。
|
||||
|
||||
## 重點
|
||||
|
||||
- **主要問題**:[[cypher 雙份分岔]],含逐檔的勘查結果
|
||||
- **指針式考古**:素材真身留在 `_archive/`,這份只給路徑——**不複製進來,避免第二份真相**
|
||||
|
||||
## 實體
|
||||
|
||||
- **HANDOFF-matrix-rearrange**(原稿)— 本卡對應的那一份交辦文件。
|
||||
|
||||
## 關聯
|
||||
|
||||
### 卡片關係
|
||||
|
||||
- HANDOFF-matrix-rearrange >> 涵蓋 >> [[cypher 雙份分岔]]
|
||||
|
||||
### 出處(原文 >> 提及 >> 本卡,可多筆)
|
||||
|
||||
- `../HANDOFF-matrix-rearrange.md` >> 提及 >> HANDOFF-matrix-rearrange
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
tags: [文件, 事故]
|
||||
gloss: 一份修復交辦,把調查做完了:哪些不用改、哪些要改、哪些要降級。
|
||||
created: 2026-06-03
|
||||
updated: 2026-08-15
|
||||
---
|
||||
# HANDOFF-mira-repair
|
||||
|
||||
← [[00-INDEX]]
|
||||
|
||||
## 摘要
|
||||
|
||||
arcrun 端的 CC 寫給 mira 端的 CC。特色是「調查已做完,照著改即可,不必重跑」。
|
||||
|
||||
## 重點
|
||||
|
||||
- **事故**:[[Mira workflow 斷鏈]]——六個 workflow 全掃過
|
||||
- **真因**:[[假零件降級]],而 mira 當初錯做了假零件
|
||||
- **這份文件本身示範了一件事**:交辦時把調查結果一起交出去,接手的人才不會重跑一遍
|
||||
|
||||
## 實體
|
||||
|
||||
- **HANDOFF-mira-repair**(原稿)— 本卡對應的那一份交辦文件。
|
||||
|
||||
## 關聯
|
||||
|
||||
### 卡片關係
|
||||
|
||||
- HANDOFF-mira-repair >> 涵蓋 >> [[Mira workflow 斷鏈]]
|
||||
- HANDOFF-mira-repair >> 涵蓋 >> [[假零件降級]]
|
||||
|
||||
### 出處(原文 >> 提及 >> 本卡,可多筆)
|
||||
|
||||
- `../HANDOFF-mira-repair.md` >> 提及 >> HANDOFF-mira-repair
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
tags: [文件, 策略]
|
||||
gloss: 一份把「今天要做的三件事」連同已查證實況整理好的交辦,核心是戰法轉向。
|
||||
created: 2026-06-01
|
||||
updated: 2026-08-15
|
||||
---
|
||||
# HANDOFF-self-host-harness
|
||||
|
||||
← [[00-INDEX]]
|
||||
|
||||
## 摘要
|
||||
|
||||
2026-06-01 撰寫。它最重要的一段不是任務,是背景:戰法已經變了。
|
||||
|
||||
## 重點
|
||||
|
||||
- **背景**:[[self-hosted 戰法轉向]]
|
||||
- **後果**:[[成功的定義]] 被整個改寫
|
||||
- **它的體例值得學**:先講「戰法已轉變(最重要的背景)」,再講今天做什麼
|
||||
|
||||
## 實體
|
||||
|
||||
- **HANDOFF-self-host-harness**(原稿)— 本卡對應的那一份交辦文件。
|
||||
|
||||
## 關聯
|
||||
|
||||
### 卡片關係
|
||||
|
||||
- HANDOFF-self-host-harness >> 涵蓋 >> [[self-hosted 戰法轉向]]
|
||||
- HANDOFF-self-host-harness >> 涵蓋 >> [[成功的定義]]
|
||||
|
||||
### 出處(原文 >> 提及 >> 本卡,可多筆)
|
||||
|
||||
- `../HANDOFF-self-host-harness.md` >> 提及 >> HANDOFF-self-host-harness
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
tags: [事故, 零件]
|
||||
gloss: Mira 六個 workflow 全斷,但真因不是 arcrun 壞了,是 Mira 當初把東西錯做成假零件。
|
||||
created: 2026-06-03
|
||||
updated: 2026-08-15
|
||||
---
|
||||
# Mira workflow 斷鏈
|
||||
|
||||
← [[HANDOFF-mira-repair]]
|
||||
|
||||
## 摘要
|
||||
|
||||
整修後 mira 的 workflow 斷了。調查結論:prod 活著、降級的 recipe 都在 KV,大部分只需小改不必重寫。
|
||||
|
||||
## 重點
|
||||
|
||||
- **不是 arcrun 壞了**——`cypher.arcrun.dev` 活著,降級後的 recipe 都在 prod KV(已驗)
|
||||
- **是 Mira 當初自己把一堆東西錯做成假零件** ⇒ [[假零件降級]] 一動它就斷
|
||||
- **教訓**:引用「不該存在的東西」的人,會在那個東西被修正時付出代價
|
||||
|
||||
## 實體
|
||||
|
||||
- **六個 workflow**(受害範圍)— mira 全部的 workflow 都掃過了。
|
||||
- **降級後的 recipe**(現況)— kbdb_get/telegram_send/gmail_send 等都在 prod KV。
|
||||
|
||||
## 關聯
|
||||
|
||||
### 內文知識關係
|
||||
|
||||
- Mira >> 錯做成 >> 假零件
|
||||
- 假零件降級 >> 導致 >> Mira workflow 斷鏈
|
||||
|
||||
### 卡片關係
|
||||
|
||||
- Mira workflow 斷鏈 >> 屬於 >> [[HANDOFF-mira-repair]]
|
||||
- Mira workflow 斷鏈 >> 肇因於 >> [[假零件降級]]
|
||||
|
||||
### 出處(原文 >> 提及 >> 本卡,可多筆)
|
||||
|
||||
- `../HANDOFF-mira-repair.md` >> 提及 >> Mira workflow 斷鏈
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
tags: [文件, 流程]
|
||||
gloss: 零件與 binding 的 PR 審核規範,五段 checklist,任一否就打回。
|
||||
created: 2026-06-03
|
||||
updated: 2026-08-15
|
||||
---
|
||||
# component-pr-review-standard
|
||||
|
||||
← [[00-INDEX]]
|
||||
|
||||
## 摘要
|
||||
|
||||
它的開場白就是整份文件的論點:靠 AI 記住規則不 scale,所以把錯誤擋在結構上。
|
||||
|
||||
## 重點
|
||||
|
||||
- **論點**:[[規則記不住]]
|
||||
- **手法**:[[零件 PR 審核閘]],五段 A–E 逐條過
|
||||
- 每一段都附了**真實反例**(`km_wiki_card_parse` 被否、drainer 本地綠但撞 1042)——**反例比規則本身有用**
|
||||
|
||||
## 實體
|
||||
|
||||
- **component-pr-review-standard**(原稿)— 本卡對應的那一份交辦文件。
|
||||
|
||||
## 關聯
|
||||
|
||||
### 卡片關係
|
||||
|
||||
- component-pr-review-standard >> 涵蓋 >> [[規則記不住]]
|
||||
- component-pr-review-standard >> 涵蓋 >> [[零件 PR 審核閘]]
|
||||
|
||||
### 出處(原文 >> 提及 >> 本卡,可多筆)
|
||||
|
||||
- `../component-pr-review-standard.md` >> 提及 >> component-pr-review-standard
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
tags: [架構, 遷移]
|
||||
gloss: matrix 與 arcrun 各有一份 cypher-executor 且已分岔——整合後只留 arcrun 一份。
|
||||
created: 2026-06-13
|
||||
updated: 2026-08-15
|
||||
---
|
||||
# cypher 雙份分岔
|
||||
|
||||
← [[HANDOFF-matrix-rearrange]]
|
||||
|
||||
## 摘要
|
||||
|
||||
勘查結果:matrix 版獨有 5 檔(皆非 SaaS 遺留,是 self-hosted 核心)、兩邊都有但內容不同 10 檔要逐檔比對。
|
||||
|
||||
## 重點
|
||||
|
||||
- **matrix 獨有的 5 檔是要保留的**:版本選擇策略、缺件自動生成、雙模式路由、WASM 執行、proxy
|
||||
- **10 檔內容分岔**要逐檔比對合併,取較完整的一邊但保留 arcrun 較新的 auth 演進
|
||||
- **同一個東西有兩份且都在演化 ⇒ 必然分岔**,這是要儘早合併的理由
|
||||
|
||||
## 實體
|
||||
|
||||
- **diverged 副本**(狀態)— 同源但各自演化後內容不同的兩份。
|
||||
|
||||
## 關聯
|
||||
|
||||
### 內文知識關係
|
||||
|
||||
- 兩份副本 >> 各自演化 >> 內容分岔
|
||||
|
||||
### 卡片關係
|
||||
|
||||
- cypher 雙份分岔 >> 屬於 >> [[HANDOFF-matrix-rearrange]]
|
||||
|
||||
### 出處(原文 >> 提及 >> 本卡,可多筆)
|
||||
|
||||
- `../HANDOFF-matrix-rearrange.md` >> 提及 >> cypher 雙份分岔
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"schema": 3,
|
||||
"note": "卡→原文寫在卡的『### 出處』;原文→卡寫在這裡。多對多。",
|
||||
"docs": [
|
||||
{
|
||||
"doc_id": "d_000",
|
||||
"node": "(根)",
|
||||
"path": "HANDOFF-config-scope-and-vectorize.md",
|
||||
"提及": [
|
||||
"HANDOFF-config-scope-and-vectorize",
|
||||
"AI 讀不到的隱形分層",
|
||||
"結構性閘",
|
||||
"scope 誤部風險"
|
||||
]
|
||||
},
|
||||
{
|
||||
"doc_id": "d_001",
|
||||
"node": "(根)",
|
||||
"path": "HANDOFF-matrix-rearrange.md",
|
||||
"提及": [
|
||||
"HANDOFF-matrix-rearrange",
|
||||
"cypher 雙份分岔"
|
||||
]
|
||||
},
|
||||
{
|
||||
"doc_id": "d_002",
|
||||
"node": "(根)",
|
||||
"path": "HANDOFF-mira-repair.md",
|
||||
"提及": [
|
||||
"假零件降級",
|
||||
"Mira workflow 斷鏈",
|
||||
"HANDOFF-mira-repair"
|
||||
]
|
||||
},
|
||||
{
|
||||
"doc_id": "d_003",
|
||||
"node": "(根)",
|
||||
"path": "HANDOFF-self-host-harness.md",
|
||||
"提及": [
|
||||
"成功的定義",
|
||||
"self-hosted 戰法轉向",
|
||||
"HANDOFF-self-host-harness"
|
||||
]
|
||||
},
|
||||
{
|
||||
"doc_id": "d_004",
|
||||
"node": "(根)",
|
||||
"path": "component-pr-review-standard.md",
|
||||
"提及": [
|
||||
"零件 PR 審核閘",
|
||||
"規則記不住",
|
||||
"結構性閘",
|
||||
"component-pr-review-standard"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
tags: [設定, 風險]
|
||||
gloss: 部署到哪個 CF 帳號,取決於你當下站在哪個資料夾——而這件事沒有任何地方寫著。
|
||||
created: 2026-06-15
|
||||
updated: 2026-08-15
|
||||
---
|
||||
# scope 誤部風險
|
||||
|
||||
← [[HANDOFF-config-scope-and-vectorize]]
|
||||
|
||||
## 摘要
|
||||
|
||||
arcrun 的設定讀取分三層(env > 專案 > 全域),但寫入只能寫全域,而「我現在會部到哪」沒有任何提示。
|
||||
|
||||
## 重點
|
||||
|
||||
- **讀分層完整、寫只能全域**:`saveConfig()` 寫死 `~/.arcrun/config.yaml`,`acr init` 四處呼叫全寫全域
|
||||
- ⇒ 想用專案層的人被迫污染全域,**怕誤打官方帳號**
|
||||
- **最陰險的是 fallback**:任一層都沒 `mode` 就靜默當 `local`,不報錯——你會看到「成功」但雲端沒有 trace
|
||||
- 真正的病灶不是缺功能,是 [[AI 讀不到的隱形分層]]
|
||||
|
||||
## 實體
|
||||
|
||||
- **scope 分層**(機制)— env > 專案 `.arcrun.yaml` > 全域 `~/.arcrun/config.yaml`。
|
||||
- **靜默 fallback**(陷阱)— 沒設 mode 就當 local,不報錯。
|
||||
|
||||
## 關聯
|
||||
|
||||
### 內文知識關係
|
||||
|
||||
- scope 分層 >> 決定 >> 部署到哪個帳號
|
||||
- 靜默 fallback >> 造成 >> 看到成功但沒有雲端 trace
|
||||
|
||||
### 卡片關係
|
||||
|
||||
- scope 誤部風險 >> 屬於 >> [[HANDOFF-config-scope-and-vectorize]]
|
||||
- scope 誤部風險 >> 根因是 >> [[AI 讀不到的隱形分層]]
|
||||
|
||||
### 出處(原文 >> 提及 >> 本卡,可多筆)
|
||||
|
||||
- `../HANDOFF-config-scope-and-vectorize.md` >> 提及 >> scope 誤部風險
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
tags: [策略, 部署]
|
||||
gloss: 2026-06-01 從 SaaS 改成 self-hosted 開源——它改的不只是商業模式,是「成功」的定義。
|
||||
created: 2026-06-01
|
||||
updated: 2026-08-15
|
||||
---
|
||||
# self-hosted 戰法轉向
|
||||
|
||||
← [[HANDOFF-self-host-harness]]
|
||||
|
||||
## 摘要
|
||||
|
||||
richblack 的決定。連帶把 self-hosted 一鍵起得來從「重要但非阻擋項」升為第一優先。
|
||||
|
||||
## 重點
|
||||
|
||||
- **改的是驗收標準**,見 [[成功的定義]]
|
||||
- ⇒ 優先序跟著翻轉:self-host 起得來變成當日第一優先
|
||||
- **戰法一變,原本排在後面的東西可能立刻變成阻擋項**——這是排序時要先問「戰法變了嗎」的理由
|
||||
|
||||
## 實體
|
||||
|
||||
- **self-hosted 開源策略**(戰法)— 使用者在自己的 CF 帳號上跑,而非平台託管。
|
||||
|
||||
## 關聯
|
||||
|
||||
### 內文知識關係
|
||||
|
||||
- 戰法轉向 >> 改變 >> 優先序
|
||||
- self-hosted >> 取代 >> SaaS
|
||||
|
||||
### 卡片關係
|
||||
|
||||
- self-hosted 戰法轉向 >> 屬於 >> [[HANDOFF-self-host-harness]]
|
||||
- self-hosted 戰法轉向 >> 改寫了 >> [[成功的定義]]
|
||||
|
||||
### 出處(原文 >> 提及 >> 本卡,可多筆)
|
||||
|
||||
- `../HANDOFF-self-host-harness.md` >> 提及 >> self-hosted 戰法轉向
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
tags: [零件, 架構]
|
||||
gloss: 打固定 endpoint 的東西不是零件是 recipe——33 顆降到 22 顆,代價是所有引用它的人斷鏈。
|
||||
created: 2026-06-03
|
||||
updated: 2026-08-15
|
||||
---
|
||||
# 假零件降級
|
||||
|
||||
← [[HANDOFF-mira-repair]]
|
||||
|
||||
## 摘要
|
||||
|
||||
arcrun 大整修:把「打固定 API 卻被做成零件」的假零件降級成 recipe 並刪掉零件目錄。判準是 DECISIONS §1:零件=endpoint 薄殼。
|
||||
|
||||
## 重點
|
||||
|
||||
- **判準**:零件是可複用的原語;**打固定 API 的是 recipe**
|
||||
- **33 → 22 顆**,被降級的解析方式變了 ⇒ 造成 [[Mira workflow 斷鏈]]
|
||||
- 這正是 [[零件 PR 審核閘]] 要在源頭擋掉的東西——**降級是補救,審核才是預防**
|
||||
|
||||
## 實體
|
||||
|
||||
- **假零件**(反模式)— 打固定 API 卻被做成零件的東西。
|
||||
- **recipe**(正解)— 具名封裝的單一 API 呼叫。
|
||||
|
||||
## 關聯
|
||||
|
||||
### 內文知識關係
|
||||
|
||||
- 假零件 >> 應降級為 >> recipe
|
||||
- 降級 >> 改變了 >> 零件名的解析方式
|
||||
|
||||
### 卡片關係
|
||||
|
||||
- 假零件降級 >> 屬於 >> [[HANDOFF-mira-repair]]
|
||||
- 假零件降級 >> 被擋於 >> [[零件 PR 審核閘]]
|
||||
- 假零件降級 >> 觸發了 >> [[Mira workflow 斷鏈]]
|
||||
|
||||
### 出處(原文 >> 提及 >> 本卡,可多筆)
|
||||
|
||||
- `../HANDOFF-mira-repair.md` >> 提及 >> 假零件降級
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
tags: [策略, 驗收]
|
||||
gloss: 舊:在我的帳號上能跑。新:任何人在自己的帳號上跑得通,而且寫錯會被擋住。
|
||||
created: 2026-06-01
|
||||
updated: 2026-08-15
|
||||
---
|
||||
# 成功的定義
|
||||
|
||||
← [[HANDOFF-self-host-harness]]
|
||||
|
||||
## 摘要
|
||||
|
||||
戰法轉向後,harness 的成功定義被整個改寫。新定義多了一個常被忽略的下半句。
|
||||
|
||||
## 重點
|
||||
|
||||
- **舊**:在 richblack 的 prod 帳號(`cypher.arcrun.dev`)上能跑
|
||||
- **新**:任何 CC 在自己的 CF 帳號 `acr init --self-hosted` 後跑通一個含 recipe 的 workflow
|
||||
- 🔴 **而且「寫錯時會被程式擋住」是定義的一部分**——不是加分項
|
||||
- ⇒ 這句話直接推導出 [[零件 PR 審核閘]] 這類 [[結構性閘]] 的必要性
|
||||
|
||||
## 實體
|
||||
|
||||
- **新成功定義**(驗收)— 任何人在自己帳號跑通,且寫錯會被擋。
|
||||
|
||||
## 關聯
|
||||
|
||||
### 內文知識關係
|
||||
|
||||
- 新定義 >> 包含 >> 寫錯會被擋住
|
||||
- 舊定義 >> 只涵蓋 >> 作者自己的帳號
|
||||
|
||||
### 卡片關係
|
||||
|
||||
- 成功的定義 >> 屬於 >> [[HANDOFF-self-host-harness]]
|
||||
- 成功的定義 >> 被改寫於 >> [[self-hosted 戰法轉向]]
|
||||
|
||||
### 出處(原文 >> 提及 >> 本卡,可多筆)
|
||||
|
||||
- `../HANDOFF-self-host-harness.md` >> 提及 >> 成功的定義
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
tags: [方法論, 流程, 架構]
|
||||
gloss: 把錯誤路徑做成「根本碰不到」,而不是叮嚀人不要走。
|
||||
created: 2026-06-03
|
||||
updated: 2026-08-15
|
||||
---
|
||||
# 結構性閘
|
||||
|
||||
← [[00-INDEX]]
|
||||
|
||||
## 摘要
|
||||
|
||||
這個主題把三張卡收在一起,因為它們是同一個手法的三個場合:不靠自覺,靠結構。
|
||||
|
||||
## 重點
|
||||
|
||||
- 把 [[零件 PR 審核閘]] 收進來,是因為它明講了理由——**零件本來就要走 PR,所以把檢查放在那裡,錯誤路徑就碰不到**
|
||||
- 而 [[規則記不住]] 是這個手法的前提:**規定說幾次都沒用,這不是紀律問題是機制缺席**
|
||||
- [[AI 讀不到的隱形分層]] 則是同一件事的反面——**規則沒被放在會被讀到的地方,等於不存在**
|
||||
- ⇒ 三張卡合起來給出一條可複用的判準:**看到「說過很多次還是會犯」,就去找那個缺席的閘**
|
||||
|
||||
## 實體
|
||||
|
||||
- **結構性閘**(手法)— 讓錯誤路徑在結構上碰不到,而非靠遵守。
|
||||
|
||||
## 關聯
|
||||
|
||||
### 卡片關係
|
||||
|
||||
- 結構性閘 >> 收攏 >> [[AI 讀不到的隱形分層]]
|
||||
- 結構性閘 >> 收攏 >> [[規則記不住]]
|
||||
- 結構性閘 >> 收攏 >> [[零件 PR 審核閘]]
|
||||
|
||||
### 出處(原文 >> 提及 >> 本卡,可多筆)
|
||||
|
||||
- `../component-pr-review-standard.md` >> 提及 >> 結構性閘
|
||||
- `../HANDOFF-config-scope-and-vectorize.md` >> 提及 >> 結構性閘
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
tags: [流程, AI 協作]
|
||||
gloss: 規定說幾次都沒用——這不是紀律問題,是機制缺席。
|
||||
created: 2026-06-03
|
||||
updated: 2026-08-15
|
||||
---
|
||||
# 規則記不住
|
||||
|
||||
← [[component-pr-review-standard]]
|
||||
|
||||
## 摘要
|
||||
|
||||
審核規範開宗明義的一句:「靠 AI 記住規則防架構錯誤不 scale」。它是整份文件存在的理由。
|
||||
|
||||
## 重點
|
||||
|
||||
- **規定寫得再清楚,沒有機制驗證就等於沒有**
|
||||
- ⇒ 對策不是寫更多規定,是**把錯誤路徑做成碰不到**
|
||||
- 這條可以推廣到任何「說過很多次還是會犯」的問題——**去找那個缺席的閘**
|
||||
|
||||
## 實體
|
||||
|
||||
- **不 scale**(性質)— 規則數量增加時,靠記憶遵守的成功率下降。
|
||||
|
||||
## 關聯
|
||||
|
||||
### 內文知識關係
|
||||
|
||||
- 靠記憶遵守規則 >> 不 scale
|
||||
- 缺席的機制 >> 才是 >> 真正的病灶
|
||||
|
||||
### 卡片關係
|
||||
|
||||
- 規則記不住 >> 屬於 >> [[component-pr-review-standard]]
|
||||
- 規則記不住 >> 屬於 >> [[結構性閘]]
|
||||
- 規則記不住 >> 治的是 >> [[零件 PR 審核閘]]
|
||||
|
||||
### 出處(原文 >> 提及 >> 本卡,可多筆)
|
||||
|
||||
- `../component-pr-review-standard.md` >> 提及 >> 規則記不住
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
tags: [流程, 架構, 審核]
|
||||
gloss: 靠「AI 記住規則」防架構錯誤不 scale,所以把錯誤擋在 PR 這道結構性閘上。
|
||||
created: 2026-06-03
|
||||
updated: 2026-08-15
|
||||
---
|
||||
# 零件 PR 審核閘
|
||||
|
||||
← [[component-pr-review-standard]]
|
||||
|
||||
## 摘要
|
||||
|
||||
零件與 service binding 本來就要走 PR,所以把檢查放在那裡,讓錯誤路徑「根本碰不到」。五段 checklist,任一否就打回。
|
||||
|
||||
## 重點
|
||||
|
||||
- **A 反過度工程**:判準是「三個月後會有第二個 workflow 用它嗎」,不會就不是零件
|
||||
- **B binding 層級**(最常踩):跨-worker 編排一律走 cypher binding,service binding 只准組 wasm
|
||||
- **D 驗證誠實**:要打**部署後的真端點**,本地 miniflare 會假綠——實例是 drainer 本地綠、production 撞 1042
|
||||
- **E 部署鐵律**:wrangler 直推,不用 `acr update`(會假綠蓋掉改動)
|
||||
- 它治的是 [[規則記不住]],手法屬於 [[結構性閘]]
|
||||
|
||||
## 實體
|
||||
|
||||
- **PR 審核閘**(機制)— 零件與 binding 變更的強制檢查點。
|
||||
- **假綠**(反模式)— 本地測試通過但生產環境會失敗。
|
||||
- **三個月判準**(判準)— 未來三個月會不會有第二個使用者。
|
||||
|
||||
## 關聯
|
||||
|
||||
### 內文知識關係
|
||||
|
||||
- PR 審核 >> 擋下 >> 架構錯誤
|
||||
- 本地 miniflare >> 產生 >> 假綠
|
||||
- 三個月判準 >> 決定 >> 是不是零件
|
||||
|
||||
### 卡片關係
|
||||
|
||||
- 零件 PR 審核閘 >> 實作 >> [[規則記不住]]
|
||||
- 零件 PR 審核閘 >> 屬於 >> [[component-pr-review-standard]]
|
||||
- 零件 PR 審核閘 >> 屬於 >> [[結構性閘]]
|
||||
- 零件 PR 審核閘 >> 擋的正是 >> [[假零件降級]]
|
||||
|
||||
### 出處(原文 >> 提及 >> 本卡,可多筆)
|
||||
|
||||
- `../component-pr-review-standard.md` >> 提及 >> 零件 PR 審核閘
|
||||
@@ -0,0 +1,55 @@
|
||||
# 00-INDEX(user_requirements)
|
||||
|
||||
← `../../.wiki/00-INDEX.md`
|
||||
|
||||
## 子節點
|
||||
|
||||
- **ADR-lib-and-landingPage** → `../ADR-lib-and-landingPage/.wiki/00-INDEX.md` — 3 篇(未萃)
|
||||
- **simplify_mvp** → `../simplify_mvp/.wiki/00-INDEX.md` — 1 篇(未萃)
|
||||
- **u6u-long-term** → `../u6u-long-term/.wiki/00-INDEX.md` — 1 篇+子節點 SourceDocs 3 篇(未萃)
|
||||
|
||||
## 文件(本層每一篇一張)
|
||||
|
||||
- [[credential_parts]] — 29KB 的 credential 規格,本次只萃了 §0–§2,其餘尚未分段。
|
||||
`#文件 #credential` 建 2026-05-25 更 2026-08-15
|
||||
- [[u6u_design]] — 一份兩千字的設計筆記,密度很高,萃出五個概念。
|
||||
`#文件 #設計` 建 2026-05-20 更 2026-08-15
|
||||
- `../wishlist.md` — **空**:指針檔,全文都在說「已移至頂層」。真身在 `InkStoneCo/docs/1-vision/product-wishlist.md`
|
||||
|
||||
## 主題(跨文件聚類產生)
|
||||
|
||||
- [[讓生態自己長]] — u6u 對「零件從哪來、誰把關、爛的怎麼淘汰」的整套答案:三件事互相咬合。
|
||||
`#生態 #零件 #方法論` 建 2026-05-20 更 2026-08-15
|
||||
|
||||
## 本層概念(7)
|
||||
|
||||
- [[AI 的 marketplace]] — marketplace 給 AI 不給人:強制用完回評價,被評差的零件其他 AI 自動避開。
|
||||
`#生態 #零件` 建 2026-05-20 更 2026-08-15
|
||||
- [[n8n 的每服務一零件]] — n8n 為每個服務寫一個 credential 零件——這份文件開宗明義說那是錯的。
|
||||
`#credential #反模式` 建 2026-05-25 更 2026-08-15
|
||||
- [[四個 primitive 不是每服務一個零件]] — 做四個 TinyGo/WASM primitive,每個服務只要一份 YAML recipe + 用戶自己的 secret。
|
||||
`#credential #架構` 建 2026-05-25 更 2026-08-15
|
||||
- [[自動審核取代人工]] — call API 的只要 call 通就過,功能性的只要通過它自己設的 Gherkin 就過。
|
||||
`#流程 #零件` 建 2026-05-20 更 2026-08-15
|
||||
- [[視覺優先開發]] — 用戶拉一個按鈕,後面自然綁上 webhook——不用解釋 webhook 是什麼。
|
||||
`#產品 #介面` 建 2026-05-20 更 2026-08-15
|
||||
- [[解釋 webhook 的困難]] — 要跟一般用戶解釋什麼是 webhook 很難——這是低門檻產品真正的門檻。
|
||||
`#產品 #介面` 建 2026-05-20 更 2026-08-15
|
||||
- [[零件長尾誰來補]] — 沒有人會為了 delete table 去寫完整的 Google Sheets 零件——但缺的那個端點會被下一個需要的人補上。
|
||||
`#生態 #零件` 建 2026-05-20 更 2026-08-15
|
||||
|
||||
## 說明
|
||||
|
||||
> 上面四節是機器讀的;說明寫這裡,不要用連結語法。
|
||||
>
|
||||
> 🔴 **`arcrun-landing-page/` 沒有出現在子節點清單裡,這是對的**——
|
||||
> 它只有 `.jsx`/`.html`/`.css`,**沒有可掃文件 ⇒ 依規範不是節點**。
|
||||
>
|
||||
> 🔴 **三個子節點標「未萃」是誠實的殘缺**:本次真實測試只做了 `docs/` 與本層兩個節點。
|
||||
> 標出來,使用者才分得出「沒東西」和「還沒做」。
|
||||
>
|
||||
> 🔴 **`wishlist.md` 是「空」的第三種**:不是沒東西(待辦)、不是紀錄(報價單),是**指針**。
|
||||
> 依規範標 `no_concept` **不產卡**——我第一版給它產了卡,`wiki-lint` 當場報孤島卡抓到。
|
||||
>
|
||||
> 🔴 **`credential_parts.md` 是 29KB、12 個 H2**,超過 20KB 分段門檻。
|
||||
> 本次只萃了 §0–§2,其餘八段**如實標明未做**——一張卡蓋不住 29KB。
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
tags: [生態, 零件]
|
||||
gloss: marketplace 給 AI 不給人:強制用完回評價,被評差的零件其他 AI 自動避開。
|
||||
created: 2026-05-20
|
||||
updated: 2026-08-15
|
||||
---
|
||||
# AI 的 marketplace
|
||||
|
||||
← [[u6u_design]]
|
||||
|
||||
## 摘要
|
||||
|
||||
u6u 的零件市集設計。它跟一般 marketplace 的差別在使用者是 AI,所以回饋可以被強制。
|
||||
|
||||
## 重點
|
||||
|
||||
- **強制回評價**——AI 不像人會忘記或懶得評
|
||||
- **被評差幾次的零件,其他 AI 自動避開** ⇒ 淘汰不需要人介入
|
||||
- 它配的是 [[自動審核取代人工]],兩者合起來讓 [[零件長尾誰來補]] 有解
|
||||
|
||||
## 實體
|
||||
|
||||
- **AI marketplace**(機制)— 使用者是 AI 的零件市集,回饋可強制。
|
||||
- **自然淘汰**(效果)— 差評零件被自動避開。
|
||||
|
||||
## 關聯
|
||||
|
||||
### 內文知識關係
|
||||
|
||||
- AI >> 被強制 >> 回覆評價
|
||||
- 低評零件 >> 被 >> 其他 AI 避開
|
||||
|
||||
### 卡片關係
|
||||
|
||||
- AI 的 marketplace >> 屬於 >> [[u6u_design]]
|
||||
- AI 的 marketplace >> 屬於 >> [[讓生態自己長]]
|
||||
- AI 的 marketplace >> 機制是 >> [[自動審核取代人工]]
|
||||
- AI 的 marketplace >> 解的是 >> [[零件長尾誰來補]]
|
||||
|
||||
### 出處(原文 >> 提及 >> 本卡,可多筆)
|
||||
|
||||
- `../u6u_design.md` >> 提及 >> AI 的 marketplace
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
tags: [文件, credential]
|
||||
gloss: 29KB 的 credential 規格,本次只萃了 §0–§2,其餘尚未分段。
|
||||
created: 2026-05-25
|
||||
updated: 2026-08-15
|
||||
---
|
||||
# credential_parts
|
||||
|
||||
← [[00-INDEX]]
|
||||
|
||||
## 摘要
|
||||
|
||||
這份文件超過本規範的 20KB 分段門檻,共 12 個 H2 段落。本次示範「只萃前段、其餘誠實標明未做」。
|
||||
|
||||
## 重點
|
||||
|
||||
- 已萃:[[四個 primitive 不是每服務一個零件]]、[[n8n 的每服務一零件]](來自 §0 TL;DR 與 §1–2)
|
||||
- 🔴 **未萃**:§3 四個 primitive 詳細規格/§4 Recipe Schema/§5 繼承/§6 WASM 介面/§7 AuthBroker API/§8 Storage/§9 授權 UI/§10 TODO/§11 風險
|
||||
- **這正是「大檔要按 H2 分段」那個洞的實例**——一張卡蓋不住 29KB
|
||||
|
||||
## 實體
|
||||
|
||||
- **credential_parts**(原稿)— 本卡對應的那一份文件。
|
||||
|
||||
## 關聯
|
||||
|
||||
### 卡片關係
|
||||
|
||||
- credential_parts >> 涵蓋 >> [[n8n 的每服務一零件]]
|
||||
- credential_parts >> 涵蓋 >> [[四個 primitive 不是每服務一個零件]]
|
||||
|
||||
### 出處(原文 >> 提及 >> 本卡,可多筆)
|
||||
|
||||
- `../credential_parts.md` >> 提及 >> credential_parts
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
tags: [credential, 反模式]
|
||||
gloss: n8n 為每個服務寫一個 credential 零件——這份文件開宗明義說那是錯的。
|
||||
created: 2026-05-25
|
||||
updated: 2026-08-15
|
||||
---
|
||||
# n8n 的每服務一零件
|
||||
|
||||
← [[credential_parts]]
|
||||
|
||||
## 摘要
|
||||
|
||||
被點名的反模式。它的問題是零件數量隨服務數量線性成長,而認證方式其實只有幾種。
|
||||
|
||||
## 重點
|
||||
|
||||
- **零件數 ∝ 服務數**,但認證方式其實只有四種
|
||||
- ⇒ 正解是 [[四個 primitive 不是每服務一個零件]]
|
||||
|
||||
## 實體
|
||||
|
||||
- **每服務一零件**(反模式)— n8n 的 credential 做法。
|
||||
|
||||
## 關聯
|
||||
|
||||
### 內文知識關係
|
||||
|
||||
- 零件數 >> 隨服務數 >> 線性成長
|
||||
|
||||
### 卡片關係
|
||||
|
||||
- n8n 的每服務一零件 >> 否定 >> [[四個 primitive 不是每服務一個零件]]
|
||||
- n8n 的每服務一零件 >> 屬於 >> [[credential_parts]]
|
||||
|
||||
### 出處(原文 >> 提及 >> 本卡,可多筆)
|
||||
|
||||
- `../credential_parts.md` >> 提及 >> n8n 的每服務一零件
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
tags: [文件, 設計]
|
||||
gloss: 一份兩千字的設計筆記,密度很高,萃出五個概念。
|
||||
created: 2026-05-20
|
||||
updated: 2026-08-15
|
||||
---
|
||||
# u6u_design
|
||||
|
||||
← [[00-INDEX]]
|
||||
|
||||
## 摘要
|
||||
|
||||
開場一句「u6u 是一個 AI Friendly 的 n8n」,後面是條列的設計主張,最後一段是觀念想法。
|
||||
|
||||
## 重點
|
||||
|
||||
- **生態那三條**被收成 [[讓生態自己長]]
|
||||
- **介面策略**是 [[視覺優先開發]],它迴避 [[解釋 webhook 的困難]]
|
||||
- ⚠️ 文件最後一句「考慮讓他自己 OWN,企業版資料獨立」**沒有展開,本次未萃成卡**
|
||||
|
||||
## 實體
|
||||
|
||||
- **u6u_design**(原稿)— 本卡對應的那一份文件。
|
||||
|
||||
## 關聯
|
||||
|
||||
### 卡片關係
|
||||
|
||||
- u6u_design >> 涵蓋 >> [[AI 的 marketplace]]
|
||||
- u6u_design >> 涵蓋 >> [[自動審核取代人工]]
|
||||
- u6u_design >> 涵蓋 >> [[視覺優先開發]]
|
||||
- u6u_design >> 涵蓋 >> [[解釋 webhook 的困難]]
|
||||
- u6u_design >> 涵蓋 >> [[零件長尾誰來補]]
|
||||
|
||||
### 出處(原文 >> 提及 >> 本卡,可多筆)
|
||||
|
||||
- `../u6u_design.md` >> 提及 >> u6u_design
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
tags: [credential, 架構]
|
||||
gloss: 做四個 TinyGo/WASM primitive,每個服務只要一份 YAML recipe + 用戶自己的 secret。
|
||||
created: 2026-05-25
|
||||
updated: 2026-08-15
|
||||
---
|
||||
# 四個 primitive 不是每服務一個零件
|
||||
|
||||
← [[credential_parts]]
|
||||
|
||||
## 摘要
|
||||
|
||||
credential 設計的核心主張。它是三層模型:primitive(程式)/recipe(公共設定)/secret(私有)。
|
||||
|
||||
## 重點
|
||||
|
||||
- **四個 primitive** 涵蓋所有認證方式,不隨服務數量成長
|
||||
- **recipe 存平台 KV(公共)、secret 存 tenant KV(私有)**,runtime 由 AuthBroker 組裝
|
||||
- 它明確反對 [[n8n 的每服務一零件]]
|
||||
- ⚠️ 本卡只涵蓋這份 29KB 文件的 §0–§2;`§3 四個 primitive 詳細規格`、`§4 Recipe Schema` 等**尚未分段萃取**
|
||||
|
||||
## 實體
|
||||
|
||||
- **primitive**(零件)— TinyGo/WASM 寫的認證原語,共四個。
|
||||
- **recipe**(設定)— 每服務一份 YAML,存公共 KV。
|
||||
- **AuthBroker**(組裝者)— runtime 把 recipe 與 secret 組成可用的 HTTP client。
|
||||
|
||||
## 關聯
|
||||
|
||||
### 內文知識關係
|
||||
|
||||
- primitive >> 加上 >> recipe 與 secret
|
||||
- AuthBroker >> 組裝 >> 可用的 HTTP client
|
||||
- recipe >> 存於 >> 公共 KV
|
||||
- secret >> 存於 >> tenant KV
|
||||
|
||||
### 卡片關係
|
||||
|
||||
- 四個 primitive 不是每服務一個零件 >> 反的是 >> [[n8n 的每服務一零件]]
|
||||
- 四個 primitive 不是每服務一個零件 >> 屬於 >> [[credential_parts]]
|
||||
|
||||
### 出處(原文 >> 提及 >> 本卡,可多筆)
|
||||
|
||||
- `../credential_parts.md` >> 提及 >> 四個 primitive 不是每服務一個零件
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
tags: [流程, 零件]
|
||||
gloss: call API 的只要 call 通就過,功能性的只要通過它自己設的 Gherkin 就過。
|
||||
created: 2026-05-20
|
||||
updated: 2026-08-15
|
||||
---
|
||||
# 自動審核取代人工
|
||||
|
||||
← [[u6u_design]]
|
||||
|
||||
## 摘要
|
||||
|
||||
把「零件能不能上架」的判斷交給可執行的檢查,省掉人工審核。
|
||||
|
||||
## 重點
|
||||
|
||||
- **打 API 的**:成功 call 通就算過
|
||||
- **功能性的**:通過投稿者自己寫的 Gherkin 就算過
|
||||
- ⚠️ 這條跟後來的 `component-pr-review-standard` 有張力——那份明講 **Gherkin 全綠 ≠ 零件安全**(投稿者可寫避重就輕的 Gherkin)
|
||||
- ⇒ **兩份文件寫於不同時期,後者修正了前者**。真正框死破壞力的是純 WASI 沙箱,不是 Gherkin
|
||||
|
||||
## 實體
|
||||
|
||||
- **Gherkin 自審**(機制)— 投稿者自寫驗收條件,通過即上架。
|
||||
|
||||
## 關聯
|
||||
|
||||
### 內文知識關係
|
||||
|
||||
- call API 成功 >> 即通過 >> 審核
|
||||
- Gherkin 全綠 >> 不等於 >> 零件安全
|
||||
|
||||
### 卡片關係
|
||||
|
||||
- 自動審核取代人工 >> 屬於 >> [[u6u_design]]
|
||||
- 自動審核取代人工 >> 屬於 >> [[讓生態自己長]]
|
||||
- 自動審核取代人工 >> 支撐 >> [[AI 的 marketplace]]
|
||||
|
||||
### 出處(原文 >> 提及 >> 本卡,可多筆)
|
||||
|
||||
- `../u6u_design.md` >> 提及 >> 自動審核取代人工
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
tags: [產品, 介面]
|
||||
gloss: 用戶拉一個按鈕,後面自然綁上 webhook——不用解釋 webhook 是什麼。
|
||||
created: 2026-05-20
|
||||
updated: 2026-08-15
|
||||
---
|
||||
# 視覺優先開發
|
||||
|
||||
← [[u6u_design]]
|
||||
|
||||
## 摘要
|
||||
|
||||
u6u 的低門檻策略:讓抽象概念從具體操作裡浮現,而不是先教會使用者術語。
|
||||
|
||||
## 重點
|
||||
|
||||
- **先做前端按鈕、輸入框,後端邏輯跟著綁上去**
|
||||
- **點下去就看到工作流** ⇒ 概念從操作裡長出來
|
||||
- 它迴避的是 [[解釋 webhook 的困難]]
|
||||
- 延伸:cypher 放大就是 graph,**每個功能可以摺疊成一點,也可以 zoom in 展開調整** ⇒ 同一個碎型結構
|
||||
|
||||
## 實體
|
||||
|
||||
- **視覺優先**(策略)— 先給看得見的操作,再讓概念浮現。
|
||||
- **摺疊與展開**(互動)— 功能可收成一點也可展開調整。
|
||||
|
||||
## 關聯
|
||||
|
||||
### 內文知識關係
|
||||
|
||||
- 前端按鈕 >> 綁定 >> webhook
|
||||
- cypher 放大 >> 就是 >> graph
|
||||
|
||||
### 卡片關係
|
||||
|
||||
- 視覺優先開發 >> 屬於 >> [[u6u_design]]
|
||||
- 視覺優先開發 >> 迴避了 >> [[解釋 webhook 的困難]]
|
||||
|
||||
### 出處(原文 >> 提及 >> 本卡,可多筆)
|
||||
|
||||
- `../u6u_design.md` >> 提及 >> 視覺優先開發
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
tags: [產品, 介面]
|
||||
gloss: 要跟一般用戶解釋什麼是 webhook 很難——這是低門檻產品真正的門檻。
|
||||
created: 2026-05-20
|
||||
updated: 2026-08-15
|
||||
---
|
||||
# 解釋 webhook 的困難
|
||||
|
||||
← [[u6u_design]]
|
||||
|
||||
## 摘要
|
||||
|
||||
一句被順帶寫下、但其實是整個介面策略起點的觀察。
|
||||
|
||||
## 重點
|
||||
|
||||
- **門檻不在功能,在術語**
|
||||
- ⇒ 對策不是寫更好的說明文件,是 [[視覺優先開發]]——讓他不必知道那個詞
|
||||
|
||||
## 實體
|
||||
|
||||
- **術語門檻**(障礙)— 使用者卡在概念名稱而非功能本身。
|
||||
|
||||
## 關聯
|
||||
|
||||
### 內文知識關係
|
||||
|
||||
- 術語 >> 構成 >> 使用門檻
|
||||
|
||||
### 卡片關係
|
||||
|
||||
- 解釋 webhook 的困難 >> 屬於 >> [[u6u_design]]
|
||||
- 解釋 webhook 的困難 >> 被迴避於 >> [[視覺優先開發]]
|
||||
|
||||
### 出處(原文 >> 提及 >> 本卡,可多筆)
|
||||
|
||||
- `../u6u_design.md` >> 提及 >> 解釋 webhook 的困難
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
tags: [生態, 零件, 方法論]
|
||||
gloss: u6u 對「零件從哪來、誰把關、爛的怎麼淘汰」的整套答案:三件事互相咬合。
|
||||
created: 2026-05-20
|
||||
updated: 2026-08-15
|
||||
---
|
||||
# 讓生態自己長
|
||||
|
||||
← [[00-INDEX]]
|
||||
|
||||
## 摘要
|
||||
|
||||
這個主題把三張卡收在一起,因為它們是同一個迴圈的三段:怎麼補、怎麼把關、怎麼淘汰。
|
||||
|
||||
## 重點
|
||||
|
||||
- 起點是 [[零件長尾誰來補]]——**不預先做完整,缺的時候由需要的人補**
|
||||
- 把關交給 [[自動審核取代人工]],因為人工審核會變成瓶頸
|
||||
- 淘汰交給 [[AI 的 marketplace]],**而它能成立的唯一原因是使用者是 AI**(回饋可以被強制)
|
||||
- ⇒ **三段缺一段這個迴圈就停**:沒人補、沒人審、或爛的不會消失,生態都長不起來
|
||||
|
||||
## 實體
|
||||
|
||||
- **生態迴圈**(結構)— 補件、把關、淘汰三段互相咬合。
|
||||
|
||||
## 關聯
|
||||
|
||||
### 卡片關係
|
||||
|
||||
- 讓生態自己長 >> 收攏 >> [[AI 的 marketplace]]
|
||||
- 讓生態自己長 >> 收攏 >> [[自動審核取代人工]]
|
||||
- 讓生態自己長 >> 收攏 >> [[零件長尾誰來補]]
|
||||
|
||||
### 出處(原文 >> 提及 >> 本卡,可多筆)
|
||||
|
||||
- `../u6u_design.md` >> 提及 >> 讓生態自己長
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
tags: [生態, 零件]
|
||||
gloss: 沒有人會為了 delete table 去寫完整的 Google Sheets 零件——但缺的那個端點會被下一個需要的人補上。
|
||||
created: 2026-05-20
|
||||
updated: 2026-08-15
|
||||
---
|
||||
# 零件長尾誰來補
|
||||
|
||||
← [[u6u_design]]
|
||||
|
||||
## 摘要
|
||||
|
||||
u6u 對「零件覆蓋率」的答案:不追求完整,追求需要時補得上,而搜尋會把碎片整合起來。
|
||||
|
||||
## 重點
|
||||
|
||||
- **每個 API call 獨立,但搜尋會整合**——有人做了 create table,下一個人要 delete 時發現沒有就直接做一個
|
||||
- **下次搜 google sheets 就同時看到兩個端點** ⇒ 覆蓋率由需求長出來,不由規劃決定
|
||||
- 這正是 [[AI 的 marketplace]] 要服務的形狀
|
||||
|
||||
## 實體
|
||||
|
||||
- **長尾覆蓋**(問題)— 沒人會預先寫完整的 API 包裝。
|
||||
- **搜尋整合**(機制)— 碎片端點在搜尋時被聚合成一組。
|
||||
|
||||
## 關聯
|
||||
|
||||
### 內文知識關係
|
||||
|
||||
- 需求 >> 決定 >> 覆蓋率
|
||||
- 搜尋 >> 整合 >> 獨立的端點
|
||||
|
||||
### 卡片關係
|
||||
|
||||
- 零件長尾誰來補 >> 對策是 >> [[AI 的 marketplace]]
|
||||
- 零件長尾誰來補 >> 屬於 >> [[u6u_design]]
|
||||
- 零件長尾誰來補 >> 屬於 >> [[讓生態自己長]]
|
||||
|
||||
### 出處(原文 >> 提及 >> 本卡,可多筆)
|
||||
|
||||
- `../u6u_design.md` >> 提及 >> 零件長尾誰來補
|
||||
@@ -0,0 +1,138 @@
|
||||
-- 0007 — record 有身分,關係是唯一機制(樹狀 record 模型第一刀)
|
||||
-- 規格:system-dev/docs/3-specs/pending-changes.md「record 要有身分」v7 定稿(leo 2026-08-15 confirm)
|
||||
--
|
||||
-- 模型一句話(leo 定案):「真身在 pool 的 entry 裡,所有的虛擬表虛擬欄位都是指向這個 entry 的指標。」
|
||||
-- · record = 池中一顆有身分的 entry(沿用原 record_id 字串當 id,既有引用不失效)
|
||||
-- · 欄位是關係、歸屬也是關係——同一種機制:一列關係 = src/rel/dst 三個型別化指標欄
|
||||
-- (「池上型別化指標欄」,parent_id 是這族欄位的既有先例。紅線:不准用關係實作關係、
|
||||
-- 不准把指標塞回 content/JSON——那是 D91 的位置換個門進來)
|
||||
-- · 舊 entry_values 四欄的下場:record_id → record entry 自己的 id/entry_id → 指標終點(dst)
|
||||
-- /slot_name → 指標謂詞(rel = field entry)/template_id → 一條屬於關係(rel = sys_belongs)
|
||||
-- · 那張表該死的理由:它是「關係」的第二套實作——同一件事兩個實作必然漂移(D92)
|
||||
--
|
||||
-- 資料遷移形狀(v7 §3):既有 value entries 一列都不動(id 穩定、向量索引不失效)、
|
||||
-- 全部純 INSERT、零 UPDATE。以舊儲存格列 id 衍生關係列 id ⇒ INSERT OR IGNORE 天然冪等。
|
||||
--
|
||||
-- 冪等設計(三條套用路徑:官方 wrangler migrations/cli deploy.ts 每次重跑/安裝器逐句重放):
|
||||
-- · 除了三句 ADD COLUMN 之外,每一句都是語句級冪等(IF NOT EXISTS/INSERT OR IGNORE)
|
||||
-- · SQLite 沒有「ADD COLUMN IF NOT EXISTS」——重跑時那三句會報 duplicate column,
|
||||
-- 套用端必須把「duplicate column」視為已套用(cli deploy.ts 的 applyD1MigrationTolerant、
|
||||
-- 安裝器逐句 try/catch 本就容錯)。官方路徑走 wrangler migrations 追蹤表,只跑一次。
|
||||
-- · 不走「重建整張 entries」的做法:那雖然能純語句冪等,但 deploy.ts 每次部署都重跑
|
||||
-- migration ⇒ 每次部署全表複製一輪,直接吃掉 D1 每日列寫入額度。
|
||||
-- · entry_values 拆表用 0006 的「墊表、搬、拆」手法:重跑時先墊一份空殼,搬 0 筆,再拆,無害。
|
||||
--
|
||||
-- 施工窗口注意:本檔跑完 entry_values 就不存在了,必須與讀寫端改版(kbdb/src 同一批)一起部署。
|
||||
-- 舊 worker 碰新庫會炸 entry_values 不存在——這是刻意的(leo:「不做長期雙讀相容層,一次翻」)。
|
||||
|
||||
-- ============================================================
|
||||
-- 1. 關係的物理載體:池上型別化指標欄 + 一對方向索引
|
||||
-- ============================================================
|
||||
|
||||
ALTER TABLE entries ADD COLUMN src_id TEXT; -- kbdb-sql-ok: 牆內 migration 本體(v7 定案載體「池上型別化指標欄」),重跑由套用端容錯 duplicate column
|
||||
ALTER TABLE entries ADD COLUMN rel_id TEXT; -- kbdb-sql-ok: 同上
|
||||
ALTER TABLE entries ADD COLUMN dst_id TEXT; -- kbdb-sql-ok: 同上
|
||||
|
||||
-- 一對方向索引(v7 §2-1):src 端服務「r1 的所有欄位」,(dst,rel) 端服務
|
||||
-- 「某張 sheet 的所有 record」(rel=屬於, dst=sheet)與「誰指到 e1」(dst=e1)。
|
||||
-- partial index:內容 entry(指標欄全 NULL)不進索引,索引大小 ≈ 關係列數。
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_rel_src ON entries(src_id) WHERE src_id IS NOT NULL; -- kbdb-sql-ok: 牆內 migration 本體,普通欄位索引(0001 十條索引的同族)
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_rel_dst ON entries(dst_id, rel_id) WHERE dst_id IS NOT NULL; -- kbdb-sql-ok: 同上
|
||||
|
||||
-- ============================================================
|
||||
-- 2. 啟動常數(v7 §2-3:一組、極小、只讀)
|
||||
-- ============================================================
|
||||
|
||||
-- 保留根:屬於鏈的終點。某顆 entry ─屬於→ sys_root = 它是一張 sheet。
|
||||
INSERT OR IGNORE INTO entries (id, content, entry_type, owner_id) VALUES ('sys_root', 'root', 'system', NULL);
|
||||
-- 「屬於」謂詞:record ─屬於→ sheet(歸屬關係的 rel)。
|
||||
INSERT OR IGNORE INTO entries (id, content, entry_type, owner_id) VALUES ('sys_belongs', 'belongs', 'system', NULL);
|
||||
-- 「欄位屬於表」謂詞:field ─field_of→ sheet(sheet 的欄位名冊,與 record 歸屬分開,
|
||||
-- 免得「某張 sheet 的所有 record」把欄位也撈進來)。
|
||||
INSERT OR IGNORE INTO entries (id, content, entry_type, owner_id) VALUES ('sys_field_of', 'field_of', 'system', NULL);
|
||||
|
||||
-- ============================================================
|
||||
-- 3. 墊表(0006 手法):本檔尾端會拆掉 entry_values,重跑時先墊空殼
|
||||
-- 讓下面的搬遷語句永遠合法(搬 0 筆),最後再拆一次。
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS entry_values ( -- kbdb-sql-ok: 表退場施工步驟①保底存在(0006 同款),非資料存取違規
|
||||
id TEXT PRIMARY KEY,
|
||||
record_id TEXT NOT NULL,
|
||||
template_id TEXT NOT NULL,
|
||||
slot_name TEXT NOT NULL,
|
||||
entry_id TEXT NOT NULL,
|
||||
created_at INTEGER DEFAULT (unixepoch()),
|
||||
UNIQUE(record_id, slot_name)
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- 4. sheet/field 落池(templates 表遷移期雙軌:欄位定義的真相源暫仍在 templates 表,
|
||||
-- sheet/field entry 是它在池中的身分——第二刀把 templates 表整個退役)
|
||||
-- ============================================================
|
||||
|
||||
-- 每個 template 一顆 sheet entry(id 沿用 template id,既有引用不失效)
|
||||
INSERT OR IGNORE INTO entries (id, content, entry_type, owner_id, created_at, updated_at)
|
||||
SELECT t.id, t.name, 'sheet', NULL, t.created_at, t.updated_at FROM templates t;
|
||||
|
||||
-- sheet ─屬於→ 保留根(「什麼讓一顆 entry 成為 sheet」的答案:這條關係)
|
||||
INSERT OR IGNORE INTO entries (id, entry_type, src_id, rel_id, dst_id)
|
||||
SELECT 'relb_' || t.id, 'relation', t.id, 'sys_belongs', 'sys_root' FROM templates t;
|
||||
|
||||
-- 每個 slot 一顆 field entry(id 決定性衍生:fld_<template>_<slot>,欄名=關係的謂詞)
|
||||
INSERT OR IGNORE INTO entries (id, content, entry_type)
|
||||
SELECT 'fld_' || t.id || '_' || j.value, j.value, 'field'
|
||||
FROM templates t, json_each(t.slots_json) j;
|
||||
|
||||
-- field ─field_of→ sheet(欄位名冊)
|
||||
INSERT OR IGNORE INTO entries (id, entry_type, src_id, rel_id, dst_id)
|
||||
SELECT 'relf_' || t.id || '_' || j.value, 'relation', 'fld_' || t.id || '_' || j.value, 'sys_field_of', t.id
|
||||
FROM templates t, json_each(t.slots_json) j;
|
||||
|
||||
-- 保險網:entry_values 裡實際用過、但 slots_json 沒宣告的 slot(宣告與實作漂移的實證),
|
||||
-- 一樣補出 field entry 與名冊,資料一筆都不能掉。
|
||||
INSERT OR IGNORE INTO entries (id, content, entry_type)
|
||||
SELECT DISTINCT 'fld_' || ev.template_id || '_' || ev.slot_name, ev.slot_name, 'field' FROM entry_values ev;
|
||||
|
||||
INSERT OR IGNORE INTO entries (id, entry_type, src_id, rel_id, dst_id)
|
||||
SELECT DISTINCT 'relf_' || ev.template_id || '_' || ev.slot_name, 'relation',
|
||||
'fld_' || ev.template_id || '_' || ev.slot_name, 'sys_field_of', ev.template_id
|
||||
FROM entry_values ev;
|
||||
|
||||
-- ============================================================
|
||||
-- 5. record 有身分:每筆 record 一顆池中 entry(id = 原 record_id 字串)
|
||||
-- 歸屬 owner 沿用「第一個非 NULL 的 slot entry owner」——與舊讀端的推導逐字同義。
|
||||
-- INSERT OR IGNORE 的另一層意義:record_id 已經是池中既有 entry(library_map 的
|
||||
-- block 即 record 慣例)時,那顆 entry 本人就是身分,不另建。
|
||||
-- ============================================================
|
||||
|
||||
INSERT OR IGNORE INTO entries (id, entry_type, owner_id, created_at, updated_at)
|
||||
SELECT ev.record_id, 'record',
|
||||
(SELECT e2.owner_id FROM entry_values ev2 JOIN entries e2 ON ev2.entry_id = e2.id
|
||||
WHERE ev2.record_id = ev.record_id AND e2.owner_id IS NOT NULL LIMIT 1),
|
||||
MIN(ev.created_at), MIN(ev.created_at)
|
||||
FROM entry_values ev GROUP BY ev.record_id;
|
||||
|
||||
-- record ─屬於→ sheet(舊 template_id 欄的下場:變成一條關係)
|
||||
INSERT OR IGNORE INTO entries (id, entry_type, owner_id, src_id, rel_id, dst_id, created_at)
|
||||
SELECT 'relb_' || ev.record_id || '_' || ev.template_id, 'relation',
|
||||
(SELECT e2.owner_id FROM entry_values ev2 JOIN entries e2 ON ev2.entry_id = e2.id
|
||||
WHERE ev2.record_id = ev.record_id AND e2.owner_id IS NOT NULL LIMIT 1),
|
||||
ev.record_id, 'sys_belongs', ev.template_id, MIN(ev.created_at)
|
||||
FROM entry_values ev GROUP BY ev.record_id, ev.template_id;
|
||||
|
||||
-- ============================================================
|
||||
-- 6. 每個儲存格 → 一條關係列(src=record、rel=欄位謂詞、dst=原 value entry)
|
||||
-- 關係列 id 以舊儲存格列 id 衍生(relv_<ev.id>)⇒ 重跑天然冪等。
|
||||
-- ============================================================
|
||||
|
||||
INSERT OR IGNORE INTO entries (id, entry_type, owner_id, src_id, rel_id, dst_id, created_at)
|
||||
SELECT 'relv_' || ev.id, 'relation', e.owner_id,
|
||||
ev.record_id, 'fld_' || ev.template_id || '_' || ev.slot_name, ev.entry_id, ev.created_at
|
||||
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id;
|
||||
|
||||
-- ============================================================
|
||||
-- 7. 拆表(本提案的完成證明:關係的第二套實作不再有位置)
|
||||
-- ============================================================
|
||||
|
||||
DROP TABLE IF EXISTS entry_values; -- kbdb-sql-ok: 表退場施工(0006 同款),v7 定案「兩套機制合成一套」的落地
|
||||
@@ -73,10 +73,20 @@ export interface ListEntriesResult {
|
||||
// 當 count 回傳,容易被誤讀成「總共只有這幾筆」。total 才是真總數,count 仍保留=本頁筆數。
|
||||
}
|
||||
|
||||
// ── 機制列隔離(0007 樹狀 record 模型)───────────────────────────────
|
||||
// 關係列/裸身分/sheet/field/啟動常數是模型的機械零件,不是使用者的「一筆知識」。
|
||||
// caller 沒指定 entry_type 時預設排除,免得 owner-scoped 列表被 content=NULL 的關係列灌爆;
|
||||
// caller 明白指定 entry_type(含指定成機制型別)→ 尊重他要的,不攔。
|
||||
// 判「是不是關係列」認指標欄(src_id IS NOT NULL),不認 entry_type 標記——
|
||||
// entry_type 在 v7 §7 白名單終局會整欄消失,這條謂詞到時只剩前半。
|
||||
const NOT_MACHINERY_PREDICATE =
|
||||
"(src_id IS NULL AND entry_type NOT IN ('record', 'sheet', 'field', 'system'))";
|
||||
|
||||
export async function listEntries(db: D1Database, f: ListEntriesFilter = {}): Promise<ListEntriesResult> {
|
||||
const conds: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
if (f.entry_type) { conds.push('entry_type = ?'); params.push(f.entry_type); }
|
||||
else { conds.push(NOT_MACHINERY_PREDICATE); }
|
||||
if (f.owner_id) { conds.push('owner_id = ?'); params.push(f.owner_id); }
|
||||
if (f.parent_id) { conds.push('parent_id = ?'); params.push(f.parent_id); }
|
||||
if (f.page_name) { conds.push('page_name = ?'); params.push(f.page_name); }
|
||||
@@ -131,7 +141,14 @@ export async function updateEntry(db: D1Database, id: string, patch: UpdateEntry
|
||||
}
|
||||
|
||||
export async function deleteEntry(db: D1Database, id: string): Promise<void> {
|
||||
// 舊世界靠 FK(entry_values.entry_id REFERENCES entries)擋「刪掉還被 record 指著的
|
||||
// entry」;新模型(0007)關係列的 dst_id 沒有 FK → 這條不變量改由牆自己保,
|
||||
// 否則會產出指向不存在 id 的孤兒關係列(孤兒巡檢見 relation-orphans.ts)。
|
||||
const ref = await db.prepare('SELECT id FROM entries WHERE dst_id = ? LIMIT 1').bind(id).first<{ id: string }>();
|
||||
if (ref) throw new Error(`entry ${id} is still referenced by record relation ${ref.id} — delete the record (or its slot) first`);
|
||||
await db.prepare('DELETE FROM entries WHERE id = ?').bind(id).run();
|
||||
// 這顆 entry 自己發出的關係列(它是 record 身分時的格子與歸屬)失去意義,一併拆
|
||||
await db.prepare('DELETE FROM entries WHERE src_id = ?').bind(id).run();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -583,6 +600,9 @@ export async function searchEntries(
|
||||
const params: unknown[] = [...plan.scoreParams];
|
||||
if (owner_id) { conds.push('owner_id = ?'); params.push(owner_id); }
|
||||
if (entry_type) { conds.push('entry_type = ?'); params.push(entry_type); }
|
||||
// 機制列隔離(0007):沒指定 entry_type 時,sheet/field 這類有 content 的機制節點
|
||||
// 不進關鍵字搜尋(搜 "status" 不該撈回一顆欄位謂詞 entry);關係列 content=NULL 本就 0 分。
|
||||
else { conds.push(NOT_MACHINERY_PREDICATE); }
|
||||
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); }
|
||||
|
||||
+118
-50
@@ -38,6 +38,13 @@ export interface LibraryMapRow {
|
||||
narrative: string | null;
|
||||
top_entities: string[]; // 全館視圖只回 top 3 名字(數百 token 內,design §4 MCP instructions 用)
|
||||
triplet_count: number;
|
||||
// Arcrun#87 三次收尾(2026-08-13,「藏書地圖說實話」):triplet_count=0 不等於「這庫沒有知識」——
|
||||
// entries(ingest 進來的原始卡片/skill 內容)與 triplet(從 entries 萃取出的三元組)是兩件事,
|
||||
// 一個庫可能有大量 entries、但三元組萃取從沒對它跑過(實測:kb 以外 7 庫皆此況,entries 存在
|
||||
// 且 library 標記正確,triplet 一筆都沒有)。entry_count 讓讀端(AI/MCP 渲染層)分得清
|
||||
// 「真的沒東西」與「有東西但還沒被萃取成三元組」,不再把後者誤讀成前者、誤判庫是空的而放棄查詢
|
||||
// (見本檔 liveEntryCountsByLibrary 的計數口徑)。
|
||||
entry_count: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
@@ -50,6 +57,7 @@ export interface LibraryMapDetail {
|
||||
relation_profile: RelationStat[];
|
||||
bridges: Bridge[];
|
||||
triplet_count: number;
|
||||
entry_count: number; // 同 LibraryMapRow.entry_count(見該欄位註解)
|
||||
commit_hash: string | null;
|
||||
status: string;
|
||||
updated_at: number;
|
||||
@@ -105,36 +113,45 @@ export async function ensureTripletLibrarySlot(db: D1Database, tripletTemplate:
|
||||
|
||||
// ---- 聚合 SQL(M2 recompute) ----
|
||||
|
||||
// record(entry_values 縱表)→ 一列一 triplet 的 pivot。MAX(CASE …) 是 SQLite 縱轉橫慣用法;
|
||||
// owner filter 直接下在 pivot 前(record 的所有 slot entries 同 owner,createRecord 寫入時同值)。
|
||||
// record → 一列一 triplet 的 pivot(0007 樹狀 record 模型:格子=關係列)。
|
||||
// b=歸屬關係列(rel=sys_belongs, dst=template 的 sheet entry)=record 成員名單;
|
||||
// r=該 record 的格子關係列;v=格子指到的內容 entry。欄位謂詞 id 決定性衍生
|
||||
// (fld_<template>_<slot>,與 0007 遷移、record-crud fieldEntryId 同一條規則)⇒
|
||||
// 直接用字串串接比對,不必 JOIN field entry。MAX(CASE …) 縱轉橫慣用法照舊;
|
||||
// owner filter 下在歸屬關係列的 owner_id(createRecord 寫入時同值,0007 遷移同一推導)。
|
||||
// 參數簽名與舊版逐字相同:[template_id, owner?]。
|
||||
function tripletPivotSql(ownerFiltered: boolean): string {
|
||||
return `SELECT ev.record_id AS rid,
|
||||
MAX(CASE WHEN ev.slot_name = 'subject' THEN e.content END) AS subject,
|
||||
MAX(CASE WHEN ev.slot_name = 'object' THEN e.content END) AS object,
|
||||
MAX(CASE WHEN ev.slot_name = 'predicate' THEN e.content END) AS predicate,
|
||||
MAX(CASE WHEN ev.slot_name = 'status' THEN e.content END) AS status,
|
||||
MAX(CASE WHEN ev.slot_name = 'library' THEN e.content END) AS library,
|
||||
MAX(CASE WHEN ev.slot_name = 'source_uri' THEN e.content END) AS source_uri
|
||||
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
|
||||
WHERE ev.template_id = ?${ownerFiltered ? ' AND e.owner_id = ?' : ''}
|
||||
GROUP BY ev.record_id`;
|
||||
return `SELECT b.src_id AS rid,
|
||||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_subject' THEN v.content END) AS subject,
|
||||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_object' THEN v.content END) AS object,
|
||||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_predicate' THEN v.content END) AS predicate,
|
||||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_status' THEN v.content END) AS status,
|
||||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_library' THEN v.content END) AS library,
|
||||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_source_uri' THEN v.content END) AS source_uri
|
||||
FROM entries b
|
||||
LEFT JOIN entries r ON r.src_id = b.src_id AND r.rel_id != 'sys_belongs'
|
||||
LEFT JOIN entries v ON v.id = r.dst_id
|
||||
WHERE b.rel_id = 'sys_belongs' AND b.dst_id = ?${ownerFiltered ? ' AND b.owner_id = ?' : ''}
|
||||
GROUP BY b.src_id`;
|
||||
}
|
||||
|
||||
// library_map 自身 record 的 pivot(讀端+supersede 查找共用)。
|
||||
// library_map 自身 record 的 pivot(讀端+supersede 查找共用;同上 0007 形狀)。
|
||||
function mapPivotSql(ownerFiltered: boolean): string {
|
||||
return `SELECT ev.record_id AS rid,
|
||||
MAX(CASE WHEN ev.slot_name = 'library' THEN e.content END) AS library,
|
||||
MAX(CASE WHEN ev.slot_name = 'narrative' THEN e.content END) AS narrative,
|
||||
MAX(CASE WHEN ev.slot_name = 'top_entities' THEN e.content END) AS top_entities,
|
||||
MAX(CASE WHEN ev.slot_name = 'relation_profile' THEN e.content END) AS relation_profile,
|
||||
MAX(CASE WHEN ev.slot_name = 'bridges' THEN e.content END) AS bridges,
|
||||
MAX(CASE WHEN ev.slot_name = 'triplet_count' THEN e.content END) AS triplet_count,
|
||||
MAX(CASE WHEN ev.slot_name = 'commit_hash' THEN e.content END) AS commit_hash,
|
||||
MAX(CASE WHEN ev.slot_name = 'status' THEN e.content END) AS status,
|
||||
MAX(ev.created_at) AS ts
|
||||
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
|
||||
WHERE ev.template_id = ?${ownerFiltered ? ' AND e.owner_id = ?' : ''}
|
||||
GROUP BY ev.record_id`;
|
||||
return `SELECT b.src_id AS rid,
|
||||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_library' THEN v.content END) AS library,
|
||||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_narrative' THEN v.content END) AS narrative,
|
||||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_top_entities' THEN v.content END) AS top_entities,
|
||||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_relation_profile' THEN v.content END) AS relation_profile,
|
||||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_bridges' THEN v.content END) AS bridges,
|
||||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_triplet_count' THEN v.content END) AS triplet_count,
|
||||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_commit_hash' THEN v.content END) AS commit_hash,
|
||||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_status' THEN v.content END) AS status,
|
||||
MAX(r.created_at) AS ts
|
||||
FROM entries b
|
||||
LEFT JOIN entries r ON r.src_id = b.src_id AND r.rel_id != 'sys_belongs'
|
||||
LEFT JOIN entries v ON v.id = r.dst_id
|
||||
WHERE b.rel_id = 'sys_belongs' AND b.dst_id = ?${ownerFiltered ? ' AND b.owner_id = ?' : ''}
|
||||
GROUP BY b.src_id`;
|
||||
}
|
||||
|
||||
function parseJsonArray<T>(raw: string | null | undefined): T[] {
|
||||
@@ -285,6 +302,10 @@ export async function recomputeLibraryMap(db: D1Database, input: RecomputeInput)
|
||||
superseded.push(row.rid);
|
||||
}
|
||||
|
||||
// entry_count(Arcrun#87 三次收尾):recompute 只重算三元組那一半,entry_count 是即時算的
|
||||
// 另一半,兩者同樣的道理一起回傳(見 LibraryMapDetail.entry_count 欄位註解)。
|
||||
const entryCount = (await liveEntryCountsByLibrary(db, owner)).get(library) ?? 0;
|
||||
|
||||
return {
|
||||
map: {
|
||||
record_id: blockEntry.id,
|
||||
@@ -295,6 +316,7 @@ export async function recomputeLibraryMap(db: D1Database, input: RecomputeInput)
|
||||
relation_profile: relationProfile,
|
||||
bridges,
|
||||
triplet_count: tripletCount,
|
||||
entry_count: entryCount,
|
||||
commit_hash: input.commit_hash ?? null,
|
||||
status: 'active',
|
||||
updated_at: blockEntry.created_at,
|
||||
@@ -347,18 +369,49 @@ async function liveTripletCountsByLibrary(
|
||||
const params: unknown[] = owner_id ? [tripletTemplateId, owner_id] : [tripletTemplateId];
|
||||
const res = await db
|
||||
.prepare( // kbdb-sql-ok:牆內本體(kbdb/src/actions/),checkout 開在巢狀 worktree matrix/arcrun/.worktree-fix-87/(避免打斷另一 session 佔用中的 matrix/arcrun 主 checkout),hook 逐字比對 matrix/arcrun/kbdb/src/ 吃不到中間多出的 worktree 目錄層,非繞牆
|
||||
`SELECT COALESCE(NULLIF(lib_e.content, ''), 'general') AS library, COUNT(*) AS n
|
||||
`SELECT COALESCE(NULLIF(tr.library, ''), 'general') AS library, COUNT(*) AS n
|
||||
FROM (
|
||||
SELECT ev.record_id AS rid,
|
||||
MAX(CASE WHEN ev.slot_name = 'status' THEN e.content END) AS status
|
||||
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
|
||||
WHERE ev.template_id = ?${owner_id ? ' AND e.owner_id = ?' : ''}
|
||||
GROUP BY ev.record_id
|
||||
SELECT b.src_id AS rid,
|
||||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_status' THEN v.content END) AS status,
|
||||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_library' THEN v.content END) AS library
|
||||
FROM entries b
|
||||
LEFT JOIN entries r ON r.src_id = b.src_id AND r.rel_id != 'sys_belongs'
|
||||
LEFT JOIN entries v ON v.id = r.dst_id
|
||||
WHERE b.rel_id = 'sys_belongs' AND b.dst_id = ?${owner_id ? ' AND b.owner_id = ?' : ''}
|
||||
GROUP BY b.src_id
|
||||
) AS tr
|
||||
LEFT JOIN entry_values lev ON lev.record_id = tr.rid AND lev.slot_name = 'library'
|
||||
LEFT JOIN entries lib_e ON lib_e.id = lev.entry_id
|
||||
WHERE COALESCE(tr.status, 'active') = 'active'
|
||||
GROUP BY COALESCE(NULLIF(lib_e.content, ''), 'general')`,
|
||||
GROUP BY COALESCE(NULLIF(tr.library, ''), 'general')`,
|
||||
)
|
||||
.bind(...params)
|
||||
.all<{ library: string; n: number }>();
|
||||
const m: LibraryCountMap = new Map();
|
||||
for (const r of res.results ?? []) m.set(r.library, r.n);
|
||||
return m;
|
||||
}
|
||||
|
||||
// 這個 owner 底下、依 entries 自己的 metadata_json.$.library 分組的即時「原始內容」數
|
||||
//(Arcrun#87 三次收尾,2026-08-13)——與 liveTripletCountsByLibrary 算的是兩張完全不同的帳:
|
||||
// 那個算「萃取出幾條三元組」,這個算「ingest 進來幾筆原始卡片/skill/block」。兩者天生會不一樣
|
||||
// (萃取是下游、有延遲甚至從沒對某些庫跑過),刻意分開算、分開回傳,不是同一數字的兩種寫法。
|
||||
//
|
||||
// 排除口徑(都是結構性排除,不是內容語意判斷——沒有違反「base 對內容語意無知」):
|
||||
// - entry_type='value':record-crud.ts 的通用機制,任何 template(含 triplet 本身)建 record
|
||||
// 時每個 slot 值都會落一筆這種 entry,是儲存實作細節,不是使用者看得到的「一筆知識」。
|
||||
// - metadata.kind='library_map' 的 block:地圖自己每次 recompute 產出的摘要 block(含歷史
|
||||
// superseded 的),算進去會自我膨脹、且無限接近雞生蛋——地圖不該把自己算進地圖裡。
|
||||
async function liveEntryCountsByLibrary(db: D1Database, owner_id?: string): Promise<LibraryCountMap> {
|
||||
const params: unknown[] = owner_id ? [owner_id] : [];
|
||||
const res = await db
|
||||
.prepare( // kbdb-sql-ok:牆內本體(kbdb/src/actions/),checkout 開在巢狀 worktree /private/tmp/wt-arcrun-library-map-honesty-87/(同 962d863/5919c6b 已記載的假警報成因:hook 逐字比對 matrix/arcrun/kbdb/src/ 吃不到中間多出的 worktree 目錄層,非繞牆)
|
||||
`SELECT COALESCE(NULLIF(json_extract(metadata_json, '$.library'), ''), 'general') AS library,
|
||||
COUNT(*) AS n
|
||||
FROM entries
|
||||
WHERE ${owner_id ? 'owner_id = ? AND ' : ''}entry_type != 'value'
|
||||
AND src_id IS NULL
|
||||
AND entry_type NOT IN ('record', 'sheet', 'field', 'system')
|
||||
AND NOT (entry_type = 'block' AND COALESCE(json_extract(metadata_json, '$.kind'), '') = 'library_map')
|
||||
GROUP BY COALESCE(NULLIF(json_extract(metadata_json, '$.library'), ''), 'general')`,
|
||||
)
|
||||
.bind(...params)
|
||||
.all<{ library: string; n: number }>();
|
||||
@@ -388,10 +441,12 @@ async function knownLibraryNames(db: D1Database, owner_id?: string): Promise<Lib
|
||||
const libParams: unknown[] = owner_id ? [libTpl.id, owner_id] : [libTpl.id];
|
||||
const libRows = await db
|
||||
.prepare(
|
||||
`SELECT MAX(CASE WHEN ev.slot_name = 'name' THEN e.content END) AS name
|
||||
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
|
||||
WHERE ev.template_id = ?${owner_id ? ' AND e.owner_id = ?' : ''}
|
||||
GROUP BY ev.record_id`,
|
||||
`SELECT MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_name' THEN v.content END) AS name
|
||||
FROM entries b
|
||||
LEFT JOIN entries r ON r.src_id = b.src_id AND r.rel_id != 'sys_belongs'
|
||||
LEFT JOIN entries v ON v.id = r.dst_id
|
||||
WHERE b.rel_id = 'sys_belongs' AND b.dst_id = ?${owner_id ? ' AND b.owner_id = ?' : ''}
|
||||
GROUP BY b.src_id`,
|
||||
)
|
||||
.bind(...libParams)
|
||||
.all<{ name: string | null }>();
|
||||
@@ -453,20 +508,26 @@ interface MapPivotRow {
|
||||
ts: number;
|
||||
}
|
||||
|
||||
// 全館地圖:每庫一行(library+narrative+top 3 entities+triplet_count),MCP instructions
|
||||
// 直接嵌用(設計上限=數百 token,R3)。template 還不存在(從未 recompute)→ 誠實回空清單。
|
||||
// 全館地圖:每庫一行(library+narrative+top 3 entities+triplet_count+entry_count),MCP
|
||||
// instructions 直接嵌用(設計上限=數百 token,R3)。template 還不存在(從未 recompute)→
|
||||
// 誠實回空清單。entry_count 與 triplet_count 並排回傳(Arcrun#87 三次收尾):一個查「原始內容
|
||||
// 有沒有」、一個查「萃取出幾條三元組」,兩者不同源、允許不一致(見 liveEntryCountsByLibrary
|
||||
// 註解),讀端不該把其中一個的 0 當成另一個的答案。
|
||||
export async function listLibraryMaps(db: D1Database, owner_id?: string): Promise<LibraryMapRow[]> {
|
||||
const tpl = await getTemplate(db, LIBRARY_MAP_TEMPLATE_NAME);
|
||||
if (!tpl) return [];
|
||||
const params: unknown[] = owner_id ? [tpl.id, owner_id] : [tpl.id];
|
||||
const res = await db
|
||||
.prepare(
|
||||
`WITH m AS (${mapPivotSql(!!owner_id)})
|
||||
SELECT * FROM m WHERE COALESCE(m.status, 'active') = 'active' AND m.library IS NOT NULL
|
||||
ORDER BY m.ts DESC`,
|
||||
)
|
||||
.bind(...params)
|
||||
.all<MapPivotRow>();
|
||||
const [res, entryCounts] = await Promise.all([
|
||||
db
|
||||
.prepare( // kbdb-sql-ok:牆內本體(kbdb/src/actions/),既有查詢(listLibraryMaps 原本就有)此次改包進 Promise.all 才重新觸發掃描,非新增違規;worktree 路徑假警報同上方 liveEntryCountsByLibrary 註解
|
||||
`WITH m AS (${mapPivotSql(!!owner_id)})
|
||||
SELECT * FROM m WHERE COALESCE(m.status, 'active') = 'active' AND m.library IS NOT NULL
|
||||
ORDER BY m.ts DESC`,
|
||||
)
|
||||
.bind(...params)
|
||||
.all<MapPivotRow>(),
|
||||
liveEntryCountsByLibrary(db, owner_id),
|
||||
]);
|
||||
// 每庫只留最新 active(supersede 失敗殘留多個 active 時,讀端自癒取最新——順序安全的另一半)。
|
||||
const byLib = new Map<string, LibraryMapRow>();
|
||||
for (const r of res.results ?? []) {
|
||||
@@ -476,6 +537,7 @@ export async function listLibraryMaps(db: D1Database, owner_id?: string): Promis
|
||||
narrative: r.narrative || null,
|
||||
top_entities: parseJsonArray<TopEntity>(r.top_entities).slice(0, 3).map((t) => t.name),
|
||||
triplet_count: Number(r.triplet_count ?? 0) || 0,
|
||||
entry_count: entryCounts.get(r.library) ?? 0,
|
||||
updated_at: r.ts,
|
||||
});
|
||||
}
|
||||
@@ -501,7 +563,12 @@ export async function getLibraryMapDetail(
|
||||
.first<MapPivotRow>();
|
||||
if (!row) return null;
|
||||
// record_id=map block entry id(recompute 寫入時綁定);entry 若被外力刪除,content 誠實回 null。
|
||||
const blockEntry = await getEntry(db, row.rid);
|
||||
// entry_count 與全館視圖(listLibraryMaps)同一套計法(liveEntryCountsByLibrary),單庫詳圖
|
||||
// 只取自己那一庫的數字——Arcrun#87 三次收尾,理由見 LibraryMapDetail.entry_count 欄位註解。
|
||||
const [blockEntry, entryCounts] = await Promise.all([
|
||||
getEntry(db, row.rid),
|
||||
liveEntryCountsByLibrary(db, owner_id),
|
||||
]);
|
||||
return {
|
||||
record_id: row.rid,
|
||||
library,
|
||||
@@ -511,6 +578,7 @@ export async function getLibraryMapDetail(
|
||||
relation_profile: parseJsonArray<RelationStat>(row.relation_profile),
|
||||
bridges: parseJsonArray<Bridge>(row.bridges),
|
||||
triplet_count: Number(row.triplet_count ?? 0) || 0,
|
||||
entry_count: entryCounts.get(library) ?? 0,
|
||||
commit_hash: row.commit_hash || null,
|
||||
status: row.status ?? 'active',
|
||||
updated_at: row.ts,
|
||||
|
||||
+314
-99
@@ -1,5 +1,15 @@
|
||||
// Template + Record CRUD. A "record" = multiple entries composed via a template's slots.
|
||||
// Base, D1 only. (Ported clean from KBDB; no vectorize/triplet imports.)
|
||||
// Template + Record CRUD — 樹狀 record 模型(v7 定稿,2026-08-15 confirm)。
|
||||
//
|
||||
// 模型(leo 定案):「真身在 pool 的 entry 裡,所有的虛擬表虛擬欄位都是指向這個 entry 的指標。」
|
||||
// · record = 池中一顆有身分的 entry(record_id 就是它的 id)
|
||||
// · 一格 = 一條關係列(src=record、rel=field entry、dst=value entry)——池上型別化指標欄
|
||||
// · 歸屬 = 一條關係列(src=record、rel=sys_belongs、dst=sheet)
|
||||
// · 欄位是關係、歸屬也是關係——同一種機制。entry_values 表已拆(0007),
|
||||
// 它是「關係」的第二套實作(D92:同一件事兩個實作必然漂移)。
|
||||
//
|
||||
// 遷移期雙軌(第二刀收):templates 表仍是「欄位定義」的真相源(slots_json/description),
|
||||
// sheet/field entry 是它們在池中的身分;createTemplate/updateTemplate 同步維護兩邊,
|
||||
// 維護語句全部 INSERT OR IGNORE(決定性 id)⇒ 冪等、可自癒。
|
||||
import type { Template } from '../types';
|
||||
import { createEntry } from './entry-crud';
|
||||
|
||||
@@ -7,7 +17,46 @@ function uid(prefix: string): string {
|
||||
return `${prefix}_${crypto.randomUUID()}`;
|
||||
}
|
||||
|
||||
// ---- Templates ----
|
||||
// ── 啟動常數(0007 seed;一組、極小、只讀)─────────────────────────────
|
||||
export const SYS_ROOT = 'sys_root'; // 屬於鏈的終點:entry ─屬於→ sys_root = 它是 sheet
|
||||
export const SYS_BELONGS = 'sys_belongs'; // 歸屬謂詞:record ─屬於→ sheet
|
||||
export const SYS_FIELD_OF = 'sys_field_of';// 欄位名冊謂詞:field ─field_of→ sheet
|
||||
|
||||
/** field entry 的決定性 id(0007 遷移與執行期寫入共用同一條衍生規則,兩邊永遠對得上)。 */
|
||||
export function fieldEntryId(templateId: string, slot: string): string {
|
||||
return `fld_${templateId}_${slot}`;
|
||||
}
|
||||
|
||||
/** 啟動常數自癒(冪等;空庫或部分遷移的實例第一次寫入時補齊)。 */
|
||||
async function ensureAnchors(db: D1Database): Promise<void> {
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO entries (id, content, entry_type, owner_id) VALUES
|
||||
('${SYS_ROOT}', 'root', 'system', NULL),
|
||||
('${SYS_BELONGS}', 'belongs', 'system', NULL),
|
||||
('${SYS_FIELD_OF}', 'field_of', 'system', NULL)`,
|
||||
)
|
||||
.run();
|
||||
}
|
||||
|
||||
/** 這批 slot 的 field entry + 名冊關係(field ─field_of→ sheet)自癒建立(冪等)。 */
|
||||
async function ensureFieldEntries(db: D1Database, templateId: string, slots: string[]): Promise<void> {
|
||||
for (const slot of slots) {
|
||||
const fid = fieldEntryId(templateId, slot);
|
||||
await db
|
||||
.prepare(`INSERT OR IGNORE INTO entries (id, content, entry_type) VALUES (?, ?, 'field')`)
|
||||
.bind(fid, slot)
|
||||
.run();
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO entries (id, entry_type, src_id, rel_id, dst_id) VALUES (?, 'relation', ?, '${SYS_FIELD_OF}', ?)`,
|
||||
)
|
||||
.bind(`relf_${templateId}_${slot}`, fid, templateId)
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Templates(遷移期雙軌:表=欄位定義真相源,池中 sheet/field entry=身分)----
|
||||
|
||||
export interface CreateTemplateInput {
|
||||
name: string;
|
||||
@@ -23,6 +72,19 @@ export async function createTemplate(db: D1Database, input: CreateTemplateInput)
|
||||
.prepare(`INSERT INTO templates (id, name, description, slots_json, created_by) VALUES (?, ?, ?, ?, ?)`)
|
||||
.bind(id, input.name, input.description ?? null, JSON.stringify(input.slots), input.created_by ?? null)
|
||||
.run();
|
||||
// 池中身分:sheet entry(id 沿用 template id)+ sheet ─屬於→ 保留根 + 欄位名冊
|
||||
await ensureAnchors(db);
|
||||
await db
|
||||
.prepare(`INSERT OR IGNORE INTO entries (id, content, entry_type) VALUES (?, ?, 'sheet')`)
|
||||
.bind(id, input.name)
|
||||
.run();
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO entries (id, entry_type, src_id, rel_id, dst_id) VALUES (?, 'relation', ?, '${SYS_BELONGS}', '${SYS_ROOT}')`,
|
||||
)
|
||||
.bind(`relb_${id}`, id)
|
||||
.run();
|
||||
await ensureFieldEntries(db, id, input.slots);
|
||||
const row = await getTemplate(db, id);
|
||||
if (!row) throw new Error('createTemplate: row not found after insert');
|
||||
return row;
|
||||
@@ -49,188 +111,341 @@ export async function updateTemplate(db: D1Database, id: string, patch: { descri
|
||||
if (cols.length === 0) return getTemplate(db, id);
|
||||
cols.push('updated_at = unixepoch()');
|
||||
await db.prepare(`UPDATE templates SET ${cols.join(', ')} WHERE id = ?`).bind(...params, id).run();
|
||||
// 新增的 slot 要有 field entry(欄名=關係的謂詞),否則後續寫格子會指到不存在的謂詞
|
||||
if (patch.slots !== undefined) await ensureFieldEntries(db, id, patch.slots);
|
||||
return getTemplate(db, id);
|
||||
}
|
||||
|
||||
// ---- Records (entry_values composed by template) ----
|
||||
// ---- Records(關係列組成;entry_values 已拆)----
|
||||
|
||||
export interface CreateRecordInput {
|
||||
template: string; // template id or name
|
||||
values: Record<string, string>; // slot_name -> content
|
||||
values?: Record<string, string>; // slot_name -> content(**新建**一筆 entry 當這個 slot 的值)
|
||||
/**
|
||||
* slot_name -> **既有** entry 的 id:把水池(entries)裡那條既有 entry 直接掛上——
|
||||
* 在新模型裡這就是「一條指標」本人(Arcrun#128 想要的外鍵,現在是唯一機制的原生形狀)。
|
||||
* 給字串 → 照舊新建 value entry 再指過去;給 id → 直接指既有 entry。**只有指標才連動**。
|
||||
*/
|
||||
entry_ids?: Record<string, string>;
|
||||
owner_id?: string | null;
|
||||
record_id?: string;
|
||||
}
|
||||
|
||||
/** 被參照 entry 的 id -> 它現在的 content(回傳值要帶真內容,不是空殼)。 */
|
||||
type ReferencedContent = Map<string, string | null>;
|
||||
|
||||
/**
|
||||
* 讀出被參照的既有 entry,並在**寫入任何一列之前**把該擋的擋掉:
|
||||
* 1. id 不存在要給看得懂的錯(新模型沒有 FK,這層檢查就是牆自己的不變量)
|
||||
* 2. 跨租戶必須擋(呼叫端可指定 entry_id,不檢查歸屬=繞過租戶邊界的門)
|
||||
* 3. 回傳值帶被參照 entry 的現有內容
|
||||
* 全部檢查在第一筆 INSERT 之前跑完 ⇒ 失敗就是「一列都沒寫」。
|
||||
* 批次以 90 個 id 一組:D1 綁定參數上限 100,沿用既有慣例。
|
||||
*/
|
||||
async function loadReferencedEntries(
|
||||
db: D1Database,
|
||||
entryIds: Record<string, string>,
|
||||
recordOwnerId: string | null,
|
||||
): Promise<ReferencedContent> {
|
||||
const ids = [...new Set(Object.values(entryIds))];
|
||||
if (ids.length === 0) return new Map();
|
||||
|
||||
const rows: { id: string; content: string | null; owner_id: string | null }[] = [];
|
||||
for (let i = 0; i < ids.length; i += 90) {
|
||||
const chunk = ids.slice(i, i + 90);
|
||||
const res = await db
|
||||
.prepare(`SELECT id, content, owner_id FROM entries WHERE id IN (${chunk.map(() => '?').join(',')})`)
|
||||
.bind(...chunk)
|
||||
.all<{ id: string; content: string | null; owner_id: string | null }>();
|
||||
rows.push(...(res.results ?? []));
|
||||
}
|
||||
|
||||
const found = new Map(rows.map((r) => [r.id, r]));
|
||||
const missing = ids.filter((id) => !found.has(id));
|
||||
if (missing.length > 0) throw new Error(`entry not found: ${missing.join(', ')}`);
|
||||
|
||||
// 歸屬不同 → 擋。owner_id 為 null 的 entry 視為無主/共用(既有資料多半如此),放行。
|
||||
if (recordOwnerId != null) {
|
||||
const foreign = rows.filter((r) => r.owner_id != null && r.owner_id !== recordOwnerId);
|
||||
if (foreign.length > 0) {
|
||||
throw new Error(
|
||||
`entry owner mismatch: ${foreign.map((r) => `${r.id}(${r.owner_id})`).join(', ')} != ${recordOwnerId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return new Map(rows.map((r) => [r.id, r.content]));
|
||||
}
|
||||
|
||||
export interface RecordResult {
|
||||
record_id: string;
|
||||
template_id: string;
|
||||
values: Record<string, string>;
|
||||
/**
|
||||
* record 的歸屬(=其底層 slot entries 的 owner_id,createRecord 寫入時同一值)。
|
||||
* 2026-08-12 補:`GET /records/:id` 原本不回這欄,所以**呼叫端無從判斷這筆是不是自己的**
|
||||
* ——按 id 直讀等於沒有租戶邊界。要讓 cypher 的 portal 資料面(授權的人/AI 走的那條)
|
||||
* 能對單筆做「不是我的就回 404」,歸屬必須跟著資料一起回來。無歸屬的舊資料 → null。
|
||||
*/
|
||||
/** record 的歸屬。新模型直接存在 record 身分 entry 上(不再從 slot entries 推導)。 */
|
||||
owner_id: string | null;
|
||||
}
|
||||
|
||||
/** record 的歸屬關係(record ─屬於→ sheet,排除 sheet 自己的 ─屬於→ 保留根)。 */
|
||||
async function recordBelongs(db: D1Database, recordId: string): Promise<{ dst_id: string } | null> {
|
||||
const row = await db
|
||||
.prepare(`SELECT dst_id FROM entries WHERE src_id = ? AND rel_id = '${SYS_BELONGS}' AND dst_id != '${SYS_ROOT}' LIMIT 1`)
|
||||
.bind(recordId)
|
||||
.first<{ dst_id: string }>();
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
async function insertCellRelation(
|
||||
db: D1Database,
|
||||
recordId: string,
|
||||
templateId: string,
|
||||
slot: string,
|
||||
dstEntryId: string,
|
||||
ownerId: string | null,
|
||||
): Promise<void> {
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO entries (id, entry_type, owner_id, src_id, rel_id, dst_id) VALUES (?, 'relation', ?, ?, ?, ?)`,
|
||||
)
|
||||
.bind(uid('relv'), ownerId, recordId, fieldEntryId(templateId, slot), dstEntryId)
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function createRecord(db: D1Database, input: CreateRecordInput): Promise<RecordResult> {
|
||||
const tpl = await getTemplate(db, input.template);
|
||||
if (!tpl) throw new Error(`template not found: ${input.template}`);
|
||||
const slots: string[] = JSON.parse(tpl.slots_json);
|
||||
const recordId = input.record_id ?? uid('rec');
|
||||
const values = input.values ?? {};
|
||||
const entryIds = input.entry_ids ?? {};
|
||||
const refSlots = Object.keys(entryIds);
|
||||
const ownerId = input.owner_id ?? null;
|
||||
|
||||
for (const slot of slots) {
|
||||
if (!(slot in input.values)) continue;
|
||||
// 同一個 slot 不准同時給字串又給 id:兩者的意思相反(複製一份 vs 指向既有),不猜。
|
||||
const both = refSlots.filter((s) => s in values);
|
||||
if (both.length > 0) throw new Error(`slot given both value and entry_id: ${both.join(', ')}`);
|
||||
|
||||
// entry_ids 指到 template 沒有的 slot → 報錯(不學 values 那條「靜默略過」——
|
||||
// 指標設了卻無聲消失是最難查的失敗)。
|
||||
const unknown = refSlots.filter((s) => !slots.includes(s));
|
||||
if (unknown.length > 0) throw new Error(`slot not in template: ${unknown.join(', ')}`);
|
||||
|
||||
// 全部檢查(存在/歸屬)先跑完再寫,失敗=一列都沒寫。
|
||||
const referenced = await loadReferencedEntries(db, entryIds, ownerId);
|
||||
|
||||
// record 身分:池中一顆 entry。record_id 已是池中既有 entry(block 即 record 慣例,
|
||||
// 如 library_map 的 map block)→ 那顆 entry 本人就是身分,不另建、不覆蓋。
|
||||
await db
|
||||
.prepare(`INSERT OR IGNORE INTO entries (id, entry_type, owner_id) VALUES (?, 'record', ?)`)
|
||||
.bind(recordId, ownerId)
|
||||
.run();
|
||||
// 歸屬=一條關係(舊 template_id 欄的下場)
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO entries (id, entry_type, owner_id, src_id, rel_id, dst_id) VALUES (?, 'relation', ?, ?, '${SYS_BELONGS}', ?)`,
|
||||
)
|
||||
.bind(`relb_${recordId}_${tpl.id}`, ownerId, recordId, tpl.id)
|
||||
.run();
|
||||
// 欄位謂詞自癒(決定性 id,冪等)——只補這次真的要寫的 slot
|
||||
const writtenSlots = slots.filter((s) => s in entryIds || s in values);
|
||||
await ensureFieldEntries(db, tpl.id, writtenSlots);
|
||||
|
||||
for (const slot of writtenSlots) {
|
||||
if (slot in entryIds) {
|
||||
// 指向既有 entry:只插一條關係列,不碰內容(這就是指標)。
|
||||
await insertCellRelation(db, recordId, tpl.id, slot, entryIds[slot], ownerId);
|
||||
continue;
|
||||
}
|
||||
const entry = await createEntry(db, {
|
||||
content: input.values[slot],
|
||||
content: values[slot],
|
||||
entry_type: 'value',
|
||||
owner_id: input.owner_id ?? null,
|
||||
owner_id: ownerId,
|
||||
});
|
||||
await db
|
||||
.prepare(`INSERT INTO entry_values (id, record_id, template_id, slot_name, entry_id) VALUES (?, ?, ?, ?, ?)`)
|
||||
.bind(uid('ev'), recordId, tpl.id, slot, entry.id)
|
||||
.run();
|
||||
await insertCellRelation(db, recordId, tpl.id, slot, entry.id, ownerId);
|
||||
}
|
||||
return { record_id: recordId, template_id: tpl.id, values: input.values, owner_id: input.owner_id ?? null };
|
||||
|
||||
// 回傳值:舊路徑照舊原樣回 input.values,指標來的 slot 補上那條既有 entry 的現有內容。
|
||||
const out: Record<string, string> = { ...values };
|
||||
for (const [slot, entryId] of Object.entries(entryIds)) out[slot] = referenced.get(entryId) ?? '';
|
||||
return { record_id: recordId, template_id: tpl.id, values: out, owner_id: ownerId };
|
||||
}
|
||||
|
||||
// Update an existing record's slot values (mira-dissolve T2.1, issue #6).
|
||||
// "Deprecate by flipping a slot value" — base append-only is NOT broken: we change the
|
||||
// underlying entries.content of the slot's entry, we do not alter table structure / add columns / delete rows.
|
||||
// - slot already on the record → UPDATE the linked entries.content.
|
||||
// - slot valid for the record's template but not yet present → create entry + entry_value (idempotent grow).
|
||||
// - slot not in the template's slots_json → reject (records must stay template-shaped).
|
||||
// Returns null if the record does not exist.
|
||||
// Update an existing record's slot values(行為契約與 entry_values 時代一字不變):
|
||||
// - slot 已有格子 → UPDATE 指到的 entries.content(**只有指標才連動**:所有指著同一顆的都看到新值)
|
||||
// - slot 在 template 裡但還沒有格子 → 新建 entry + 一條關係列(grow)
|
||||
// - slot 不在 template → reject
|
||||
// 回 null = record 不存在(沒有歸屬關係)。
|
||||
export async function updateRecord(
|
||||
db: D1Database,
|
||||
recordId: string,
|
||||
values: Record<string, string>,
|
||||
): Promise<RecordResult | null> {
|
||||
// Existing slot → entry_id + template_id for this record.
|
||||
// JOIN entries 帶回 owner_id:grow 路徑建新 entry 時要沿用 record 既有 owner_id
|
||||
//(portal-auth design §2.2 附帶修復——原本漏帶 → 孤兒 entry(owner_id=NULL),
|
||||
// owner-scoped 查詢(searchByTemplate / searchEntries)看不到該 slot 值)。
|
||||
const evRes = await db
|
||||
const belongs = await recordBelongs(db, recordId);
|
||||
if (!belongs) return null; // record does not exist
|
||||
const templateId = belongs.dst_id;
|
||||
|
||||
// 既有格子:slot(謂詞 entry 的 content)→ 指到的 entry id(重複 slot 允許 → 全部收)
|
||||
const cellRes = await db
|
||||
.prepare(
|
||||
`SELECT ev.slot_name AS slot_name, ev.entry_id AS entry_id, ev.template_id AS template_id, e.owner_id AS owner_id
|
||||
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
|
||||
WHERE ev.record_id = ?`,
|
||||
`SELECT f.content AS slot_name, r.dst_id AS entry_id
|
||||
FROM entries r JOIN entries f ON r.rel_id = f.id
|
||||
WHERE r.src_id = ? AND r.rel_id != '${SYS_BELONGS}'`,
|
||||
)
|
||||
.bind(recordId)
|
||||
.all<{ slot_name: string; entry_id: string; template_id: string; owner_id: string | null }>();
|
||||
const evRows = evRes.results ?? [];
|
||||
if (evRows.length === 0) return null; // record does not exist
|
||||
.all<{ slot_name: string; entry_id: string }>();
|
||||
const cells = cellRes.results ?? [];
|
||||
const slotToEntries = new Map<string, string[]>();
|
||||
for (const c of cells) {
|
||||
const list = slotToEntries.get(c.slot_name) ?? [];
|
||||
list.push(c.entry_id);
|
||||
slotToEntries.set(c.slot_name, list);
|
||||
}
|
||||
|
||||
const templateId = evRows[0].template_id;
|
||||
// record 的歸屬=其既有 slot entries 的 owner_id(createRecord 寫入時同一值)。
|
||||
const recordOwnerId = evRows.find((r) => r.owner_id != null)?.owner_id ?? null;
|
||||
const slotToEntry = new Map(evRows.map((r) => [r.slot_name, r.entry_id]));
|
||||
// record 的歸屬=身分 entry 的 owner(grow 建新 entry 時沿用,防孤兒 entry)
|
||||
const identity = await db.prepare('SELECT owner_id FROM entries WHERE id = ?').bind(recordId).first<{ owner_id: string | null }>();
|
||||
const recordOwnerId = identity?.owner_id ?? null;
|
||||
|
||||
const tpl = await getTemplate(db, templateId);
|
||||
const allowed: string[] = tpl ? JSON.parse(tpl.slots_json) : [...slotToEntry.keys()];
|
||||
const allowed: string[] = tpl ? JSON.parse(tpl.slots_json) : [...slotToEntries.keys()];
|
||||
|
||||
for (const [slot, content] of Object.entries(values)) {
|
||||
if (!allowed.includes(slot)) {
|
||||
throw new Error(`slot not in template: ${slot}`);
|
||||
}
|
||||
const entryId = slotToEntry.get(slot);
|
||||
if (entryId) {
|
||||
// flip the slot value: update the linked entry's content (table structure untouched)
|
||||
await db.prepare(`UPDATE entries SET content = ?, updated_at = unixepoch() WHERE id = ?`).bind(content, entryId).run();
|
||||
const entryIds = slotToEntries.get(slot);
|
||||
if (entryIds && entryIds.length > 0) {
|
||||
// flip the slot value: update the linked entry's content(指標連動語意)
|
||||
for (const entryId of entryIds) {
|
||||
await db.prepare(`UPDATE entries SET content = ?, updated_at = unixepoch() WHERE id = ?`).bind(content, entryId).run();
|
||||
}
|
||||
} else {
|
||||
// valid template slot not yet on this record → grow it (create entry + link)
|
||||
// owner_id 帶 record 既有歸屬(design §2.2 附帶修復,防孤兒 entry)
|
||||
// valid template slot not yet on this record → grow(entry + 關係列)
|
||||
await ensureFieldEntries(db, templateId, [slot]);
|
||||
const entry = await createEntry(db, { content, entry_type: 'value', owner_id: recordOwnerId });
|
||||
await db
|
||||
.prepare(`INSERT INTO entry_values (id, record_id, template_id, slot_name, entry_id) VALUES (?, ?, ?, ?, ?)`)
|
||||
.bind(uid('ev'), recordId, templateId, slot, entry.id)
|
||||
.run();
|
||||
await insertCellRelation(db, recordId, templateId, slot, entry.id, recordOwnerId);
|
||||
}
|
||||
}
|
||||
return getRecord(db, recordId);
|
||||
}
|
||||
|
||||
export async function getRecord(db: D1Database, recordId: string): Promise<RecordResult | null> {
|
||||
const belongs = await recordBelongs(db, recordId);
|
||||
if (!belongs) return null;
|
||||
const res = await db
|
||||
.prepare(
|
||||
`SELECT ev.slot_name as slot, e.content as content, ev.template_id as template_id, e.owner_id as owner_id
|
||||
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
|
||||
WHERE ev.record_id = ?`,
|
||||
`SELECT f.content AS slot, v.content AS content
|
||||
FROM entries r
|
||||
JOIN entries f ON r.rel_id = f.id
|
||||
JOIN entries v ON r.dst_id = v.id
|
||||
WHERE r.src_id = ? AND r.rel_id != '${SYS_BELONGS}'`,
|
||||
)
|
||||
.bind(recordId)
|
||||
.all<{ slot: string; content: string; template_id: string; owner_id: string | null }>();
|
||||
const rows = res.results ?? [];
|
||||
if (rows.length === 0) return null;
|
||||
.all<{ slot: string; content: string }>();
|
||||
const values: Record<string, string> = {};
|
||||
for (const r of rows) values[r.slot] = r.content;
|
||||
// 歸屬取第一個非 null 的 slot entry owner(同一 record 的 slot entries 同歸屬)
|
||||
const owner_id = rows.find((r) => r.owner_id != null)?.owner_id ?? null;
|
||||
return { record_id: recordId, template_id: rows[0].template_id, values, owner_id };
|
||||
for (const r of res.results ?? []) values[r.slot] = r.content;
|
||||
const identity = await db.prepare('SELECT owner_id FROM entries WHERE id = ?').bind(recordId).first<{ owner_id: string | null }>();
|
||||
return { record_id: recordId, template_id: belongs.dst_id, values, owner_id: identity?.owner_id ?? null };
|
||||
}
|
||||
|
||||
export async function searchByTemplate(db: D1Database, template: string, owner_id?: string, limit = 100): Promise<RecordResult[]> {
|
||||
const tpl = await getTemplate(db, template);
|
||||
if (!tpl) return [];
|
||||
// owner_id 過濾在 SQL 做:record 的歸屬存在底層 entries.owner_id(createRecord 寫入時帶)。
|
||||
// 給了 owner_id → JOIN entries 限定該 owner(租戶隔離,cypher proxy 強制注入);
|
||||
// 沒給 → 不限(內部/全域查詢)。先前 `|| true` 是 stub,會洩漏跨租戶資料(2026-06-14 修)。
|
||||
const cap = Math.min(limit, 500);
|
||||
// record ids:歸屬關係(rel=屬於, dst=sheet)就是成員名單——(dst_id, rel_id) 索引直達。
|
||||
// owner 過濾下在歸屬關係列的 owner_id(createRecord 寫入時同值,0007 遷移同一推導)。
|
||||
const res = owner_id
|
||||
? await db
|
||||
.prepare(
|
||||
`SELECT DISTINCT ev.record_id as record_id FROM entry_values ev
|
||||
JOIN entries e ON ev.entry_id = e.id
|
||||
WHERE ev.template_id = ? AND e.owner_id = ?
|
||||
ORDER BY ev.created_at DESC LIMIT ?`,
|
||||
`SELECT src_id AS record_id FROM entries
|
||||
WHERE rel_id = '${SYS_BELONGS}' AND dst_id = ? AND owner_id = ?
|
||||
ORDER BY created_at DESC, rowid DESC LIMIT ?`,
|
||||
)
|
||||
.bind(tpl.id, owner_id, cap)
|
||||
.all<{ record_id: string }>()
|
||||
: await db
|
||||
.prepare(`SELECT DISTINCT record_id FROM entry_values WHERE template_id = ? ORDER BY created_at DESC LIMIT ?`)
|
||||
.prepare(
|
||||
`SELECT src_id AS record_id FROM entries
|
||||
WHERE rel_id = '${SYS_BELONGS}' AND dst_id = ?
|
||||
ORDER BY created_at DESC, rowid DESC LIMIT ?`,
|
||||
)
|
||||
.bind(tpl.id, cap)
|
||||
.all<{ record_id: string }>();
|
||||
// 批次撈齊所有 record 的 slot 值(2026-07-18 修 N+1:原本逐筆 getRecord=每筆 1 次 D1
|
||||
// 往返,100 筆 triplet ≈ 19 秒——graph/總圖/rag_chat 的「查詢 20-30 秒」病根就是這裡)。
|
||||
// D1 綁定參數上限 100 → id 以 90 一組分批 IN 查詢;輸出保持原排序(created_at DESC)。
|
||||
const ids = (res.results ?? []).map((r) => r.record_id);
|
||||
if (ids.length === 0) return [];
|
||||
|
||||
// 批次撈齊格子與身分(N+1 教訓照舊:D1 綁定參數上限 100 → 90 一組)。
|
||||
const byId = new Map<string, RecordResult>();
|
||||
for (const id of ids) byId.set(id, { record_id: id, template_id: tpl.id, values: {}, owner_id: null });
|
||||
for (let i = 0; i < ids.length; i += 90) {
|
||||
const chunk = ids.slice(i, i + 90);
|
||||
const placeholders = chunk.map(() => '?').join(',');
|
||||
const evRes = await db
|
||||
.prepare(
|
||||
`SELECT ev.record_id as record_id, ev.slot_name as slot, e.content as content, ev.template_id as template_id, e.owner_id as owner_id
|
||||
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
|
||||
WHERE ev.record_id IN (${placeholders})`,
|
||||
)
|
||||
.bind(...chunk)
|
||||
.all<{ record_id: string; slot: string; content: string; template_id: string; owner_id: string | null }>();
|
||||
for (const r of evRes.results ?? []) {
|
||||
let rec = byId.get(r.record_id);
|
||||
if (!rec) {
|
||||
rec = { record_id: r.record_id, template_id: r.template_id, values: {}, owner_id: null };
|
||||
byId.set(r.record_id, rec);
|
||||
}
|
||||
rec.values[r.slot] = r.content;
|
||||
if (rec.owner_id == null && r.owner_id != null) rec.owner_id = r.owner_id;
|
||||
const [cellRes, identRes] = await Promise.all([
|
||||
db
|
||||
.prepare(
|
||||
`SELECT r.src_id AS record_id, f.content AS slot, v.content AS content
|
||||
FROM entries r
|
||||
JOIN entries f ON r.rel_id = f.id
|
||||
JOIN entries v ON r.dst_id = v.id
|
||||
WHERE r.src_id IN (${placeholders}) AND r.rel_id != '${SYS_BELONGS}'`,
|
||||
)
|
||||
.bind(...chunk)
|
||||
.all<{ record_id: string; slot: string; content: string }>(),
|
||||
db
|
||||
.prepare(`SELECT id, owner_id FROM entries WHERE id IN (${placeholders})`)
|
||||
.bind(...chunk)
|
||||
.all<{ id: string; owner_id: string | null }>(),
|
||||
]);
|
||||
for (const r of cellRes.results ?? []) {
|
||||
const rec = byId.get(r.record_id);
|
||||
if (rec) rec.values[r.slot] = r.content;
|
||||
}
|
||||
for (const r of identRes.results ?? []) {
|
||||
const rec = byId.get(r.id);
|
||||
if (rec) rec.owner_id = r.owner_id;
|
||||
}
|
||||
}
|
||||
return ids.map((id) => byId.get(id)).filter((r): r is RecordResult => !!r);
|
||||
}
|
||||
|
||||
/** 刪除一筆 record:先刪 entry_values(FK),再刪底層 entries。回 false 表示 record 不存在。 */
|
||||
/**
|
||||
* 刪除一筆 record:刪它的所有關係列+(裸的)身分 entry,指到的內容 entry
|
||||
* 只在「已經沒有任何關係指著、也不自己發出關係」時才刪。回 false = record 不存在。
|
||||
*
|
||||
* 🔴 **還有別人指著的 entry 不刪**(Arcrun#128 配套,新模型的原生形狀):
|
||||
* 指標共用天生成立 ⇒ 同一顆 entry 會被多筆 record 指著;只刪自己的指標,不刪別人的真身。
|
||||
* 🔴 **block 即 record 的身分不刪**:record_id 是既有 block(library_map 慣例)時,
|
||||
* 身分 entry 的 entry_type 不是 'record' ⇒ 只拆關係,block 本體留在池裡(與舊行為一致)。
|
||||
*/
|
||||
export async function deleteRecord(db: D1Database, recordId: string): Promise<boolean> {
|
||||
const evRes = await db
|
||||
.prepare('SELECT entry_id FROM entry_values WHERE record_id = ?')
|
||||
const belongs = await recordBelongs(db, recordId);
|
||||
if (!belongs) return false;
|
||||
const cellRes = await db
|
||||
.prepare(`SELECT dst_id FROM entries WHERE src_id = ? AND rel_id != '${SYS_BELONGS}'`)
|
||||
.bind(recordId)
|
||||
.all<{ entry_id: string }>();
|
||||
const rows = evRes.results ?? [];
|
||||
if (rows.length === 0) return false;
|
||||
await db.prepare('DELETE FROM entry_values WHERE record_id = ?').bind(recordId).run();
|
||||
for (const { entry_id } of rows) {
|
||||
await db.prepare('DELETE FROM entries WHERE id = ?').bind(entry_id).run();
|
||||
.all<{ dst_id: string }>();
|
||||
const dsts = (cellRes.results ?? []).map((r) => r.dst_id);
|
||||
|
||||
// 這筆 record 發出的所有關係列(格子+歸屬)一次拆掉
|
||||
await db.prepare(`DELETE FROM entries WHERE src_id = ?`).bind(recordId).run();
|
||||
// 裸身分 entry(entry_type='record')才刪;被別的關係指著就留(變回池中普通 entry)
|
||||
await db
|
||||
.prepare(
|
||||
`DELETE FROM entries WHERE id = ?1 AND entry_type = 'record'
|
||||
AND NOT EXISTS (SELECT 1 FROM entries WHERE dst_id = ?1)`,
|
||||
)
|
||||
.bind(recordId)
|
||||
.run();
|
||||
// 指到的內容 entry:沒有任何關係指著、自己也不發出關係、且不是機制節點 → 才刪
|
||||
for (const dst of dsts) {
|
||||
await db
|
||||
.prepare(
|
||||
`DELETE FROM entries WHERE id = ?1
|
||||
AND entry_type NOT IN ('sheet', 'field', 'system')
|
||||
AND NOT EXISTS (SELECT 1 FROM entries WHERE dst_id = ?1)
|
||||
AND NOT EXISTS (SELECT 1 FROM entries WHERE src_id = ?1)
|
||||
AND NOT EXISTS (SELECT 1 FROM entries WHERE rel_id = ?1)`,
|
||||
)
|
||||
.bind(dst)
|
||||
.run();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// 關係列孤兒巡檢(0007 樹狀 record 模型的配套,v7 §5 明訂「要寫成明確的巡檢項,不能默認」)。
|
||||
//
|
||||
// 為什麼要有這支(不是順手加的):舊模型的孤兒偵測靠 entry_values 的外鍵形狀——
|
||||
// 2026-06-24 那次 11 萬筆誤寫的清理就是拿 FK LEFT JOIN 找斷鏈
|
||||
// (system-dev/docs/5-records/2026-06-24-official-kbdb-cleanup-leo-misdelete.md)。
|
||||
// 0007 拆掉 entry_values 後,關係列的 src/rel/dst 指標**沒有 FK**(同一張表指自己,
|
||||
// SQLite 自參照 FK 會把插入順序綁死,且 D1 逐句執行無交易可 defer)⇒ 斷鏈不再被
|
||||
// 資料庫擋下,改由本巡檢主動找:**孤兒=指標指向不存在 id 的關係列**。
|
||||
// 掃法與舊 FK 形狀同款(LEFT JOIN 找斷鏈),v7 押的「形狀可承接」在這裡兌現。
|
||||
//
|
||||
// 什麼情況會產生孤兒(誠實列):
|
||||
// 1. DELETE /entries/:id 的舊資料時代殘骸(新 deleteEntry 已擋 dst 被指著的刪除)
|
||||
// 2. 遷移時 template 已被刪但 entry_values 還留著格子(0007 保險網補 field entry,
|
||||
// 但 dst=template 的名冊關係可能指到不存在的 sheet)
|
||||
// 3. 未來任何繞過牆的直接寫入(本巡檢就是抓它們的網)
|
||||
import type { D1Database } from '@cloudflare/workers-types';
|
||||
|
||||
export interface RelationOrphan {
|
||||
relation_id: string;
|
||||
role: 'src' | 'rel' | 'dst';
|
||||
missing_id: string;
|
||||
}
|
||||
|
||||
export interface RelationOrphanReport {
|
||||
orphans: RelationOrphan[];
|
||||
count: number; // 本次回報筆數(受 limit 截斷)
|
||||
truncated: boolean; // true = 還有更多,加大 limit 或先清這批再掃
|
||||
}
|
||||
|
||||
export async function scanRelationOrphans(db: D1Database, limit = 200): Promise<RelationOrphanReport> {
|
||||
const cap = Math.min(Math.max(limit, 1), 1000);
|
||||
const res = await db
|
||||
.prepare(
|
||||
`SELECT relation_id, role, missing_id FROM (
|
||||
SELECT r.id AS relation_id, 'src' AS role, r.src_id AS missing_id
|
||||
FROM entries r LEFT JOIN entries t ON t.id = r.src_id
|
||||
WHERE r.src_id IS NOT NULL AND t.id IS NULL
|
||||
UNION ALL
|
||||
SELECT r.id, 'rel', r.rel_id
|
||||
FROM entries r LEFT JOIN entries t ON t.id = r.rel_id
|
||||
WHERE r.rel_id IS NOT NULL AND t.id IS NULL
|
||||
UNION ALL
|
||||
SELECT r.id, 'dst', r.dst_id
|
||||
FROM entries r LEFT JOIN entries t ON t.id = r.dst_id
|
||||
WHERE r.dst_id IS NOT NULL AND t.id IS NULL
|
||||
) LIMIT ?`,
|
||||
)
|
||||
.bind(cap + 1)
|
||||
.all<RelationOrphan>();
|
||||
const rows = res.results ?? [];
|
||||
const truncated = rows.length > cap;
|
||||
const orphans = truncated ? rows.slice(0, cap) : rows;
|
||||
return { orphans, count: orphans.length, truncated };
|
||||
}
|
||||
@@ -39,6 +39,16 @@ app.use('*', async (c, next) => {
|
||||
app.get('/', (c) => c.json({ service: 'arcrun-kbdb', tier: 'base', status: 'ok' }));
|
||||
app.get('/health', (c) => c.json({ ok: true }));
|
||||
|
||||
// 關係列孤兒巡檢(0007 配套;v7 §5「新模型的孤兒=指標指向不存在 id 的關係列」,
|
||||
// 舊 entry_values FK 形狀的承接——2026-06-24 清理事故用的就是同款 LEFT JOIN 斷鏈掃描)。
|
||||
// 唯讀,不自動清:清哪些要人裁(同 embed 孤兒清理的慣例,發現與處置分開)。
|
||||
app.get('/maintenance/relation-orphans', async (c) => {
|
||||
const { scanRelationOrphans } = await import('./actions/relation-orphans');
|
||||
const limit = Number(c.req.query('limit') ?? '200');
|
||||
const report = await scanRelationOrphans(c.env.DB, Number.isFinite(limit) ? limit : 200);
|
||||
return c.json({ success: true, ...report });
|
||||
});
|
||||
|
||||
app.route('/entries', entryRoutes);
|
||||
app.route('/templates', templateRoutes);
|
||||
app.route('/records', recordRoutes);
|
||||
|
||||
+27
-19
@@ -5,11 +5,25 @@ import { createRecord, deleteRecord, getRecord, searchByTemplate, updateRecord }
|
||||
|
||||
export const recordRoutes = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
// POST /records — { template, values:{slot:content}, owner_id? }
|
||||
// POST /records — { template, values:{slot:content}?, entry_ids:{slot:既有entry_id}?, owner_id? }
|
||||
//
|
||||
// values = 給字串 → **新建**一筆 entry 當這個 slot 的值(原本就有的路,行為不變)
|
||||
// entry_ids = 給既有 entry 的 id → **指向**水池裡那條 entry,不複製(Arcrun#128 的外鍵)
|
||||
// 兩者可混用;至少要有一個。**只給 entry_ids 是合法的**——例如一則 wiki 的五段在 KBDB 裡
|
||||
// 本來就已經是五筆既有 entry,建 record 只是替它們取名字,不該再生出任何新 entry。
|
||||
const isStringMap = (v: unknown): boolean =>
|
||||
!!v && typeof v === 'object' && !Array.isArray(v) && Object.values(v as object).every((x) => typeof x === 'string');
|
||||
|
||||
recordRoutes.post('/', async (c) => {
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (!body || !body.template || !body.values) {
|
||||
return c.json({ success: false, error: 'template and values required' }, 400);
|
||||
if (!body || !body.template || (!body.values && !body.entry_ids)) {
|
||||
return c.json({ success: false, error: 'template and values (or entry_ids) required' }, 400);
|
||||
}
|
||||
if (body.values !== undefined && !isStringMap(body.values)) {
|
||||
return c.json({ success: false, error: 'values must be an object of {slot: string}' }, 400);
|
||||
}
|
||||
if (body.entry_ids !== undefined && !isStringMap(body.entry_ids)) {
|
||||
return c.json({ success: false, error: 'entry_ids must be an object of {slot: entry_id}' }, 400);
|
||||
}
|
||||
try {
|
||||
const rec = await createRecord(c.env.DB, body);
|
||||
@@ -22,27 +36,21 @@ recordRoutes.post('/', async (c) => {
|
||||
// GET /records/triplet-stats?owner_id=... — 每個庫的三元組(關聯)數。
|
||||
// t142(2026-07-29):政府驗收——顯示每個庫整理出幾條知識關聯。
|
||||
// 計法:依 triplet 型 record 的 'library' slot 值分組計數。無 library slot 的舊三元組歸 general。
|
||||
// 使用子查詢先取 distinct triplet record IDs(針對 owner),再 LEFT JOIN library slot,
|
||||
// 避免 N+1(全部一次 SQL 完成,不逐筆 getRecord)。
|
||||
// 0007 之後:record 成員名單=歸屬關係列(rel=sys_belongs, dst=triplet sheet),
|
||||
// library 格子=rel 為決定性欄位謂詞 id(fld_<template>_library)的關係列——
|
||||
// 一次 SQL 完成(無 N+1),owner 過濾下在歸屬關係列的 owner_id。
|
||||
recordRoutes.get('/triplet-stats', async (c) => {
|
||||
const owner = c.req.query('owner_id') || '';
|
||||
// 子查詢:找到屬於這個 owner 的所有 triplet records;LEFT JOIN library slot 取庫名
|
||||
const rows = await c.env.DB.prepare(
|
||||
`SELECT
|
||||
COALESCE(NULLIF(lib_e.content, ''), 'general') AS library,
|
||||
COALESCE(NULLIF(lib_v.content, ''), 'general') AS library,
|
||||
COUNT(*) AS triplet_count
|
||||
FROM (
|
||||
SELECT DISTINCT ev.record_id
|
||||
FROM entry_values ev
|
||||
JOIN templates t ON ev.template_id = t.id
|
||||
JOIN entries e ON ev.entry_id = e.id
|
||||
WHERE t.name = 'triplet'
|
||||
AND (?1 = '' OR e.owner_id = ?1)
|
||||
) AS tr
|
||||
LEFT JOIN entry_values lev
|
||||
ON lev.record_id = tr.record_id AND lev.slot_name = 'library'
|
||||
LEFT JOIN entries lib_e ON lib_e.id = lev.entry_id
|
||||
GROUP BY COALESCE(NULLIF(lib_e.content, ''), 'general')
|
||||
FROM entries b
|
||||
JOIN templates t ON b.dst_id = t.id AND t.name = 'triplet'
|
||||
LEFT JOIN entries lr ON lr.src_id = b.src_id AND lr.rel_id = ('fld_' || b.dst_id || '_library')
|
||||
LEFT JOIN entries lib_v ON lib_v.id = lr.dst_id
|
||||
WHERE b.rel_id = 'sys_belongs' AND (?1 = '' OR b.owner_id = ?1)
|
||||
GROUP BY COALESCE(NULLIF(lib_v.content, ''), 'general')
|
||||
ORDER BY library`,
|
||||
)
|
||||
.bind(owner)
|
||||
|
||||
+15
-1
@@ -49,7 +49,16 @@ export type EntryType =
|
||||
| 'execution_log'
|
||||
| 'execution_log_usage'
|
||||
| 'embed_backfill_usage'
|
||||
| 'kbdb_maintenance_usage';
|
||||
| 'kbdb_maintenance_usage'
|
||||
// 樹狀 record 模型(0007,v7 定稿 2026-08-15):機制節點與關係列。
|
||||
// 誠實註記:entry_type 在 v7 §7 的白名單終局裡會整欄消失(型別只能由關係推導),
|
||||
// 這裡先沿用它做遷移期的機制列標記——程式邏輯一律以指標欄(src_id IS NOT NULL)判斷
|
||||
// 「是不是關係列」,不依賴這個標記。
|
||||
| 'relation' // 一列關係:src ─rel→ dst(池上型別化指標欄)
|
||||
| 'record' // record 的裸身分 entry(block 即 record 時身分是那顆 block,不是這個型別)
|
||||
| 'sheet' // 一張表(template 在池中的身分,id 沿用 template id)
|
||||
| 'field' // 一個欄位(欄名=關係的謂詞)
|
||||
| 'system'; // 啟動常數(sys_root / sys_belongs / sys_field_of)
|
||||
|
||||
export interface Entry {
|
||||
id: string;
|
||||
@@ -65,6 +74,11 @@ export interface Entry {
|
||||
is_embedded: number;
|
||||
confidence: number | null;
|
||||
metadata_json: string | null;
|
||||
// 關係的物理載體(0007):池上型別化指標欄。內容 entry 三欄全 NULL;
|
||||
// 關係列三欄全非 NULL(src ─rel→ dst)。紅線:指標永不塞回 content/JSON(D91)。
|
||||
src_id: string | null;
|
||||
rel_id: string | null;
|
||||
dst_id: string | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user