diff --git a/services/comptroller/node-async-hooks.d.ts b/services/comptroller/node-async-hooks.d.ts new file mode 100644 index 0000000..7e96143 --- /dev/null +++ b/services/comptroller/node-async-hooks.d.ts @@ -0,0 +1,9 @@ +// Minimal ambient type for node:async_hooks AsyncLocalStorage. +// Available at runtime on Cloudflare Workers via the `nodejs_compat` flag. +// Declared locally to avoid pulling in all of @types/node. +declare module "node:async_hooks" { + export class AsyncLocalStorage { + run(store: T, callback: () => R): R; + getStore(): T | undefined; + } +} diff --git a/services/comptroller/package.json b/services/comptroller/package.json new file mode 100644 index 0000000..c03d7e2 --- /dev/null +++ b/services/comptroller/package.json @@ -0,0 +1,19 @@ +{ + "name": "comptroller", + "version": "1.0.0", + "private": true, + "description": "ChittyComptroller — sibling budget observer + delegated enforcer (comptroller.chitty.cc)", + "main": "worker.ts", + "scripts": { + "check": "tsc --noEmit", + "deploy": "wrangler deploy" + }, + "dependencies": { + "postgres": "^3.4.5" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250510.0", + "typescript": "^5.6.3", + "wrangler": "^4.0.0" + } +} diff --git a/services/comptroller/pnpm-lock.yaml b/services/comptroller/pnpm-lock.yaml new file mode 100644 index 0000000..b800f4e --- /dev/null +++ b/services/comptroller/pnpm-lock.yaml @@ -0,0 +1,898 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + postgres: + specifier: ^3.4.5 + version: 3.4.9 + devDependencies: + '@cloudflare/workers-types': + specifier: ^4.20250510.0 + version: 4.20260610.1 + typescript: + specifier: ^5.6.3 + version: 5.9.3 + wrangler: + specifier: ^4.0.0 + version: 4.99.0(@cloudflare/workers-types@4.20260610.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.20260609.1': + resolution: {integrity: sha512-AK8tYLQm+8BqQMzjZ55ZfuhfIm1eCkj+Ykxz6kWXojdACwjjU03MrwdM9fBDdgzU3upXOs4e1scOFHySlfVQjA==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@cloudflare/workerd-darwin-arm64@1.20260609.1': + resolution: {integrity: sha512-4kKXfr7ZHU6xQ/R9ShdSuj1A1bEouoRcHzUWdjnuMPBlRsAAVanlxAVYISotFUulLEinayOpRFbhpsfwzrpSSw==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@cloudflare/workerd-linux-64@1.20260609.1': + resolution: {integrity: sha512-T2Ebir2OPHAvvZ0HUh5mi1lN8q30sVi4lf7LIpc28AHoWtoOmJ0jA5AJK4IYJm1MKEbBldq+QsckaHOCQFmRpQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@cloudflare/workerd-linux-arm64@1.20260609.1': + resolution: {integrity: sha512-INfcYoSsKqEIvPL69/3RkqYoP8WUR0VEN6loWN/3tekXLoJrVOj3E5NjIetsdS8MJN6zc3st/ae4bMuWRRzoDg==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@cloudflare/workerd-windows-64@1.20260609.1': + resolution: {integrity: sha512-EWhfxKI1aqUr7S8xuGxgmRCumEzB8iSsCIz6oEqJN+3pZuW3EWiKDGFW4EY1BmwNINLW1eO5VMGYb8Fj6FVYxA==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + + '@cloudflare/workers-types@4.20260610.1': + resolution: {integrity: sha512-Mk/f3lUygeIHzQ4HnJjU/JvGg/kllgp9gISty9nylHE/2M2MFeKO+hgAKSgiPpmwUbuhewdYGgqFGgT/ADK0/g==} + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + + '@emnapi/runtime@1.11.0': + resolution: {integrity: sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==} + + '@esbuild/aix-ppc64@0.27.3': + resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.3': + resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.3': + resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.3': + resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.3': + resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.3': + resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.3': + resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.3': + resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.3': + resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.3': + resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.3': + resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.3': + resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.3': + resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.3': + resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.3': + resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.3': + resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.3': + resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.3': + resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.3': + resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.3': + resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.3': + resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.3': + resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.3': + resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.3': + resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.3': + resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.3': + resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + + '@poppinss/colors@4.1.6': + resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} + + '@poppinss/dumper@0.6.5': + resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==} + + '@poppinss/exception@1.2.3': + resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + + '@sindresorhus/is@7.2.0': + resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} + engines: {node: '>=18'} + + '@speed-highlight/core@1.2.16': + resolution: {integrity: sha512-yNm/fYEcnpRjYduLMaddTK9XKYil6xB88+qFg79ZdZhHu1PadfoQmFW7pVTx7FZqMBNcUuThiAhxhENgtAO2/w==} + + blake3-wasm@2.1.5: + resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + error-stack-parser-es@1.0.5: + resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + + esbuild@0.27.3: + resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} + engines: {node: '>=18'} + hasBin: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + miniflare@4.20260609.0: + resolution: {integrity: sha512-4ZfNh9ACDa/mKKQvTSO2vigyQS2MB7dEU02KRPle4FqL7S6nek+2Fq6WGzazZbt1OORYgb4OGVLnOCx+My2NNA==} + engines: {node: '>=22.0.0'} + hasBin: true + + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + postgres@3.4.9: + resolution: {integrity: sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==} + engines: {node: '>=12'} + + semver@7.8.4: + resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==} + engines: {node: '>=10'} + hasBin: true + + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici@7.24.8: + resolution: {integrity: sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==} + engines: {node: '>=20.18.1'} + + unenv@2.0.0-rc.24: + resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + + workerd@1.20260609.1: + resolution: {integrity: sha512-KF/Y/8f4VoXCk87NuU6RqmO0X5fdzcrxU3XzAgoPUpnH9t1ZyzRgX1O/9sJvjItxroCBTEBzKssda02Dz9i6BA==} + engines: {node: '>=16'} + hasBin: true + + wrangler@4.99.0: + resolution: {integrity: sha512-i7GA2mZETTyq3ljWdEzM908FjLaMWZ1AaAHKaOJ8pFA/tonf2VqIWDyBGzKleIVBbNQxOTIY2wnbv0iaK3rC6g==} + engines: {node: '>=22.0.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^4.20260609.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + + ws@8.20.1: + resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} + 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.20260609.1)': + dependencies: + unenv: 2.0.0-rc.24 + optionalDependencies: + workerd: 1.20260609.1 + + '@cloudflare/workerd-darwin-64@1.20260609.1': + optional: true + + '@cloudflare/workerd-darwin-arm64@1.20260609.1': + optional: true + + '@cloudflare/workerd-linux-64@1.20260609.1': + optional: true + + '@cloudflare/workerd-linux-arm64@1.20260609.1': + optional: true + + '@cloudflare/workerd-windows-64@1.20260609.1': + optional: true + + '@cloudflare/workers-types@4.20260610.1': {} + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + + '@emnapi/runtime@1.11.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.27.3': + optional: true + + '@esbuild/android-arm64@0.27.3': + optional: true + + '@esbuild/android-arm@0.27.3': + optional: true + + '@esbuild/android-x64@0.27.3': + optional: true + + '@esbuild/darwin-arm64@0.27.3': + optional: true + + '@esbuild/darwin-x64@0.27.3': + optional: true + + '@esbuild/freebsd-arm64@0.27.3': + optional: true + + '@esbuild/freebsd-x64@0.27.3': + optional: true + + '@esbuild/linux-arm64@0.27.3': + optional: true + + '@esbuild/linux-arm@0.27.3': + optional: true + + '@esbuild/linux-ia32@0.27.3': + optional: true + + '@esbuild/linux-loong64@0.27.3': + optional: true + + '@esbuild/linux-mips64el@0.27.3': + optional: true + + '@esbuild/linux-ppc64@0.27.3': + optional: true + + '@esbuild/linux-riscv64@0.27.3': + optional: true + + '@esbuild/linux-s390x@0.27.3': + optional: true + + '@esbuild/linux-x64@0.27.3': + optional: true + + '@esbuild/netbsd-arm64@0.27.3': + optional: true + + '@esbuild/netbsd-x64@0.27.3': + optional: true + + '@esbuild/openbsd-arm64@0.27.3': + optional: true + + '@esbuild/openbsd-x64@0.27.3': + optional: true + + '@esbuild/openharmony-arm64@0.27.3': + optional: true + + '@esbuild/sunos-x64@0.27.3': + optional: true + + '@esbuild/win32-arm64@0.27.3': + optional: true + + '@esbuild/win32-ia32@0.27.3': + optional: true + + '@esbuild/win32-x64@0.27.3': + optional: true + + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.11.0 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@poppinss/colors@4.1.6': + dependencies: + kleur: 4.1.5 + + '@poppinss/dumper@0.6.5': + dependencies: + '@poppinss/colors': 4.1.6 + '@sindresorhus/is': 7.2.0 + supports-color: 10.2.2 + + '@poppinss/exception@1.2.3': {} + + '@sindresorhus/is@7.2.0': {} + + '@speed-highlight/core@1.2.16': {} + + blake3-wasm@2.1.5: {} + + cookie@1.1.1: {} + + detect-libc@2.1.2: {} + + error-stack-parser-es@1.0.5: {} + + esbuild@0.27.3: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.3 + '@esbuild/android-arm': 0.27.3 + '@esbuild/android-arm64': 0.27.3 + '@esbuild/android-x64': 0.27.3 + '@esbuild/darwin-arm64': 0.27.3 + '@esbuild/darwin-x64': 0.27.3 + '@esbuild/freebsd-arm64': 0.27.3 + '@esbuild/freebsd-x64': 0.27.3 + '@esbuild/linux-arm': 0.27.3 + '@esbuild/linux-arm64': 0.27.3 + '@esbuild/linux-ia32': 0.27.3 + '@esbuild/linux-loong64': 0.27.3 + '@esbuild/linux-mips64el': 0.27.3 + '@esbuild/linux-ppc64': 0.27.3 + '@esbuild/linux-riscv64': 0.27.3 + '@esbuild/linux-s390x': 0.27.3 + '@esbuild/linux-x64': 0.27.3 + '@esbuild/netbsd-arm64': 0.27.3 + '@esbuild/netbsd-x64': 0.27.3 + '@esbuild/openbsd-arm64': 0.27.3 + '@esbuild/openbsd-x64': 0.27.3 + '@esbuild/openharmony-arm64': 0.27.3 + '@esbuild/sunos-x64': 0.27.3 + '@esbuild/win32-arm64': 0.27.3 + '@esbuild/win32-ia32': 0.27.3 + '@esbuild/win32-x64': 0.27.3 + + fsevents@2.3.3: + optional: true + + kleur@4.1.5: {} + + miniflare@4.20260609.0: + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.34.5 + undici: 7.24.8 + workerd: 1.20260609.1 + ws: 8.20.1 + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + path-to-regexp@6.3.0: {} + + pathe@2.0.3: {} + + postgres@3.4.9: {} + + semver@7.8.4: {} + + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.4 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + + supports-color@10.2.2: {} + + tslib@2.8.1: + optional: true + + typescript@5.9.3: {} + + undici@7.24.8: {} + + unenv@2.0.0-rc.24: + dependencies: + pathe: 2.0.3 + + workerd@1.20260609.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20260609.1 + '@cloudflare/workerd-darwin-arm64': 1.20260609.1 + '@cloudflare/workerd-linux-64': 1.20260609.1 + '@cloudflare/workerd-linux-arm64': 1.20260609.1 + '@cloudflare/workerd-windows-64': 1.20260609.1 + + wrangler@4.99.0(@cloudflare/workers-types@4.20260610.1): + dependencies: + '@cloudflare/kv-asset-handler': 0.5.0 + '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260609.1) + blake3-wasm: 2.1.5 + esbuild: 0.27.3 + miniflare: 4.20260609.0 + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.24 + workerd: 1.20260609.1 + optionalDependencies: + '@cloudflare/workers-types': 4.20260610.1 + fsevents: 2.3.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + ws@8.20.1: {} + + 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.16 + cookie: 1.1.1 + youch-core: 0.3.3 diff --git a/services/comptroller/tsconfig.json b/services/comptroller/tsconfig.json new file mode 100644 index 0000000..41a62ab --- /dev/null +++ b/services/comptroller/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022"], + "types": ["@cloudflare/workers-types"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "isolatedModules": true + }, + "include": ["worker.ts", "crypto.ts", "types.ts", "node-async-hooks.d.ts"] +} diff --git a/services/comptroller/worker.ts b/services/comptroller/worker.ts index 8714282..dadb7fe 100644 --- a/services/comptroller/worker.ts +++ b/services/comptroller/worker.ts @@ -8,19 +8,45 @@ * * Safe-state: cold-start defaults to L1-only for 24h. * Forecasting: EWMA + seasonality. NEVER uses LLM above T0. + * + * Data layer: + * - READ : env.NEON_COMPTROLLER (Hyperdrive, comptroller_reader, read-only) → getDb(env) + * - WRITE : env.NEON_COMPTROLLER_WRITER (Hyperdrive over RW role) → getWriteDb(env) + * Both Hyperdrive bindings expose a `.connectionString`; we drive them with + * porsager `postgres` (works on Workers over Hyperdrive's TCP socket). + * getWriteDb() FAILS CLOSED if the writer binding is absent (Phase-A blocker). */ +import postgres from "postgres"; +// AsyncLocalStorage is provided at runtime by the `nodejs_compat` flag. Its type comes +// from a minimal ambient declaration (node-async-hooks.d.ts) rather than all of @types/node. +import { AsyncLocalStorage } from "node:async_hooks"; + +// ---- Cloudflare Workers Hyperdrive binding (typed locally to avoid pulling full env types) ---- +interface HyperdriveLike { + connectionString: string; +} + +// Workers-AI binding (T0). Typed locally to avoid pulling full @cloudflare/workers-types Ai. +interface AiLike { + run(model: string, inputs: Record): Promise; +} + interface Env { - NEON_COMPTROLLER: Hyperdrive; // comptroller_reader role on chittyops schema + AI: AiLike; // Workers-AI (T0) — narrative generation for /api/v1/insights ONLY + NEON_COMPTROLLER: HyperdriveLike; // comptroller_reader role (read-only) + NEON_COMPTROLLER_WRITER?: HyperdriveLike; // RW role — Phase-A writer (provision separately) KV_STATE: KVNamespace; - CF_AI_GATEWAY_TOKEN: string; - ANTHROPIC_BILLING_KEY: string; - GOOGLE_AI_STUDIO_KEY: string; - CF_ACCOUNT_API_TOKEN: string; - QUO_API_KEY: string; - NOTION_API_KEY: string; - NOTION_BUSINESS_REPORT_PAGE_ID: string; - NOTION_LEGALINK_REPORT_PAGE_ID: string; + CF_AI_GATEWAY_TOKEN?: string; + ANTHROPIC_BILLING_KEY?: string; + GOOGLE_AI_STUDIO_KEY?: string; + CF_ACCOUNT_API_TOKEN?: string; + CF_ACCOUNT_ID?: string; + COMPTROLLER_HMAC_KEY?: string; + QUO_API_KEY?: string; + NOTION_API_KEY?: string; + NOTION_BUSINESS_REPORT_PAGE_ID?: string; + NOTION_LEGALINK_REPORT_PAGE_ID?: string; REGISTRY_URL: string; // registry.chitty.cc HEARTBEAT_URL: string; // discovery.chitty.cc/heartbeat/comptroller } @@ -28,93 +54,264 @@ interface Env { const COLD_START_AT_KEY = "cold_start_at"; const SAFE_STATE_KEY = "safe_state_active"; const BASELINE_LEARNING_KEY = "baseline_learning_until"; +const BASELINE_LEARNING_DAYS = 14; + +// CF account that owns the AI Gateways (ChittyCorp). +const CF_ACCOUNT_ID = "0bc21e3a5a9de1a4cc843be9c3e98121"; + +// Active AI Gateways to ingest. codex-orchestration is empty → skipped. +const ACTIVE_GATEWAYS = ["chittygateway", "chittycounsel", "default", "chittyclaw"]; + +// Per-page log fetch + pagination bound (one 5-min run must stay bounded). +const LOGS_PER_PAGE = 50; +const MAX_PAGES_PER_GATEWAY = 20; + +// Phase-A hard caps (per-service MTD ceiling, USD). Real comparison; const map until +// per-service caps live in the registry/manifest. +const HARD_CAP_MTD_USD: Record = { + chittygateway: 50.0, + chittycounsel: 100.0, + default: 25.0, + chittyclaw: 50.0, +}; +const DEFAULT_HARD_CAP_MTD_USD = 50.0; + +// =================================================================================== +// DB helpers +// =================================================================================== + +type Sql = ReturnType; + +/** + * Per-invocation DB scope. + * + * On Cloudflare Workers, a porsager `postgres` client opens a TCP socket bound to the + * request/invocation that created it. Reusing a module-global client across invocations + * (cron + concurrent fetch) throws "Cannot perform I/O on behalf of a different request" + * once a later invocation touches that socket — and is a cross-request state leak. + * + * We therefore create the client(s) ONCE per invocation and store them in an + * AsyncLocalStorage scope (concurrency-safe: each scheduled() run and each fetch() + * request gets its own clients). getDb/getWriteDb resolve from the active scope, so the + * ~14 callsites stay untouched. withDbScope() ends the clients via ctx.waitUntil() after + * the handler returns, so no connections leak. + */ +interface DbScope { + read: Sql; + write: Sql | null; +} + +const dbScope = new AsyncLocalStorage(); + +function makeReadDb(env: Env): Sql { + return postgres(env.NEON_COMPTROLLER.connectionString, { + max: 5, + fetch_types: false, + prepare: false, + }); +} + +function makeWriteDb(env: Env): Sql | null { + if (!env.NEON_COMPTROLLER_WRITER?.connectionString) return null; + return postgres(env.NEON_COMPTROLLER_WRITER.connectionString, { + max: 5, + fetch_types: false, + prepare: false, + }); +} + +/** + * Run `fn` inside a fresh per-invocation DB scope and guarantee the clients are + * closed afterwards. Closing is deferred to ctx.waitUntil so an in-flight query in + * a floating promise can drain, but we never leave sockets open across invocations. + */ +async function withDbScope(env: Env, ctx: ExecutionContext, fn: () => Promise): Promise { + const scope: DbScope = { read: makeReadDb(env), write: makeWriteDb(env) }; + try { + return await dbScope.run(scope, fn); + } finally { + ctx.waitUntil(scope.read.end({ timeout: 5 }).catch(() => {})); + if (scope.write) ctx.waitUntil(scope.write.end({ timeout: 5 }).catch(() => {})); + } +} + +/** Read-only connection (comptroller_reader via Hyperdrive), scoped to this invocation. */ +function getDb(_env: Env): Sql { + const scope = dbScope.getStore(); + if (!scope) throw new Error("getDb() called outside a DB scope — wrap the handler in withDbScope()"); + return scope.read; +} + +/** + * Writer connection (RW role via a SEPARATE Hyperdrive binding), scoped to this invocation. + * FAILS CLOSED: returns null when the writer binding is not provisioned. Callers MUST + * treat null as "skip the write, log a clear reason" — never crash the poll. + */ +function getWriteDb(_env: Env): Sql | null { + const scope = dbScope.getStore(); + if (!scope) throw new Error("getWriteDb() called outside a DB scope — wrap the handler in withDbScope()"); + return scope.write; +} + +// =================================================================================== +// Worker entry +// =================================================================================== export default { async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext) { - const cronSpec = event.cron; - if (cronSpec === "*/5 * * * *") { - // every 5 min — metric collection - await pollMetrics(env); - } else if (cronSpec === "0 7 * * *") { - // 7 AM CT — daily report - await emitDailyReport(env); - } else if (cronSpec === "0 7 * * 1") { - // Mon 7 AM CT — weekly forecast - await emitWeeklyForecast(env); - } else if (cronSpec === "0 9 1 * *") { - // 1st of month, 9 AM CT — monthly closeout - await emitMonthlyCloseout(env); - } - await heartbeat(env); + await withDbScope(env, ctx, async () => { + await ensureColdStartState(env); + const cronSpec = event.cron; + if (cronSpec === "*/5 * * * *") { + await pollMetrics(env); + } else if (cronSpec === "0 7 * * *") { + await emitDailyReport(env); + } else if (cronSpec === "0 7 * * 1") { + await emitWeeklyForecast(env); + } else if (cronSpec === "0 9 1 * *") { + await emitMonthlyCloseout(env); + } + await heartbeat(env); + }); }, - async fetch(req: Request, env: Env): Promise { + async fetch(req: Request, env: Env, ctx: ExecutionContext): Promise { + return withDbScope(env, ctx, () => handleFetch(req, env)); + }, +}; + +async function handleFetch(req: Request, env: Env): Promise { const url = new URL(req.url); const now = new Date().toISOString(); - // ===== Compliance endpoints ===== if (url.pathname === "/health") { return Response.json({ status: "ok", service: "comptroller", version: "1.0.0", ts: now }); } if (url.pathname === "/api/v1/status") { - const authority = (await env.KV_STATE.get("authority")) ?? "L1"; - const baselineLearningUntil = await env.KV_STATE.get("baseline_learning_until"); + await ensureColdStartState(env); + const authority = (await env.KV_STATE.get("authority_level")) ?? "L1"; + const baselineLearningUntil = await env.KV_STATE.get(BASELINE_LEARNING_KEY); + const coldStartAt = await env.KV_STATE.get(COLD_START_AT_KEY); return Response.json({ status: "ok", service: "comptroller", version: "1.0.0", authority, + cold_start_at: coldStartAt, + safe_state: await isSafeStateActive(env), baseline_learning: baselineLearningUntil ? Date.now() < Date.parse(baselineLearningUntil) : false, baseline_learning_until: baselineLearningUntil, + writer_configured: !!env.NEON_COMPTROLLER_WRITER?.connectionString, ts: now, }); } if (url.pathname === "/api/v1/metrics") { - return Response.json({ status: "ok", service: "comptroller", note: "metrics endpoint scaffold — see /reports/daily for the full report", ts: now }); + try { + return Response.json(await fetchMetrics(env)); + } catch (e) { + return Response.json({ status: "error", service: "comptroller", error: String(e), ts: now }, { status: 500 }); + } } - // ===== Public API ===== if (url.pathname.endsWith("/status") && url.pathname.startsWith("/budget/")) { const service = url.pathname.split("/")[2]; return Response.json(await budgetStatus(env, service)); } if (url.pathname === "/reports/daily") { - return Response.json(await fetchDailyReport(env)); + try { + return Response.json(await fetchDailyReport(env)); + } catch (e) { + return Response.json({ status: "error", error: String(e), ts: now }, { status: 500 }); + } } if (url.pathname === "/anomalies") { return Response.json(await listAnomalies(env)); } - // ===== Admin API (auth-gated) ===== + // AI-categorization + deeper insight (Workers-AI T0). Numbers from SQL; model writes prose only. + // Cached ~6h in KV (insights:{chicago-date}); ?refresh=1 bypasses. Never on the 5-min poll. + if (url.pathname === "/api/v1/insights") { + try { + const refresh = url.searchParams.get("refresh") === "1"; + return Response.json(await fetchInsights(env, refresh)); + } catch (e) { + return Response.json({ status: "error", service: "comptroller", error: String(e), ts: now }, { status: 500 }); + } + } + if (url.pathname === "/_admin/authority" && req.method === "POST") { return await handleAuthorityChange(req, env); } if (url.pathname === "/_admin/baseline_learning/end" && req.method === "POST") { - // Operator manually ends baseline-learning early (after SMS confirm) return await handleBaselineLearningEnd(req, env); } + // Auth-gated manual poll trigger — forces a CF AI Gateway ingest + anomaly pass now + // instead of waiting for the */5 cron. Bearer token must equal COMPTROLLER_HMAC_KEY + // (constant-time compare). Same data path as the scheduled "*/5 * * * *" cron. + if (url.pathname === "/_admin/poll" && req.method === "POST") { + if (!(await requireAdminBearer(req, env))) { + return new Response("forbidden", { status: 403 }); + } + try { + await ensureColdStartState(env); + await pollMetrics(env); + return Response.json({ status: "ok", triggered: "pollMetrics", ts: now }); + } catch (e) { + return Response.json({ status: "error", error: String(e), ts: now }, { status: 500 }); + } + } + return Response.json({ service: "comptroller", version: "1.0.0", status: "ok" }); - }, -}; +} + +/** Constant-time bearer-token check against COMPTROLLER_HMAC_KEY for admin routes. */ +async function requireAdminBearer(req: Request, env: Env): Promise { + const key = env.COMPTROLLER_HMAC_KEY; + if (!key) return false; + const auth = req.headers.get("Authorization") ?? ""; + const presented = auth.startsWith("Bearer ") ? auth.slice(7) : ""; + const enc = new TextEncoder(); + const a = enc.encode(presented); + const b = enc.encode(key); + if (a.byteLength !== b.byteLength) return false; + return crypto.subtle.timingSafeEqual(a, b); +} + +// =================================================================================== +// Cold-start / baseline-learning state +// =================================================================================== + +async function ensureColdStartState(env: Env): Promise { + const cold = await env.KV_STATE.get(COLD_START_AT_KEY); + if (!cold) { + await env.KV_STATE.put(COLD_START_AT_KEY, String(Date.now())); + } + const baseline = await env.KV_STATE.get(BASELINE_LEARNING_KEY); + if (!baseline) { + const until = new Date(Date.now() + BASELINE_LEARNING_DAYS * 24 * 3600 * 1000).toISOString(); + await env.KV_STATE.put(BASELINE_LEARNING_KEY, until); + } +} + +// =================================================================================== +// Metric collection (every 5 min) +// =================================================================================== -// ===== Metric collection (every 5 min) ===== async function pollMetrics(env: Env): Promise { - // Pull from each source in parallel; tolerate individual failures const results = await Promise.allSettled([ pullCFAIGatewayAnalytics(env), - pullAnthropicBilling(env), - pullGoogleAIStudioQuota(env), - pullCFWorkersAIMetrics(env), refreshCostLedgerView(env), ]); + for (const r of results) { + if (r.status === "rejected") console.error("[poll] source failed:", r.reason); + } - // Check for anomalies const anomalies = await detectAnomalies(env); if (anomalies.length > 0) { @@ -123,49 +320,487 @@ async function pollMetrics(env: Env): Promise { const baselineLearning = await isBaselineLearningActive(env); if (!safeState && !baselineLearning) { - // Real anomaly: maybe issue L2 signal for (const a of anomalies) { - if (a.severity === "high") { - await emitL2Signal(env, a); - } - if (a.severity === "critical" && a.suggests_l3) { - await emitL3Signal(env, a); // honors pause_exemptions - } + if (a.severity === "high") await emitL2Signal(env, a); + if (a.severity === "critical" && a.suggests_l3) await emitL3Signal(env, a); } } else { - // Safe-state or baseline: alert only for (const a of anomalies) { if (a.severity === "critical") await sendQuoAlert(env, a); } } } - // Check hard budget caps independently of anomaly detection await checkHardCaps(env); } -// ===== Anomaly detection (EWMA + 3-sigma) ===== +// =================================================================================== +// CF AI Gateway log ingestion (Phase A) +// =================================================================================== + +interface CFLog { + id: string; + created_at: string; + provider?: string; + model?: string; + tokens_in?: number; + tokens_out?: number; + cost?: number; + timings?: { latency?: number }; + usage_metadata?: { input_cached_tokens?: number }; + metadata?: Record; +} + +/** + * Deterministic tier from model name. MUST return a value allowed by the + * chittyops.cost_ledger CHECK constraint `cost_ledger_tier_check`: + * T0 · T1_workspace · T1_personal · T2_haiku · T3_sonnet · T2_pro · T3_opus · manual + * @cf/* → Workers AI (T0). Anthropic families map to their tier. Unknown external → 'manual'. + */ +function tierFromModel(model: string | undefined): string { + if (!model) return "manual"; + const m = model.toLowerCase(); + if (m.startsWith("@cf/")) return "T0"; + if (m.includes("opus")) return "T3_opus"; + if (m.includes("sonnet")) return "T3_sonnet"; + if (m.includes("haiku")) return "T2_haiku"; + return "manual"; +} + +async function pullCFAIGatewayAnalytics(env: Env): Promise { + if (!env.CF_ACCOUNT_API_TOKEN) { + console.warn("[ingest] CF_ACCOUNT_API_TOKEN not configured — skipping AI Gateway ingest"); + return; + } + const writeDb = getWriteDb(env); + if (!writeDb) { + console.warn( + "[ingest] Phase-A: writer connection (NEON_COMPTROLLER_WRITER) not configured — " + + "skipping cost_ledger ingest. Provision an RW Hyperdrive binding to enable writes.", + ); + return; + } + + const accountId = env.CF_ACCOUNT_ID ?? CF_ACCOUNT_ID; + + for (const gw of ACTIVE_GATEWAYS) { + try { + await ingestGateway(env, writeDb, accountId, gw); + } catch (e) { + console.error(`[ingest] gateway ${gw} failed:`, e); + // tolerate per-gateway failure and continue + } + } +} + +async function ingestGateway(env: Env, writeDb: Sql, accountId: string, gw: string): Promise { + const hwmKey = `hwm:${gw}`; + const hwm = await env.KV_STATE.get(hwmKey); // last-ingested created_at ISO, or null + const hwmMs = hwm ? Date.parse(hwm) : 0; + + let maxSeen = hwmMs; + let inserted = 0; + let page = 1; + + while (page <= MAX_PAGES_PER_GATEWAY) { + const apiUrl = + `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai-gateway/gateways/${gw}/logs` + + `?per_page=${LOGS_PER_PAGE}&page=${page}&order_by=created_at&order_by_direction=desc`; + + const resp = await fetch(apiUrl, { + headers: { Authorization: `Bearer ${env.CF_ACCOUNT_API_TOKEN}` }, + }); + if (!resp.ok) { + const body = await resp.text(); + throw new Error(`logs fetch ${resp.status}: ${body.slice(0, 200)}`); + } + const json = (await resp.json()) as { + success: boolean; + result?: CFLog[]; + result_info?: { total_count?: number }; + }; + const logs = json.result ?? []; + if (logs.length === 0) break; + + // Only logs strictly newer than the high-water mark. + const fresh = logs.filter((l) => Date.parse(l.created_at) > hwmMs); + if (fresh.length > 0) { + const rows = fresh.map((l) => ({ + service: gw, + tier: tierFromModel(l.model), + provider: l.provider ?? "unknown", + model: l.model ?? "unknown", + tokens_in: Math.round(l.tokens_in ?? 0), + tokens_out: Math.round(l.tokens_out ?? 0), + cached_tokens_in: Math.round(l.usage_metadata?.input_cached_tokens ?? 0), + cost_usd: Number(l.cost ?? 0), + latency_ms: Math.round(l.timings?.latency ?? 0), + item_id_hash: l.id, + run_id: null as string | null, + fallback_chain: null as string[] | null, + ts: l.created_at, + cost_constrained: false, + })); + + await writeDb` + INSERT INTO chittyops.cost_ledger ${writeDb( + rows, + "service", + "tier", + "provider", + "model", + "tokens_in", + "tokens_out", + "cached_tokens_in", + "cost_usd", + "latency_ms", + "item_id_hash", + "run_id", + "fallback_chain", + "ts", + "cost_constrained", + )} + `; + inserted += rows.length; + for (const l of fresh) maxSeen = Math.max(maxSeen, Date.parse(l.created_at)); + } + + // Stop paginating once the page no longer contains logs newer than the hwm + // (results are desc by created_at, so older pages can't be newer). + const pageHasFresh = logs.some((l) => Date.parse(l.created_at) > hwmMs); + if (!pageHasFresh || logs.length < LOGS_PER_PAGE) break; + page++; + } + + if (maxSeen > hwmMs) { + await env.KV_STATE.put(hwmKey, new Date(maxSeen).toISOString()); + } + if (inserted > 0) console.log(`[ingest] ${gw}: inserted ${inserted} cost_ledger rows`); +} + +// =================================================================================== +// AI insights (Workers-AI T0) — categorization + grounded recommendations +// +// HARD RULE: every NUMBER is computed in SQL/JS and assembled here. The LLM receives the +// finished figures and emits ONLY narrative fields (category, characterization, drivers +// prose, trend prose, recommendations). It is forbidden to invent or restate costs. This +// makes "grounded, no fabrication" structurally true, not prompt-dependent. +// =================================================================================== + +const INSIGHTS_MODEL = "@cf/meta/llama-3.1-8b-instruct"; +const INSIGHTS_TTL_SECONDS = 6 * 3600; + +/** Chicago calendar date (YYYY-MM-DD) — matches the day boundaries used everywhere else. */ +function chicagoDate(): string { + return new Intl.DateTimeFormat("en-CA", { + timeZone: "America/Chicago", + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(new Date()); +} + +interface InsightsAggregates { + generated_at: string; + window: { today: string; trend_days: number }; + totals: { + today_cost_usd: number; + today_calls: number; + all_time_cost_usd: number; + all_time_rows: number; + workers_ai_cost_usd: number; + external_provider_cost_usd: number; + workers_ai_calls: number; + external_provider_calls: number; + }; + per_service_today: Array<{ + service: string; + cost_usd: number; + calls: number; + tokens_in: number; + tokens_out: number; + top_provider: string; + top_tier: string; + }>; + daily_trend: Array<{ day: string; cost_usd: number; calls: number }>; + top_models_by_cost: Array<{ model: string; provider: string; cost_usd: number; calls: number }>; + top_models_by_calls: Array<{ model: string; provider: string; cost_usd: number; calls: number }>; +} + +/** Pull every figure the insight needs straight from cost_ledger. No model involvement. */ +async function queryInsightsAggregates(env: Env): Promise { + const db = getDb(env); + + const perService = (await db` + SELECT service, + coalesce(sum(cost_usd),0)::float8 AS cost_usd, + count(*)::int AS calls, + coalesce(sum(tokens_in),0)::bigint AS tokens_in, + coalesce(sum(tokens_out),0)::bigint AS tokens_out, + (array_agg(provider ORDER BY cost_usd DESC NULLS LAST))[1] AS top_provider, + (array_agg(tier ORDER BY cost_usd DESC NULLS LAST))[1] AS top_tier + FROM chittyops.cost_ledger + WHERE ts >= date_trunc('day', now() AT TIME ZONE 'America/Chicago') + GROUP BY service + ORDER BY cost_usd DESC + `) as any[]; + + const trend = (await db` + SELECT (ts AT TIME ZONE 'America/Chicago')::date::text AS day, + coalesce(sum(cost_usd),0)::float8 AS cost_usd, + count(*)::int AS calls + FROM chittyops.cost_ledger + WHERE ts >= date_trunc('day', now() AT TIME ZONE 'America/Chicago') - interval '6 days' + GROUP BY 1 + ORDER BY 1 + `) as any[]; + + const modelsByCost = (await db` + SELECT model, (array_agg(provider))[1] AS provider, + coalesce(sum(cost_usd),0)::float8 AS cost_usd, count(*)::int AS calls + FROM chittyops.cost_ledger + WHERE ts >= date_trunc('day', now() AT TIME ZONE 'America/Chicago') - interval '6 days' + GROUP BY model + ORDER BY cost_usd DESC + LIMIT 5 + `) as any[]; + + const modelsByCalls = (await db` + SELECT model, (array_agg(provider))[1] AS provider, + coalesce(sum(cost_usd),0)::float8 AS cost_usd, count(*)::int AS calls + FROM chittyops.cost_ledger + WHERE ts >= date_trunc('day', now() AT TIME ZONE 'America/Chicago') - interval '6 days' + GROUP BY model + ORDER BY calls DESC + LIMIT 5 + `) as any[]; + + const split = (await db` + SELECT + coalesce(sum(cost_usd) FILTER (WHERE provider = 'workers-ai'),0)::float8 AS wai_cost, + coalesce(sum(cost_usd) FILTER (WHERE provider <> 'workers-ai'),0)::float8 AS ext_cost, + coalesce(count(*) FILTER (WHERE provider = 'workers-ai'),0)::int AS wai_calls, + coalesce(count(*) FILTER (WHERE provider <> 'workers-ai'),0)::int AS ext_calls + FROM chittyops.cost_ledger + `) as any[]; + + const allTime = (await db` + SELECT count(*)::int AS rows, coalesce(sum(cost_usd),0)::float8 AS cost FROM chittyops.cost_ledger + `) as any[]; + + const todayCost = perService.reduce((s, r) => s + Number(r.cost_usd), 0); + const todayCalls = perService.reduce((s, r) => s + Number(r.calls), 0); + const sp = split[0] ?? {}; + + const round = (n: number, d = 6) => Number(Number(n).toFixed(d)); + + return { + generated_at: new Date().toISOString(), + window: { today: chicagoDate(), trend_days: 7 }, + totals: { + today_cost_usd: round(todayCost), + today_calls: todayCalls, + all_time_cost_usd: round(Number(allTime[0]?.cost ?? 0)), + all_time_rows: Number(allTime[0]?.rows ?? 0), + workers_ai_cost_usd: round(Number(sp.wai_cost ?? 0)), + external_provider_cost_usd: round(Number(sp.ext_cost ?? 0)), + workers_ai_calls: Number(sp.wai_calls ?? 0), + external_provider_calls: Number(sp.ext_calls ?? 0), + }, + per_service_today: perService.map((r) => ({ + service: r.service, + cost_usd: round(Number(r.cost_usd)), + calls: Number(r.calls), + tokens_in: Number(r.tokens_in), + tokens_out: Number(r.tokens_out), + top_provider: r.top_provider ?? "unknown", + top_tier: r.top_tier ?? "unknown", + })), + daily_trend: trend.map((r) => ({ day: r.day, cost_usd: round(Number(r.cost_usd)), calls: Number(r.calls) })), + top_models_by_cost: modelsByCost.map((r) => ({ + model: r.model, + provider: r.provider ?? "unknown", + cost_usd: round(Number(r.cost_usd)), + calls: Number(r.calls), + })), + top_models_by_calls: modelsByCalls.map((r) => ({ + model: r.model, + provider: r.provider ?? "unknown", + cost_usd: round(Number(r.cost_usd)), + calls: Number(r.calls), + })), + }; +} + +/** Narrative-only schema the model fills. NO numeric fields — those come from SQL. */ +interface InsightsNarrative { + per_service: Array<{ service: string; category: string; characterization: string }>; + drivers: string[]; + trends: string[]; + recommendations: string[]; +} + +async function runInsightsModel(env: Env, agg: InsightsAggregates): Promise<{ narrative: InsightsNarrative | null; raw: string }> { + const services = agg.per_service_today.map((s) => s.service).join(", ") || "(none today)"; + const systemPrompt = + "You are a FinOps analyst for the ChittyOS AI-spend ledger. You are given PRE-COMPUTED figures " + + "from a Postgres cost ledger. You MUST only use the provided numbers — never invent, restate, or " + + "recompute any cost. Costs are in USD and may be sub-cent; do not editorialize magnitude beyond what " + + "the numbers show (do not call sub-dollar totals 'high'). Characterize each service by what its AI " + + "usage pattern implies (e.g. high tokens_in with ~0 tokens_out = embedding/classification workload; " + + "balanced in/out = generative chat). Reply with ONLY a JSON object, no prose outside it, matching: " + + '{"per_service":[{"service":string,"category":string,"characterization":string}],' + + '"drivers":[string],"trends":[string],"recommendations":[string]}. ' + + "category is a short label (2-4 words). characterization, drivers, trends, recommendations are one " + + "sentence each, every claim grounded in a provided figure. Give 2-4 recommendations."; + + const userPrompt = + `Services active today: ${services}.\n` + + `Figures (JSON):\n${JSON.stringify({ + totals: agg.totals, + per_service_today: agg.per_service_today, + daily_trend: agg.daily_trend, + top_models_by_cost: agg.top_models_by_cost, + top_models_by_calls: agg.top_models_by_calls, + })}\n` + + "Produce the JSON object now."; + + const out = (await env.AI.run(INSIGHTS_MODEL, { + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: userPrompt }, + ], + max_tokens: 1024, + temperature: 0.2, + })) as { response?: string }; + + const raw = typeof out?.response === "string" ? out.response : JSON.stringify(out); + const narrative = parseNarrative(raw, agg); + return { narrative, raw }; +} + +/** Extract the JSON object from the model text. Returns null on failure (no fabricated fallback). */ +function parseNarrative(raw: string, agg: InsightsAggregates): InsightsNarrative | null { + let text = raw.trim(); + const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/); + if (fence) text = fence[1].trim(); + const start = text.indexOf("{"); + const end = text.lastIndexOf("}"); + if (start === -1 || end === -1 || end <= start) return null; + try { + const obj = JSON.parse(text.slice(start, end + 1)); + const allowed = new Set(agg.per_service_today.map((s) => s.service)); + const perService = Array.isArray(obj.per_service) + ? obj.per_service + .filter((p: any) => p && typeof p.service === "string" && allowed.has(p.service)) + .map((p: any) => ({ + service: String(p.service), + category: String(p.category ?? "uncategorized"), + characterization: String(p.characterization ?? ""), + })) + : []; + const asStrings = (v: any): string[] => + Array.isArray(v) ? v.filter((x) => typeof x === "string").map((x) => String(x)) : []; + return { + per_service: perService, + drivers: asStrings(obj.drivers), + trends: asStrings(obj.trends), + recommendations: asStrings(obj.recommendations), + }; + } catch { + return null; + } +} + +async function fetchInsights(env: Env, refresh: boolean): Promise { + const cacheKey = `insights:${chicagoDate()}`; + if (!refresh) { + const cached = await env.KV_STATE.get(cacheKey); + if (cached) { + const obj = JSON.parse(cached); + obj.cached = true; + return obj; + } + } + + const agg = await queryInsightsAggregates(env); + + // Empty-state: do not ask the model to characterize nothing. + if (agg.totals.today_calls === 0 && agg.totals.all_time_rows === 0) { + return { + status: "ok", + service: "comptroller", + generated_at: agg.generated_at, + window: agg.window, + empty: true, + reason: "cost_ledger has no rows in the window — no insight generated (no fabrication)", + totals: agg.totals, + model_used: null, + }; + } + + const { narrative, raw } = await runInsightsModel(env, agg); + + const result: any = { + status: "ok", + service: "comptroller", + generated_at: agg.generated_at, + window: agg.window, + totals: agg.totals, + per_service: narrative?.per_service ?? [], + drivers: narrative?.drivers ?? [], + trends: narrative?.trends ?? [], + recommendations: narrative?.recommendations ?? [], + daily_trend: agg.daily_trend, + top_models_by_cost: agg.top_models_by_cost, + top_models_by_calls: agg.top_models_by_calls, + model_used: INSIGHTS_MODEL, + cached: false, + }; + + // If the model produced no parseable narrative, surface the raw text — never fabricate. + if (!narrative) { + result.narrative_error = "model output did not parse as JSON narrative"; + result.model_raw = raw.slice(0, 2000); + } + + // Cache the grounded result (~6h) so it's not recomputed per request (avoids meta-cost). + await env.KV_STATE.put(cacheKey, JSON.stringify({ ...result, cached: false }), { + expirationTtl: INSIGHTS_TTL_SECONDS, + }); + + return result; +} + +// =================================================================================== +// Anomaly detection (EWMA + 3-sigma) +// =================================================================================== + async function detectAnomalies(env: Env): Promise { - // Query cost_ledger_daily for last 14 days per service+tier - const sql = ` - SELECT service, tier, day_ct, total_cost_usd - FROM chittyops.cost_ledger_daily - WHERE day_ct >= now() - interval '14 days' - ORDER BY service, tier, day_ct - `; - const rows = await env.NEON_COMPTROLLER.query(sql); - - // Group by service+tier, compute EWMA + stdev, compare today - const byKey = groupBy(rows, (r: any) => `${r.service}:${r.tier}`); + const db = getDb(env); + let rows: Array<{ service: string; tier: string; day_ct: string; total_cost_usd: string | number }>; + try { + rows = (await db` + SELECT service, tier, day_ct, total_cost_usd + FROM chittyops.cost_ledger_daily + WHERE day_ct >= now() - interval '14 days' + ORDER BY service, tier, day_ct + `) as any; + } catch (e) { + console.error("[detectAnomalies] query failed:", e); + return []; + } + + const byKey = groupBy(rows, (r) => `${r.service}:${r.tier}`); const anomalies: Anomaly[] = []; for (const [key, series] of byKey.entries()) { - if (series.length < 7) continue; // not enough history + if (series.length < 7) continue; const today = series[series.length - 1]; const history = series.slice(0, -1); - const ewma = computeEWMA(history.map((r: any) => Number(r.total_cost_usd))); - const stdev = computeStdev(history.map((r: any) => Number(r.total_cost_usd))); + const ewma = computeEWMA(history.map((r) => Number(r.total_cost_usd))); + const stdev = computeStdev(history.map((r) => Number(r.total_cost_usd))); const todayActual = Number(today.total_cost_usd); const threshold = ewma + 3 * stdev; @@ -179,7 +814,7 @@ async function detectAnomalies(env: Env): Promise { actual: todayActual, expected_max: threshold, ewma, - msg: `${service}/${tier}: $${todayActual.toFixed(4)} vs forecast $${ewma.toFixed(4)} (+${((todayActual/ewma - 1) * 100).toFixed(0)}%)`, + msg: `${service}/${tier}: $${todayActual.toFixed(4)} vs forecast $${ewma.toFixed(4)} (+${((todayActual / ewma - 1) * 100).toFixed(0)}%)`, detected_at: new Date().toISOString(), suggests_l3: todayActual > 5 * threshold, }); @@ -189,14 +824,16 @@ async function detectAnomalies(env: Env): Promise { return anomalies; } -// ===== L2: tier_degrade signal ===== +// =================================================================================== +// L2 / L3 signals +// =================================================================================== + async function emitL2Signal(env: Env, anomaly: Anomaly): Promise { const service = await fetchServiceFromRegistry(env, anomaly.service); if (!service?.tier_degrade_endpoint) return; const isExempt = await isServiceExempt(env, anomaly.service); if (isExempt) { - // Even L2 honors exemptions for `active_deadline` services await sendQuoAlert(env, anomaly, "exempt — skipping L2 throttle"); return; } @@ -211,13 +848,12 @@ async function emitL2Signal(env: Env, anomaly: Anomaly): Promise { const resp = await fetch(service.tier_degrade_endpoint, { method: "POST", - headers: { "X-Comptroller-Signature": await signHmac(env, signal) }, + headers: { "X-Comptroller-Signature": await signHmac(env, signal), "Content-Type": "application/json" }, body: JSON.stringify(signal), }); await logSignalEmitted(env, "L2", anomaly.service, signal, resp.status); } -// ===== L3: pause signal (requires SMS confirm if exempt) ===== async function emitL3Signal(env: Env, anomaly: Anomaly): Promise { const service = await fetchServiceFromRegistry(env, anomaly.service); if (!service?.pause_endpoint) return; @@ -240,15 +876,18 @@ async function emitL3Signal(env: Env, anomaly: Anomaly): Promise { const resp = await fetch(service.pause_endpoint, { method: "POST", - headers: { "X-Comptroller-Signature": await signHmac(env, signal) }, + headers: { "X-Comptroller-Signature": await signHmac(env, signal), "Content-Type": "application/json" }, body: JSON.stringify(signal), }); await logSignalEmitted(env, "L3", anomaly.service, signal, resp.status); } -// ===== Authority change endpoint ===== +// =================================================================================== +// Admin endpoints +// =================================================================================== + async function handleAuthorityChange(req: Request, env: Env): Promise { - const body = await req.json() as { level: string; sms_confirm_token: string }; + const body = (await req.json()) as { level: string; sms_confirm_token: string }; const validToken = await verifySMSConfirm(env, body.sms_confirm_token); if (!validToken) return new Response("sms confirm required", { status: 403 }); @@ -257,25 +896,33 @@ async function handleAuthorityChange(req: Request, env: Env): Promise return Response.json({ status: "ok", level: body.level }); } -// ===== Baseline-learning end (manual override) ===== async function handleBaselineLearningEnd(req: Request, env: Env): Promise { - const body = await req.json() as { sms_confirm_token: string }; - if (!await verifySMSConfirm(env, body.sms_confirm_token)) { + const body = (await req.json()) as { sms_confirm_token: string }; + if (!(await verifySMSConfirm(env, body.sms_confirm_token))) { return new Response("sms confirm required", { status: 403 }); } await env.KV_STATE.delete(BASELINE_LEARNING_KEY); return Response.json({ status: "baseline_learning_ended" }); } -// ===== Helper signatures (real impls call respective APIs) ===== -async function pullCFAIGatewayAnalytics(env: Env): Promise { /* GET gateway.chitty.cc/_analytics */ } -async function pullAnthropicBilling(env: Env): Promise { /* GET api.anthropic.com/v1/billing */ } -async function pullGoogleAIStudioQuota(env: Env): Promise { /* generativelanguage.googleapis.com/v1/quota */ } -async function pullCFWorkersAIMetrics(env: Env): Promise { /* CF GraphQL */ } +// =================================================================================== +// Matview refresh — needs privileges; fail-soft if reader can't refresh +// =================================================================================== + async function refreshCostLedgerView(env: Env): Promise { - await env.NEON_COMPTROLLER.query("SELECT chittyops.refresh_cost_ledger_daily()"); + // Prefer the writer connection (has the privilege); fall back to reader (EXECUTE granted). + const db = getWriteDb(env) ?? getDb(env); + try { + await db`SELECT chittyops.refresh_cost_ledger_daily()`; + } catch (e) { + console.warn("[refreshCostLedgerView] refresh skipped (insufficient privilege?):", String(e)); + } } +// =================================================================================== +// Safe-state / exemptions / registry +// =================================================================================== + async function isSafeStateActive(env: Env): Promise { const coldStartAt = await env.KV_STATE.get(COLD_START_AT_KEY); if (!coldStartAt) return true; @@ -286,13 +933,20 @@ async function isSafeStateActive(env: Env): Promise { async function isBaselineLearningActive(env: Env): Promise { const until = await env.KV_STATE.get(BASELINE_LEARNING_KEY); if (!until) return false; - return Date.now() < Number(until); + return Date.now() < Date.parse(until); } async function isServiceExempt(env: Env, serviceId: string): Promise { - const sql = "SELECT 1 FROM chittyops.pause_exemptions WHERE service_id = $1 LIMIT 1"; - const rows = await env.NEON_COMPTROLLER.query(sql, [serviceId]); - return rows.length > 0; + const db = getDb(env); + try { + const rows = (await db` + SELECT 1 FROM chittyops.pause_exemptions WHERE service_id = ${serviceId} LIMIT 1 + `) as any[]; + return rows.length > 0; + } catch (e) { + console.error("[isServiceExempt] query failed:", e); + return false; // fail-safe: do not block on lookup failure for L2/L3 gate caller + } } async function fetchServiceFromRegistry(env: Env, serviceId: string): Promise { @@ -301,53 +955,397 @@ async function fetchServiceFromRegistry(env: Env, serviceId: string): Promise { - const sql = ` - SELECT - (SELECT sum(cost_usd) FROM chittyops.cost_ledger WHERE service = $1 AND ts >= date_trunc('day', now() AT TIME ZONE 'America/Chicago')) AS today, - (SELECT sum(cost_usd) FROM chittyops.cost_ledger WHERE service = $1 AND ts >= date_trunc('month', now() AT TIME ZONE 'America/Chicago')) AS mtd - `; - const [row] = await env.NEON_COMPTROLLER.query(sql, [serviceId]); - // Cap config from registry/manifest + const db = getDb(env); + let row: any = {}; + try { + const res = (await db` + SELECT + (SELECT coalesce(sum(cost_usd),0) FROM chittyops.cost_ledger + WHERE service = ${serviceId} + AND ts >= date_trunc('day', now() AT TIME ZONE 'America/Chicago')) AS today, + (SELECT coalesce(sum(cost_usd),0) FROM chittyops.cost_ledger + WHERE service = ${serviceId} + AND ts >= date_trunc('month', now() AT TIME ZONE 'America/Chicago')) AS mtd + `) as any[]; + row = res[0] ?? {}; + } catch (e) { + console.error("[budgetStatus] query failed:", e); + } + const dailyCap = 2.0; + const monthlyCap = HARD_CAP_MTD_USD[serviceId] ?? DEFAULT_HARD_CAP_MTD_USD; + const today = Number(row?.today ?? 0); + const mtd = Number(row?.mtd ?? 0); return { service: serviceId, - daily_used_usd: Number(row?.today ?? 0), - daily_cap_usd: 2.0, // pull from manifest in real impl - monthly_used_usd: Number(row?.mtd ?? 0), - monthly_cap_usd: 15.0, - halt: Number(row?.today ?? 0) >= 2.0 || Number(row?.mtd ?? 0) >= 15.0, - tier_breakdown: {}, - anomalies: [], + daily_used_usd: today, + daily_cap_usd: dailyCap, + monthly_used_usd: mtd, + monthly_cap_usd: monthlyCap, + halt: today >= dailyCap || mtd >= monthlyCap, + baseline_learning: await isBaselineLearningActive(env), + authority: (await env.KV_STATE.get("authority_level")) ?? "L1", + }; +} + +async function fetchMetrics(env: Env): Promise { + const db = getDb(env); + const today = (await db` + SELECT service, tier, coalesce(sum(cost_usd),0)::float8 AS cost_usd, + coalesce(sum(tokens_in),0)::int AS tokens_in, + coalesce(sum(tokens_out),0)::int AS tokens_out, + count(*)::int AS calls + FROM chittyops.cost_ledger + WHERE ts >= date_trunc('day', now() AT TIME ZONE 'America/Chicago') + GROUP BY service, tier + ORDER BY cost_usd DESC + `) as any[]; + + const mtd = (await db` + SELECT service, tier, coalesce(sum(cost_usd),0)::float8 AS cost_usd, + count(*)::int AS calls + FROM chittyops.cost_ledger + WHERE ts >= date_trunc('month', now() AT TIME ZONE 'America/Chicago') + GROUP BY service, tier + ORDER BY cost_usd DESC + `) as any[]; + + const totalRow = (await db`SELECT count(*)::int AS total_count FROM chittyops.cost_ledger`) as any[]; + + return { + status: "ok", + service: "comptroller", + today: today.map((r) => ({ + service: r.service, + tier: r.tier, + cost_usd: Number(r.cost_usd), + tokens_in: Number(r.tokens_in), + tokens_out: Number(r.tokens_out), + calls: Number(r.calls), + })), + mtd: mtd.map((r) => ({ service: r.service, tier: r.tier, cost_usd: Number(r.cost_usd), calls: Number(r.calls) })), + total_count: Number(totalRow[0]?.total_count ?? 0), + ts: new Date().toISOString(), + }; +} + +async function fetchDailyReport(env: Env): Promise { + const db = getDb(env); + const byService = (await db` + SELECT service, coalesce(sum(cost_usd),0)::float8 AS cost_usd, count(*)::int AS calls + FROM chittyops.cost_ledger + WHERE ts >= date_trunc('day', now() AT TIME ZONE 'America/Chicago') + GROUP BY service + ORDER BY cost_usd DESC + `) as any[]; + + const topDrivers = (await db` + SELECT service, tier, model, coalesce(sum(cost_usd),0)::float8 AS cost_usd, count(*)::int AS calls + FROM chittyops.cost_ledger + WHERE ts >= date_trunc('day', now() AT TIME ZONE 'America/Chicago') + GROUP BY service, tier, model + ORDER BY cost_usd DESC + LIMIT 10 + `) as any[]; + + const anomalyCount = (await db` + SELECT count(*)::int AS n FROM chittyops.anomalies + WHERE detected_at >= date_trunc('day', now() AT TIME ZONE 'America/Chicago') + `) as any[]; + + const total = byService.reduce((s, r) => s + Number(r.cost_usd), 0); + + return { + status: "ok", + date: new Date().toISOString().slice(0, 10), + total_cost_usd: total, + by_service: byService.map((r) => ({ service: r.service, cost_usd: Number(r.cost_usd), calls: Number(r.calls) })), + top_drivers: topDrivers.map((r) => ({ + service: r.service, + tier: r.tier, + model: r.model, + cost_usd: Number(r.cost_usd), + calls: Number(r.calls), + })), + anomaly_count: Number(anomalyCount[0]?.n ?? 0), + ts: new Date().toISOString(), }; } -// ===== Reports ===== +async function listAnomalies(env: Env): Promise { + const db = getDb(env); + try { + const rows = (await db` + SELECT id, service, tier, severity, actual, expected_max, ewma, msg, suggests_l3, detected_at + FROM chittyops.anomalies + ORDER BY detected_at DESC + LIMIT 100 + `) as any[]; + return rows.map((r) => ({ + id: r.id, + service: r.service, + tier: r.tier, + severity: r.severity, + actual: Number(r.actual), + expected_max: Number(r.expected_max), + ewma: Number(r.ewma), + msg: r.msg, + suggests_l3: r.suggests_l3, + detected_at: r.detected_at, + })); + } catch (e) { + console.error("[listAnomalies] query failed:", e); + return []; + } +} + +async function storeAnomalies(env: Env, list: Anomaly[]): Promise { + if (list.length === 0) return; + const writeDb = getWriteDb(env); + if (!writeDb) { + console.warn( + "[storeAnomalies] Phase-A: writer connection (NEON_COMPTROLLER_WRITER) not configured — " + + `skipping INSERT of ${list.length} anomalies into chittyops.anomalies.`, + ); + return; + } + const rows = list.map((a) => ({ + id: a.id, + service: a.service, + tier: a.tier, + severity: a.severity, + actual: a.actual, + expected_max: a.expected_max, + ewma: a.ewma, + msg: a.msg, + suggests_l3: a.suggests_l3, + detected_at: a.detected_at, + })); + try { + await writeDb` + INSERT INTO chittyops.anomalies ${writeDb( + rows, + "id", + "service", + "tier", + "severity", + "actual", + "expected_max", + "ewma", + "msg", + "suggests_l3", + "detected_at", + )} + `; + console.log(`[storeAnomalies] inserted ${rows.length} anomalies`); + } catch (e) { + console.error("[storeAnomalies] insert failed:", e); + } +} + +async function checkHardCaps(env: Env): Promise { + const db = getDb(env); + let rows: any[] = []; + try { + rows = (await db` + SELECT service, coalesce(sum(cost_usd),0)::float8 AS mtd + FROM chittyops.cost_ledger + WHERE ts >= date_trunc('month', now() AT TIME ZONE 'America/Chicago') + GROUP BY service + `) as any[]; + } catch (e) { + console.error("[checkHardCaps] query failed:", e); + return; + } + for (const r of rows) { + const cap = HARD_CAP_MTD_USD[r.service] ?? DEFAULT_HARD_CAP_MTD_USD; + const mtd = Number(r.mtd); + if (mtd >= cap) { + console.warn(`[checkHardCaps] BREACH ${r.service}: MTD $${mtd.toFixed(2)} >= cap $${cap.toFixed(2)}`); + } + } +} + +// =================================================================================== +// Reports — Notion (Phase B; guarded not-configured) +// =================================================================================== + +function notionConfigured(env: Env, pageId?: string): boolean { + return !!env.NOTION_API_KEY && !!pageId && pageId !== "REPLACE_AT_DEPLOY"; +} + async function emitDailyReport(env: Env): Promise { - // Two reports: Business-aggregated + Legalink-detailed (F-L13) - await writeNotionReport(env, env.NOTION_BUSINESS_REPORT_PAGE_ID, await buildBusinessReport(env)); - await writeNotionReport(env, env.NOTION_LEGALINK_REPORT_PAGE_ID, await buildLegalinkReport(env)); -} -async function emitWeeklyForecast(env: Env): Promise { /* ... */ } -async function emitMonthlyCloseout(env: Env): Promise { /* ... */ } -async function buildBusinessReport(env: Env): Promise { return "..."; } -async function buildLegalinkReport(env: Env): Promise { return "..."; } -async function writeNotionReport(env: Env, pageId: string, body: string): Promise { /* notion api */ } - -async function fetchDailyReport(env: Env): Promise { return {}; } -async function listAnomalies(env: Env): Promise { return []; } -async function storeAnomalies(env: Env, list: Anomaly[]): Promise { /* insert into comptroller.anomalies */ } -async function checkHardCaps(env: Env): Promise { /* per-service hard cap check */ } -async function requestSMSConfirm(env: Env, anomaly: Anomaly): Promise { - // Send Quo SMS with confirmation link; wait briefly or return null for async + if (notionConfigured(env, env.NOTION_BUSINESS_REPORT_PAGE_ID)) { + await writeNotionReport(env, env.NOTION_BUSINESS_REPORT_PAGE_ID!, await buildBusinessReport(env)); + } else { + console.log("[report] Phase-B: Notion business report not configured, skipping"); + } + if (notionConfigured(env, env.NOTION_LEGALINK_REPORT_PAGE_ID)) { + await writeNotionReport(env, env.NOTION_LEGALINK_REPORT_PAGE_ID!, await buildLegalinkReport(env)); + } else { + console.log("[report] Phase-B: Notion legalink report not configured, skipping"); + } +} + +async function emitWeeklyForecast(env: Env): Promise { + if (!notionConfigured(env, env.NOTION_BUSINESS_REPORT_PAGE_ID)) { + console.log("[report] Phase-B: Notion not configured, skipping weekly forecast"); + return; + } + await writeNotionReport(env, env.NOTION_BUSINESS_REPORT_PAGE_ID!, await buildBusinessReport(env)); +} + +async function emitMonthlyCloseout(env: Env): Promise { + if (!notionConfigured(env, env.NOTION_BUSINESS_REPORT_PAGE_ID)) { + console.log("[report] Phase-B: Notion not configured, skipping monthly closeout"); + return; + } + await writeNotionReport(env, env.NOTION_BUSINESS_REPORT_PAGE_ID!, await buildBusinessReport(env)); +} + +/** Real report content from the cost ledger (used only when Notion IS configured). */ +async function buildBusinessReport(env: Env): Promise { + const r = await fetchDailyReport(env); + const lines = [ + `ChittyComptroller — Daily Cost Report (${r.date})`, + `Total: $${Number(r.total_cost_usd).toFixed(4)} · Anomalies: ${r.anomaly_count}`, + "", + "By service:", + ...r.by_service.map((s: any) => ` - ${s.service}: $${Number(s.cost_usd).toFixed(4)} (${s.calls} calls)`), + ]; + return lines.join("\n"); +} + +/** Legalink-detailed report: full per-service/tier/model breakdown. */ +async function buildLegalinkReport(env: Env): Promise { + const r = await fetchDailyReport(env); + const lines = [ + `ChittyComptroller — Legalink Daily Cost Detail (${r.date})`, + `Total: $${Number(r.total_cost_usd).toFixed(4)}`, + "", + "Top drivers:", + ...r.top_drivers.map( + (d: any) => ` - ${d.service}/${d.tier} ${d.model}: $${Number(d.cost_usd).toFixed(4)} (${d.calls} calls)`, + ), + ]; + return lines.join("\n"); +} + +async function writeNotionReport(env: Env, pageId: string, body: string): Promise { + if (!notionConfigured(env, pageId)) { + console.log("[notion] Phase-B: NOTION_API_KEY/page-id not configured, skipping write"); + return; + } + const resp = await fetch(`https://api.notion.com/v1/blocks/${pageId}/children`, { + method: "PATCH", + headers: { + Authorization: `Bearer ${env.NOTION_API_KEY}`, + "Notion-Version": "2022-06-28", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + children: [ + { + object: "block", + type: "paragraph", + paragraph: { rich_text: [{ type: "text", text: { content: body.slice(0, 1900) } }] }, + }, + ], + }), + }); + if (!resp.ok) console.error("[notion] write failed:", resp.status, await resp.text()); +} + +// =================================================================================== +// Quo (Phase B; guarded not-configured) +// =================================================================================== + +async function sendQuoAlert(env: Env, anomaly: Anomaly, extra?: string): Promise { + if (!env.QUO_API_KEY) { + console.log( + `[quo] Phase-B: QUO_API_KEY not configured, skipping alert (${anomaly.msg}${extra ? " — " + extra : ""})`, + ); + return; + } + const resp = await fetch("https://api.quo.chitty.cc/v1/messages", { + method: "POST", + headers: { Authorization: `Bearer ${env.QUO_API_KEY}`, "Content-Type": "application/json" }, + body: JSON.stringify({ text: `[comptroller] ${anomaly.msg}${extra ? " — " + extra : ""}` }), + }); + if (!resp.ok) console.error("[quo] alert failed:", resp.status); +} + +async function requestSMSConfirm(env: Env, _anomaly: Anomaly): Promise { + if (!env.QUO_API_KEY) { + console.log("[quo] Phase-B: QUO_API_KEY not configured, cannot request SMS confirm — denying (fail-closed)"); + return null; + } + // A real confirm is async (operator replies later). Phase-A returns null (no token yet), + // which the L3 caller treats as "not confirmed" → no pause. This is the safe default. return null; } -async function verifySMSConfirm(env: Env, token: string): Promise { return false; } -async function sendQuoAlert(env: Env, anomaly: Anomaly, extra?: string): Promise { /* quo SMS */ } -async function signHmac(env: Env, payload: unknown): Promise { return ""; } -async function logSignalEmitted(env: Env, level: string, service: string, signal: unknown, status: number): Promise { /* insert */ } -async function heartbeat(env: Env): Promise { await fetch(env.HEARTBEAT_URL, { method: "POST", body: JSON.stringify({ ts: new Date().toISOString() }) }); } -// ===== Math helpers (pure JS — no LLM) ===== +async function verifySMSConfirm(env: Env, token: string): Promise { + if (!env.QUO_API_KEY) { + console.log("[quo] Phase-B: QUO_API_KEY not configured, cannot verify SMS confirm — denying (fail-closed)"); + return false; + } + if (!token) return false; + const stored = await env.KV_STATE.get(`sms_confirm:${token}`); + if (!stored) return false; + // single-use + await env.KV_STATE.delete(`sms_confirm:${token}`); + return stored === "valid"; +} + +// =================================================================================== +// HMAC signing (real; fail-closed) +// =================================================================================== + +async function signHmac(env: Env, payload: unknown): Promise { + if (!env.COMPTROLLER_HMAC_KEY) { + throw new Error("COMPTROLLER_HMAC_KEY not configured — cannot sign signal (fail-closed)"); + } + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(env.COMPTROLLER_HMAC_KEY), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(JSON.stringify(payload))); + return Array.from(new Uint8Array(sig)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +async function logSignalEmitted( + _env: Env, + level: string, + service: string, + signal: unknown, + status: number, +): Promise { + // Observability via tail consumer (chittytrack); structured console line is the record. + console.log(JSON.stringify({ kind: "signal_emitted", level, service, status, signal, ts: new Date().toISOString() })); +} + +async function heartbeat(env: Env): Promise { + try { + await fetch(env.HEARTBEAT_URL, { method: "POST", body: JSON.stringify({ ts: new Date().toISOString() }) }); + } catch (e) { + console.warn("[heartbeat] failed:", String(e)); + } +} + +// =================================================================================== +// Math helpers (pure JS — no LLM) +// =================================================================================== + function computeEWMA(series: number[], alpha = 0.3): number { if (series.length === 0) return 0; let ewma = series[0]; @@ -365,6 +1363,7 @@ function computeStdev(series: number[]): number { function degradeTo(tier: string): string { const order = ["T3_sonnet", "T2_haiku", "T1_personal", "T1_workspace", "T0"]; const idx = order.indexOf(tier); + if (idx === -1) return "T0"; return order[Math.min(idx + 1, order.length - 1)]; } @@ -379,7 +1378,10 @@ function groupBy(arr: T[], keyFn: (item: T) => K): Map { return map; } -// ===== Types ===== +// =================================================================================== +// Types +// =================================================================================== + interface Anomaly { id: string; service: string; diff --git a/services/comptroller/wrangler.toml b/services/comptroller/wrangler.toml index 0986e1b..188a41d 100644 --- a/services/comptroller/wrangler.toml +++ b/services/comptroller/wrangler.toml @@ -1,31 +1,48 @@ name = "comptroller" main = "worker.ts" compatibility_date = "2026-05-01" +compatibility_flags = ["nodejs_compat"] account_id = "0bc21e3a5a9de1a4cc843be9c3e98121" # ChittyCorp LLC # Comptroller is Tier-Infrastructure: must always run. # L1 only at cold-start for 24h. L2/L3 require BASELINE_LEARNING=false in policy_flags. -[triggers] -crons = ["*/5 * * * *"] # 5-min budget poll, always on - routes = [ - { pattern = "comptroller.chitty.cc/*", custom_domain = true } + { pattern = "comptroller.chitty.cc", custom_domain = true } ] +[triggers] +crons = ["*/5 * * * *"] # 5-min budget poll, always on + [vars] REGISTRY_URL = "https://registry.chitty.cc" HEARTBEAT_URL = "https://discovery.chitty.cc/heartbeat/comptroller" NOTION_BUSINESS_REPORT_PAGE_ID = "REPLACE_AT_DEPLOY" NOTION_LEGALINK_REPORT_PAGE_ID = "REPLACE_AT_DEPLOY" +CF_ACCOUNT_ID = "0bc21e3a5a9de1a4cc843be9c3e98121" +# READ path: comptroller_reader (read-only) on restless-grass-40598426 / ChittyOS-Core, direct host [[hyperdrive]] binding = "NEON_COMPTROLLER" -id = "REPLACE_AT_DEPLOY" # comptroller_reader role on restless-grass-40598426 +id = "90b3bda653a64a96ae79580991623d31" + +# WRITE path (Phase-A ingest): SEPARATE Hyperdrive binding over the comptroller_writer role +# (append-only INSERT on chittyops.cost_ledger + chittyops.anomalies) on the SAME Neon project +# (restless-grass-40598426 / ChittyOS-Core). porsager `postgres` needs a TCP socket, which on +# Workers only comes from Hyperdrive's connectionString. getWriteDb() fails closed if absent. +# origin: ep-green-water-ael1lksw.c-2.us-east-2.aws.neon.tech / db neondb / role comptroller_writer +[[hyperdrive]] +binding = "NEON_COMPTROLLER_WRITER" +id = "4427ea043c524bb1a22a5b4e0953d297" [[kv_namespaces]] binding = "KV_STATE" -id = "REPLACE_AT_DEPLOY" +id = "03cd968da5eb41d6bc13c9ee48d22254" + +# Workers-AI binding (T0 — free, on-account). Drives GET /api/v1/insights narrative ONLY. +# SPEC: "NEVER uses LLM above T0". @cf/* models are T0. Numbers come from SQL, never the model. +[ai] +binding = "AI" [observability] enabled = true