diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 855bd36bb..3ecf2f5b8 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -110,6 +110,10 @@ import { settleConnectedService, workbenchLaunchPersistExtra, } from "@corbits/chat"; +import { + createDrizzleMailboxWriter, + type MailboxFanoutDeps, +} from "@corbits/chat/mailbox-fanout"; import type { RelaunchNoticePort } from "@corbits/chat"; import { reportError } from "@corbits/error-sink"; import type { FinalizedTurnToolCall } from "@corbits/turn-artifacts"; @@ -1683,6 +1687,40 @@ export async function createHub(config: HubConfig) { // messages to it. releaseAgentInstance: (address, reason) => sidecarRouter.sendAgentUndeploy(address, reason), + // CL-7450: fans a sent human message into every human participant's + // `@corbits/mailbox` inbox, on the same `mailboxDb`/`mailboxBus` every + // other mailbox consumer in this file shares. `resolveKnownPrincipalIds` + // reads the control plane's own `principal` table directly (the + // authoritative "is this a real principal in this tenant" check), + // rather than `@corbits/mailbox`'s FK, so an unknown participant is a + // reported skip, not a database error deep in a transaction. + mailbox: { + writer: createDrizzleMailboxWriter(mailboxDb, mailboxBus), + resolveKnownPrincipalIds: async (tenantId, candidateIds) => { + if (candidateIds.length === 0) return new Set(); + const rows = await db.query.principal.findMany({ + where: (p, { eq: equals, and: andAll }) => + andAll(equals(p.tenantId, tenantId), inArray(p.id, candidateIds)), + columns: { id: true }, + }); + return new Set(rows.map((row) => row.id)); + }, + // A row's Message-ID always addresses under the row's OWN tenant's + // domain, never the acting caller's — see `mailbox-fanout.ts`'s + // `MailboxFanoutDeps.resolveTenantDomain` doc comment. Same + // `tenant` lookup `workflowDeployer.deploy` above uses for the + // identical reason (an instance's trigger address, minted against + // its own tenant's domain). + resolveTenantDomain: async (tenantId) => { + const tenantRow = await db.query.tenant.findFirst({ + where: eq(tenantTable.id, tenantId), + }); + if (tenantRow === undefined) { + throw new Error(`no tenant "${tenantId}" to address a mailbox from`); + } + return tenantRow.domain; + }, + } satisfies MailboxFanoutDeps, }; app.route(`${TENANT_PREFIX}/chat`, createChatRoutes(chatDeps)); // Myra's workflow-run chat surfaces (`@corbits/agent-directory-tools`' diff --git a/bun.lock b/bun.lock index d8e99222b..3e7830334 100644 --- a/bun.lock +++ b/bun.lock @@ -433,6 +433,7 @@ "@corbits/error-sink": "workspace:*", "@corbits/folded-runs": "workspace:*", "@corbits/hub-api-client": "workspace:*", + "@corbits/mailbox": "github:corbitsdev/corbits-mailbox#118a2cd3b0b2d83abcd57b4bed1698e95ee41b6d", "@corbits/memory": "github:corbitsdev/corbits-memory#9e6f213fa2c002b531d3f6af1aa0abd737b8afe3", "@corbits/turn-artifacts": "workspace:*", "@corbits/url-path": "workspace:*", @@ -2098,17 +2099,13 @@ "@better-fetch/fetch": ["@better-fetch/fetch@1.3.1", "", {}, "sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g=="], - "@bufbuild/protobuf": ["@bufbuild/protobuf@2.14.1", "", {}, "sha512-agRJn3+EJDUe8AvxTx/LnHA/GErvLE62pSaSk7+MwFOtOv8eWBu/qCq2qoZjBjVZ3C2aiFJCveuSs17KMkYGOw=="], - - "@cacheable/memory": ["@cacheable/memory@2.2.0", "", { "dependencies": { "@cacheable/utils": "^2.5.0", "@keyv/bigmap": "^1.3.1", "hookified": "^1.15.1", "keyv": "^5.6.0" } }, "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ=="], + "@bufbuild/protobuf": ["@bufbuild/protobuf@2.14.0", "", {}, "sha512-C3UGsiCwSprE2NKIIFA3hCDlpXTMCAXRZuEVp88L1GY36Y41+rYL5fryE+nOFhp4p4JPQvdV8PQ4DWgHgeTE+w=="], - "@cacheable/utils": ["@cacheable/utils@2.5.0", "", { "dependencies": { "hashery": "^1.5.1", "keyv": "^5.6.0" } }, "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA=="], + "@chat-adapter/shared": ["@chat-adapter/shared@4.38.1", "", { "dependencies": { "chat": "4.38.1" } }, "sha512-zJiMl43fiS3a2RjLFdCyTUTqcDY2xss1Acbzc9yfsw8YAkS0LZRdrNmp9/RjLhZB0+FzLarNAayvdKqF2GwnQg=="], - "@chat-adapter/shared": ["@chat-adapter/shared@4.39.0", "", { "dependencies": { "chat": "4.39.0" } }, "sha512-zc5mB5rYqbp4ts+Z3tG/YwkCypPYphriYpG95ZMRNGiEYx3cYZJZFmAFFM4YLfdz3VIQ99Fd0HFDYVNyAMV7cg=="], + "@chat-adapter/slack": ["@chat-adapter/slack@4.38.1", "", { "dependencies": { "@chat-adapter/shared": "4.38.1", "@slack/socket-mode": "^2.0.5", "@slack/web-api": "^7.18.0", "chat": "4.38.1" } }, "sha512-xMZ43YUQKzqOAiePR5mEFjhJPI6/jsEHCMq9Y+Rrm+N4+DotBddQDpbGMzb5oiogZD6iyMVCMvWSPLST7ZX0sg=="], - "@chat-adapter/slack": ["@chat-adapter/slack@4.39.0", "", { "dependencies": { "@chat-adapter/shared": "4.39.0", "@slack/socket-mode": "^2.0.5", "@slack/web-api": "^7.18.0", "chat": "4.39.0" } }, "sha512-nDBYFEmD9MUobUH+HJbOYwKYcaeoCFJDXVvd41flh5sslpixSUG2SFcz7F3py62jJ9c0YYNEdwprIExO20IyZQ=="], - - "@chat-adapter/state-memory": ["@chat-adapter/state-memory@4.39.0", "", { "dependencies": { "chat": "4.39.0" } }, "sha512-sTslMdRA9MG2MYUF9anD+eatZ1o3Nu64TkQCJ9GrGWfRCefM8DnIG/Op+Ej9XS6XjqOORb3eDJvy6IpvWrfT4A=="], + "@chat-adapter/state-memory": ["@chat-adapter/state-memory@4.38.1", "", { "dependencies": { "chat": "4.38.1" } }, "sha512-XYRmnnuckJ7HubQIRqKTwOn2XbpK+GQclN7mRJEpiGk/4LXD46g0XKrYPrQMGe8PGLMdSQ4139Huu34rvyfgSw=="], "@connectrpc/connect": ["@connectrpc/connect@2.1.2", "", { "peerDependencies": { "@bufbuild/protobuf": "^2.7.0" } }, "sha512-MXkBijtcX09R10Eb6sFeIetc6w6746eio6xtfuyVOH7oQAacT1X0GzMIQFux6Qy8cq3W/T5qX5Bei8YbFtmRGA=="], @@ -2380,7 +2377,7 @@ "@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="], - "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.3", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q=="], + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.2", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A=="], "@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="], @@ -2392,7 +2389,7 @@ "@gar/promise-retry": ["@gar/promise-retry@1.0.3", "", {}, "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA=="], - "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.14.0", "", { "dependencies": { "@types/node": ">=20.0.0", "happy-dom": "^20.14.0" } }, "sha512-Xn0kdkaA1eHOaFFopr+YQ37sPNTZ3JScpUMz3j4rIsn1Nneqoh94z79iW23Ej0Ytt99+LlFr4JrSDd6ow9c7MQ=="], + "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.11.6", "", { "dependencies": { "@types/node": ">=20.0.0", "happy-dom": "^20.11.6" } }, "sha512-ZQ47qUTeNbGhHkCGExJ1oZhruoxKRaxO44RgFETl3T4c1rRxIBlAOnM3SAH4XHu7Ue2owJXP+jOx1vOuKuxcSg=="], "@hono/node-server": ["@hono/node-server@2.1.1", "", { "peerDependencies": { "hono": "^4" } }, "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg=="], @@ -2464,25 +2461,21 @@ "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], - "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "@keyv/bigmap": ["@keyv/bigmap@1.3.1", "", { "dependencies": { "hashery": "^1.4.0", "hookified": "^1.15.0" }, "peerDependencies": { "keyv": "^5.6.0" } }, "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ=="], - - "@keyv/serialize": ["@keyv/serialize@1.1.1", "", {}, "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA=="], + "@logtape/hono": ["@logtape/hono@2.3.1", "", { "peerDependencies": { "@logtape/logtape": "^2.3.1", "hono": "^4.0.0" } }, "sha512-ryqg3HBdd79M0/wkiDIcFyB6RXBM1laauYDGWVqAVMrUu53mqRp5lDXrlQYmbIZBtyAyZK/TS2YJR22/79NzpQ=="], - "@logtape/hono": ["@logtape/hono@2.3.3", "", { "peerDependencies": { "@logtape/logtape": "^2.3.3", "hono": "^4.0.0" } }, "sha512-Tu/O2sTRZ3OKSGRX3w1h/TO2ohva8ke38HtO01A4w7TA3lZpzqmtlnT869lOJtVAkzBrXjiLldZPa7mRB0OEhA=="], - - "@logtape/logtape": ["@logtape/logtape@2.3.3", "", {}, "sha512-H8AqgCq6M6Wu97FVPyfE5UT4w6H+Wdt9iGzLKPgtJRI/jrG9HbzzNUw9JL1LarkZD8xeKx9FqKfcLaXr0hGJuw=="], + "@logtape/logtape": ["@logtape/logtape@2.3.1", "", {}, "sha512-nYLsBWMKk0Llp62U6cDbQxNcEKc0E0s5hWbAxkmUM8ax6VchGkPSjPrVp3ixFQUuvUB9ku7MSgPzFhmVTSh2Jw=="], "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.30.0", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA=="], "@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="], - "@noble/ciphers": ["@noble/ciphers@2.4.0", "", {}, "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw=="], + "@noble/ciphers": ["@noble/ciphers@2.3.0", "", {}, "sha512-Clu/xdfgVTf9o7ngLOURaxePwR0j8sjclKEtVij10/jGulwFsPWCvvRgG/XjUVf8Nei+jLG6uwyXzUTGY1DQrw=="], - "@noble/hashes": ["@noble/hashes@2.4.0", "", {}, "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA=="], + "@noble/hashes": ["@noble/hashes@2.3.0", "", {}, "sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ=="], "@npmcli/agent": ["@npmcli/agent@4.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.1", "lru-cache": "^11.2.1", "socks-proxy-agent": "^8.0.3" } }, "sha512-EUEuWAxnL07Sp5/iC/1X6Xj+XThUvnbei9zfRWZdEXa7lss9RTHMhAHBeg+MZ5To9s/gGaSI+UwZTPdYMvKSeg=="], @@ -2494,7 +2487,7 @@ "@phosphor-icons/react": ["@phosphor-icons/react@2.1.10", "", { "peerDependencies": { "react": ">= 16.8", "react-dom": ">= 16.8" } }, "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA=="], - "@puppeteer/browsers": ["@puppeteer/browsers@3.2.2", "", { "dependencies": { "modern-tar": "^0.8.4", "yargs": "^18.0.0" }, "peerDependencies": { "proxy-agent": ">=8.0.1", "yauzl": "^2.10.0 || ^3.4.0" }, "optionalPeers": ["proxy-agent", "yauzl"], "bin": { "browsers": "lib/main-cli.js" } }, "sha512-q2BU4YfO9h/Wt7IcWPcggpOOqLk2Tbs1hDwolvKZrweRjy751OJBKMN9zO5bfD0pzU7X/tvKw/exQds4pM/LOg=="], + "@puppeteer/browsers": ["@puppeteer/browsers@3.2.1", "", { "dependencies": { "modern-tar": "^0.8.0", "yargs": "^18.0.0" }, "peerDependencies": { "proxy-agent": ">=8.0.1", "yauzl": "^2.10.0 || ^3.4.0" }, "optionalPeers": ["proxy-agent", "yauzl"], "bin": { "browsers": "lib/main-cli.js" } }, "sha512-KDz+3qDRdBAlRlMjmKyj6dEs33YHTk/xRHEENSXq6TNnhgoU15ruSHtEBeVF6OZ9tBDY55Se4P0nFMNsipzU9A=="], "@radix-ui/primitive": ["@radix-ui/primitive@1.1.7", "", {}, "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q=="], @@ -2556,55 +2549,55 @@ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="], - "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.63.1", "", { "os": "android", "cpu": "arm" }, "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ=="], + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.5", "", { "os": "android", "cpu": "arm" }, "sha512-jfkGfTwhQpsiSckPF8r9bU3pn3vyd72NlWaO+TgEO6WPSDnUhXzrNYCHBMOYj0ACaUgjm6eERLF+XV9a6RstoA=="], - "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.63.1", "", { "os": "android", "cpu": "arm64" }, "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ=="], + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.62.5", "", { "os": "android", "cpu": "arm64" }, "sha512-oGVqyQlxnrz9/ty89oHpU857VUHEl5/Xu4R2lS+aivCTrNnSsbiENzTnNaBsjxH0CNWGPhzHArOLFwo+oKXveA=="], - "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.63.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q=="], + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bW7B8xMEq8n99Q3ieEcPRGuphurdZAaFzQc9Efyyw3FL6DZO6pMy9xhdN+kBoD7Sy05xNXSr4OyPPnpkYriS/A=="], - "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.63.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg=="], + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.62.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-YSwBS86QeHOGlrxJ1PSOIZSkzRL/JmKeunhc+lV6M1a6En8QuVCD/T/qIA0J4Gd2Y86RIOBYrLcOUtqGh9+/1w=="], - "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.63.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw=="], + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.62.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-2fST8lILgl7cKbme/1KDdPCmbXbG+gqoV3bHp19L0ypX/3akYMBVdOunPleRCwonoLnXOZ/0F+Mt/v8POFmfcQ=="], - "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.63.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q=="], + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.62.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-cpIxQCP9J+EVad0a6LO1kY3ZGODlk80VlI+2I96B8xMcdHZ4pLVhfQ49JFpYqjPF91FFkQWftf57YlDcTiw9yQ=="], - "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.63.1", "", { "os": "linux", "cpu": "arm" }, "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw=="], + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.62.5", "", { "os": "linux", "cpu": "arm" }, "sha512-r9fGh3eFs3e/udWh5ZjXQtxiYK/xoFxQaYR/cELxac/Udkl5Th+IsFm0CX3Kl9hmUH/we7EoMpjJgeQNnE0+IA=="], - "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.63.1", "", { "os": "linux", "cpu": "arm" }, "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw=="], + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.62.5", "", { "os": "linux", "cpu": "arm" }, "sha512-xdvFdp7OM6KLJviJT2g/YuRSUjnZgGHk4RNgwIbN7X6cPugOucV60DdHXWzsBVCUdrGb6qSXnJQrrAKMmQuj3Q=="], - "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.63.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg=="], + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.62.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-rRqILAndyzHzP7T9NFQrq+4HFWNhqkqkKur7eiBpfLmz01PO0JKx5Vchu3YllE4YXI/Ftgq/szrDWg5GJ0mI8g=="], - "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.63.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw=="], + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.62.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-Gf4X3qVMucayUvux6aXXPgXovocSFUC0rrffDuPI/S2nHhNMhjcZxsrAFYCOF350PRreW1XwzFj3CT/3bKsWCw=="], - "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.63.1", "", { "os": "linux", "cpu": "none" }, "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ=="], + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.62.5", "", { "os": "linux", "cpu": "none" }, "sha512-+s5qA0TNM0qm8PK/a5gt/1Hpx+NV08uSuCncvhziIlQzT6AEV2fnUQo7eBtFTFO0nA9scauvoR2HusfXmQnO4w=="], - "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.63.1", "", { "os": "linux", "cpu": "none" }, "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA=="], + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.62.5", "", { "os": "linux", "cpu": "none" }, "sha512-ybb6QvWwWJCbBWqERpc8K3pYVGIrXlG8MEQ8IIuJY6Y9KdHQxoFoNyfkAOtKn1VHu3KuLidXvwrvGR1mEjeWCw=="], - "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.63.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA=="], + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.62.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-nZb1DtnOyhCmYvsC8A2CwOkopVg+IS1+fPUa7rMOAXtNw5+lLCLLPqd6XAiNrGtoQKsbvIBOwsHnBH/3wnb4HQ=="], - "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.63.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA=="], + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.62.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-yMbj63Sp89ryrXLWyz+sy+fYD2HpOnMCLGbe4Oa1smclFSUukdtD/BgdiHaAetJNb74URD8U4hM+qG5KVzMEkg=="], - "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.63.1", "", { "os": "linux", "cpu": "none" }, "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w=="], + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.62.5", "", { "os": "linux", "cpu": "none" }, "sha512-mhoan3OJw2kYV/e1jtIdmvUZgyBFeA6zGWsOswmR0Tg19TQbowZuR+JMLID6spbbBN7Zee2ejrgmy3+FxGrIdA=="], - "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.63.1", "", { "os": "linux", "cpu": "none" }, "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ=="], + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.62.5", "", { "os": "linux", "cpu": "none" }, "sha512-5ZTLmjWbb1VZdjuyhe83K/8QO0/h11midQCBP+X5OYn32ra7eOBoM0ZqtaY4nkgNsYgmdVhMYPoyVPTjUpHf3w=="], - "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.63.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A=="], + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.62.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-m53kG+br6PGxOTmgBEM2DHSDs9RVjsyEbUwjJPJGTFm1grWOG8EKJggDCTb60unD4Tjby8fi7/m9XfkEWasVWg=="], - "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.63.1", "", { "os": "linux", "cpu": "x64" }, "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w=="], + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.62.5", "", { "os": "linux", "cpu": "x64" }, "sha512-6RHPJR1g/uvdYU8uXBnfq3nlqyZCP82Fr6NHgfGoaIeSh0YEqnX/x6uA9MmJJbnSH7swqX4F+CkGdUF+6doiQA=="], - "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.63.1", "", { "os": "linux", "cpu": "x64" }, "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow=="], + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.62.5", "", { "os": "linux", "cpu": "x64" }, "sha512-xs+OXQtEXgpXT0DmA5+U3qnRZHdCST/5HRQxS8wSPZTUZN/EMWeHuSIod32LQklTBZBV9DyfncKBQ8n5V3eFdw=="], - "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.63.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA=="], + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.62.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e7hD+sl3s+mcLQDZ8pbudBVsdG6r5yN4w3LqG2TJ8sQHDpblWj5lrJs/3m01Cvlxbt4x13zu5thLjgypgtkYzw=="], - "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.63.1", "", { "os": "none", "cpu": "arm64" }, "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw=="], + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.62.5", "", { "os": "none", "cpu": "arm64" }, "sha512-GiyJaCf+WpMub/17aPcKk27QMl5W6f+KhdPTjlFOn5akH5Wa/DCM9Stdx5cDfmasyKB08MqpVQ1uJE2RkkpbXg=="], - "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.63.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg=="], + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.62.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-+OQ8U2DdoEfXl8T4Fb18AjmEwbXMerKDKCL8yCPAYhKCEEKoul7rkbeGCBFCbAlaGaa7pmtRTpkAJM2LE/i5FA=="], - "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.63.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg=="], + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.62.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-KanvAZrPKbDBFwrgiU9yEVpQoox9QPV1WZOXX7HudJQY+eSlu82CtWxDU8WtuRRvtN5EGkLczkd6Y6DTcvm9wA=="], - "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.63.1", "", { "os": "win32", "cpu": "x64" }, "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg=="], + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.62.5", "", { "os": "win32", "cpu": "x64" }, "sha512-1aC3UEWTtRl3RK3VpDJ/Tqk1XI4SLTmXIthAq6wRWo8XiSXJNd+VprJM4/1P4+i6HIaFEFlVi9sTTziniD2tOQ=="], - "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.63.1", "", { "os": "win32", "cpu": "x64" }, "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w=="], + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.5", "", { "os": "win32", "cpu": "x64" }, "sha512-/gDJaRs4gl0NPIwqCz+6PkpmhhjRAD2j6P4rSNHBzUkO3naEx2mIU0pRle1vUNRQ7mE/+8OOeXLTv/J56FKiQg=="], "@slack/logger": ["@slack/logger@4.0.1", "", { "dependencies": { "@types/node": ">=18" } }, "sha512-6cmdPrV/RYfd2U0mDGiMK8S7OJqpCTm7enMLRR3edccsPX8j7zXTLnaEF4fhxxJJTAIOil6+qZrnUPTuaLvwrQ=="], @@ -2650,9 +2643,9 @@ "@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="], - "@tanstack/query-core": ["@tanstack/query-core@5.102.8", "", {}, "sha512-ZNjkJ33CqvPNec/6lZBnHqLc3EVGPZ9ySLhYahU9TcuRFdmwXewuj0c4hwSWcGHqEUwcSrKeZ+oGcvPBqXcQcg=="], + "@tanstack/query-core": ["@tanstack/query-core@5.101.4", "", {}, "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw=="], - "@tanstack/react-query": ["@tanstack/react-query@5.102.8", "", { "dependencies": { "@tanstack/query-core": "5.102.8" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-TYBea4OuXWD7MhaSHq069TWbFe7rcwWN6kzT7JF0OKi1K6c1gTv2IzD6A6ExJsCMozdkqBWeuIUZmu4KQg0O5A=="], + "@tanstack/react-query": ["@tanstack/react-query@5.101.4", "", { "dependencies": { "@tanstack/query-core": "5.101.4" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], @@ -2676,11 +2669,11 @@ "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], - "@types/node": ["@types/node@26.4.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA=="], + "@types/node": ["@types/node@26.2.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg=="], "@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="], - "@types/react-dom": ["@types/react-dom@19.2.7", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ=="], + "@types/react-dom": ["@types/react-dom@19.2.4", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw=="], "@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="], @@ -2694,25 +2687,25 @@ "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.69.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.69.0", "@typescript-eslint/type-utils": "8.69.0", "@typescript-eslint/utils": "8.69.0", "@typescript-eslint/visitor-keys": "8.69.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.69.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-t5jQTKPIgVW1PE6dR6H6Qz5gm8zjMlX5/2gRaOGd9eO6V7J+tQc6iWKukEe7dY8u9HyYasQ0yfF0/FSSTEO2gA=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.67.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.67.0", "@typescript-eslint/type-utils": "8.67.0", "@typescript-eslint/utils": "8.67.0", "@typescript-eslint/visitor-keys": "8.67.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.67.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ=="], - "@typescript-eslint/parser": ["@typescript-eslint/parser@8.69.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.69.0", "@typescript-eslint/types": "8.69.0", "@typescript-eslint/typescript-estree": "8.69.0", "@typescript-eslint/visitor-keys": "8.69.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-l4b0DhWioGg6Gt2ebGlvfkFMOjRsauxtsnDRwUSRX1qHq3HdTfQHV8wW9zEXeciai6HfeaKOedQn2Zoofx3WBw=="], + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.67.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.67.0", "@typescript-eslint/types": "8.67.0", "@typescript-eslint/typescript-estree": "8.67.0", "@typescript-eslint/visitor-keys": "8.67.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w=="], - "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.69.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.69.0", "@typescript-eslint/types": "^8.69.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-yi4obFrHMmnsesWehHbkg9zMA7Jt8cXT+mKM08G999pH1yT6nqgsHx7MYm0uY1wAj8CqiBXYRJ7WAT0QdQHQXg=="], + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.67.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.67.0", "@typescript-eslint/types": "^8.67.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw=="], - "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.69.0", "", { "dependencies": { "@typescript-eslint/types": "8.69.0", "@typescript-eslint/visitor-keys": "8.69.0" } }, "sha512-ewfspqWvSxKSOaplqAUNbaSFO0eB6w1EtQ+esfYFRm3614Ty4uNtExkcbgd6nWsXphbqKyf9ZYdbZdv2xEoWEQ=="], + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.67.0", "", { "dependencies": { "@typescript-eslint/types": "8.67.0", "@typescript-eslint/visitor-keys": "8.67.0" } }, "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg=="], - "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.69.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-xNqK7YTDZsLniQMV/4rpFR8Z5JlqeRvVjuG1YgF/mdPVH84HSD19L8CczMA0qg2RfwEV231GHH3VnToJDo4MfQ=="], + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.67.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg=="], - "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.69.0", "", { "dependencies": { "@typescript-eslint/types": "8.69.0", "@typescript-eslint/typescript-estree": "8.69.0", "@typescript-eslint/utils": "8.69.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-ZfoJAVg3JZndQEpEl9petVlxau3lRuElc4HRMuAlLCf8to04/iHz692RUSNmXKDjEuJmIL+KZ2/BsOcBc16dsA=="], + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.67.0", "", { "dependencies": { "@typescript-eslint/types": "8.67.0", "@typescript-eslint/typescript-estree": "8.67.0", "@typescript-eslint/utils": "8.67.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q=="], - "@typescript-eslint/types": ["@typescript-eslint/types@8.69.0", "", {}, "sha512-K3VrubUPhlo9VDBS6QdI8YB5j7ClpqLRdefcz6PFrhnwicehBweqQ9Evhl4l+FYz0HdDmMqIiSX0aldGRYtDCA=="], + "@typescript-eslint/types": ["@typescript-eslint/types@8.67.0", "", {}, "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww=="], - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.69.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.69.0", "@typescript-eslint/tsconfig-utils": "8.69.0", "@typescript-eslint/types": "8.69.0", "@typescript-eslint/visitor-keys": "8.69.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-AdFkgqck3Vudb/kWnxlyafU/4aBhHrbQ9locP2N4psXTy5mOBg0SHJumnLvx7r6g1gV4DKvUFwV2nJZBoqOD8w=="], + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.67.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.67.0", "@typescript-eslint/tsconfig-utils": "8.67.0", "@typescript-eslint/types": "8.67.0", "@typescript-eslint/visitor-keys": "8.67.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw=="], - "@typescript-eslint/utils": ["@typescript-eslint/utils@8.69.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.69.0", "@typescript-eslint/types": "8.69.0", "@typescript-eslint/typescript-estree": "8.69.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-tUbx60BBqQa31kXF5MCsOOLL5E/WzUuxIn7YpAvq+eaUlqvk8/NXnXMBNAdLCr0icjkzem7iUA5QqWHe/hJ1aw=="], + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.67.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.67.0", "@typescript-eslint/types": "8.67.0", "@typescript-eslint/typescript-estree": "8.67.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A=="], - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.69.0", "", { "dependencies": { "@typescript-eslint/types": "8.69.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w=="], + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.67.0", "", { "dependencies": { "@typescript-eslint/types": "8.67.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="], @@ -2760,7 +2753,7 @@ "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], - "axios": ["axios@1.20.0", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg=="], + "axios": ["axios@1.19.0", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw=="], "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], @@ -2768,7 +2761,7 @@ "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.11.21", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.11.16", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-H/bNPUFHewJHyCTdjn1n3Pit5+2GmWT6mmeHImPX+8MA9NA6b67jO4gYmi4jTbCJb2otq34KMZnovndDPqJwhQ=="], "better-auth": ["better-auth@1.6.29", "", { "dependencies": { "@better-auth/core": "1.6.29", "@better-auth/drizzle-adapter": "1.6.29", "@better-auth/kysely-adapter": "1.6.29", "@better-auth/memory-adapter": "1.6.29", "@better-auth/mongo-adapter": "1.6.29", "@better-auth/prisma-adapter": "1.6.29", "@better-auth/telemetry": "1.6.29", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "@noble/ciphers": "^2.1.1", "@noble/hashes": "^2.0.1", "better-call": "1.4.0", "defu": "^6.1.4", "jose": "^6.1.3", "kysely": "^0.28.17 || ^0.29.0", "nanostores": "^1.1.1", "zod": "^4.3.6" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": "^0.45.2", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-7i2kOry+Jb1mRUR14kCDVS23GCKUGWxayZRXULbNz/6nFLPT6XSuGJVrwo/d+Qfq/iubA0/4Acw2/eg/tPCCnA=="], @@ -2778,7 +2771,7 @@ "brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="], - "browserslist": ["browserslist@4.28.9", "", { "dependencies": { "baseline-browser-mapping": "^2.11.20", "caniuse-lite": "^1.0.30001810", "electron-to-chromium": "^1.5.420", "node-releases": "^2.0.54", "update-browserslist-db": "^1.3.2" }, "bin": { "browserslist": "cli.js" } }, "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg=="], + "browserslist": ["browserslist@4.28.8", "", { "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", "electron-to-chromium": "^1.5.402", "node-releases": "^2.0.53", "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA=="], "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], @@ -2792,15 +2785,13 @@ "cacache": ["cacache@20.0.4", "", { "dependencies": { "@npmcli/fs": "^5.0.0", "fs-minipass": "^3.0.0", "glob": "^13.0.0", "lru-cache": "^11.1.0", "minipass": "^7.0.3", "minipass-collect": "^2.0.1", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "p-map": "^7.0.2", "ssri": "^13.0.0" } }, "sha512-M3Lab8NPYlZU2exsL3bMVvMrMqgwCnMWfdZbK28bn3pK6APT/Te/I8hjRPNu1uwORY9a1eEQoifXbKPQMfMTOA=="], - "cacheable": ["cacheable@2.5.0", "", { "dependencies": { "@cacheable/memory": "^2.2.0", "@cacheable/utils": "^2.5.0", "hookified": "^1.15.0", "keyv": "^5.6.0", "qified": "^0.10.1" } }, "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g=="], - "call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], - "caniuse-lite": ["caniuse-lite@1.0.30001810", "", {}, "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg=="], + "caniuse-lite": ["caniuse-lite@1.0.30001809", "", {}, "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ=="], "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], @@ -2808,7 +2799,7 @@ "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], - "chat": ["chat@4.39.0", "", { "dependencies": { "@workflow/serde": "4.1.0-beta.2", "mdast-util-to-string": "^4.0.0", "remark-gfm": "^4.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "remend": "^1.2.1", "unified": "^11.0.5" }, "peerDependencies": { "ai": "^6.0.182 || ^7.0.0", "workflow": "^5.0.0-beta.35", "zod": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["ai", "workflow", "zod"] }, "sha512-o7D+oAbQAYFVoc9+OH8wya11609ybRThNTF3s/5C1LAABGGi6IrxoQQEN/aIe+4h5pmKYzUjXeqTcuOZcpIAZA=="], + "chat": ["chat@4.38.1", "", { "dependencies": { "@workflow/serde": "4.1.0-beta.2", "mdast-util-to-string": "^4.0.0", "remark-gfm": "^4.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "remend": "^1.2.1", "unified": "^11.0.5" }, "peerDependencies": { "ai": "^6.0.182 || ^7.0.0", "workflow": "^5.0.0-beta.35", "zod": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["ai", "workflow", "zod"] }, "sha512-FD6vNLT8clXHKhN3I1m+3Bn2nz0rsB6/bmNPsJeh6Cr7kwWLg0MYl2uor9YaKBp38F77sBRVKdYKVA6ihUby3w=="], "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], @@ -2884,11 +2875,11 @@ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], - "e2b": ["e2b@2.46.1", "", { "dependencies": { "@bufbuild/protobuf": "^2.14.0", "@connectrpc/connect": "^2.1.2", "@connectrpc/connect-web": "^2.1.2", "chalk": "^5.3.0", "compare-versions": "^6.1.0", "dockerfile-ast": "^0.7.1", "glob": "^13.0.6", "openapi-fetch": "^0.14.1", "platform": "^1.3.6", "tar": "^7.5.22", "undici": "^7.29.0" }, "optionalDependencies": { "undici8": "npm:undici@8.10.0" } }, "sha512-OqYovS2oFrt4mk737CgfW/RoMadBYK84l5qjKpvbEoOB9KKxaZIXm7YUwOKSRTlijrrwDRX7oZlyPoVXiCpyTw=="], + "e2b": ["e2b@2.44.1", "", { "dependencies": { "@bufbuild/protobuf": "^2.12.1", "@connectrpc/connect": "^2.1.2", "@connectrpc/connect-web": "^2.1.2", "chalk": "^5.3.0", "compare-versions": "^6.1.0", "dockerfile-ast": "^0.7.1", "glob": "^13.0.6", "openapi-fetch": "^0.14.1", "platform": "^1.3.6", "tar": "^7.5.19", "undici": "^7.29.0" }, "optionalDependencies": { "undici8": "npm:undici@8.10.0" } }, "sha512-6yx3FjYnR2zlidvF5QLaFQAtv7WndKAIrP8NEi9lhJm5+98Iinb7zHYel/Ug/o9BEZTb7ODYUvD/0P9b2Q6ufg=="], "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - "electron-to-chromium": ["electron-to-chromium@1.5.422", "", {}, "sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA=="], + "electron-to-chromium": ["electron-to-chromium@1.5.411", "", {}, "sha512-gglkxzokjHfawpGxq75XdBV2/l3BAPzrsMs70qgaZdTW5rpV1tC4MdgJVP9fN126bODA4ZJQkn1wryEzJyQXIg=="], "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], @@ -2914,7 +2905,7 @@ "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - "eslint": ["eslint@10.10.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.3", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "11.1.5 || >11.1.6 <12", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-NPXn6r5zl4uET1DAVPaOwzX3rut4c0wcmw3dWJAfOsTM5+TogXo0DDjz8pwm/hL8cyVNpHqeK4JpN0NjnyFFNw=="], + "eslint": ["eslint@10.8.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ=="], "eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="], @@ -2944,7 +2935,7 @@ "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], - "express-rate-limit": ["express-rate-limit@8.7.0", "", { "dependencies": { "debug": "^4.4.3", "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g=="], + "express-rate-limit": ["express-rate-limit@8.6.2", "", { "dependencies": { "debug": "^4.4.3", "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A=="], "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], @@ -2956,17 +2947,17 @@ "fast-text-encoding": ["fast-text-encoding@1.0.6", "", {}, "sha512-VhXlQgj9ioXCqGstD37E/HBeqEGV/qOD/kmbVG8h5xKBYvM1L3lR1Zn4555cQ8GkYbJa8aJSipLPndE1k6zK2w=="], - "fast-uri": ["fast-uri@3.1.7", "", {}, "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg=="], + "fast-uri": ["fast-uri@3.1.5", "", {}, "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - "file-entry-cache": ["file-entry-cache@11.1.5", "", { "dependencies": { "flat-cache": "^6.1.23" } }, "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q=="], + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], - "flat-cache": ["flat-cache@6.1.23", "", { "dependencies": { "cacheable": "^2.5.0", "flatted": "^3.4.2", "hookified": "^1.15.0" } }, "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA=="], + "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], "flatted": ["flatted@3.4.4", "", {}, "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q=="], @@ -3008,7 +2999,7 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], - "happy-dom": ["happy-dom@20.14.0", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-4bRh1KzRvKDnFNTlLhzT1RZTpkKhQbQDl9j+7GXszWsvuspYdo29k6OHRf4PwiM6oLb8r/pMWeYiJjkfod5AvQ=="], + "happy-dom": ["happy-dom@20.11.6", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-Hldbg8AdAa5a5oDcZpjqnGitp7JB0hqWmfv/8qr+kft4vzSD8BHsbdRfzYvL/0QcbKcURC/yyoygbeDQarPvYg=="], "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], @@ -3016,16 +3007,12 @@ "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], - "hashery": ["hashery@1.5.1", "", { "dependencies": { "hookified": "^1.15.0" } }, "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ=="], - "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], "hono": ["hono@4.13.3", "", {}, "sha512-r8AO2mYHoLxSHkgafNeC/BXyb2vWRxD3jem4Ts+ptav8oTG5FIRifAjuJEmZI4bSvvc2ns0GxmIYiZnHqN3mMw=="], "hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="], - "hookified": ["hookified@1.15.1", "", {}, "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg=="], - "hosted-git-info": ["hosted-git-info@8.1.0", "", { "dependencies": { "lru-cache": "^10.0.1" } }, "sha512-Rw/B2DNQaPBICNXEm8balFz9a6WpZrkCGpcWFpy7nCj+NyhSdqXipmfvtmWt9xGfp0wZnBxB+iVpLmQMYt47Tw=="], "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], @@ -3046,7 +3033,7 @@ "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - "ip-address": ["ip-address@10.7.0", "", {}, "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA=="], + "ip-address": ["ip-address@10.5.0", "", {}, "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g=="], "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], @@ -3070,7 +3057,7 @@ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - "isomorphic-git": ["isomorphic-git@1.41.9", "", { "dependencies": { "async-lock": "^1.4.1", "clean-git-ref": "^2.0.1", "crc-32": "^1.2.0", "diff3": "0.0.3", "ignore": "^5.1.4", "minimisted": "^2.0.0", "pako": "^1.0.10", "pify": "^4.0.1", "readable-stream": "^4.0.0", "sha.js": "^2.4.12", "simple-get": "^4.0.1" }, "bin": { "isogit": "cli.cjs" } }, "sha512-WOh4ujm5mznHphzQvwj9bVj+pQpV0oZ8H4lAnh4dSaie+lN/lJ2rb6rNOOcP+2m4z8IOQp186TkEULD28NMBJg=="], + "isomorphic-git": ["isomorphic-git@1.41.7", "", { "dependencies": { "async-lock": "^1.4.1", "clean-git-ref": "^2.0.1", "crc-32": "^1.2.0", "diff3": "0.0.3", "ignore": "^5.1.4", "minimisted": "^2.0.0", "pako": "^1.0.10", "pify": "^4.0.1", "readable-stream": "^4.0.0", "sha.js": "^2.4.12", "simple-get": "^4.0.1" }, "bin": { "isogit": "cli.cjs" } }, "sha512-ZCyMNg0eLUmuhGXdVQFi56fM93kwIqqlUSVnWbgzf1oDYidrQbWfrf+hEvn2E8xyo191zRPQyDCIlwg6YJ6isQ=="], "isomorphic-textencoder": ["isomorphic-textencoder@1.0.1", "", { "dependencies": { "fast-text-encoding": "^1.0.0" } }, "sha512-676hESgHullDdHDsj469hr+7t3i/neBKU9J7q1T4RHaWwLAsaQnywC0D1dIUId0YZ+JtVrShzuBk1soo0+GVcQ=="], @@ -3078,12 +3065,14 @@ "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], - "jose": ["jose@6.2.11", "", {}, "sha512-A5NPn7g8EAzGU3IzRs+Yiq8K5n3ypYS75M5+KKiVHdUexfpWK1kP4ZMq7QnTGDoMj6TJ1dtcEJjW60yZDXS4hg=="], + "jose": ["jose@6.2.9", "", {}, "sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], @@ -3098,7 +3087,7 @@ "just-once": ["just-once@1.1.0", "", {}, "sha512-+rZVpl+6VyTilK7vB/svlMPil4pxqIJZkbnN7DKZTOzyXfun6ZiFeq2Pk4EtCEHZ0VU4EkdFzG8ZK5F3PErcDw=="], - "keyv": ["keyv@5.6.0", "", { "dependencies": { "@keyv/serialize": "^1.1.1" } }, "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw=="], + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], "kysely": ["kysely@0.29.5", "", {}, "sha512-ooa+eSbBNPTo3MycPEuW5jdrxQdQwdtB3LC3h43FiXQbIry5tR0C5lDG7eealK0E4D7XjrnOP5DIUg/LyjRMYQ=="], @@ -3136,7 +3125,7 @@ "lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], - "lucide-react": ["lucide-react@1.41.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-6lksP35l6KszDKUeRTi4LV7i6DEe0Yzl2ALJm9j4c5xEYN91GdW1xGsawGMOg2mgjF5GHBVX8pKX9kP+cWsP3Q=="], + "lucide-react": ["lucide-react@1.33.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-MTRwMy0ZlL8Ur/vOAiJ9XGHE+kFPC7brq6MxAm0GiGXEBj0qy0jA/pG4N675oSzciO/UCdX8T+5yUQdmDeTLxg=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], @@ -3262,13 +3251,13 @@ "nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="], - "nanostores": ["nanostores@1.5.3", "", {}, "sha512-rQLB6eV4f2AW/n3L0JmwCROpaisYy9EDEADvEFSd1C/qG8hB6O5TPlh9A791JRbJr4CnMQBzptDcvD9OR1+6WA=="], + "nanostores": ["nanostores@1.5.2", "", {}, "sha512-B0UbxzK1s0CN8Xht6r+7iT5+xV8PTaRERR1nATeplRv1Rw5YLWfVAid0hkqY3EceqpG4RjTk8GAwIxQY39Rnwg=="], "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], "negotiator": ["negotiator@1.1.0", "", { "dependencies": { "content-type": "^2.1.0" } }, "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg=="], - "node-releases": ["node-releases@2.0.54", "", {}, "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ=="], + "node-releases": ["node-releases@2.0.53", "", {}, "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ=="], "npm-install-checks": ["npm-install-checks@7.1.2", "", { "dependencies": { "semver": "^7.1.1" } }, "sha512-z9HJBCYw9Zr8BqXcllKIs5nI+QggAImbBdHphOzVYrz2CB4iQ6FzWyKmlqDZua+51nAu7FcemlbTc9VgQN5XDQ=="], @@ -3302,7 +3291,7 @@ "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], - "p-map": ["p-map@7.0.7", "", {}, "sha512-VaWRu2i4FJNRtiRWCuuQRgfQ1B7a6+gMSrO+3j0EQi/k0ULfS9kosRxGoiqwzIjZTDI02tGfk5mXXltLg6QtfQ=="], + "p-map": ["p-map@7.0.6", "", {}, "sha512-I4Prw6ivkd6p8PiYR1tXASOAOBzIJwu0TB7fqaX0c/8c3QAehNYmX57EijyGGGBt3c/BIowGwV03RVBtXvHEVg=="], "p-queue": ["p-queue@6.6.2", "", { "dependencies": { "eventemitter3": "^4.0.4", "p-timeout": "^3.2.0" } }, "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ=="], @@ -3324,7 +3313,7 @@ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], "pify": ["pify@4.0.1", "", {}, "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g=="], @@ -3334,7 +3323,7 @@ "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], - "postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="], + "postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="], "postgres": ["postgres@3.4.9", "", {}, "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="], @@ -3352,11 +3341,9 @@ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], - "puppeteer-core": ["puppeteer-core@25.10.0", "", { "dependencies": { "@puppeteer/browsers": "3.2.2", "chromium-bidi": "17.0.2", "devtools-protocol": "0.0.1666840", "typed-query-selector": "^2.12.2", "webdriver-bidi-protocol": "0.4.3", "ws": "^8.21.3" } }, "sha512-Hy5eMQshOEMil4JUUx03h5pw1HYkYCso1RG/gcpPlFSd4cYPOcopxcXEAxpLPOkOPJb9LIJtwxuj66bSdvknFg=="], - - "qified": ["qified@0.10.1", "", { "dependencies": { "hookified": "^2.1.1" } }, "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA=="], + "puppeteer-core": ["puppeteer-core@25.8.0", "", { "dependencies": { "@puppeteer/browsers": "3.2.1", "chromium-bidi": "17.0.2", "devtools-protocol": "0.0.1666840", "typed-query-selector": "^2.12.2", "webdriver-bidi-protocol": "0.4.2", "ws": "^8.21.1" } }, "sha512-LDOrawV8vfCVk+yLj2ozvajNP4Sv3OV9y3Tpiyy2g2Z+aQlbcozP6KJfI4iSBq7YQER+86ihEtPa5ioiZyWxMQ=="], - "qs": ["qs@6.16.0", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA=="], + "qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="], "quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="], @@ -3384,7 +3371,7 @@ "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], - "remend": ["remend@1.3.1", "", {}, "sha512-N3DiY5qbRPoa5vkxn1oDLMyXOVTeo6Hp+XOj6SIqJAYUgLS0Q587gILPMom/qm86AQ/ZrcOdwEIzCz8V3J0nxQ=="], + "remend": ["remend@1.3.0", "", {}, "sha512-iIhggPkhW3hFImKtB10w0dz4EZbs28mV/dmbcYVonWEJ6UGHHpP+bFZnTh6GNWJONg5m+U56JrL+8IxZRdgWjw=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], @@ -3392,7 +3379,7 @@ "retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], - "rollup": ["rollup@4.63.1", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.63.1", "@rollup/rollup-android-arm64": "4.63.1", "@rollup/rollup-darwin-arm64": "4.63.1", "@rollup/rollup-darwin-x64": "4.63.1", "@rollup/rollup-freebsd-arm64": "4.63.1", "@rollup/rollup-freebsd-x64": "4.63.1", "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", "@rollup/rollup-linux-arm-musleabihf": "4.63.1", "@rollup/rollup-linux-arm64-gnu": "4.63.1", "@rollup/rollup-linux-arm64-musl": "4.63.1", "@rollup/rollup-linux-loong64-gnu": "4.63.1", "@rollup/rollup-linux-loong64-musl": "4.63.1", "@rollup/rollup-linux-ppc64-gnu": "4.63.1", "@rollup/rollup-linux-ppc64-musl": "4.63.1", "@rollup/rollup-linux-riscv64-gnu": "4.63.1", "@rollup/rollup-linux-riscv64-musl": "4.63.1", "@rollup/rollup-linux-s390x-gnu": "4.63.1", "@rollup/rollup-linux-x64-gnu": "4.63.1", "@rollup/rollup-linux-x64-musl": "4.63.1", "@rollup/rollup-openbsd-x64": "4.63.1", "@rollup/rollup-openharmony-arm64": "4.63.1", "@rollup/rollup-win32-arm64-msvc": "4.63.1", "@rollup/rollup-win32-ia32-msvc": "4.63.1", "@rollup/rollup-win32-x64-gnu": "4.63.1", "@rollup/rollup-win32-x64-msvc": "4.63.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg=="], + "rollup": ["rollup@4.62.5", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.62.5", "@rollup/rollup-android-arm64": "4.62.5", "@rollup/rollup-darwin-arm64": "4.62.5", "@rollup/rollup-darwin-x64": "4.62.5", "@rollup/rollup-freebsd-arm64": "4.62.5", "@rollup/rollup-freebsd-x64": "4.62.5", "@rollup/rollup-linux-arm-gnueabihf": "4.62.5", "@rollup/rollup-linux-arm-musleabihf": "4.62.5", "@rollup/rollup-linux-arm64-gnu": "4.62.5", "@rollup/rollup-linux-arm64-musl": "4.62.5", "@rollup/rollup-linux-loong64-gnu": "4.62.5", "@rollup/rollup-linux-loong64-musl": "4.62.5", "@rollup/rollup-linux-ppc64-gnu": "4.62.5", "@rollup/rollup-linux-ppc64-musl": "4.62.5", "@rollup/rollup-linux-riscv64-gnu": "4.62.5", "@rollup/rollup-linux-riscv64-musl": "4.62.5", "@rollup/rollup-linux-s390x-gnu": "4.62.5", "@rollup/rollup-linux-x64-gnu": "4.62.5", "@rollup/rollup-linux-x64-musl": "4.62.5", "@rollup/rollup-openbsd-x64": "4.62.5", "@rollup/rollup-openharmony-arm64": "4.62.5", "@rollup/rollup-win32-arm64-msvc": "4.62.5", "@rollup/rollup-win32-ia32-msvc": "4.62.5", "@rollup/rollup-win32-x64-gnu": "4.62.5", "@rollup/rollup-win32-x64-msvc": "4.62.5", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-/tqMfgP7GPA3PHhCmuiS4vIjrSVhHLgY++i+dhbG462euyAj7FpM4D9uq1X3BgjlqRdpcOrYhcQtfiQLNc8tqw=="], "rou3": ["rou3@0.9.2", "", {}, "sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ=="], @@ -3434,11 +3421,11 @@ "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], - "simple-icons": ["simple-icons@16.29.0", "", {}, "sha512-4H94f5ZgcCcgJroc902TFlFdgPu2IU2eD7+WebN2Z14xKYrKHeJ4UQcZwzgSuH4Rgzw+jp7sbHl40NefuIEYsg=="], + "simple-icons": ["simple-icons@16.28.0", "", {}, "sha512-sQPR5AtK/ijRjou7zw7mlLp08oB6FH7i0lOy5XJ2zp9mJs/yejgiOn7KvQoe2q4YJIx6VmgUSW5AOefebPt5kg=="], "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], - "socks": ["socks@2.8.10", "", { "dependencies": { "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" } }, "sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ=="], + "socks": ["socks@2.8.9", "", { "dependencies": { "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" } }, "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw=="], "socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="], @@ -3480,7 +3467,7 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "tsx": ["tsx@4.23.13", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw=="], + "tsx": ["tsx@4.23.12", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q=="], "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], @@ -3492,9 +3479,9 @@ "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], - "typescript-eslint": ["typescript-eslint@8.69.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.69.0", "@typescript-eslint/parser": "8.69.0", "@typescript-eslint/typescript-estree": "8.69.0", "@typescript-eslint/utils": "8.69.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-B3MltX0VqjUBNEe3b3sSuiRbfa6XrfHFtBiPamjT5AsW/dfq+y+bc0wyuS9DxAS1LyzCxRp2+rxzpLUvqM2BvA=="], + "typescript-eslint": ["typescript-eslint@8.67.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.67.0", "@typescript-eslint/parser": "8.67.0", "@typescript-eslint/typescript-estree": "8.67.0", "@typescript-eslint/utils": "8.67.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg=="], - "undici": ["undici@7.29.1", "", {}, "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q=="], + "undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], @@ -3512,7 +3499,7 @@ "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], - "update-browserslist-db": ["update-browserslist-db@1.3.2", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw=="], + "update-browserslist-db": ["update-browserslist-db@1.3.1", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ=="], "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], @@ -3530,11 +3517,11 @@ "vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="], - "vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.14", "", {}, "sha512-EQyqJMi552E4ZTf46izQ4Fj6XquqxCySR3J5ZSD1SisMf6RfpeOWHxGBE8Gr6V0/3GHIGdAzDn8F8+1nTGCnoQ=="], + "vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="], - "vscode-languageserver-types": ["vscode-languageserver-types@3.18.3", "", {}, "sha512-XIlzJ7Qp/jzSI1ds7/FwPAWrPeTZA7pAtlW4hdJ1J6xXWJL6dR9QYnDhJOdLzdKhUQ5Mm6mvUMw+3DcOQQasPw=="], + "vscode-languageserver-types": ["vscode-languageserver-types@3.18.0", "", {}, "sha512-8TsGPNMIMiiBdkORgRSvLjuiEIiAFtO+KssmYWxQ+uSVvlf7RjK8YKCOjPzZ+YA04jXEV7+7LvkSmHkhpNS99g=="], - "webdriver-bidi-protocol": ["webdriver-bidi-protocol@0.4.3", "", {}, "sha512-uuN0goWfxP22B7J/uAgBpOYNPttC+XVseYE+rSY5+rQ+YBeVz/VORw8WbmLVcqW78zNg5A4qnjNXYUWR3il2ig=="], + "webdriver-bidi-protocol": ["webdriver-bidi-protocol@0.4.2", "", {}, "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA=="], "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], @@ -3564,7 +3551,7 @@ "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], - "zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], @@ -3576,6 +3563,10 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@corbits/inbox/@corbits/mailbox": ["@corbits/mailbox@github:corbitsdev/corbits-mailbox#118a2cd", { "dependencies": { "@hono/standard-validator": "0.2.3", "@standard-community/standard-json": "0.3.5", "@standard-community/standard-openapi": "0.2.9", "arktype": "2.1.29", "hono-openapi": "1.3.1" }, "peerDependencies": { "@intx/log": "^0.2.2", "@intx/mime": "^0.2.2", "@intx/types": "^0.2.2", "drizzle-orm": "^0.45.2", "hono": "^4.12.0", "postgres": "^3.4.0" } }, "corbitsdev-corbits-mailbox-118a2cd", "sha512-jVRl6/IH34JBPVUz7mqWyBm1w7uJxvYAI27gjw7zL1dFO4L+D0E5q/8b+mpwKMNOKKlGXU6UF5pdHnY8ilYx8g=="], + + "@corbits/memory-hub/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#9e6f213", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-9e6f213", "sha512-utnM4ZT2zmslcPXYWAAqxlDNLcpGsXFiTOtj8h7+OXnhCP0Eaw8yl25+yCTyHpvt3jcdeG4h5uFsSj7ou0BZCA=="], + "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], @@ -3596,7 +3587,11 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.8", "", {}, "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q=="], + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.6", "", {}, "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw=="], + + "@workbench/hub/@corbits/mailbox": ["@corbits/mailbox@github:corbitsdev/corbits-mailbox#118a2cd", { "dependencies": { "@hono/standard-validator": "0.2.3", "@standard-community/standard-json": "0.3.5", "@standard-community/standard-openapi": "0.2.9", "arktype": "2.1.29", "hono-openapi": "1.3.1" }, "peerDependencies": { "@intx/log": "^0.2.2", "@intx/mime": "^0.2.2", "@intx/types": "^0.2.2", "drizzle-orm": "^0.45.2", "hono": "^4.12.0", "postgres": "^3.4.0" } }, "corbitsdev-corbits-mailbox-118a2cd", "sha512-jVRl6/IH34JBPVUz7mqWyBm1w7uJxvYAI27gjw7zL1dFO4L+D0E5q/8b+mpwKMNOKKlGXU6UF5pdHnY8ilYx8g=="], + + "@workbench/hub/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#9e6f213", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-9e6f213", "sha512-utnM4ZT2zmslcPXYWAAqxlDNLcpGsXFiTOtj8h7+OXnhCP0Eaw8yl25+yCTyHpvt3jcdeG4h5uFsSj7ou0BZCA=="], "ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], @@ -3634,8 +3629,6 @@ "p-queue/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], - "qified/hookified": ["hookified@2.2.0", "", {}, "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA=="], - "tsx/esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="], "type-is/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], diff --git a/docs/CHAT.md b/docs/CHAT.md index ba6d95c5a..582515186 100644 --- a/docs/CHAT.md +++ b/docs/CHAT.md @@ -68,6 +68,75 @@ platform's mail-send shape — a single text part rides as bare mail content; anything else becomes a list of `text/plain`/`application/json` MIME attachments — and that encoding is confined to the dispatch seam. +The dispatched frame itself carries RFC 5322 threading (CL-7450): its +`Message-ID` is `mailMessageIdFor(sourceMessageId, domain)` — the same +derivation `mailboxFanOutForSend` already stamped for that row, so the two +always agree — and `In-Reply-To`/`References` name that row's own parent +chain (`mailAncestryOf` in `./threads.ts`). This is what lets a reply's own +`In-Reply-To` correlate back to the exact row that triggered the turn, +independent of the process-local `turnMailCorrelation` bookkeeping +`dispatchTurn` also does. Degrades to unthreaded, exactly as before, on a +composition with no mailbox domain to derive it from. + +## Every human's mailbox gets a copy (CL-7450) + +`sendWorkbenchMessage` runs a strict order: store the row -> stamp its +RFC 5322 `Message-ID` -> fan the message into every human participant's +`@corbits/mailbox` inbox, as one batch -> publish the row onto the room's +live stream -> dispatch to whichever agents the message names. The mailbox +fan-out runs BEFORE the row is published and BEFORE any agent is asked for +a turn: a fan-out failure fails the send outright — the just-inserted row is +deleted and the failure is rethrown to the caller, never swallowed into a +send that looks fully delivered when it wasn't. Only once the fan-out batch +has committed does the row publish, giving a client-visible bubble and a +durable mailbox copy the same all-or-nothing guarantee. + +This is not transactional across the row insert and the delete: the row +write and the mailbox batch are two separate commits (chat and +`@corbits/mailbox` are separate Postgres handles in the hub's own +composition), so a concurrent `GET` of the timeline in the window between +the insert and a fan-out failure's delete CAN read the row before it is +removed — no `chat.message` publish has happened yet (so no live client +sees it), but a poller hitting the REST list endpoint in that same window +can. The window is real and bounded to that one gap, not claimed away. A +fan-out failure is reported once, under one `refId`, as +`MailboxFanoutFailedError` (`./mailbox-fanout.ts`) — the send route +answers with that same ref rather than letting the hub's generic +unhandled-route-error handler report it again under a second one. + +The fan-out itself writes an "outbound" row in the sender's own mailbox and +an "inbound" row in every other human participant's, all sharing the +timeline row's own `Message-ID` (``, stamped onto +`workbench_messages.mail_message_id` — see `./mail-headers.ts`) as the +frame's threading identity; the mailbox write's own idempotency key is left +to `@corbits/mailbox`'s default (a transport key derived from that same +Message-ID plus each recipient's principal and direction), never a +caller-minted one. The whole batch is ONE `@corbits/mailbox` transaction +(`writeMailboxMessages`): every row commits together or none does, so a +retry after a genuine failure re-attempts every recipient rather than +risking a partial delivery. An agent participant never gets a mailbox row +this way — its inbox is its run's own live mail queue, reached through +`WorkbenchMail.sendMail` instead. Each row carries a +`{ kind: "workbench", id }` ref back to the room it came from, and threads +under its parent via `In-Reply-To`/`References` when the message answers +one (`mailAncestryOf` in `./threads.ts`). + +A shared workbench's rows live in the OWNING tenant, never the acting +(projected) tenant a share member sends from: the row's `Message-ID` and any +`In-Reply-To` are minted against the owning tenant's own mail domain, and +every recipient — the sender included — is addressed under that same +domain. The acting sender's principal is resolved in the OWNING tenant, not +the tenant they authenticated against; when a share member has no principal +row there at all, that sender's own outbound copy is skipped quietly (a +debug log, not a report) since it is the expected shape for a share member, +while every other human participant still gets their row. Any OTHER +participant address with no matching principal in the tenant — a stale or +removed member — is reported and skipped rather than attempted; any other +batch failure is reported and re-thrown. See `./mailbox-fanout.ts` for the +fan-out logic and its `MailboxWriter` seam, and `./platform-port.ts`/ +`routes.ts`'s `mailbox` dep for how a host wires a live `@corbits/mailbox` +instance in. + ## One turn in flight per workbench (CL-6331) A workbench claims itself before it asks any agent for a turn: `dispatchTurn` diff --git a/packages/chat/package.json b/packages/chat/package.json index 2889baa10..45386a9e3 100644 --- a/packages/chat/package.json +++ b/packages/chat/package.json @@ -18,7 +18,8 @@ "./consumer-inference-text": "./src/consumer-inference-text.ts", "./workbench-host-naming": "./src/workbench-host-naming.ts", "./display-name": "./src/display-name.ts", - "./id-leak-guard": "./src/id-leak-guard.ts" + "./id-leak-guard": "./src/id-leak-guard.ts", + "./mailbox-fanout": "./src/mailbox-fanout.ts" }, "scripts": { "typecheck": "tsc --noEmit", @@ -34,6 +35,7 @@ "@corbits/error-sink": "workspace:*", "@corbits/hub-api-client": "workspace:*", "@corbits/folded-runs": "workspace:*", + "@corbits/mailbox": "github:corbitsdev/corbits-mailbox#118a2cd3b0b2d83abcd57b4bed1698e95ee41b6d", "@corbits/memory": "github:corbitsdev/corbits-memory#9e6f213fa2c002b531d3f6af1aa0abd737b8afe3", "@corbits/turn-artifacts": "workspace:*", "@corbits/workflows": "workspace:*", diff --git a/packages/chat/src/codec.ts b/packages/chat/src/codec.ts index f1bd7b250..a8b82b0d8 100644 --- a/packages/chat/src/codec.ts +++ b/packages/chat/src/codec.ts @@ -27,6 +27,15 @@ export type MailContent = { * including everything sent directly to a workbench's own anchor. */ replyTo?: string; + /** + * RFC 5322 threading headers for the dispatched frame (CL-7450): the + * row's own `Message-ID` and the ancestry a reply correlates back + * through. `mailThreadHeaders` in `./mail-headers.ts` builds these from + * `mailAncestryOf`; absent on mail with no chat-row identity of its own. + */ + messageId?: string; + inReplyTo?: string; + references?: readonly string[]; }; function encodeBase64(text: string): string { @@ -49,12 +58,24 @@ function decodeBase64(data: string): string { */ export function encodeParts( parts: Part[], - opts?: { replyTo?: string }, + opts?: { + replyTo?: string; + messageId?: string; + inReplyTo?: string; + references?: readonly string[]; + }, ): MailContent { const replyTo = opts?.replyTo; + const threading = { + ...(opts?.messageId !== undefined ? { messageId: opts.messageId } : {}), + ...(opts?.inReplyTo !== undefined ? { inReplyTo: opts.inReplyTo } : {}), + ...(opts?.references !== undefined && opts.references.length > 0 + ? { references: opts.references } + : {}), + }; if (parts.length === 1 && parts[0]?.kind === "text") { - const base = { content: parts[0].text }; + const base = { content: parts[0].text, ...threading }; return replyTo !== undefined ? { ...base, replyTo } : base; } @@ -73,7 +94,7 @@ export function encodeParts( }; }); - const base = { content: "", attachments }; + const base = { content: "", attachments, ...threading }; return replyTo !== undefined ? { ...base, replyTo } : base; } diff --git a/packages/chat/src/mail-headers.test.ts b/packages/chat/src/mail-headers.test.ts new file mode 100644 index 000000000..a0684e45d --- /dev/null +++ b/packages/chat/src/mail-headers.test.ts @@ -0,0 +1,105 @@ +// Unit tests for the RFC 5322 threading headers a dispatched timeline row +// carries (CL-7104): the derived `Message-ID`, the `References` chain the +// thread parentage produces, and the parent an inbound reply names. +import { describe, expect, test } from "bun:test"; + +import { + mailMessageIdFor, + mailThreadHeaders, + parentMailMessageId, + parseReferences, + rowIdFromMailMessageId, +} from "./mail-headers"; + +describe("mailMessageIdFor", () => { + test("frames the row id against the mail domain", () => { + expect(mailMessageIdFor("msg_1", "ten1.workbench.test")).toBe( + "", + ); + }); + + test("round-trips back to the row it names", () => { + expect( + rowIdFromMailMessageId(mailMessageIdFor("msg_1", "ten1.workbench.test")), + ).toBe("msg_1"); + }); + + test("an id from another transport misses the row instead of crashing", () => { + expect(rowIdFromMailMessageId("deadbeef")).toBe("deadbeef"); + expect(rowIdFromMailMessageId("")).toBe(""); + expect(rowIdFromMailMessageId("<>")).toBe("<>"); + }); +}); + +describe("mailThreadHeaders", () => { + test("a root-feed row threads under nothing", () => { + expect( + mailThreadHeaders({ rowId: "msg_1", domain: "d.test", ancestors: [] }), + ).toEqual({ messageId: "" }); + }); + + test("a depth-1 reply names its anchor in both headers", () => { + expect( + mailThreadHeaders({ + rowId: "msg_2", + domain: "d.test", + ancestors: ["msg_1"], + }), + ).toEqual({ + messageId: "", + inReplyTo: "", + references: [""], + }); + }); + + test("a sub-thread row carries the full ancestry, root first", () => { + expect( + mailThreadHeaders({ + rowId: "msg_3", + domain: "d.test", + ancestors: ["msg_1", "msg_2"], + }), + ).toEqual({ + messageId: "", + inReplyTo: "", + references: ["", ""], + }); + }); +}); + +describe("parentMailMessageId", () => { + test("In-Reply-To names the parent", () => { + expect(parentMailMessageId({ inReplyTo: "" })).toBe( + "", + ); + }); + + test("References' tail names the parent when In-Reply-To is absent", () => { + expect( + parentMailMessageId({ + references: ["", ""], + }), + ).toBe(""); + }); + + test("a reply threading under nothing resolves to no parent", () => { + expect(parentMailMessageId({})).toBeUndefined(); + expect( + parentMailMessageId({ inReplyTo: "", references: [] }), + ).toBeUndefined(); + }); +}); + +describe("parseReferences", () => { + test("splits a folded header value into its Message-IDs", () => { + expect(parseReferences("\r\n ")).toEqual([ + "", + "", + "", + ]); + }); + + test("an empty header value carries no references", () => { + expect(parseReferences(" ")).toEqual([]); + }); +}); diff --git a/packages/chat/src/mail-headers.ts b/packages/chat/src/mail-headers.ts new file mode 100644 index 000000000..811d3c7ce --- /dev/null +++ b/packages/chat/src/mail-headers.ts @@ -0,0 +1,86 @@ +// RFC 5322 threading headers for a workbench timeline row (CL-7104). +// +// Chat is a mail thread: a message row dispatched to an agent carries its +// own `Message-ID`, and the reply that answers it names that id in +// `In-Reply-To` / `References`. Correlation is those headers and nothing +// else — no `Interchange-Correlation-ID`, no reply-to-address heuristic. +// +// A row's Message-ID is derived, never minted separately: `` +// over the row's own primary key and the mail domain the workbench's +// participants are addressed in. Deriving it means the same row always +// produces the same header, so stamping it twice is a no-op and reading a +// header back names exactly one row. +import { localPartOf } from "./agent-address"; + +/** The RFC 5322 `Message-ID` for a timeline row: ``. */ +export function mailMessageIdFor(rowId: string, domain: string): string { + return `<${rowId}@${domain}>`; +} + +/** + * The timeline row a `Message-ID` names — the inverse of + * `mailMessageIdFor`. A value with no `<...>` framing, or no `@`, is + * returned unchanged, so an id minted by any other transport simply + * misses the row lookup rather than crashing it. + */ +export function rowIdFromMailMessageId(header: string): string { + const framed = + header.startsWith("<") && header.endsWith(">") && header.length > 2 + ? header.slice(1, -1) + : header; + return localPartOf(framed); +} + +/** + * The threading headers for a row being dispatched as mail. `ancestors` + * is the row's parent chain, root first (see `mailAncestryOf` in + * `./threads.ts`); `References` is exactly that chain and `In-Reply-To` + * is its tail, per RFC 5322. A root-feed row has neither. + */ +export function mailThreadHeaders(input: { + readonly rowId: string; + readonly domain: string; + readonly ancestors: readonly string[]; +}): { + readonly messageId: string; + readonly inReplyTo?: string; + readonly references?: readonly string[]; +} { + const messageId = mailMessageIdFor(input.rowId, input.domain); + const references = input.ancestors.map((ancestor) => + mailMessageIdFor(ancestor, input.domain), + ); + const inReplyTo = references[references.length - 1]; + return { + messageId, + ...(inReplyTo !== undefined ? { inReplyTo } : {}), + ...(references.length > 0 ? { references } : {}), + }; +} + +/** + * The `Message-ID` an inbound reply answers: `In-Reply-To` when it has + * one, otherwise the tail of `References` — the nearest ancestor either + * header names. Undefined when the reply threads under nothing at all, + * which the caller must report rather than guess a parent for. + */ +export function parentMailMessageId(headers: { + readonly inReplyTo?: string; + readonly references?: readonly string[]; +}): string | undefined { + if (headers.inReplyTo !== undefined && headers.inReplyTo !== "") { + return headers.inReplyTo; + } + const references = headers.references ?? []; + const tail = references[references.length - 1]; + return tail !== undefined && tail !== "" ? tail : undefined; +} + +/** + * Splits a raw `References` header value into its Message-IDs. + * RFC 5322 separates them by whitespace (folding included), so any run + * of whitespace is the separator and empty segments are dropped. + */ +export function parseReferences(value: string): readonly string[] { + return value.split(/\s+/).filter((entry) => entry.length > 0); +} diff --git a/packages/chat/src/mailbox-fanout.ts b/packages/chat/src/mailbox-fanout.ts new file mode 100644 index 000000000..88e9bca26 --- /dev/null +++ b/packages/chat/src/mailbox-fanout.ts @@ -0,0 +1,328 @@ +// Fans a sent workbench message into every human participant's mailbox +// (CL-7450). A person's message already lands durably on the room's own +// timeline (`room-messages.ts`); this is the second, independent copy +// `@corbits/mailbox` keeps per human principal, addressed by the same +// RFC 5322 `Message-ID` the row was stamped with (`./mail-headers.ts`). +// +// Only human participants get a mailbox row — an agent's own inbox is its +// run's live mail queue, dispatched through `WorkbenchMail.sendMail` +// (`./platform-port.ts`), never this package's `@corbits/mailbox` mount. +// `mentions.ts`'s `isAgentAddress` is the same human/agent split the rest +// of this package already uses for fan-out. +// +// The write itself is behind the small `MailboxWriter` port below, not +// called against `@corbits/mailbox` directly: `writeChatMailboxFanout`'s +// own logic (who gets a row, which direction, what the shared Message-ID +// and refs are) is what this ticket is actually about, and it is +// unit-testable with an in-memory writer that never touches Postgres. +// `createDrizzleMailboxWriter` is the one production implementation, and +// is the only piece that needs a live `@corbits/mailbox` schema. +// +// Every recipient's row shares this row's own frame `messageId` — the +// caller-supplied `messageId` on every `writeMailboxMessages` item — so +// the stored frame's `Message-ID:` header, the cached +// `principal_mail.message_id` column, and the value a reply's +// `In-Reply-To` names all agree. Idempotency rides the package's own +// default transport key (`mailboxKey.transport`, direction-scoped) rather +// than a caller-minted one: nothing here has a reason to override it. +// +// The whole fan-out is ONE batch, written in ONE transaction +// (`@corbits/mailbox`'s `writeMailboxMessages`): a failure partway +// through never leaves some recipients delivered and others missing, and +// a retry after a genuine failure simply re-attempts every row (none of +// which committed). No fallback: a write that fails is reported through +// `reportError` and rethrown — never swallowed into a partially-delivered +// send that looks successful to its caller. A participant address this +// tenant has no principal for is a different, expected case (a stale or +// removed member) and is reported and skipped rather than failing the +// whole send; the sender's OWN principal missing from the row's tenant +// (a share member from another tenant, sending into the owning bench) is +// equally expected and is logged at debug rather than reported — see +// `writeChatMailboxFanout`'s doc comment. +import { getLogger } from "@intx/log"; +import { + writeMailboxMessages, + type MailboxDb, + type MailboxEventBus, + type MailboxRef, + type WriteMailboxMessagesItem, +} from "@corbits/mailbox"; +import { reportError } from "@corbits/error-sink"; +import { isAgentAddress } from "./mentions"; +import type { ParticipantRecord } from "./participants"; + +const logger = getLogger(["chat", "mailbox-fanout"]); + +/** + * Thrown by `writeChatMailboxFanout` when its batch write fails. Carries + * the `refId` its own `reportError` call already minted, so a caller that + * turns this into a consumer-facing response quotes that same ref rather + * than reporting the failure a second time under a different one — see + * `routes.ts`'s send handler, which is the one place this is caught. + */ +export class MailboxFanoutFailedError extends Error { + readonly refId: string; + constructor(refId: string, options?: { cause?: unknown }) { + super("mailbox fan-out failed", options); + this.name = "MailboxFanoutFailedError"; + this.refId = refId; + } +} + +/** One recipient's row within a fan-out batch. */ +export type MailboxBatchItem = { + readonly tenantId: string; + readonly principalId: string; + readonly address: string; + readonly fromAddress: string; + readonly subject: string; + readonly body: string; + /** This row's own RFC 5322 `Message-ID` — the frame header, the + * `principal_mail.message_id` cache column, and (barring a caller + * override the batch never supplies) the transport idempotency key + * all derive from this one value. */ + readonly messageId: string; + readonly direction: "inbound" | "outbound"; + readonly inReplyTo?: string; + /** The full ancestry chain, oldest first (`mailAncestryOf`) — what the + * stored frame's `References:` header carries. Absent on a root-feed + * row, which answers nothing. */ + readonly references?: readonly string[]; + readonly refs?: readonly MailboxRef[]; +}; + +export type MailboxBatchResult = { + readonly messageKey: string; + readonly id: string | null; +}; + +/** + * The write seam `writeChatMailboxFanout` calls through — an entire + * conversation turn's mailbox rows as ONE transaction. Split from + * `@corbits/mailbox`'s own call surface so this package's fan-out LOGIC + * (who gets a row, which direction, the shared Message-ID and refs) is + * unit-testable against an in-memory fake, with only + * `createDrizzleMailboxWriter` below needing a live database. + */ +export interface MailboxWriter { + /** + * Writes every item in one transaction — `@corbits/mailbox`'s + * `writeMailboxMessages` semantics: a throw from any item rolls back + * the whole batch, and a per-item `messageKey` collision (retry + * idempotency) is a no-op for that item, not a rollback trigger. + * Returns one result per item, in item order. + */ + writeBatch( + items: readonly MailboxBatchItem[], + ): Promise; +} + +/** The production `MailboxWriter`: a thin pass-through onto + * `@corbits/mailbox`'s own `writeMailboxMessages` batch call — no + * hand-rolled inserts against the package's schema. */ +export function createDrizzleMailboxWriter( + db: MailboxDb, + bus?: MailboxEventBus, +): MailboxWriter { + return { + async writeBatch(items) { + const batch: WriteMailboxMessagesItem[] = items.map((item) => ({ + scope: { tenantId: item.tenantId, principalId: item.principalId }, + args: { + address: item.address, + fromAddress: item.fromAddress, + subject: item.subject, + body: item.body, + messageId: item.messageId, + direction: item.direction, + ...(item.inReplyTo !== undefined + ? { inReplyTo: item.inReplyTo } + : {}), + ...(item.references !== undefined && item.references.length > 0 + ? { references: [...item.references] } + : {}), + ...(item.refs !== undefined ? { refs: [...item.refs] } : {}), + }, + })); + return writeMailboxMessages(db, batch, bus !== undefined ? { bus } : {}); + }, + }; +} + +export type MailboxFanoutDeps = { + readonly writer: MailboxWriter; + /** + * The subset of `candidateIds` that name a real principal in + * `tenantId` — the host's own control-plane check (Interchange's + * `principal` table). A participant address whose principal this + * returns nothing for is reported and skipped rather than attempted: + * `@corbits/mailbox`'s FK would refuse the insert anyway, but as a + * database error deep in a transaction rather than a name a person + * reading the report can act on. + */ + readonly resolveKnownPrincipalIds: ( + tenantId: string, + candidateIds: readonly string[], + ) => Promise>; + /** + * The mail domain rows in `tenantId` are addressed under. Always the + * ROW'S OWN tenant (the workbench's owning bench, `ownerTenantId` in + * `routes.ts` terms) — never the caller's acting tenant, which for a + * shared-workbench (projected-tenant) sender can differ. Every + * recipient's address, including the sender's own, uses this domain. + */ + readonly resolveTenantDomain: (tenantId: string) => Promise; +}; + +export type WriteChatMailboxFanoutInput = { + readonly tenantId: string; + readonly workbenchId: string; + readonly senderAddress: string; + readonly senderPrincipalId: string; + readonly participants: readonly ParticipantRecord[]; + /** This row's own RFC 5322 `Message-ID` (`mailMessageIdFor`), already + * minted against the row's OWNING tenant's domain. */ + readonly messageId: string; + readonly inReplyTo?: string; + /** The full ancestry chain, oldest first — see `MailboxBatchItem`'s own + * doc comment. */ + readonly references?: readonly string[]; + readonly subject: string; + readonly body: string; +}; + +/** The human participants of a workbench, by their bare principal id — + * a human's own participant address is never suffixed with a domain + * (see `workbench-service.ts`'s member-add path), unlike an agent's. */ +function humanPrincipalIds( + participants: readonly ParticipantRecord[], +): readonly string[] { + return participants + .filter((participant) => !isAgentAddress(participant.address)) + .map((participant) => participant.address); +} + +/** + * Writes the sent message into every human participant's mailbox: an + * "outbound" copy in the sender's own, an "inbound" copy in every other + * human's, all sharing this row's Message-ID and its workbench ref, as + * ONE batch (`MailboxWriter.writeBatch`) — one transaction, all rows or + * none. Throws when the batch itself fails — the caller must not report + * the send as fully delivered when it wasn't. + * + * The sender's own principal missing from `tenantId` (rows for a shared + * workbench live in the OWNING tenant; a share member sending from a + * projected tenant may have no principal row there at all) is expected, + * not an error: the sender's own copy is skipped quietly, at debug, and + * every other human participant still gets their row. Any OTHER + * participant missing a known principal (a stale or removed member) is + * reported and skipped the way it always was. + */ +export async function writeChatMailboxFanout( + deps: MailboxFanoutDeps, + input: WriteChatMailboxFanoutInput, +): Promise { + const domain = await deps.resolveTenantDomain(input.tenantId); + + const candidateIds = new Set(humanPrincipalIds(input.participants)); + candidateIds.add(input.senderPrincipalId); + const candidateList = [...candidateIds]; + + const known = await deps.resolveKnownPrincipalIds( + input.tenantId, + candidateList, + ); + + const refs: MailboxRef[] = [{ kind: "workbench", id: input.workbenchId }]; + const batch: MailboxBatchItem[] = []; + + for (const principalId of candidateList) { + if (!known.has(principalId)) { + if (principalId === input.senderPrincipalId) { + logger.debug( + "sender {principalId} has no principal in the row's tenant " + + "{tenantId} (a share member sending into a bench it does not " + + "belong to); skipping its own mailbox copy", + { principalId, tenantId: input.tenantId }, + ); + continue; + } + reportError(new Error(`no principal "${principalId}" in tenant`), { + operation: "chat.mailboxFanout.resolveParticipant", + tenantId: input.tenantId, + roomId: input.workbenchId, + extra: { principalId }, + }); + continue; + } + + const address = `${principalId}@${domain}`; + batch.push({ + tenantId: input.tenantId, + principalId, + address, + fromAddress: input.senderAddress, + subject: input.subject, + body: input.body, + messageId: input.messageId, + direction: + principalId === input.senderPrincipalId ? "outbound" : "inbound", + refs, + ...(input.inReplyTo !== undefined ? { inReplyTo: input.inReplyTo } : {}), + ...(input.references !== undefined && input.references.length > 0 + ? { references: input.references } + : {}), + }); + } + + if (batch.length === 0) return; + + try { + await deps.writer.writeBatch(batch); + } catch (err) { + const refId = reportError(err, { + operation: "chat.mailboxFanout.write", + tenantId: input.tenantId, + roomId: input.workbenchId, + extra: { messageId: input.messageId }, + }); + throw new MailboxFanoutFailedError(refId, { cause: err }); + } +} + +/** A plain-text rendering of a message's parts, for the mailbox frame's + * body — the same set of `TextPart`s a mention scan reads, joined the + * way a person would read the message top to bottom. A message with no + * text at all (attachment-only) falls back to listing its parts, so a + * mailbox copy of a file share is never a blank body. */ +export function mailboxBodyOf( + parts: readonly { kind: string; text?: string; name?: string }[], +): string { + const textParts = parts + .filter( + (part): part is { kind: "text"; text: string } => + part.kind === "text" && typeof part.text === "string", + ) + .map((part) => part.text); + if (textParts.length > 0) return textParts.join("\n\n"); + + const attachments = parts + .filter( + (part): part is { kind: "file"; name: string } => + part.kind === "file" && typeof part.name === "string", + ) + .map((part) => `Attachment: ${part.name}`); + return attachments.join("\n"); +} + +/** A short subject line derived from a message's body: its first line, + * clipped to a conventional mail-subject length. Applies equally to an + * attachment-only body (`mailboxBodyOf`'s "Attachment: " lines), + * so a file share gets a real subject rather than "(no subject)". */ +export function mailboxSubjectOf(body: string): string { + const firstLine = body.split("\n")[0]?.trim() ?? ""; + const MAX_SUBJECT_LENGTH = 78; + return firstLine.length > MAX_SUBJECT_LENGTH + ? `${firstLine.slice(0, MAX_SUBJECT_LENGTH - 1)}…` + : firstLine || "(no subject)"; +} diff --git a/packages/chat/src/migrations.ts b/packages/chat/src/migrations.ts index f038659de..42a99a3ee 100644 --- a/packages/chat/src/migrations.ts +++ b/packages/chat/src/migrations.ts @@ -545,6 +545,23 @@ export const chatMigrations: readonly ChatMigration[] = [ ); `, }, + { + // CL-7104: the RFC 5322 `Message-ID` a timeline row was dispatched + // as. Derived from the row's own id, so there is nothing to + // backfill: a row dispatched before this landed answers to the same + // `` it always did, and a row that was never dispatched + // stays null. Indexed for the inbound direction — a reply's + // `In-Reply-To` names a Message-ID, and the row it belongs to is + // found by that header, never by the sender's address. + name: "0029_workbench_messages_mail_message_id", + sql: ` + ALTER TABLE "chat"."workbench_messages" + ADD COLUMN IF NOT EXISTS "mail_message_id" text; + + CREATE INDEX IF NOT EXISTS "workbench_messages_mail_message_id_idx" + ON "chat"."workbench_messages" ("tenant_id", "mail_message_id"); + `, + }, ]; /** diff --git a/packages/chat/src/platform-adapter.ts b/packages/chat/src/platform-adapter.ts index 309f9582b..0567bc296 100644 --- a/packages/chat/src/platform-adapter.ts +++ b/packages/chat/src/platform-adapter.ts @@ -1264,11 +1264,28 @@ export function createHubChatPlatform( attachments !== undefined ? { ...sendMailBase, attachments } : sendMailBase; - const sent = await sendFoldedMailWithReclaimRetry( + const withReplyTo = input.content.replyTo !== undefined ? { ...withAttachments, replyTo: input.content.replyTo } - : withAttachments, - ); + : withAttachments; + // RFC 5322 threading, straight from the timeline row this mail + // carries (CL-7450): its own `Message-ID`, and the parentage a + // reply correlates back through. `replyTo` above is the unrelated + // mention-fan-out room hint `chat-orchestrator.ts` reads back off + // the event — both can be present on the same mail. + const withThreading = { + ...withReplyTo, + ...(input.content.messageId !== undefined + ? { messageId: input.content.messageId } + : {}), + ...(input.content.inReplyTo !== undefined + ? { inReplyTo: input.content.inReplyTo } + : {}), + ...(input.content.references !== undefined + ? { references: input.content.references } + : {}), + }; + const sent = await sendFoldedMailWithReclaimRetry(withThreading); lifecycle?.recordActivity(deliveryAddress); diff --git a/packages/chat/src/room-messages.ts b/packages/chat/src/room-messages.ts index dbc03fada..ef76bb79b 100644 --- a/packages/chat/src/room-messages.ts +++ b/packages/chat/src/room-messages.ts @@ -39,6 +39,12 @@ export interface RoomMessage { /** The agent run this message came out of; null for a human's. */ readonly runId: string | null; readonly threadId: string | null; + /** + * The RFC 5322 `Message-ID` this row was dispatched to an agent as + * (CL-7104); null for a row nobody was ever asked to answer. See + * `./mail-headers.ts` for how it is derived. + */ + readonly mailMessageId: string | null; readonly parts: readonly Part[]; } @@ -95,9 +101,42 @@ export interface RoomMessageStore { readonly workbenchId: string; readonly messageId: string; }): Promise; + /** + * Records the `Message-ID` a row went out as (CL-7104). The header is + * derived from the row's own id, so writing it twice writes the same + * value — this is a stamp, not a mint, and needs no claim. + */ + stampMailMessageId(input: { + readonly tenantId: string; + readonly workbenchId: string; + readonly messageId: string; + readonly mailMessageId: string; + }): Promise; + /** + * The row a `Message-ID` names — the inbound half of correlation. An + * `In-Reply-To` no row answers to resolves to undefined, which the + * caller reports rather than attributing to a guess. + */ + findByMailMessageId(input: { + readonly tenantId: string; + readonly mailMessageId: string; + }): Promise; listActivity( input: ListRoomActivityInput, ): Promise>; + /** + * Removes a just-inserted row that never reached a client (CL-7450): + * the mailbox fan-out step runs AFTER the row is stored but BEFORE it + * is published, and a fan-out failure must not leave a durable row + * nobody's mailbox agrees exists — nothing has seen it yet, so deleting + * it is safe, and a client retry of the same send does not then + * duplicate. Never called on a row that has been published. + */ + deleteMessage(input: { + readonly tenantId: string; + readonly workbenchId: string; + readonly messageId: string; + }): Promise; } /** One page of timeline, newest first — the same page size the timeline @@ -237,19 +276,32 @@ function consumerFacingParts(parts: readonly Part[]): Part[] { }); } -export async function postRoomMessage( - deps: { - readonly roomMessages: RoomMessageStore; - readonly publish: WorkbenchSubscriberRegistry["publish"]; - }, +/** + * Just the durable insert half of `postRoomMessage` — no publish. CL-7450's + * mailbox fan-out needs to run AFTER the row is stored (so its own + * Message-ID can be stamped and used) but BEFORE anything is published (so + * a fan-out failure can delete the row with no client ever having seen + * it). `postRoomMessage` itself is unchanged for every other caller, which + * has no such between-insert-and-publish step to run. + */ +export async function insertRoomMessageRow( + deps: { readonly roomMessages: RoomMessageStore }, input: PostRoomMessageInput, ): Promise { const parts = consumerFacingParts(input.parts); - const message = await deps.roomMessages.insertMessage({ + return deps.roomMessages.insertMessage({ ...input, parts, id: newMessageId(), }); +} + +/** The publish half of `postRoomMessage`, for a row `insertRoomMessageRow` + * already stored. */ +export function publishRoomMessageEvent( + deps: { readonly publish: WorkbenchSubscriberRegistry["publish"] }, + message: RoomMessage, +): void { const data = ChatMessageEventData.assert({ id: message.id, workbenchId: message.workbenchId, @@ -259,6 +311,17 @@ export async function postRoomMessage( parts: message.parts, }); deps.publish(message.workbenchId, { type: "chat.message", data }); +} + +export async function postRoomMessage( + deps: { + readonly roomMessages: RoomMessageStore; + readonly publish: WorkbenchSubscriberRegistry["publish"]; + }, + input: PostRoomMessageInput, +): Promise { + const message = await insertRoomMessageRow(deps, input); + publishRoomMessageEvent(deps, message); return message; } @@ -292,6 +355,7 @@ interface MessageRow { senderPrincipalId: string | null; runId: string | null; threadId: string | null; + mailMessageId: string | null; parts: unknown; createdAt: Date; } @@ -305,6 +369,7 @@ function toRoomMessage(row: MessageRow): RoomMessage { senderPrincipalId: row.senderPrincipalId, runId: row.runId, threadId: row.threadId, + mailMessageId: row.mailMessageId, parts: row.parts as Part[], }; } @@ -382,6 +447,45 @@ export function createDrizzleRoomMessageStore( return row === undefined ? undefined : toRoomMessage(row as MessageRow); }, + async stampMailMessageId(input) { + await db + .update(workbenchMessages) + .set({ mailMessageId: input.mailMessageId }) + .where( + and( + eq(workbenchMessages.id, input.messageId), + eq(workbenchMessages.tenantId, input.tenantId), + eq(workbenchMessages.workbenchId, input.workbenchId), + ), + ); + }, + + async deleteMessage(input) { + await db + .delete(workbenchMessages) + .where( + and( + eq(workbenchMessages.id, input.messageId), + eq(workbenchMessages.tenantId, input.tenantId), + eq(workbenchMessages.workbenchId, input.workbenchId), + ), + ); + }, + + async findByMailMessageId(input) { + const [row] = await db + .select() + .from(workbenchMessages) + .where( + and( + eq(workbenchMessages.tenantId, input.tenantId), + eq(workbenchMessages.mailMessageId, input.mailMessageId), + ), + ) + .limit(1); + return row === undefined ? undefined : toRoomMessage(row as MessageRow); + }, + async listActivity(input) { if (input.workbenches.length === 0) return {}; const workbenchIds = input.workbenches.map( @@ -485,6 +589,7 @@ export function createInMemoryRoomMessageStore(): RoomMessageStore { senderPrincipalId: input.senderPrincipalId ?? null, runId: input.runId ?? null, threadId: input.threadId ?? null, + mailMessageId: null, parts: input.parts, }; const key = keyOf(input.tenantId, input.workbenchId); @@ -492,6 +597,41 @@ export function createInMemoryRoomMessageStore(): RoomMessageStore { return message; }, + async stampMailMessageId(input) { + const key = keyOf(input.tenantId, input.workbenchId); + const messages = byWorkbench.get(key); + if (messages === undefined) return; + byWorkbench.set( + key, + messages.map((message) => + message.id === input.messageId + ? { ...message, mailMessageId: input.mailMessageId } + : message, + ), + ); + }, + + async findByMailMessageId(input) { + for (const [key, messages] of byWorkbench) { + if (!key.startsWith(`${input.tenantId}:`)) continue; + const match = messages.find( + (message) => message.mailMessageId === input.mailMessageId, + ); + if (match !== undefined) return match; + } + return undefined; + }, + + async deleteMessage(input) { + const key = keyOf(input.tenantId, input.workbenchId); + const messages = byWorkbench.get(key); + if (messages === undefined) return; + byWorkbench.set( + key, + messages.filter((message) => message.id !== input.messageId), + ); + }, + async listMessages(input) { const messages = byWorkbench.get(keyOf(input.tenantId, input.workbenchId)) ?? []; diff --git a/packages/chat/src/routes.ts b/packages/chat/src/routes.ts index 0e840d00c..6c3166786 100644 --- a/packages/chat/src/routes.ts +++ b/packages/chat/src/routes.ts @@ -117,6 +117,10 @@ import { cookiesFromHeader } from "@corbits/hub-api-client"; import type { AgentTurnStore } from "./agent-turns"; import type { ThreadStore } from "./threads"; import { ThreadDepthCapError } from "./threads"; +import { + MailboxFanoutFailedError, + type MailboxFanoutDeps, +} from "./mailbox-fanout"; import type { WorkbenchShareStore } from "./workbench-share"; import { monogramFromName } from "./workbench-share"; import type { FederationTrustStore } from "./federation-trust"; @@ -213,6 +217,14 @@ export type CreateChatRoutesDeps = { * CRUD stay free of thread tables. */ threads?: ThreadStore; + /** + * CL-7450's mailbox fan-out: writes a sent human message into every + * human participant's `@corbits/mailbox` inbox — see + * `SendWorkbenchMessageDeps`'s field of the same name in + * `./workbench-service.ts`. Omitted, a sent message reaches only the + * room's own timeline, the pre-CL-7450 behavior. + */ + mailbox?: MailboxFanoutDeps; /** * Durable dispatch-mail -> source-message correlation (CL-6314) — * threaded through to every `sendWorkbenchMessage` call this router @@ -2290,44 +2302,70 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { // reaching an agent happens off this path — an agent that cannot // be reached answers with a notice on the timeline in its own // voice (see `sendWorkbenchMessage`), never a failed send. - const sent = await sendWorkbenchMessage( - { - store: deps.store, - platform: deps.platform, - roomMessages: deps.roomMessages, - publish, - turnQueue, - turnCancellation, - ...(deps.agentTurns !== undefined - ? { agentTurns: deps.agentTurns } - : {}), - ...(deps.threads !== undefined ? { threads: deps.threads } : {}), - ...(deps.turnMailCorrelation !== undefined - ? { turnMailCorrelation: deps.turnMailCorrelation } - : {}), - ...(deps.turnDispatchTimeoutMs !== undefined - ? { turnDispatchTimeoutMs: deps.turnDispatchTimeoutMs } - : {}), - ...(deps.waitUntilFreeTimeoutMs !== undefined - ? { waitUntilFreeTimeoutMs: deps.waitUntilFreeTimeoutMs } - : {}), - }, - { - tenantId: ownerTenantId, - principalId: principal.id, - senderAddress: senderAddressOf(c), - workbenchId, - messageParts, - ...(parsed.inReplyToMessageId !== undefined - ? { inReplyToMessageId: parsed.inReplyToMessageId } - : {}), - ...(targetThreadId !== undefined ? { threadId: targetThreadId } : {}), - ...(commandDecision !== undefined && - "routeToParticipant" in commandDecision - ? { forcedRecipientAddress: commandDecision.routeToParticipant } - : {}), - }, - ); + // + // `MailboxFanoutFailedError` is caught here rather than falling + // through to the hub's global `app.onError`: `writeChatMailboxFanout` + // already reported it once, under the `refId` the error carries, so + // this quotes that ref rather than reporting the same failure again + // under a second one. + let sent; + try { + sent = await sendWorkbenchMessage( + { + store: deps.store, + platform: deps.platform, + roomMessages: deps.roomMessages, + publish, + turnQueue, + turnCancellation, + ...(deps.agentTurns !== undefined + ? { agentTurns: deps.agentTurns } + : {}), + ...(deps.threads !== undefined ? { threads: deps.threads } : {}), + ...(deps.turnMailCorrelation !== undefined + ? { turnMailCorrelation: deps.turnMailCorrelation } + : {}), + ...(deps.turnDispatchTimeoutMs !== undefined + ? { turnDispatchTimeoutMs: deps.turnDispatchTimeoutMs } + : {}), + ...(deps.waitUntilFreeTimeoutMs !== undefined + ? { waitUntilFreeTimeoutMs: deps.waitUntilFreeTimeoutMs } + : {}), + ...(deps.mailbox !== undefined ? { mailbox: deps.mailbox } : {}), + }, + { + tenantId: ownerTenantId, + principalId: principal.id, + senderAddress: senderAddressOf(c), + workbenchId, + messageParts, + ...(parsed.inReplyToMessageId !== undefined + ? { inReplyToMessageId: parsed.inReplyToMessageId } + : {}), + ...(targetThreadId !== undefined + ? { threadId: targetThreadId } + : {}), + ...(commandDecision !== undefined && + "routeToParticipant" in commandDecision + ? { forcedRecipientAddress: commandDecision.routeToParticipant } + : {}), + }, + ); + } catch (err) { + if (err instanceof MailboxFanoutFailedError) { + return c.json( + makeErrorEnvelope({ + code: "mailbox_fanout_failed", + userMessage: + "Your message could not be saved to everyone's inbox; " + + "nothing was sent. Try again.", + refId: err.refId, + }), + 502, + ); + } + throw err; + } deps.onMessageFanout?.(sent.fanoutDelivered); if (parsed.clientId !== undefined && deps.clientIds !== undefined) { @@ -2520,6 +2558,9 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { ...(deps.waitUntilFreeTimeoutMs !== undefined ? { waitUntilFreeTimeoutMs: deps.waitUntilFreeTimeoutMs } : {}), + ...(deps.mailbox !== undefined + ? { mailbox: deps.mailbox } + : {}), }, { tenantId: ownerTenantId, @@ -2531,11 +2572,17 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { ); deps.onMessageFanout?.(answer.fanoutDelivered); } catch (err) { - const refId = reportError(err, { - operation: "chat.blockResponse.notifyQuestionAnswer", - tenantId: ownerTenantId, - roomId: workbenchId, - }); + // `MailboxFanoutFailedError` already reported itself under its + // own `refId` — quoting that instead of calling `reportError` + // again is what keeps one failure to one ref (CL-7450). + const refId = + err instanceof MailboxFanoutFailedError + ? err.refId + : reportError(err, { + operation: "chat.blockResponse.notifyQuestionAnswer", + tenantId: ownerTenantId, + roomId: workbenchId, + }); await deps.blockResponses.releaseBlockResponseNotification( responseKey, claimToken, diff --git a/packages/chat/src/schema.ts b/packages/chat/src/schema.ts index 1ee8ff3de..3a42198ec 100644 --- a/packages/chat/src/schema.ts +++ b/packages/chat/src/schema.ts @@ -175,6 +175,14 @@ export const workbenchMessages = chatSchema.table( senderPrincipalId: text("sender_principal_id"), runId: text("run_id"), threadId: text("thread_id"), + /** + * The RFC 5322 `Message-ID` this row went out as when it was + * dispatched to an agent (CL-7104) — ``, derived from the + * row's own primary key and stamped once at dispatch. Null for a row + * that was never dispatched as mail (a join notice, an event, a + * message nobody was asked to answer). + */ + mailMessageId: text("mail_message_id"), parts: jsonb("parts").notNull(), // Millisecond precision, not the default microsecond: a cursor is a // JS `Date` rendered to an ISO string, which carries milliseconds diff --git a/packages/chat/src/threads.ts b/packages/chat/src/threads.ts index ea63e0286..35911f530 100644 --- a/packages/chat/src/threads.ts +++ b/packages/chat/src/threads.ts @@ -3,9 +3,10 @@ // threads on first reply, and creates delivery threads for routine // run deliveries. // -// Platform mail `MailContent.replyTo` still carries a *workbench* id for -// mention fan-out (see codec.ts). Message-id reply correlation is a -// workbench concern here — do not fork Interchange mail to change that. +// A thread is also what a dispatched message's RFC 5322 threading headers +// are derived from (CL-7104): `mailAncestryOf` below walks a message's +// parent chain, and `./mail-headers.ts` turns that chain into the +// `References` / `In-Reply-To` a reply correlates back through. import { and, eq, asc, sql } from "drizzle-orm"; import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; @@ -185,6 +186,47 @@ export function resolveTargetThread(args: { return { threadId: args.rootThreadId, needsReplyOpen: false }; } +/** + * The parent chain of a message living in `threadId`, root first: that + * thread's anchor message, then the anchor's own anchor. Empty for the + * root feed (or a delivery thread), which answers nothing. Bounded by + * the two-level cap — a chain is never longer than two entries — so this + * walks the parentage rather than recursing without a bound. + * + * Takes the thread rather than the message on purpose: a message is + * posted with its thread on the row before the membership row is + * written, and a dispatch that fires in between must still see the + * parentage it was posted into. + * + * This is what a dispatched message's `References` header is built from + * (`mailThreadHeaders` in `./mail-headers.ts`): the chain in RFC 5322's + * own order, oldest ancestor first. + */ +export async function mailAncestryOf( + store: Pick, + tenantId: string, + workbenchId: string, + threadId: string | null, +): Promise { + const ancestors: string[] = []; + let current = threadId; + // The cap is two levels (thread → sub-thread), so a chain has at most + // two ancestors; the bound is the cap, never a guess about cycles. + for (let depth = 0; depth < 2; depth++) { + if (current === null) break; + const thread = await store.getThread(tenantId, current); + if (thread === undefined || thread.parentMessageId === null) break; + ancestors.unshift(thread.parentMessageId); + current = + (await store.threadIdForMessage( + tenantId, + workbenchId, + thread.parentMessageId, + )) ?? null; + } + return ancestors; +} + export function createInMemoryThreadStore(): ThreadStore { const threads = new Map(); const byWorkbench = new Map(); diff --git a/packages/chat/src/workbench-service.ts b/packages/chat/src/workbench-service.ts index 149852dff..b71e566de 100644 --- a/packages/chat/src/workbench-service.ts +++ b/packages/chat/src/workbench-service.ts @@ -50,7 +50,20 @@ import type { ChatWorkbenchEvent, InvitableDefinition, } from "./platform-port"; -import { postRoomMessage, type RoomMessageStore } from "./room-messages"; +import { + postRoomMessage, + insertRoomMessageRow, + publishRoomMessageEvent, + type RoomMessageStore, +} from "./room-messages"; +import { mailMessageIdFor, mailThreadHeaders } from "./mail-headers"; +import { mailAncestryOf } from "./threads"; +import { + writeChatMailboxFanout, + mailboxBodyOf, + mailboxSubjectOf, + type MailboxFanoutDeps, +} from "./mailbox-fanout"; import type { TurnMailCorrelationStore } from "./turn-mail-correlation"; import type { WorkbenchSubscriberRegistry } from "./workbench-events"; import type { QueuedTurn, WorkbenchTurnQueue } from "./turn-queue"; @@ -1278,7 +1291,19 @@ export type SendWorkbenchMessageDeps = { * Narrows a turn's context to its own thread. Absent, a turn is asked * with the whole room. See `./turn-context.ts`. */ - readonly threads?: Pick; + readonly threads?: Pick< + ThreadStore, + "listThreadAssignments" | "getThread" | "threadIdForMessage" + >; + /** + * The mail domain and fan-out target for CL-7450's mailbox copy: + * writing a sent human message into every human participant's + * `@corbits/mailbox` inbox, addressed by this row's own RFC 5322 + * Message-ID (`./mail-headers.ts`). Optional so unit suites that only + * exercise routing stay free of the mailbox tables; the hub always + * injects a real one (`platform-adapter.ts`'s composition). + */ + readonly mailbox?: MailboxFanoutDeps; /** * The turn-level deadline (CL-6644): `dispatchTurnBatch` wraps every * recipient's `dispatchTurn` call in this single wall-clock budget, @@ -1466,11 +1491,37 @@ async function replyTargetAgent( * and a multi-agent one routes through its host instead of going * silent. */ +/** + * Posts a message, then — CL-7450 — writes it into every human + * participant's mailbox BEFORE the row is published or any agent is + * dispatched: store row -> stamp Message-ID -> fan-out (one batch) -> + * publish -> dispatch. A fan-out failure must fail the send before + * anything is VISIBLE: no phantom bubble on the sender's own timeline + * (no `chat.message` publish yet), and no duplicate row on a client + * retry. The just-inserted row is therefore deleted on a fan-out failure + * and the failure is rethrown to the caller — unlike agent dispatch + * (`routeMessage`, fire-and-forget so a slept agent's wake never blocks + * the sender's own bubble), a mailbox write failure is never swallowed + * into an apparently-successful send. + * + * This is not a transactional guarantee: the row and the mailbox batch + * are two separate writes (`insertRoomMessageRow` commits before + * `mailboxFanOutForSend` ever runs), so a concurrent `GET` of the + * timeline between the two CAN read the row before this function decides + * whether to delete it again. The window is real, not merely believed + * closed — it is bounded to the time between those two writes, not open + * indefinitely, and nothing durable is built on top of what that window + * exposes (no reply has been dispatched, no mailbox row written) before + * the delete either lands or the row survives for good. A fan-out + * failure surfaces as `MailboxFanoutFailedError` (`./mailbox-fanout.ts`), + * which already carries the one `refId` `writeChatMailboxFanout` reported + * under — the route layer quotes that ref rather than reporting again. + */ export async function sendWorkbenchMessage( deps: SendWorkbenchMessageDeps, input: SendWorkbenchMessageInput, ): Promise { - const posted = await postRoomMessage(deps, { + const posted = await insertRoomMessageRow(deps, { tenantId: input.tenantId, workbenchId: input.workbenchId, sender: { name: null, address: input.senderAddress }, @@ -1479,6 +1530,22 @@ export async function sendWorkbenchMessage( ...(input.threadId !== undefined ? { threadId: input.threadId } : {}), }); + const mailboxDeps = deps.mailbox; + if (mailboxDeps !== undefined) { + try { + await mailboxFanOutForSend(deps, mailboxDeps, input, posted.id); + } catch (err) { + await deps.roomMessages.deleteMessage({ + tenantId: input.tenantId, + workbenchId: input.workbenchId, + messageId: posted.id, + }); + throw err; + } + } + + publishRoomMessageEvent(deps, posted); + return { id: posted.id, createdAt: posted.createdAt, @@ -1486,6 +1553,78 @@ export async function sendWorkbenchMessage( }; } +/** + * CL-7450: the human write path's own copy — every human participant's + * `@corbits/mailbox` inbox gets this row, addressed by its own RFC 5322 + * Message-ID, before this call returns. Unlike agent dispatch + * (`routeMessage`, fire-and-forget so a slept agent's wake never blocks + * the sender's own bubble), a mailbox write failure must reach the + * caller: `writeChatMailboxFanout` throws on anything but a genuinely + * unknown participant, and this function does not catch it (its own + * caller, `sendWorkbenchMessage`, does — to delete the just-inserted row). + * + * The Message-ID (and any `In-Reply-To` it carries) is minted with the + * row's OWNING tenant's domain (`input.tenantId`, always `ownerTenantId` + * at the route layer — see `routes.ts`'s `resolveWorkbenchAccess`), never + * the acting caller's own tenant: a shared-workbench (projected-tenant) + * sender's `input.senderAddress` carries their OWN tenant's domain, which + * would otherwise stamp a row living in the owner tenant with a + * Message-ID nobody else's mail agrees is addressed under. + */ +async function mailboxFanOutForSend( + deps: SendWorkbenchMessageDeps, + mailboxDeps: MailboxFanoutDeps, + input: SendWorkbenchMessageInput, + messageId: string, +): Promise { + const domain = await mailboxDeps.resolveTenantDomain(input.tenantId); + const mailMessageId = mailMessageIdFor(messageId, domain); + await deps.roomMessages.stampMailMessageId({ + tenantId: input.tenantId, + workbenchId: input.workbenchId, + messageId, + mailMessageId, + }); + + const settingsRow = await deps.store.getWorkbenchSettings( + input.tenantId, + input.workbenchId, + ); + const participants = + settingsRow !== undefined ? participantsOf(settingsRow.settings) : []; + + const ancestors = + deps.threads !== undefined + ? await mailAncestryOf( + deps.threads, + input.tenantId, + input.workbenchId, + input.threadId ?? null, + ) + : []; + const inReplyTo = + ancestors.length > 0 + ? mailMessageIdFor(ancestors[ancestors.length - 1] as string, domain) + : undefined; + const references = ancestors.map((ancestor) => + mailMessageIdFor(ancestor, domain), + ); + + const body = mailboxBodyOf(input.messageParts); + await writeChatMailboxFanout(mailboxDeps, { + tenantId: input.tenantId, + workbenchId: input.workbenchId, + senderAddress: input.senderAddress, + senderPrincipalId: input.principalId, + participants, + messageId: mailMessageId, + subject: mailboxSubjectOf(body), + body, + ...(inReplyTo !== undefined ? { inReplyTo } : {}), + ...(references.length > 0 ? { references } : {}), + }); +} + /** * Everything the sender's own message does NOT have to wait for: * resolving which agents this message is for, loading the re-situating @@ -1665,6 +1804,8 @@ async function dispatchTurnBatch( | "agentTurns" | "turnCancellation" | "turnMailCorrelation" + | "mailbox" + | "threads" >, tenantId: string, workbenchId: string, @@ -1885,6 +2026,8 @@ export async function dispatchTurn( | "roomMessages" | "publish" | "turnMailCorrelation" + | "mailbox" + | "threads" >, input: DispatchTurnInput, signal?: AbortSignal, @@ -1940,11 +2083,55 @@ export async function dispatchTurn( signal?.addEventListener("abort", closeAsTimedOut, { once: true }); try { + // RFC 5322 threading (CL-7450): the dispatched frame carries the same + // identity the row it answers already carries on the timeline — its + // Message-ID is derived, never minted separately (`mailMessageIdFor`), + // so it always equals what `mailboxFanOutForSend` already stamped for + // that row — and names the row's own parent chain in + // `In-Reply-To`/`References`. Degrades to unthreaded (as before) when + // this composition has no mailbox domain to derive it from. + const sourceMessageId = + input.requestMessageIds[input.requestMessageIds.length - 1]; + const mailboxDeps = deps.mailbox; + const threadHeaders = + mailboxDeps !== undefined && sourceMessageId !== undefined + ? await (async () => { + const domain = await mailboxDeps.resolveTenantDomain( + input.tenantId, + ); + const threadId = + deps.threads !== undefined + ? ((await deps.threads.threadIdForMessage( + input.tenantId, + input.workbenchId, + sourceMessageId, + )) ?? null) + : null; + const ancestors = + deps.threads !== undefined + ? await mailAncestryOf( + deps.threads, + input.tenantId, + input.workbenchId, + threadId, + ) + : []; + return mailThreadHeaders({ + rowId: sourceMessageId, + domain, + ancestors, + }); + })() + : undefined; + const sent = await deps.platform.sendMail({ tenantId: input.tenantId, workbenchId: localPartOf(input.agentAddress), principalId: input.principalId, - content: encodeParts(input.parts, { replyTo: input.workbenchId }), + content: encodeParts(input.parts, { + replyTo: input.workbenchId, + ...threadHeaders, + }), fromWorkbenchId: input.workbenchId, }); // The reply path threads under the turn that produced it (CL-6314), @@ -1954,8 +2141,6 @@ export async function dispatchTurn( // the conversation is where its newest message is. A record that // fails is reported, never thrown: the turn was dispatched, and // threading degrades to unthreaded rather than failing it. - const sourceMessageId = - input.requestMessageIds[input.requestMessageIds.length - 1]; if ( sourceMessageId !== undefined && deps.turnMailCorrelation !== undefined diff --git a/packages/chat/test/chat-orchestrator.test.ts b/packages/chat/test/chat-orchestrator.test.ts index 3dfd93e59..f4b597b41 100644 --- a/packages/chat/test/chat-orchestrator.test.ts +++ b/packages/chat/test/chat-orchestrator.test.ts @@ -72,6 +72,9 @@ function fakeRoom(options?: { failPostOnCall: number }) { listMessages: store.listMessages, getMessage: store.getMessage, listActivity: store.listActivity, + stampMailMessageId: store.stampMailMessageId, + findByMailMessageId: store.findByMailMessageId, + deleteMessage: store.deleteMessage, }; const publish: WorkbenchSubscriberRegistry["publish"] = ( workbenchId, diff --git a/packages/chat/test/dispatch-turn-mail-headers.test.ts b/packages/chat/test/dispatch-turn-mail-headers.test.ts new file mode 100644 index 000000000..ad425300e --- /dev/null +++ b/packages/chat/test/dispatch-turn-mail-headers.test.ts @@ -0,0 +1,151 @@ +// CL-7450 (finding 1): a message dispatched to an agent for a turn is +// itself mail, so it must carry the same RFC 5322 threading the human +// write path already stamps onto the timeline row — its own `Message-ID`, +// derived from the row (`mailMessageIdFor`, never minted separately), and +// `In-Reply-To` naming the row it replies to. Before this landed, +// `dispatchTurn`'s `sendMail` call carried none of that: the frame an +// agent actually receives named no row at all. +import { describe, expect, test } from "bun:test"; +import type { MailContent } from "../src/codec"; +import { createInMemoryChatStore } from "../src/store"; +import { createInMemoryRoomMessageStore } from "../src/room-messages"; +import { createInMemoryThreadStore } from "../src/threads"; +import { createWorkbenchTurnQueue } from "../src/turn-queue"; +import { createInMemoryTurnClaimStore } from "../src/turn-claims"; +import { createTurnCancelRegistry } from "../src/turn-cancellation"; +import { dispatchTurn, sendWorkbenchMessage } from "../src/workbench-service"; +import { mailMessageIdFor } from "../src/mail-headers"; +import type { MailboxWriter } from "../src/mailbox-fanout"; + +const TENANT_ID = "tnt_1"; +const WORKBENCH_ID = "wb_1"; +const DOMAIN = "acme.example"; +const SENDER = "prn_alice"; +const AGENT_ADDRESS = "ins_echo1@acme.example"; + +function noopMailbox() { + const writer: MailboxWriter = { + async writeBatch(items) { + return items.map((_item, index) => ({ + messageKey: String(index), + id: `mbx_${String(index)}`, + })); + }, + }; + return { + writer, + resolveKnownPrincipalIds: async () => new Set([SENDER]), + resolveTenantDomain: async () => DOMAIN, + }; +} + +function participants() { + return [ + { address: SENDER, handle: SENDER }, + { address: AGENT_ADDRESS, handle: "echo" }, + ]; +} + +async function makeDeps(sentMail: MailContent[]) { + const store = createInMemoryChatStore(); + const roomMessages = createInMemoryRoomMessageStore(); + const threads = createInMemoryThreadStore(); + const claims = createInMemoryTurnClaimStore({ ttlMs: 60_000 }); + const turnQueue = createWorkbenchTurnQueue({ + claims, + publish: () => undefined, + }); + const turnCancellation = createTurnCancelRegistry(); + + await store.createWorkbenchSettings({ + tenantId: TENANT_ID, + workbenchId: WORKBENCH_ID, + updatedBy: SENDER, + settings: { "chat/participants": participants() }, + }); + + const deps = { + store, + roomMessages, + threads, + publish: () => undefined, + platform: { + async sendMail(input: { content: MailContent }) { + sentMail.push(input.content); + return { id: "mail_agent_1", createdAt: new Date().toISOString() }; + }, + }, + turnQueue, + turnCancellation, + mailbox: noopMailbox(), + }; + return { deps, roomMessages, threads }; +} + +describe("dispatchTurn's RFC 5322 threading (CL-7450)", () => { + test("the dispatched frame's messageId equals the stored row's own mailMessageId", async () => { + const sentMail: MailContent[] = []; + const { deps, roomMessages } = await makeDeps(sentMail); + + const posted = await sendWorkbenchMessage(deps, { + tenantId: TENANT_ID, + principalId: SENDER, + senderAddress: `${SENDER}@${DOMAIN}`, + workbenchId: WORKBENCH_ID, + messageParts: [{ kind: "text", text: "hello" }], + }); + await posted.fanoutDelivered; + + const stored = await roomMessages.getMessage({ + tenantId: TENANT_ID, + workbenchId: WORKBENCH_ID, + messageId: posted.id, + }); + expect(stored?.mailMessageId).toBe(`<${posted.id}@${DOMAIN}>`); + + expect(sentMail).toHaveLength(1); + expect(sentMail[0]?.messageId).toBe(stored?.mailMessageId ?? undefined); + expect(sentMail[0]?.inReplyTo).toBeUndefined(); + }); + + // Exercises `dispatchTurn` directly rather than through + // `sendWorkbenchMessage`'s async fan-out: the reply-thread membership a + // dispatch's ancestry reads must already be assigned by the time it + // fires, which is exactly the ordering `routeMessage`'s doc comment + // promises never blocks the sender — assigning it upfront here is what + // isolates the ancestry-walk behavior from that unrelated race. + test("a reply's dispatched frame names its parent row's Message-ID in In-Reply-To and References", async () => { + const sentMail: MailContent[] = []; + const { deps, threads } = await makeDeps(sentMail); + + const parentMessageId = "msg_parent"; + const childMessageId = "msg_child"; + const replyThread = await threads.openReplyThread({ + tenantId: TENANT_ID, + workbenchId: WORKBENCH_ID, + parentMessageId, + }); + await threads.assignMessage({ + tenantId: TENANT_ID, + workbenchId: WORKBENCH_ID, + threadId: replyThread.id, + messageId: childMessageId, + }); + + await dispatchTurn(deps, { + tenantId: TENANT_ID, + workbenchId: WORKBENCH_ID, + principalId: SENDER, + agentAddress: AGENT_ADDRESS, + parts: [{ kind: "text", text: "reply" }], + requestMessageIds: [childMessageId], + }); + + const expectedMessageId = mailMessageIdFor(childMessageId, DOMAIN); + const expectedParentId = mailMessageIdFor(parentMessageId, DOMAIN); + expect(sentMail).toHaveLength(1); + expect(sentMail[0]?.messageId).toBe(expectedMessageId); + expect(sentMail[0]?.inReplyTo).toBe(expectedParentId); + expect(sentMail[0]?.references).toEqual([expectedParentId]); + }); +}); diff --git a/packages/chat/test/mailbox-fanout.drizzle.test.ts b/packages/chat/test/mailbox-fanout.drizzle.test.ts new file mode 100644 index 000000000..202be4af7 --- /dev/null +++ b/packages/chat/test/mailbox-fanout.drizzle.test.ts @@ -0,0 +1,299 @@ +// DB-gated: skipped when no DATABASE_URL is reachable, mirroring +// `threads.drizzle.test.ts`. Runs against its own scratch database with +// a minimal control plane (`tenant`/`principal`) the mailbox FKs need. +// +// Proves `createDrizzleMailboxWriter`'s one write path — a batch through +// `@corbits/mailbox`'s own `writeMailboxMessages` — against a real +// `@corbits/mailbox` schema: an outbound row and an inbound row committed +// together in one transaction, both dedupe on a retried batch, and — the +// critique's frame-Message-ID probe — the stored frame's `Message-ID:` +// header, the row's own cached `message_id` column, and the batch's +// caller-supplied `messageKey` all agree, with `In-Reply-To:` likewise +// matching what the caller passed in. +import { afterAll, beforeAll, expect, test } from "bun:test"; +import postgres from "postgres"; +import { sql } from "drizzle-orm"; +import { createMailboxDb, runMailboxMigrations } from "@corbits/mailbox"; + +import { e2eDatabaseUrl } from "../../../scripts/e2e/harness"; +import { dbGate } from "../../../scripts/e2e/db-gate"; +import { + createDrizzleMailboxWriter, + type MailboxBatchItem, +} from "../src/mailbox-fanout"; + +function scratchUrlFor(e2eUrl: string): string { + const url = new URL(e2eUrl); + const database = url.pathname.replace(/^\//, ""); + url.pathname = `/${database}_chat_mailbox_fanout_drizzle_test`; + return url.toString(); +} + +const databaseUrl = e2eDatabaseUrl(); +const describeIfDb = dbGate(databaseUrl, import.meta.path); + +const TENANT_ID = "tnt_1"; +const PRINCIPAL_ID = "prn_alice"; +const OTHER_PRINCIPAL_ID = "prn_bob"; +const DOMAIN = "acme.example"; + +describeIfDb("createDrizzleMailboxWriter", () => { + const scratchUrl = scratchUrlFor( + databaseUrl ?? "postgres://localhost:5432/unused", + ); + const scratchTarget = new URL(scratchUrl); + const scratchDatabase = scratchTarget.pathname.replace(/^\//, ""); + + async function freshDb() { + const maintenanceUrl = new URL(scratchUrl); + maintenanceUrl.pathname = "/postgres"; + const maintenance = postgres(maintenanceUrl.toString(), { + max: 1, + onnotice: () => undefined, + }); + try { + await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); + await maintenance.unsafe(`CREATE DATABASE "${scratchDatabase}"`); + } finally { + await maintenance.end(); + } + const { db, close } = createMailboxDb(scratchUrl); + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "tenant" ("id" text PRIMARY KEY) + `); + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "principal" ( + "id" text PRIMARY KEY, + "tenant_id" text NOT NULL REFERENCES "tenant" ("id") ON DELETE CASCADE + ) + `); + await db.execute(sql`INSERT INTO "tenant" ("id") VALUES (${TENANT_ID})`); + await db.execute( + sql`INSERT INTO "principal" ("id", "tenant_id") VALUES (${PRINCIPAL_ID}, ${TENANT_ID}), (${OTHER_PRINCIPAL_ID}, ${TENANT_ID})`, + ); + await runMailboxMigrations(db); + return { db, close }; + } + + beforeAll(async () => { + const maintenanceUrl = new URL(scratchUrl); + maintenanceUrl.pathname = "/postgres"; + const maintenance = postgres(maintenanceUrl.toString(), { + max: 1, + onnotice: () => undefined, + }); + try { + await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); + await maintenance.unsafe(`CREATE DATABASE "${scratchDatabase}"`); + } finally { + await maintenance.end(); + } + }, 20000); + + afterAll(async () => { + const maintenanceUrl = new URL(scratchUrl); + maintenanceUrl.pathname = "/postgres"; + const maintenance = postgres(maintenanceUrl.toString(), { + max: 1, + onnotice: () => undefined, + }); + try { + await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); + } finally { + await maintenance.end(); + } + }, 20000); + + test("a batch writes an outbound and an inbound row together and dedupes a retried batch", async () => { + const { db, close } = await freshDb(); + try { + const writer = createDrizzleMailboxWriter(db); + const messageId = ""; + + const items: MailboxBatchItem[] = [ + { + tenantId: TENANT_ID, + principalId: PRINCIPAL_ID, + address: `${PRINCIPAL_ID}@${DOMAIN}`, + fromAddress: `${PRINCIPAL_ID}@${DOMAIN}`, + subject: "hello", + body: "hello", + messageId, + direction: "outbound", + }, + { + tenantId: TENANT_ID, + principalId: OTHER_PRINCIPAL_ID, + address: `${OTHER_PRINCIPAL_ID}@${DOMAIN}`, + fromAddress: `${PRINCIPAL_ID}@${DOMAIN}`, + subject: "hello", + body: "hello", + messageId, + direction: "inbound", + }, + ]; + + const results = await writer.writeBatch(items); + expect(results.map((r) => r.id !== null)).toEqual([true, true]); + + const rows = await db.execute<{ + principal_id: string; + direction: string; + message_id: string; + }>( + sql`SELECT principal_id, direction, message_id FROM "mailbox"."principal_mail" WHERE "tenant_id" = ${TENANT_ID} ORDER BY principal_id`, + ); + expect([...rows]).toEqual([ + { + principal_id: PRINCIPAL_ID, + direction: "outbound", + message_id: messageId, + }, + { + principal_id: OTHER_PRINCIPAL_ID, + direction: "inbound", + message_id: messageId, + }, + ]); + + // A retried batch is idempotent — the default transport key dedupes + // both rows without writing a duplicate of either. + const retried = await writer.writeBatch(items); + expect(retried.every((r) => r.id === null)).toBe(true); + + const countRows = await db.execute<{ count: string }>( + sql`SELECT count(*)::text FROM "mailbox"."principal_mail" WHERE "tenant_id" = ${TENANT_ID}`, + ); + expect(countRows[0]?.count).toBe("2"); + } finally { + await close(); + } + }, 20000); + + test("a batch that fails partway writes nothing, and a retry after a real success writes nothing twice", async () => { + const { db, close } = await freshDb(); + try { + const writer = createDrizzleMailboxWriter(db); + const messageId = ""; + + const badItems: MailboxBatchItem[] = [ + { + tenantId: TENANT_ID, + principalId: PRINCIPAL_ID, + address: `${PRINCIPAL_ID}@${DOMAIN}`, + fromAddress: `${PRINCIPAL_ID}@${DOMAIN}`, + subject: "hello", + body: "hello", + messageId, + direction: "outbound", + }, + { + // An unknown principal: the FK refuses this row, and the whole + // batch's transaction must roll back — including the first, + // otherwise-valid item above. + tenantId: TENANT_ID, + principalId: "prn_ghost", + address: `prn_ghost@${DOMAIN}`, + fromAddress: `${PRINCIPAL_ID}@${DOMAIN}`, + subject: "hello", + body: "hello", + messageId, + direction: "inbound", + }, + ]; + + await expect(writer.writeBatch(badItems)).rejects.toThrow(); + + const afterFailure = await db.execute<{ count: string }>( + sql`SELECT count(*)::text FROM "mailbox"."principal_mail" WHERE "tenant_id" = ${TENANT_ID}`, + ); + expect(afterFailure[0]?.count).toBe("0"); + + // Retry with a corrected batch: succeeds and writes exactly the + // rows a first-time success would have. + const goodItems: MailboxBatchItem[] = [ + badItems[0] as MailboxBatchItem, + { + tenantId: TENANT_ID, + principalId: OTHER_PRINCIPAL_ID, + address: `${OTHER_PRINCIPAL_ID}@${DOMAIN}`, + fromAddress: `${PRINCIPAL_ID}@${DOMAIN}`, + subject: "hello", + body: "hello", + messageId, + direction: "inbound", + }, + ]; + const succeeded = await writer.writeBatch(goodItems); + expect(succeeded.every((r) => r.id !== null)).toBe(true); + + // Retrying the now-successful batch again writes nothing more. + const retried = await writer.writeBatch(goodItems); + expect(retried.every((r) => r.id === null)).toBe(true); + + const countRows = await db.execute<{ count: string }>( + sql`SELECT count(*)::text FROM "mailbox"."principal_mail" WHERE "tenant_id" = ${TENANT_ID}`, + ); + expect(countRows[0]?.count).toBe("2"); + } finally { + await close(); + } + }, 20000); + + // Critique probe folded in: the stored frame's `Message-ID:` header, + // the row's cached `message_id` column, and `In-Reply-To:` all agree + // with what the caller passed in — never a separately-minted id. + test("the stored frame's Message-ID and In-Reply-To match the caller's own", async () => { + const { db, close } = await freshDb(); + try { + const writer = createDrizzleMailboxWriter(db); + const messageId = ""; + const inReplyTo = ""; + + await writer.writeBatch([ + { + tenantId: TENANT_ID, + principalId: PRINCIPAL_ID, + address: `${PRINCIPAL_ID}@${DOMAIN}`, + fromAddress: `${PRINCIPAL_ID}@${DOMAIN}`, + subject: "s", + body: "b", + messageId, + direction: "outbound", + inReplyTo, + }, + { + tenantId: TENANT_ID, + principalId: OTHER_PRINCIPAL_ID, + address: `${OTHER_PRINCIPAL_ID}@${DOMAIN}`, + fromAddress: `${PRINCIPAL_ID}@${DOMAIN}`, + subject: "s", + body: "b", + messageId, + direction: "inbound", + inReplyTo, + }, + ]); + + const rows = await db.execute<{ principal_id: string; raw: Buffer }>( + sql`SELECT principal_id, raw FROM "mailbox"."principal_mail" ORDER BY principal_id`, + ); + const headers = [...rows].map((r) => { + const text = Buffer.from(r.raw).toString("utf8"); + const h = (n: string) => + text.match(new RegExp(`^${n}:\\s*(.*)$`, "im"))?.[1] ?? null; + return { + principal: r.principal_id, + messageId: h("Message-ID"), + inReplyTo: h("In-Reply-To"), + }; + }); + for (const header of headers) { + expect(header.messageId).toBe(messageId); + expect(header.inReplyTo).toBe(inReplyTo); + } + } finally { + await close(); + } + }, 20000); +}); diff --git a/packages/chat/test/mailbox-fanout.test.ts b/packages/chat/test/mailbox-fanout.test.ts new file mode 100644 index 000000000..cf9750fdb --- /dev/null +++ b/packages/chat/test/mailbox-fanout.test.ts @@ -0,0 +1,548 @@ +// CL-7450: a sent human message lands in every human participant's +// mailbox — an "outbound" copy in the sender's own, "inbound" in every +// other human's — all sharing the row's own RFC 5322 Message-ID, all in +// ONE batch (one `@corbits/mailbox` transaction), and the same default +// transport idempotency key makes a retried send a no-op rather than a +// duplicate. Exercised against an in-memory `MailboxWriter`, never a +// live `@corbits/mailbox` schema — see `../src/mailbox-fanout.ts`'s own +// doc comment for why the write is behind that seam. +import { describe, expect, test } from "bun:test"; +import { + writeChatMailboxFanout, + mailboxBodyOf, + mailboxSubjectOf, + MailboxFanoutFailedError, + type MailboxBatchItem, + type MailboxBatchResult, + type MailboxWriter, +} from "../src/mailbox-fanout"; +import type { ParticipantRecord } from "../src/participants"; +import { sendWorkbenchMessage } from "../src/workbench-service"; +import { createInMemoryRoomMessageStore } from "../src/room-messages"; +import { createInMemoryChatStore } from "../src/store"; +import { createWorkbenchTurnQueue } from "../src/turn-queue"; +import { createInMemoryTurnClaimStore } from "../src/turn-claims"; +import { createTurnCancelRegistry } from "../src/turn-cancellation"; + +function inMemoryWriter(): { + writer: MailboxWriter; + rows: MailboxBatchItem[]; + batches: MailboxBatchItem[][]; +} { + const rows: MailboxBatchItem[] = []; + const batches: MailboxBatchItem[][] = []; + const seen = new Set(); + function keyOf(item: MailboxBatchItem): string { + return `${item.tenantId}:${item.principalId}:${item.messageId}:${item.direction}`; + } + return { + rows, + batches, + writer: { + async writeBatch(items) { + batches.push([...items]); + const results: MailboxBatchResult[] = []; + for (const item of items) { + const key = keyOf(item); + if (seen.has(key)) { + results.push({ messageKey: key, id: null }); + continue; + } + seen.add(key); + rows.push(item); + results.push({ messageKey: key, id: `mail_${String(rows.length)}` }); + } + return results; + }, + }, + }; +} + +function knownPrincipals(ids: readonly string[]) { + const known = new Set(ids); + return async ( + _tenantId: string, + candidateIds: readonly string[], + ): Promise> => + new Set(candidateIds.filter((id) => known.has(id))); +} + +function domainOf(domain: string) { + return async (_tenantId: string) => domain; +} + +const TENANT_ID = "tnt_1"; +const WORKBENCH_ID = "wb_1"; +const DOMAIN = "acme.example"; +const SENDER = "prn_alice"; +const OTHER_HUMANS = ["prn_bob", "prn_carol"]; +const AGENT_ADDRESS = "ins_echo1@acme.example"; + +function participantsOf( + senderId: string, + humanIds: readonly string[], + agentAddress: string, +): ParticipantRecord[] { + return [ + { address: senderId, handle: senderId }, + ...humanIds.map((id) => ({ address: id, handle: id })), + { address: agentAddress, handle: "echo" }, + ]; +} + +describe("writeChatMailboxFanout (CL-7450)", () => { + test("a send to a three-human, one-agent workbench yields three mailbox writes, in one batch, sharing one Message-ID", async () => { + const { writer, rows, batches } = inMemoryWriter(); + const senderAddress = `${SENDER}@${DOMAIN}`; + + await writeChatMailboxFanout( + { + writer, + resolveKnownPrincipalIds: knownPrincipals([SENDER, ...OTHER_HUMANS]), + resolveTenantDomain: domainOf(DOMAIN), + }, + { + tenantId: TENANT_ID, + workbenchId: WORKBENCH_ID, + senderAddress, + senderPrincipalId: SENDER, + participants: participantsOf(SENDER, OTHER_HUMANS, AGENT_ADDRESS), + messageId: "", + subject: "hello", + body: "hello", + }, + ); + + expect(batches).toHaveLength(1); + expect(rows).toHaveLength(3); + const messageIds = new Set(rows.map((row) => row.messageId)); + expect(messageIds).toEqual(new Set([""])); + + const byPrincipal = new Map(rows.map((row) => [row.principalId, row])); + expect(byPrincipal.get(SENDER)?.direction).toBe("outbound"); + expect(byPrincipal.get("prn_bob")?.direction).toBe("inbound"); + expect(byPrincipal.get("prn_carol")?.direction).toBe("inbound"); + + // The agent never gets a mailbox row — its inbox is its run's own + // mail queue, dispatched separately through `WorkbenchMail.sendMail`. + expect(byPrincipal.has(AGENT_ADDRESS)).toBe(false); + + for (const row of rows) { + expect(row.refs).toEqual([{ kind: "workbench", id: WORKBENCH_ID }]); + expect(row.address).toBe(`${row.principalId}@${DOMAIN}`); + } + }); + + test("a retried send is idempotent on the default transport key", async () => { + const { writer, rows } = inMemoryWriter(); + const senderAddress = `${SENDER}@${DOMAIN}`; + const input = { + tenantId: TENANT_ID, + workbenchId: WORKBENCH_ID, + senderAddress, + senderPrincipalId: SENDER, + participants: participantsOf(SENDER, OTHER_HUMANS, AGENT_ADDRESS), + messageId: "", + subject: "hello", + body: "hello", + }; + const deps = { + writer, + resolveKnownPrincipalIds: knownPrincipals([SENDER, ...OTHER_HUMANS]), + resolveTenantDomain: domainOf(DOMAIN), + }; + + await writeChatMailboxFanout(deps, input); + await writeChatMailboxFanout(deps, input); + + expect(rows).toHaveLength(3); + }); + + test("skips a participant address with no known principal, reporting rather than writing", async () => { + const { writer, rows } = inMemoryWriter(); + const senderAddress = `${SENDER}@${DOMAIN}`; + + await writeChatMailboxFanout( + { + writer, + // "prn_bob" is a participant, but not a known tenant principal — + // a stale or removed member. + resolveKnownPrincipalIds: knownPrincipals([SENDER, "prn_carol"]), + resolveTenantDomain: domainOf(DOMAIN), + }, + { + tenantId: TENANT_ID, + workbenchId: WORKBENCH_ID, + senderAddress, + senderPrincipalId: SENDER, + participants: participantsOf(SENDER, OTHER_HUMANS, AGENT_ADDRESS), + messageId: "", + subject: "hello", + body: "hello", + }, + ); + + const principalIds = rows.map((row) => row.principalId); + expect(principalIds).toContain(SENDER); + expect(principalIds).toContain("prn_carol"); + expect(principalIds).not.toContain("prn_bob"); + }); + + test("a same-tenant sender gets its own outbound copy", async () => { + const { writer, rows } = inMemoryWriter(); + + await writeChatMailboxFanout( + { + writer, + resolveKnownPrincipalIds: knownPrincipals([SENDER, "prn_bob"]), + resolveTenantDomain: domainOf(DOMAIN), + }, + { + tenantId: TENANT_ID, + workbenchId: WORKBENCH_ID, + senderAddress: `${SENDER}@${DOMAIN}`, + senderPrincipalId: SENDER, + participants: participantsOf(SENDER, ["prn_bob"], AGENT_ADDRESS), + messageId: "", + subject: "hello", + body: "hello", + }, + ); + + const bySender = rows.find((row) => row.principalId === SENDER); + expect(bySender?.direction).toBe("outbound"); + expect(bySender?.address).toBe(`${SENDER}@${DOMAIN}`); + }); + + test("a share member sending into a bench it has no principal in skips its own copy quietly, but every other human still gets theirs", async () => { + const { writer, rows } = inMemoryWriter(); + + // The sender ("prn_dave") is a projected-tenant share member: not a + // known principal in the workbench's OWNING tenant, unlike every + // other (real bench-tenant) human participant. + await writeChatMailboxFanout( + { + writer, + resolveKnownPrincipalIds: knownPrincipals(OTHER_HUMANS), + resolveTenantDomain: domainOf(DOMAIN), + }, + { + tenantId: TENANT_ID, + workbenchId: WORKBENCH_ID, + senderAddress: `prn_dave@projected.example`, + senderPrincipalId: "prn_dave", + participants: participantsOf("prn_dave", OTHER_HUMANS, AGENT_ADDRESS), + messageId: "", + subject: "hello", + body: "hello", + }, + ); + + const principalIds = rows.map((row) => row.principalId); + expect(principalIds).not.toContain("prn_dave"); + expect(principalIds.sort()).toEqual([...OTHER_HUMANS].sort()); + // Every other human is addressed under the OWNER tenant's domain, + // not the share member's own (projected) tenant's domain. + for (const row of rows) { + expect(row.address.endsWith(`@${DOMAIN}`)).toBe(true); + expect(row.direction).toBe("inbound"); + } + }); + + test("propagates a batch write failure rather than swallowing it, as a MailboxFanoutFailedError carrying its own refId", async () => { + const failing: MailboxWriter = { + async writeBatch() { + throw new Error("db exploded"); + }, + }; + + let caught: unknown; + try { + await writeChatMailboxFanout( + { + writer: failing, + resolveKnownPrincipalIds: knownPrincipals([SENDER, "prn_bob"]), + resolveTenantDomain: domainOf(DOMAIN), + }, + { + tenantId: TENANT_ID, + workbenchId: WORKBENCH_ID, + senderAddress: `${SENDER}@${DOMAIN}`, + senderPrincipalId: SENDER, + participants: participantsOf(SENDER, ["prn_bob"], AGENT_ADDRESS), + messageId: "", + subject: "hello", + body: "hello", + }, + ); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(MailboxFanoutFailedError); + const failure = caught as MailboxFanoutFailedError; + expect(typeof failure.refId).toBe("string"); + expect((failure.cause as Error)?.message).toBe("db exploded"); + }); + + test("inReplyTo threads through to every recipient's write", async () => { + const { writer, rows } = inMemoryWriter(); + + await writeChatMailboxFanout( + { + writer, + resolveKnownPrincipalIds: knownPrincipals([SENDER, "prn_bob"]), + resolveTenantDomain: domainOf(DOMAIN), + }, + { + tenantId: TENANT_ID, + workbenchId: WORKBENCH_ID, + senderAddress: `${SENDER}@${DOMAIN}`, + senderPrincipalId: SENDER, + participants: participantsOf(SENDER, ["prn_bob"], AGENT_ADDRESS), + messageId: "", + inReplyTo: "", + subject: "hello", + body: "hello", + }, + ); + + for (const row of rows) { + expect(row.inReplyTo).toBe(""); + } + }); + + test("references threads through to every recipient's write (CL-7450 finding 2)", async () => { + const { writer, rows } = inMemoryWriter(); + + await writeChatMailboxFanout( + { + writer, + resolveKnownPrincipalIds: knownPrincipals([SENDER, "prn_bob"]), + resolveTenantDomain: domainOf(DOMAIN), + }, + { + tenantId: TENANT_ID, + workbenchId: WORKBENCH_ID, + senderAddress: `${SENDER}@${DOMAIN}`, + senderPrincipalId: SENDER, + participants: participantsOf(SENDER, ["prn_bob"], AGENT_ADDRESS), + messageId: "", + inReplyTo: "", + references: ["", ""], + subject: "hello", + body: "hello", + }, + ); + + for (const row of rows) { + expect(row.references).toEqual([ + "", + "", + ]); + } + }); + + test("a root-feed send carries no references at all", async () => { + const { writer, rows } = inMemoryWriter(); + + await writeChatMailboxFanout( + { + writer, + resolveKnownPrincipalIds: knownPrincipals([SENDER, "prn_bob"]), + resolveTenantDomain: domainOf(DOMAIN), + }, + { + tenantId: TENANT_ID, + workbenchId: WORKBENCH_ID, + senderAddress: `${SENDER}@${DOMAIN}`, + senderPrincipalId: SENDER, + participants: participantsOf(SENDER, ["prn_bob"], AGENT_ADDRESS), + messageId: "", + subject: "hello", + body: "hello", + }, + ); + + for (const row of rows) { + expect(row.references).toBeUndefined(); + } + }); +}); + +describe("sendWorkbenchMessage's mailbox fan-out wiring (CL-7450)", () => { + test("posting a message stamps the row's mail Message-ID and fans it into every human participant's mailbox, before the row publishes", async () => { + const { writer, rows } = inMemoryWriter(); + const store = createInMemoryChatStore(); + const roomMessages = createInMemoryRoomMessageStore(); + const claims = createInMemoryTurnClaimStore({ ttlMs: 60_000 }); + const publishedIds: string[] = []; + const turnQueue = createWorkbenchTurnQueue({ + claims, + publish: () => undefined, + }); + const turnCancellation = createTurnCancelRegistry(); + + await store.createWorkbenchSettings({ + tenantId: TENANT_ID, + workbenchId: WORKBENCH_ID, + updatedBy: SENDER, + settings: { + "chat/participants": participantsOf( + SENDER, + OTHER_HUMANS, + AGENT_ADDRESS, + ), + }, + }); + + const result = await sendWorkbenchMessage( + { + store, + roomMessages, + publish: (_workbenchId, event) => { + if (event.type === "chat.message") { + publishedIds.push((event.data as { id: string }).id); + } + }, + platform: { + async sendMail() { + return { id: "mail_agent_1", createdAt: new Date().toISOString() }; + }, + }, + turnQueue, + turnCancellation, + mailbox: { + writer, + resolveKnownPrincipalIds: knownPrincipals([SENDER, ...OTHER_HUMANS]), + resolveTenantDomain: domainOf(DOMAIN), + }, + }, + { + tenantId: TENANT_ID, + principalId: SENDER, + senderAddress: `${SENDER}@${DOMAIN}`, + workbenchId: WORKBENCH_ID, + messageParts: [{ kind: "text", text: "hello everyone" }], + }, + ); + await result.fanoutDelivered; + + const stored = await roomMessages.getMessage({ + tenantId: TENANT_ID, + workbenchId: WORKBENCH_ID, + messageId: result.id, + }); + expect(stored?.mailMessageId).toBe(`<${result.id}@${DOMAIN}>`); + + expect(rows).toHaveLength(3); + for (const row of rows) { + expect(row.messageId).toBe(`<${result.id}@${DOMAIN}>`); + } + + // The publish happened — proving the row is visible once fan-out + // succeeds — and named exactly this message. + expect(publishedIds).toEqual([result.id]); + }); + + test("a fan-out failure deletes the just-inserted row and never publishes it", async () => { + const store = createInMemoryChatStore(); + const roomMessages = createInMemoryRoomMessageStore(); + const claims = createInMemoryTurnClaimStore({ ttlMs: 60_000 }); + const turnQueue = createWorkbenchTurnQueue({ + claims, + publish: () => undefined, + }); + const turnCancellation = createTurnCancelRegistry(); + const publishedIds: string[] = []; + + await store.createWorkbenchSettings({ + tenantId: TENANT_ID, + workbenchId: WORKBENCH_ID, + updatedBy: SENDER, + settings: { + "chat/participants": participantsOf( + SENDER, + OTHER_HUMANS, + AGENT_ADDRESS, + ), + }, + }); + + const failingWriter: MailboxWriter = { + async writeBatch() { + throw new Error("db exploded"); + }, + }; + + await expect( + sendWorkbenchMessage( + { + store, + roomMessages, + publish: (_workbenchId, event) => { + if (event.type === "chat.message") { + publishedIds.push((event.data as { id: string }).id); + } + }, + platform: { + async sendMail() { + return { + id: "mail_agent_1", + createdAt: new Date().toISOString(), + }; + }, + }, + turnQueue, + turnCancellation, + mailbox: { + writer: failingWriter, + resolveKnownPrincipalIds: knownPrincipals([ + SENDER, + ...OTHER_HUMANS, + ]), + resolveTenantDomain: domainOf(DOMAIN), + }, + }, + { + tenantId: TENANT_ID, + principalId: SENDER, + senderAddress: `${SENDER}@${DOMAIN}`, + workbenchId: WORKBENCH_ID, + messageParts: [{ kind: "text", text: "hello everyone" }], + }, + ), + ).rejects.toBeInstanceOf(MailboxFanoutFailedError); + + expect(publishedIds).toEqual([]); + const page = await roomMessages.listMessages({ + tenantId: TENANT_ID, + workbenchId: WORKBENCH_ID, + }); + expect(page.items).toHaveLength(0); + }); +}); + +describe("mailboxBodyOf / mailboxSubjectOf", () => { + test("joins text parts and clips the subject to the first line", () => { + const body = mailboxBodyOf([ + { kind: "text", text: "line one" }, + { kind: "event" }, + { kind: "text", text: "line two" }, + ]); + expect(body).toBe("line one\n\nline two"); + expect(mailboxSubjectOf(body)).toBe("line one"); + }); + + test("a bodyless message gets a placeholder subject", () => { + expect(mailboxSubjectOf("")).toBe("(no subject)"); + }); + + test("an attachment-only message gets a real subject and a body listing its parts", () => { + const body = mailboxBodyOf([ + { kind: "file", name: "diagram.png" }, + { kind: "file", name: "notes.txt" }, + ]); + expect(body).toBe("Attachment: diagram.png\nAttachment: notes.txt"); + expect(mailboxSubjectOf(body)).toBe("Attachment: diagram.png"); + }); +}); diff --git a/packages/chat/test/migrations.test.ts b/packages/chat/test/migrations.test.ts index 2fec9e3a1..23a811db5 100644 --- a/packages/chat/test/migrations.test.ts +++ b/packages/chat/test/migrations.test.ts @@ -50,6 +50,7 @@ const migrationNames = [ "0026_workbench_threads_delivery_key", "0027_block_responses_notified_at", "0028_turn_mail_correlation", + "0029_workbench_messages_mail_message_id", ]; describeIfDb("applyChatMigrations", () => { diff --git a/packages/folded-runs/src/mail.ts b/packages/folded-runs/src/mail.ts index d1e583944..9a078aaa9 100644 --- a/packages/folded-runs/src/mail.ts +++ b/packages/folded-runs/src/mail.ts @@ -24,6 +24,16 @@ export type SendFoldedMailParams = { content: string; attachments?: MessageAttachment[]; replyTo?: string; + /** + * RFC 5322 threading (CL-7450): the caller's own `Message-ID` for this + * send, and the ancestry a reply correlates back through. A caller that + * owns an identity for what it is sending (a chat timeline row) supplies + * these; absent, `messageId` falls back to `<{mailId}@{domain}>` and + * `inReplyTo` falls back to the legacy `replyTo` mapping below. + */ + messageId?: string; + inReplyTo?: string; + references?: readonly string[]; cryptoProvider: CryptoProvider; }; @@ -44,7 +54,7 @@ async function deliverFoldedMailMIME( const userMessageParams = { agentAddress: params.agentAddress, from: params.from, - messageId: `<${mailId}@${params.domain}>`, + messageId: params.messageId ?? `<${mailId}@${params.domain}>`, date: now, content: params.content, sessionId: params.sessionId, @@ -55,11 +65,16 @@ async function deliverFoldedMailMIME( params.attachments !== undefined ? { ...userMessageParams, attachments: params.attachments } : userMessageParams; + const inReplyTo = params.inReplyTo ?? params.replyTo; const withReplyTo = - params.replyTo !== undefined - ? { ...withAttachments, inReplyTo: params.replyTo } + inReplyTo !== undefined + ? { ...withAttachments, inReplyTo } : withAttachments; - return deps.sessionService.sendUserMessage(withReplyTo); + const withReferences = + params.references !== undefined && params.references.length > 0 + ? { ...withReplyTo, references: [...params.references] } + : withReplyTo; + return deps.sessionService.sendUserMessage(withReferences); } /** diff --git a/packages/folded-runs/test/mail.test.ts b/packages/folded-runs/test/mail.test.ts index 04f9231bb..a559589f3 100644 --- a/packages/folded-runs/test/mail.test.ts +++ b/packages/folded-runs/test/mail.test.ts @@ -216,6 +216,106 @@ describe("sendFoldedMail", () => { "ins_workbench1@ten1.workbench.test", ); }); + + // CL-7450 finding 1: a caller that owns RFC 5322 threading identity for + // what it is sending (chat's dispatched frame, whose Message-ID must + // equal the timeline row's own) supplies `messageId`/`inReplyTo`/ + // `references` natively — no more `correlationId` side channel, and no + // more silently dropping them on the floor. + test("messageId/inReplyTo/references reach sendUserMessage verbatim, overriding the mailId-derived default", async () => { + const sendUserMessageCalls: unknown[] = []; + const db = { + insert() { + return { values: async () => undefined }; + }, + }; + const sessionService = { + async sendUserMessage(params: unknown) { + sendUserMessageCalls.push(params); + return new TextEncoder().encode("raw-mime-bytes"); + }, + }; + const sidecarRouter = { + dispatchAgentEvent() { + return undefined; + }, + }; + + await sendFoldedMail( + { + db: db as never, + sessionService: sessionService as never, + sidecarRouter: sidecarRouter as never, + }, + { + tenantId: "ten_1", + sessionId: "ses_1", + agentAddress: "ins_workbench1@ten1.workbench.test", + from: "prn_sender@ten1.workbench.test", + domain: "ten1.workbench.test", + content: "hello workbench", + cryptoProvider: {} as never, + messageId: "", + inReplyTo: "", + references: [""], + }, + ); + + expect(sendUserMessageCalls).toHaveLength(1); + const call = sendUserMessageCalls[0] as { + messageId: string; + inReplyTo?: string; + references?: string[]; + }; + expect(call.messageId).toBe(""); + expect(call.inReplyTo).toBe(""); + expect(call.references).toEqual([""]); + }); + + test("with no explicit threading, messageId derives from mailId and inReplyTo falls back to the legacy replyTo mapping", async () => { + const sendUserMessageCalls: unknown[] = []; + const db = { + insert() { + return { values: async () => undefined }; + }, + }; + const sessionService = { + async sendUserMessage(params: unknown) { + sendUserMessageCalls.push(params); + return new TextEncoder().encode("raw-mime-bytes"); + }, + }; + const sidecarRouter = { + dispatchAgentEvent() { + return undefined; + }, + }; + + const sent = await sendFoldedMail( + { + db: db as never, + sessionService: sessionService as never, + sidecarRouter: sidecarRouter as never, + }, + { + tenantId: "ten_1", + sessionId: "ses_1", + agentAddress: "ins_workbench1@ten1.workbench.test", + from: "prn_sender@ten1.workbench.test", + domain: "ten1.workbench.test", + content: "hello workbench", + cryptoProvider: {} as never, + replyTo: "wb_room1", + }, + ); + + const call = sendUserMessageCalls[0] as { + messageId: string; + inReplyTo?: string; + }; + expect(call.messageId).toBe(`<${sent.id}@ten1.workbench.test>`); + expect(call.inReplyTo).toBe("wb_room1"); + }); }); function fakeMailDeps(sendUserMessage: () => Promise) {