diff --git a/.agents/skills/mock-agentmail-testing/SKILL.md b/.agents/skills/mock-agentmail-testing/SKILL.md new file mode 100644 index 000000000..0dc36cb36 --- /dev/null +++ b/.agents/skills/mock-agentmail-testing/SKILL.md @@ -0,0 +1,182 @@ +--- +name: mock-agentmail-testing +description: Run Roomote email (AgentMail) integration flows through the checked-in mock AgentMail API harness instead of the real AgentMail service. Use when testing email task entry, inbox provisioning, webhook registration, Svix-signed `message.received` deliveries, duplicate/oversize/auto-submitted email handling, outbound email replies, `AGENTMAIL_API_BASE_URL` routing, `/mock/state`, or `/mock/events`. +--- + +# Mock AgentMail Testing + +Use this skill to exercise Roomote's email integration against the checked-in mock AgentMail harness. Do not invent another fake AgentMail stack and do not use the real AgentMail service unless the user explicitly asks for that parity test. + +Email continuity is inferred from **thread id → conversation**. The harness mints reply headers (`in_reply_to`, `references`) the way a real mail chain would, and signs every webhook delivery exactly like Svix does, so the production verifier accepts mock deliveries unchanged. Webhook signature verification, thread continuity, and duplicate-delivery dedup are the highest-value things to test here. + +## Quick Reference + +| What | Value | +| ------------------------------ | ------------------------------------------------------------ | +| Harness port | `3015` | +| Harness base URL | `http://127.0.0.1:3015` | +| AgentMail API base for Roomote | `AGENTMAIL_API_BASE_URL=http://127.0.0.1:3015` | +| API webhook endpoint | `http://localhost:3001/api/webhooks/agentmail` | +| Mock state endpoint | `http://127.0.0.1:3015/mock/state` | +| Mock event replay endpoint | `http://127.0.0.1:3015/mock/events` | +| Example scenario | `packages/communication/scripts/mock-agentmail.example.json` | +| Mock inbox identity | `roomote@agentmail.to` | +| Seeded webhook secret | `whsec_...` from the scenario file (or minted at register) | + +## Step 1: Wire the API server env + +Set these in the API server's environment (or `.env.local`) before it starts: + +```bash +R_AGENTMAIL_API_KEY=mock-agentmail-api-key # any value; the harness accepts all bearer tokens unless acceptedApiKeys is set +AGENTMAIL_API_BASE_URL=http://127.0.0.1:3015 # reroutes ALL outbound AgentMail API calls to the harness +``` + +Webhook secrets need no manual wiring: when the app registers its webhook through `POST /v0/webhooks`, the harness mints the `whsec_...` secret and returns it, exactly like real AgentMail. If the app relies on a pre-provisioned secret (`R_AGENTMAIL_WEBHOOK_SECRET`), seed a webhook with that secret in the scenario file instead — deliveries are signed with whatever secret the registration holds. + +## Step 2: Create a scenario file + +Copy the example and fix the webhook target to point at the sandbox API (port 3001, not 4000): + +```bash +cp packages/communication/scripts/mock-agentmail.example.json /tmp/mock-agentmail-test.json +sed -i '' 's|localhost:4000|localhost:3001|g' /tmp/mock-agentmail-test.json # macOS; drop '' on Linux +grep url /tmp/mock-agentmail-test.json +# Should show: "url": "http://localhost:3001/api/webhooks/agentmail" +``` + +For custom scenarios, edit `/tmp/mock-agentmail-test.json` directly. Never mutate the committed example. + +## Step 3: Start the harness + +```bash +pnpm --filter @roomote/communication mock:agentmail --state /tmp/mock-agentmail-test.json +``` + +The harness starts on port 3015, replays any events in the `replay` array (delivering signed webhooks to every matching registration), and keeps listening. For one-shot replay that exits after: add `--exit-after-replay`. + +## Step 4: Inject inbound emails manually (optional) + +Ids (`msg_*`, `thread_*`, `evt_*`, svix delivery ids) are minted automatically and unique per run. Pass `threadId` to continue an existing thread; omit it to start a fresh one. + +```bash +# New email → new thread, signed message.received delivery +curl -s -X POST http://127.0.0.1:3015/mock/events \ + -H 'Content-Type: application/json' \ + -d '{ + "inboxId": "roomote@agentmail.to", + "from": "grace@example.com", + "subject": "Flaky login test", + "text": "Hi Roomote — can you look into the flaky login test?" + }' + +# Follow-up in the same thread (use threadId from the previous dispatchResult) +curl -s -X POST http://127.0.0.1:3015/mock/events \ + -H 'Content-Type: application/json' \ + -d '{ + "inboxId": "roomote@agentmail.to", + "from": "grace@example.com", + "text": "also check the retry logic please", + "threadId": "" + }' + +# Auto-generated sender (adds the Auto-Submitted header — loop-guard scenarios) +curl -s -X POST http://127.0.0.1:3015/mock/events \ + -H 'Content-Type: application/json' \ + -d '{ + "inboxId": "roomote@agentmail.to", + "from": "noreply@example.com", + "text": "Your build failed.", + "autoSubmitted": true + }' + +# Oversize payload: webhook arrives WITHOUT text/html (1MB cap); the app must +# re-fetch the full message via GET /v0/inboxes/{id}/messages/{message_id} +curl -s -X POST http://127.0.0.1:3015/mock/events \ + -H 'Content-Type: application/json' \ + -d '{ + "inboxId": "roomote@agentmail.to", + "from": "grace@example.com", + "subject": "Huge recap", + "text": "pretend this is 2MB of text", + "oversize": true + }' + +# Duplicate delivery: resends the PREVIOUS event verbatim with the SAME +# svix-id → exactly-once handling +curl -s -X POST http://127.0.0.1:3015/mock/events \ + -H 'Content-Type: application/json' \ + -d '{ "inboxId": "roomote@agentmail.to", "from": "grace@example.com", "duplicate": true }' + +# Redeliver any past event by id (same svix-id, fresh timestamp + signature) +curl -s -X POST http://127.0.0.1:3015/mock/events \ + -H 'Content-Type: application/json' \ + -d '{ "kind": "redeliver", "eventId": "" }' + +# Permanent bounce → the app must suppress the recipient (message.bounced). +# bounceType defaults to "Permanent"; pass "Transient" to assert NO suppression. +curl -s -X POST http://127.0.0.1:3015/mock/events \ + -H 'Content-Type: application/json' \ + -d '{ "kind": "bounce", "inboxId": "roomote@agentmail.to", "recipients": ["gone@example.com"] }' + +# Spam complaint → the app must suppress the recipient (message.complained) +curl -s -X POST http://127.0.0.1:3015/mock/events \ + -H 'Content-Type: application/json' \ + -d '{ "kind": "complaint", "inboxId": "roomote@agentmail.to", "recipients": ["angry@example.com"] }' +``` + +Every response carries `dispatchResult` with `eventId`, `svixId`, `messageId`, `threadId`, and per-webhook `deliveries` (status + body from the Roomote endpoint). + +## Step 5: Inspect results + +Always check the mock state after replay — do not declare success just because the harness returned 200: + +```bash +# Full state dump +curl -s http://127.0.0.1:3015/mock/state | jq . + +# Outbound emails the system under test sent (replies + fresh sends) +curl -s http://127.0.0.1:3015/mock/state | jq '.messages[] | select(.direction == "outbound")' + +# Replies threaded onto the inbound email (email continuity) +curl -s http://127.0.0.1:3015/mock/state | jq '.messages[] | select(.direction == "outbound" and .in_reply_to != null)' + +# Webhook registrations the app created (secret, inbox filter, event filter) +curl -s http://127.0.0.1:3015/mock/state | jq '.webhooks' + +# Delivery log per event (status of every webhook POST, including retries) +curl -s http://127.0.0.1:3015/mock/state | jq '.events[] | {event_id, svix_id, deliveries}' +``` + +To reset between scenarios, `POST /mock/state` with a fresh state object (it replaces inboxes, webhooks, messages, and events wholesale). + +## Scenario Selection + +- **`email-task-entry`** — new inbound email creates a task; assert an outbound reply lands in the same thread +- **`followup-to-active-thread`** — second email with the same `threadId` queues to the running job instead of launching a new task +- **`duplicate-delivery`** — `duplicate: true` → same svix-id twice → exactly-once handling +- **`oversize-payload`** — `oversize: true` → app must re-fetch the message body by id before acting +- **`auto-submitted-loop-guard`** — `autoSubmitted: true` → automated senders must not trigger reply loops +- **`webhook-registration`** — app boots, registers its webhook via `POST /v0/webhooks` (idempotent per `client_id`), and the secret round-trips into signature verification +- **`reply-idempotency`** — app retries a reply with the same `Idempotency-Key` → exactly one outbound message in `/mock/state` +- **`bounce-suppression`** — `kind: 'bounce'` (Permanent) / `kind: 'complaint'` → the recipient lands in `agentmail_suppressions` and outbound-initiated email to them is refused; `bounceType: 'Transient'` must NOT suppress + +## Guardrails + +- Do not create a second mock AgentMail server. Use the harness in `packages/communication/`. +- Do not use the real AgentMail service unless the user explicitly asks for that. +- Do not declare success because the harness started. Always inspect `/mock/state`. +- Do not mutate the committed example scenario — copy it to `/tmp/` first. +- Do not assume the example webhook target port is correct. The sandbox API runs on 3001. +- Do not hand-roll webhook signatures in test drivers — deliver through `/mock/events` so the svix-id bookkeeping (and duplicate semantics) stays correct. +- Do not claim duplicate handling is covered unless you observed the second delivery being dropped (exactly one task/reply) in `/mock/state` and the app's own state. + +## Output Standard + +End each use of this skill with: + +- the scenario used and the webhook target +- the inbound emails injected (if any), including flags (`duplicate`, `oversize`, `autoSubmitted`) +- the key outbound messages, webhook registrations, or delivery statuses observed in `/mock/state` +- a pass or fail judgment +- the next debugging lead if the behavior failed diff --git a/apps/api/package.json b/apps/api/package.json index 911435cf9..dd5e33c75 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -48,6 +48,7 @@ "jsdom": "26.1.0", "p-map": "^7.0.4", "snowflake-sdk": "^2.4.3", + "svix": "^1.99.1", "undici": "^7.29.0", "zod": "^3.25.76" }, diff --git a/apps/api/src/handlers/agentmail/__tests__/rui-answer.test.ts b/apps/api/src/handlers/agentmail/__tests__/rui-answer.test.ts new file mode 100644 index 000000000..a4332b70d --- /dev/null +++ b/apps/api/src/handlers/agentmail/__tests__/rui-answer.test.ts @@ -0,0 +1,175 @@ +import { randomUUID } from 'node:crypto'; + +import { + getPendingCommunicationRequestUserInput, + setPendingCommunicationRequestUserInput, +} from '@roomote/communication'; +import { db, taskFactory, taskRuns, userFactory } from '@roomote/db/server'; +import { + buildAgentMailRuiAnswerToken, + buildAgentMailRuiAnswerUrl, + verifyAgentMailRuiAnswerToken, +} from '@roomote/sdk/server'; +import { RunStatus, TaskPayloadKind } from '@roomote/types'; + +import { agentmail } from '../index.js'; + +async function createRun(): Promise { + const task = await taskFactory.create(); + const [run] = await db + .insert(taskRuns) + .values({ + taskId: task.id, + payloadKind: TaskPayloadKind.StandardTask, + status: RunStatus.Running, + payload: { repo: 'acme/repo', description: 'test' }, + }) + .returning({ id: taskRuns.id }); + if (!run) throw new Error('run insert failed'); + return run.id; +} + +describe('agentmail one-click request_user_input answers', () => { + it('round-trips and rejects tampered or expired tokens', () => { + const token = buildAgentMailRuiAnswerToken({ + conversationId: 'conv-1', + requestId: 'req-1', + questionId: 'q-1', + optionIndex: 0, + userId: 'user-1', + }); + expect(verifyAgentMailRuiAnswerToken(token)).toMatchObject({ + conversationId: 'conv-1', + requestId: 'req-1', + optionIndex: 0, + userId: 'user-1', + }); + + expect(verifyAgentMailRuiAnswerToken(`${token}x`)).toBeNull(); + + const expired = buildAgentMailRuiAnswerToken({ + conversationId: 'conv-1', + requestId: 'req-1', + questionId: 'q-1', + optionIndex: 0, + userId: 'user-1', + expiresAtMs: Date.now() - 1_000, + }); + expect(verifyAgentMailRuiAnswerToken(expired)).toBeNull(); + }); + + it('records the answer atomically and treats the second click as already answered', async () => { + const user = await userFactory.create(); + const runId = await createRun(); + const conversationId = randomUUID(); + const requestId = `req-${randomUUID()}`; + const questions = [ + { + id: 'approach', + header: 'Approach', + question: 'Which approach should I take?', + isOther: false, + isSecret: false, + options: [ + { label: 'Quick fix', description: 'Patch it' }, + { label: 'Full refactor', description: 'Do it properly' }, + ], + }, + ]; + + await setPendingCommunicationRequestUserInput('agentmail', conversationId, { + requestId, + runId, + taskId: 'task-unused', + questions, + currentQuestionIndex: 0, + }); + + const url = new URL( + buildAgentMailRuiAnswerUrl( + buildAgentMailRuiAnswerToken({ + conversationId, + requestId, + questionId: 'approach', + optionIndex: 1, + userId: user.id, + }), + ), + ); + + // The GET is read-only (mail link scanners follow every URL in an + // email): it renders a confirmation form and must not claim anything. + const preview = await agentmail.request(`/answer${url.search}`); + expect(preview.status).toBe(200); + expect(await preview.text()).toContain( + 'Confirm your answer: Full refactor', + ); + expect( + ( + await getPendingCommunicationRequestUserInput( + 'agentmail', + conversationId, + ) + )?.status, + ).toBe('pending'); + + const token = url.searchParams.get('token')!; + const submit = () => + agentmail.request('/answer', { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ token }).toString(), + }); + + const first = await submit(); + expect(first.status).toBe(200); + expect(await first.text()).toContain('Answer recorded: Full refactor'); + + const pending = await getPendingCommunicationRequestUserInput( + 'agentmail', + conversationId, + ); + expect(pending?.status).toBe('submitted'); + + const second = await submit(); + expect(await second.text()).toContain('Already answered'); + }); + + it('rejects a token for a superseded request', async () => { + const user = await userFactory.create(); + const runId = await createRun(); + const conversationId = randomUUID(); + + await setPendingCommunicationRequestUserInput('agentmail', conversationId, { + requestId: 'req-new', + runId, + taskId: 'task-unused', + questions: [ + { + id: 'q', + header: 'Continue', + question: 'Continue?', + isOther: false, + isSecret: false, + options: [{ label: 'Yes', description: 'Proceed' }], + }, + ], + currentQuestionIndex: 0, + }); + + const url = new URL( + buildAgentMailRuiAnswerUrl( + buildAgentMailRuiAnswerToken({ + conversationId, + requestId: 'req-old', + questionId: 'q', + optionIndex: 0, + userId: user.id, + }), + ), + ); + + const response = await agentmail.request(`/answer${url.search}`); + expect(await response.text()).toContain('no longer active'); + }); +}); diff --git a/apps/api/src/handlers/agentmail/__tests__/unsubscribe.test.ts b/apps/api/src/handlers/agentmail/__tests__/unsubscribe.test.ts new file mode 100644 index 000000000..7a2384a01 --- /dev/null +++ b/apps/api/src/handlers/agentmail/__tests__/unsubscribe.test.ts @@ -0,0 +1,105 @@ +import { randomUUID } from 'node:crypto'; + +import { agentmailSuppressions, db, eq } from '@roomote/db/server'; +import { + buildAgentMailEmailLinkToken, + buildAgentMailUnsubscribeToken, + buildAgentMailUnsubscribeUrl, + isAgentMailAddressSuppressed, + verifyAgentMailUnsubscribeToken, +} from '@roomote/sdk/server'; + +import { agentmail } from '../index.js'; + +function uniqueEmail(): string { + return `${randomUUID()}@example.test`; +} + +describe('agentmail unsubscribe tokens', () => { + it('round-trips and rejects tampered or expired tokens', () => { + const email = uniqueEmail(); + const token = buildAgentMailUnsubscribeToken(email); + expect(verifyAgentMailUnsubscribeToken(token)).toEqual({ + emailAddress: email, + }); + expect(verifyAgentMailUnsubscribeToken(`${token}x`)).toBeNull(); + expect( + verifyAgentMailUnsubscribeToken( + buildAgentMailUnsubscribeToken(email, Date.now() - 1_000), + ), + ).toBeNull(); + }); + + it('is domain-separated from email-link tokens', () => { + const email = uniqueEmail(); + expect( + verifyAgentMailUnsubscribeToken(buildAgentMailEmailLinkToken(email)), + ).toBeNull(); + }); +}); + +describe('agentmail unsubscribe endpoint', () => { + it('GET is read-only: renders the confirm form without suppressing', async () => { + const email = uniqueEmail(); + const url = new URL(buildAgentMailUnsubscribeUrl(email)); + + const response = await agentmail.request(`/unsubscribe${url.search}`); + expect(response.status).toBe(200); + const html = await response.text(); + expect(html).toContain('Unsubscribe'); + expect(html).toContain('method="post"'); + + expect(await isAgentMailAddressSuppressed(email)).toBe(false); + }); + + it('POST with a query token (RFC 8058 one-click) suppresses the address', async () => { + const email = uniqueEmail(); + const url = new URL(buildAgentMailUnsubscribeUrl(email)); + + const response = await agentmail.request(`/unsubscribe${url.search}`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: 'List-Unsubscribe=One-Click', + }); + expect(response.status).toBe(200); + + expect(await isAgentMailAddressSuppressed(email)).toBe(true); + const row = await db.query.agentmailSuppressions.findFirst({ + where: eq(agentmailSuppressions.emailAddress, email), + }); + expect(row?.reason).toBe('unsubscribe'); + }); + + it('POST with a form-body token suppresses the address and repeats are no-ops', async () => { + const email = uniqueEmail(); + const token = buildAgentMailUnsubscribeToken(email); + const body = new URLSearchParams({ token }).toString(); + + const first = await agentmail.request('/unsubscribe', { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body, + }); + expect(first.status).toBe(200); + const second = await agentmail.request('/unsubscribe', { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body, + }); + expect(second.status).toBe(200); + + expect(await isAgentMailAddressSuppressed(email)).toBe(true); + }); + + it('rejects missing or invalid tokens', async () => { + const missing = await agentmail.request('/unsubscribe', { + method: 'POST', + }); + expect(missing.status).toBe(400); + + const invalid = await agentmail.request('/unsubscribe?token=nope', { + method: 'POST', + }); + expect(invalid.status).toBe(400); + }); +}); diff --git a/apps/api/src/handlers/agentmail/__tests__/webhook-gate.test.ts b/apps/api/src/handlers/agentmail/__tests__/webhook-gate.test.ts new file mode 100644 index 000000000..43b48d439 --- /dev/null +++ b/apps/api/src/handlers/agentmail/__tests__/webhook-gate.test.ts @@ -0,0 +1,68 @@ +import { Webhook } from 'svix'; + +import { verifyAgentMailWebhook } from '../webhook-gate.js'; + +const SECRET = 'whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw'; + +describe('verifyAgentMailWebhook', () => { + beforeAll(() => { + process.env.R_AGENTMAIL_WEBHOOK_SECRET = SECRET; + process.env.R_AGENTMAIL_API_KEY = 'am_test_key'; + }); + + function sign(rawBody: string, msgId = 'msg_test_1') { + const timestamp = new Date(); + const signature = new Webhook(SECRET).sign(msgId, timestamp, rawBody); + return { + svixId: msgId, + svixTimestamp: String(Math.floor(timestamp.getTime() / 1000)), + svixSignature: signature, + }; + } + + it('accepts a correctly signed delivery', async () => { + const rawBody = JSON.stringify({ event_type: 'message.received' }); + const result = await verifyAgentMailWebhook({ + rawBody, + headers: sign(rawBody), + }); + expect(result).toEqual({ ok: true, deliveryId: 'msg_test_1' }); + }); + + it('rejects a tampered body', async () => { + const rawBody = JSON.stringify({ event_type: 'message.received' }); + const headers = sign(rawBody); + const result = await verifyAgentMailWebhook({ + rawBody: `${rawBody} `, + headers, + }); + expect(result).toMatchObject({ ok: false, status: 401 }); + }); + + it('rejects a stale timestamp outside the replay window', async () => { + const rawBody = JSON.stringify({ event_type: 'message.received' }); + const stale = new Date(Date.now() - 60 * 60 * 1000); + const signature = new Webhook(SECRET).sign('msg_old', stale, rawBody); + const result = await verifyAgentMailWebhook({ + rawBody, + headers: { + svixId: 'msg_old', + svixTimestamp: String(Math.floor(stale.getTime() / 1000)), + svixSignature: signature, + }, + }); + expect(result).toMatchObject({ ok: false, status: 401 }); + }); + + it('rejects missing signature headers', async () => { + const result = await verifyAgentMailWebhook({ + rawBody: '{}', + headers: { + svixId: undefined, + svixTimestamp: undefined, + svixSignature: undefined, + }, + }); + expect(result).toMatchObject({ ok: false, status: 401 }); + }); +}); diff --git a/apps/api/src/handlers/agentmail/index.ts b/apps/api/src/handlers/agentmail/index.ts new file mode 100644 index 000000000..4eb63f614 --- /dev/null +++ b/apps/api/src/handlers/agentmail/index.ts @@ -0,0 +1,301 @@ +import { Hono } from 'hono'; + +import { + escapeAgentMailHtml, + getDiscordRequestUserInputCurrentQuestion, + getPendingCommunicationRequestUserInput, + submitPendingCommunicationRequestUserInputAnswer, +} from '@roomote/communication'; +import { setTrustedRunActingUserOnSuccess } from '@roomote/db/server'; +import { + recordAgentMailWebhookEvent, + suppressAgentMailAddress, + verifyAgentMailRuiAnswerToken, + verifyAgentMailUnsubscribeToken, +} from '@roomote/sdk/server'; + +import { apiLogger } from '../../logging.js'; +import { verifyAgentMailWebhook } from './webhook-gate.js'; + +export const agentmail = new Hono(); + +function answerPage(title: string, detail: string): string { + return `${escapeAgentMailHtml(title)}

${escapeAgentMailHtml(title)}

${escapeAgentMailHtml(detail)}

`; +} + +/** + * One-click request_user_input answer links from question emails. The signed + * token was delivered to the responder's mailbox (magic-link trust), but + * corporate mail link scanners GET every URL in an email — so the GET is + * strictly read-only and renders a confirmation form; only the form's POST + * performs the claim, which still goes through the atomic pending → + * submitted transition so double submits resolve as already answered. + */ +type AnswerContext = { + payload: NonNullable>; + pendingRequest: NonNullable< + Awaited> + >; + questionId: string; + optionLabel: string; +}; + +async function resolveAnswerContext( + token: string | undefined, +): Promise< + | { ok: true; context: AnswerContext } + | { ok: false; page: string; status: 200 | 400 } +> { + const payload = token ? verifyAgentMailRuiAnswerToken(token) : null; + if (!payload) { + return { + ok: false, + status: 400, + page: answerPage( + 'This link is no longer valid', + 'The answer link is malformed or has expired. Reply to the question email instead.', + ), + }; + } + + const pendingRequest = await getPendingCommunicationRequestUserInput( + 'agentmail', + payload.conversationId, + ); + if (!pendingRequest || pendingRequest.requestId !== payload.requestId) { + return { + ok: false, + status: 200, + page: answerPage( + 'This question is no longer active', + 'The task has moved on. If it still needs input, it will email you again.', + ), + }; + } + if (pendingRequest.status === 'submitted') { + return { + ok: false, + status: 200, + page: answerPage( + 'Already answered', + 'An answer for this question was already recorded.', + ), + }; + } + + const current = getDiscordRequestUserInputCurrentQuestion(pendingRequest); + const option = + current && current.question.id === payload.questionId + ? current.question.options?.[payload.optionIndex] + : undefined; + if (!current || !option) { + return { + ok: false, + status: 200, + page: answerPage( + 'That option is no longer available', + 'The question has changed since this email was sent. Reply to the latest question email instead.', + ), + }; + } + + return { + ok: true, + context: { + payload, + pendingRequest, + questionId: current.question.id, + optionLabel: option.label, + }, + }; +} + +agentmail.get('/answer', async (c) => { + const token = c.req.query('token'); + const resolved = await resolveAnswerContext(token); + if (!resolved.ok) { + return c.html(resolved.page, resolved.status); + } + + // Read-only: link scanners and prefetchers follow this GET, so nothing + // may change state here. The human confirms with one tap. + const confirmForm = `
`; + return c.html( + answerPage( + `Confirm your answer: ${resolved.context.optionLabel}`, + 'Tap confirm to record this answer. If you did not mean to pick this option, close this tab and use a different button or reply to the email.', + ).replace('', `${confirmForm}`), + ); +}); + +agentmail.post('/answer', async (c) => { + const form = await c.req.parseBody(); + const token = typeof form.token === 'string' ? form.token : undefined; + const resolved = await resolveAnswerContext(token); + if (!resolved.ok) { + return c.html(resolved.page, resolved.status); + } + + const { payload, pendingRequest, questionId, optionLabel } = resolved.context; + const queued = await setTrustedRunActingUserOnSuccess({ + runId: pendingRequest.runId, + userId: payload.userId, + operation: async () => + submitPendingCommunicationRequestUserInputAnswer( + 'agentmail', + payload.conversationId, + pendingRequest, + { + answers: { + [questionId]: { answers: [optionLabel] }, + }, + userId: payload.userId, + timestamp: Date.now(), + }, + ), + }); + + if (!queued) { + return c.html( + answerPage( + 'Already answered', + 'An answer for this question was already recorded.', + ), + ); + } + + return c.html( + answerPage( + `Answer recorded: ${optionLabel}`, + 'Roomote will continue and reply in the email thread. You can close this tab.', + ), + ); +}); + +/** + * Unsubscribe from Roomote-initiated (transactional) email. Two entry + * points share the signed token: mail providers fire an RFC 8058 one-click + * POST straight at the List-Unsubscribe URL, and a human clicking the footer + * link lands on the GET, which — like the answer links — stays strictly + * read-only (mail scanners GET every URL) and confirms via form POST. + * Suppression only stops Roomote from *initiating* email; replying to the + * user's own emails is unaffected. + */ +agentmail.get('/unsubscribe', async (c) => { + const token = c.req.query('token'); + const payload = token ? verifyAgentMailUnsubscribeToken(token) : null; + if (!payload) { + return c.html( + answerPage( + 'This link is no longer valid', + 'The unsubscribe link is malformed or has expired.', + ), + 400, + ); + } + + const confirmForm = `
`; + return c.html( + answerPage( + 'Stop receiving emails from Roomote?', + `Confirm to stop Roomote from sending new emails to ${payload.emailAddress}. Replies to emails you send Roomote are not affected.`, + ).replace('', `${confirmForm}`), + ); +}); + +agentmail.post('/unsubscribe', async (c) => { + // One-click posts carry the token in the query string; the confirm form + // carries it in the body. Accept either. + let token = c.req.query('token'); + if (!token) { + const form = await c.req.parseBody().catch(() => ({}) as never); + token = typeof form.token === 'string' ? form.token : undefined; + } + + const payload = token ? verifyAgentMailUnsubscribeToken(token) : null; + if (!payload) { + return c.html( + answerPage( + 'This link is no longer valid', + 'The unsubscribe link is malformed or has expired.', + ), + 400, + ); + } + + await suppressAgentMailAddress({ + emailAddress: payload.emailAddress, + reason: 'unsubscribe', + }); + apiLogger.info(`[agentmail] Unsubscribed ${payload.emailAddress}`); + + return c.html( + answerPage( + 'Unsubscribed', + `Roomote will no longer send new emails to ${payload.emailAddress}. You can close this tab.`, + ), + ); +}); + +/** + * Inbound AgentMail webhook. The contract with the durable ingestion + * pipeline: verify the Svix signature over the raw body, record the delivery + * in the `agentmail_webhook_events` outbox, dispatch its processing job, and + * only then return 200. Processing (sender resolution, conversation routing, + * Fast admission) happens asynchronously from the BullMQ queue; a crash after + * this 200 can never lose an email because the outbox row is the commitment. + */ +agentmail.post('/', async (c) => { + const rawBody = await c.req.text(); + + const verification = await verifyAgentMailWebhook({ + rawBody, + headers: { + svixId: c.req.header('svix-id'), + svixTimestamp: c.req.header('svix-timestamp'), + svixSignature: c.req.header('svix-signature'), + }, + }); + + if (!verification.ok) { + return c.json( + { ok: false, error: verification.error }, + verification.status, + ); + } + + let payload: unknown; + try { + payload = JSON.parse(rawBody); + } catch { + return c.json({ ok: false, error: 'Invalid JSON body.' }, 400); + } + + const eventType = + payload && typeof payload === 'object' && 'event_type' in payload + ? String((payload as { event_type: unknown }).event_type) + : ''; + const eventId = + payload && typeof payload === 'object' && 'event_id' in payload + ? String((payload as { event_id: unknown }).event_id) + : null; + + const result = await recordAgentMailWebhookEvent({ + deliveryId: verification.deliveryId, + eventId, + eventType, + payload, + }); + + if (!result.accepted) { + return c.json({ ok: true, ignored: result.reason }); + } + + if (result.duplicate) { + apiLogger.debug( + `[agentmail] Duplicate delivery ${verification.deliveryId} acknowledged`, + ); + } + + return c.json({ ok: true, queued: true, duplicate: result.duplicate }); +}); diff --git a/apps/api/src/handlers/agentmail/webhook-gate.ts b/apps/api/src/handlers/agentmail/webhook-gate.ts new file mode 100644 index 000000000..874116e07 --- /dev/null +++ b/apps/api/src/handlers/agentmail/webhook-gate.ts @@ -0,0 +1,56 @@ +import { Webhook } from 'svix'; + +import { resolveAgentMailRuntimeCredentials } from '@roomote/db/server'; + +type AgentMailWebhookVerification = + | { ok: true; deliveryId: string } + | { ok: false; status: 401 | 503; error: string }; + +/** + * Verify an AgentMail webhook delivery. AgentMail delivers through Svix, so + * verification uses the svix library (signature, timestamp tolerance, and + * replay validation together) over the RAW body — never a hand-rolled HMAC. + */ +export async function verifyAgentMailWebhook(input: { + rawBody: string; + headers: { + svixId: string | undefined; + svixTimestamp: string | undefined; + svixSignature: string | undefined; + }; +}): Promise { + const { webhookSecret } = await resolveAgentMailRuntimeCredentials(); + + if (!webhookSecret) { + return { + ok: false, + status: 503, + error: 'AgentMail webhook secret is not configured.', + }; + } + + const { svixId, svixTimestamp, svixSignature } = input.headers; + if (!svixId || !svixTimestamp || !svixSignature) { + return { + ok: false, + status: 401, + error: 'Missing Svix signature headers.', + }; + } + + try { + new Webhook(webhookSecret).verify(input.rawBody, { + 'svix-id': svixId, + 'svix-timestamp': svixTimestamp, + 'svix-signature': svixSignature, + }); + } catch { + return { + ok: false, + status: 401, + error: 'Invalid webhook signature.', + }; + } + + return { ok: true, deliveryId: svixId }; +} diff --git a/apps/api/src/handlers/index.ts b/apps/api/src/handlers/index.ts index 35e5a71f7..57a748e22 100644 --- a/apps/api/src/handlers/index.ts +++ b/apps/api/src/handlers/index.ts @@ -13,6 +13,7 @@ export { slack } from './slack'; export { linear } from './linear'; export { teams } from './teams'; export { telegram } from './telegram'; +export { agentmail } from './agentmail'; export { discord } from './discord'; export { cloudDeploymentAccess } from './cloud-deployment-access'; diff --git a/apps/api/src/handlers/mcp/__tests__/communication-channel-discovery.test.ts b/apps/api/src/handlers/mcp/__tests__/communication-channel-discovery.test.ts index 6e2ce2078..41eca75e0 100644 --- a/apps/api/src/handlers/mcp/__tests__/communication-channel-discovery.test.ts +++ b/apps/api/src/handlers/mcp/__tests__/communication-channel-discovery.test.ts @@ -181,6 +181,15 @@ describe('listCommunicationChannels', () => { }, ], }, + { + provider: 'agentmail', + platform: 'Email', + connected: false, + discoverySupported: false, + channels: [], + limitation: + 'Email runs through a single Roomote inbox and conversations are inbound-initiated; there are no enumerable channels.', + }, ], }); }); diff --git a/apps/api/src/handlers/mcp/__tests__/communication-thread-replies.test.ts b/apps/api/src/handlers/mcp/__tests__/communication-thread-replies.test.ts index b79987608..30b21896d 100644 --- a/apps/api/src/handlers/mcp/__tests__/communication-thread-replies.test.ts +++ b/apps/api/src/handlers/mcp/__tests__/communication-thread-replies.test.ts @@ -2,6 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createTeamsCommunicationProviderFromRuntimeCredentials } from '@roomote/sdk/server'; const { + agentmailPostMessageMock, + resolveAgentMailAdapterMock, buildThreadReplyImagesMock, clearLatestUserMessageForReplyQuoteIfIdMock, discordAddReactionMock, @@ -21,6 +23,8 @@ const { upsertBackgroundAutomationSlackThreadMock, withThreadReplyFooterLockMock, } = vi.hoisted(() => ({ + agentmailPostMessageMock: vi.fn(), + resolveAgentMailAdapterMock: vi.fn(), buildThreadReplyImagesMock: vi.fn(), clearLatestUserMessageForReplyQuoteIfIdMock: vi.fn(), discordAddReactionMock: vi.fn(), @@ -97,6 +101,12 @@ vi.mock('@roomote/communication/thread-reply-footer-state', () => ({ })); vi.mock('@roomote/sdk/server', () => ({ + resolveAgentMailReplyRoute: vi.fn(async () => ({ + inboxId: 'inbox-1', + replyToMessageId: 'anchor-1', + recipientEmail: 'user@example.com', + subject: null, + })), createTeamsCommunicationProviderFromRuntimeCredentials: vi.fn(), createTelegramCommunicationProviderFromRuntimeCredentials: vi.fn(async () => { const { botToken } = await resolveTelegramRuntimeCredentialsMock(); @@ -118,6 +128,9 @@ vi.mock('@roomote/sdk/server', () => ({ } : null; }), + getCommunicationProviderAdapter: vi.fn(async () => + resolveAgentMailAdapterMock(), + ), })); vi.mock('../chat-reply-helpers.js', () => ({ @@ -177,6 +190,132 @@ const discordTaskRun = { }, }; +const agentmailTaskRun = { + id: 45, + taskId: 'task-4', + prRepo: null, + prNumber: null, + payload: { + communicationProvider: 'agentmail', + communicationChannelId: 'inbox-1', + communicationThreadId: 'conversation-1', + }, +}; + +describe('maybeSendCommunicationThreadReply (AgentMail)', () => { + beforeEach(() => { + vi.clearAllMocks(); + resolveAgentMailAdapterMock.mockReturnValue({ + postMessage: agentmailPostMessageMock, + }); + agentmailPostMessageMock.mockResolvedValue({ messageId: 'msg-1' }); + }); + + it('replies through the durable conversation route with a stable Idempotency-Key', async () => { + const response = await maybeSendCommunicationThreadReply({ + taskRun: agentmailTaskRun, + parsedBody: { text: 'done', images: [] }, + }); + + expect(response).not.toBeNull(); + expect(response?.status).toBe(200); + expect(agentmailPostMessageMock).toHaveBeenCalledTimes(1); + expect(agentmailPostMessageMock).toHaveBeenCalledWith({ + channelId: 'inbox-1', + threadId: 'conversation-1', + text: 'done', + textFormat: 'markdown', + idempotencyKey: expect.stringMatching( + /^agentmail:conversation-1:45-[0-9a-f]{16}:thread-reply$/, + ), + }); + // Email is not live: no typing heartbeat is triggered. + expect(sendChatActionMock).not.toHaveBeenCalled(); + await expect(response!.json()).resolves.toEqual({ messageTs: 'msg-1' }); + }); + + it('keys replies by run, text, and inbound anchor', async () => { + // A worker retry of the same tool call (same text, same inbound anchor) + // maps to the same key so a lost-response retry cannot double-send; a + // different reply text is a new logical send. + await maybeSendCommunicationThreadReply({ + taskRun: agentmailTaskRun, + parsedBody: { text: 'same reply', images: [] }, + }); + await maybeSendCommunicationThreadReply({ + taskRun: agentmailTaskRun, + parsedBody: { text: 'same reply', images: [] }, + }); + await maybeSendCommunicationThreadReply({ + taskRun: agentmailTaskRun, + parsedBody: { text: 'different reply', images: [] }, + }); + + const keys = agentmailPostMessageMock.mock.calls.map( + ([input]) => input.idempotencyKey, + ); + expect(keys).toHaveLength(3); + expect(keys[0]).toBe(keys[1]); + expect(keys[2]).not.toBe(keys[0]); + }); + + it('requires an email conversation context', async () => { + const response = await maybeSendCommunicationThreadReply({ + taskRun: { + ...agentmailTaskRun, + payload: { + communicationProvider: 'agentmail', + communicationChannelId: 'inbox-1', + }, + }, + parsedBody: { text: 'done', images: [] }, + }); + + expect(response?.status).toBe(403); + expect(agentmailPostMessageMock).not.toHaveBeenCalled(); + }); + + it('returns 503 when AgentMail credentials are not configured', async () => { + resolveAgentMailAdapterMock.mockReturnValue(null); + + const response = await maybeSendCommunicationThreadReply({ + taskRun: agentmailTaskRun, + parsedBody: { text: 'done', images: [] }, + }); + + expect(response?.status).toBe(503); + expect(agentmailPostMessageMock).not.toHaveBeenCalled(); + }); + + it('requires text and does not attempt image-only email replies', async () => { + const response = await maybeSendCommunicationThreadReply({ + taskRun: agentmailTaskRun, + parsedBody: { images: [{ artifactId: 'artifact-1' }] }, + }); + + expect(response?.status).toBe(400); + expect(agentmailPostMessageMock).not.toHaveBeenCalled(); + }); + + it('reports reactions as unsupported gracefully', async () => { + const response = await maybeAddCommunicationReaction({ + taskRun: agentmailTaskRun, + parsedBody: { + channel: 'inbox-1', + messageTs: 'msg-1', + name: '👀', + }, + }); + + expect(response).not.toBeNull(); + expect(response?.status).toBe(400); + await expect(response!.json()).resolves.toEqual({ + error: + 'AgentMail does not support reactions. Email has no reactions; send a reply instead.', + }); + }); +}); + describe('maybeSendCommunicationThreadReply (Discord)', () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/apps/api/src/handlers/mcp/communication-channel-discovery.ts b/apps/api/src/handlers/mcp/communication-channel-discovery.ts index 631efd404..b0c3de17e 100644 --- a/apps/api/src/handlers/mcp/communication-channel-discovery.ts +++ b/apps/api/src/handlers/mcp/communication-channel-discovery.ts @@ -212,6 +212,16 @@ export async function listCommunicationChannels(options: { limitation: 'Telegram Bot API does not provide a way to enumerate chats available to a bot.', }); + const agentmail = await getCommunicationProviderAdapter('agentmail'); + platformsByProvider.set('agentmail', { + provider: 'agentmail', + platform: getCommunicationProviderDisplayName('agentmail'), + connected: agentmail !== null, + discoverySupported: false, + channels: [], + limitation: + 'Email runs through a single Roomote inbox and conversations are inbound-initiated; there are no enumerable channels.', + }); const platforms = communicationProviders.map( (provider) => platformsByProvider.get(provider)!, ); diff --git a/apps/api/src/handlers/mcp/communication-channel-posts.ts b/apps/api/src/handlers/mcp/communication-channel-posts.ts index 017f5f611..5ebc067c6 100644 --- a/apps/api/src/handlers/mcp/communication-channel-posts.ts +++ b/apps/api/src/handlers/mcp/communication-channel-posts.ts @@ -66,6 +66,10 @@ const PROVIDER_UNAVAILABLE_ERRORS: Record< message: 'Discord bot token is not configured for outbound posts', status: 503, }, + agentmail: { + message: 'AgentMail credentials are not configured for outbound posts', + status: 503, + }, }; function isOriginChannel( @@ -329,6 +333,14 @@ async function resolveChannelPostTarget(params: { return resolveTelegramTarget(params); case 'discord': return resolveDiscordTarget({ ...params, provider: params.provider }); + case 'agentmail': + // Email is inbound-initiated in v1: replies stay inside the durable + // conversation (via the thread-reply path), and there is no channel + // surface to post into. + throw new McpProxyError( + 400, + 'Email is inbound-initiated; posting to arbitrary addresses is not supported.', + ); } } diff --git a/apps/api/src/handlers/mcp/communication-thread-replies.ts b/apps/api/src/handlers/mcp/communication-thread-replies.ts index 7981d9e97..803dc0114 100644 --- a/apps/api/src/handlers/mcp/communication-thread-replies.ts +++ b/apps/api/src/handlers/mcp/communication-thread-replies.ts @@ -30,6 +30,7 @@ import { createTelegramCommunicationProviderFromRuntimeCredentials as createTelegramCommunicationProvider, getCommunicationProviderAdapter, } from '@roomote/sdk/server'; +import { createHash } from 'node:crypto'; import { THREAD_REPLY_FOOTER_LOCK_TIMEOUT_MESSAGE } from './chat-reply-helpers'; import { @@ -556,6 +557,104 @@ async function sendTelegramThreadReply(params: { }); } +/** + * Email replies must be delivered at most once per logical reply. The worker + * mints a clientSendId per tool invocation and every HTTP retry of that call + * carries it, so the key is stable across the whole logical send without + * depending on mutable route state; a fresh tool call (including an agent's + * own deliberate retry) is a new send. The text digest is only the legacy + * fallback for callers that predate clientSendId. + */ +function buildAgentMailThreadReplyIdempotencyKey(params: { + conversationId: string; + runId: number; + text: string; + clientSendId?: string; +}): string { + const sendId = + params.clientSendId ?? + createHash('sha256').update(params.text).digest('hex').slice(0, 16); + return `agentmail:${params.conversationId}:${params.runId}-${sendId}:thread-reply`; +} + +async function sendAgentMailThreadReply(params: { + taskRun: CommunicationReplyTaskRun; + parsedBody: ParsedThreadReplyBody; +}): Promise { + const channelId = getCommunicationChannelFromTaskPayload( + params.taskRun.payload, + ); + // For email tasks the payload thread id is the INTERNAL AgentMail + // conversation id; the adapter resolves the actual reply anchor and + // recipient from the durable conversation row at send time. + const conversationId = getCommunicationThreadIdFromTaskPayload( + params.taskRun.payload, + ); + + if (!channelId || !conversationId) { + return new Response( + JSON.stringify({ + error: + 'Email thread reply is only available for jobs with an email conversation context', + }), + { status: 403 }, + ); + } + + const provider = await getCommunicationProviderAdapter('agentmail'); + if (!provider) { + return new Response( + JSON.stringify({ + error: 'AgentMail credentials are not configured for outbound replies', + }), + { status: 503 }, + ); + } + + const text = params.parsedBody.text?.trim(); + if (!text) { + return new Response( + JSON.stringify({ + error: + 'Email thread replies require text; image attachments are not supported over email yet', + }), + { status: 400 }, + ); + } + + // Email is not live: no typing heartbeat, no reactions, and no managed + // footer edits (a sent email cannot be updated). The reply text is already + // composed by the worker; delivery only needs the durable conversation + // route plus an Idempotency-Key so retries never double-send. + const reply = await provider.postMessage({ + channelId, + threadId: conversationId, + text, + textFormat: 'markdown', + idempotencyKey: buildAgentMailThreadReplyIdempotencyKey({ + conversationId, + runId: params.taskRun.id, + text, + ...(params.parsedBody.clientSendId + ? { clientSendId: params.parsedBody.clientSendId } + : {}), + }), + }); + + return new Response( + JSON.stringify({ + messageTs: reply.messageId, + ...(params.parsedBody.images.length > 0 + ? { + warning: + 'Email replies do not support image attachments yet; the reply was sent without them.', + } + : {}), + }), + { headers: { 'content-type': 'application/json' } }, + ); +} + async function sendDiscordThreadReply(params: { taskRun: CommunicationReplyTaskRun; parsedBody: ParsedThreadReplyBody; @@ -911,6 +1010,21 @@ async function addTeamsReaction(params: { } } +/** + * Email has no reactions. Report the limitation gracefully (mirroring the + * UnsupportedCommunicationOperationError shape other providers surface) + * instead of erroring at the provider. + */ +function addAgentMailReaction(): Response { + return new Response( + JSON.stringify({ + error: + 'AgentMail does not support reactions. Email has no reactions; send a reply instead.', + }), + { status: 400 }, + ); +} + async function addSlackReaction(params: { taskRun: { id: number; payload: unknown }; parsedBody: { channel: string; messageTs: string; name: string }; @@ -977,6 +1091,8 @@ export async function maybeSendCommunicationThreadReply(params: { return sendTelegramThreadReply(params); case 'discord': return sendDiscordThreadReply(params); + case 'agentmail': + return sendAgentMailThreadReply(params); default: return null; } @@ -995,6 +1111,8 @@ export async function maybeAddCommunicationReaction(params: { return addTelegramReaction(params); case 'discord': return addDiscordReaction(params); + case 'agentmail': + return addAgentMailReaction(); default: return null; } diff --git a/apps/api/src/handlers/mcp/communication-thread-reply-shared.ts b/apps/api/src/handlers/mcp/communication-thread-reply-shared.ts index fdd04bbe2..55f7e612b 100644 --- a/apps/api/src/handlers/mcp/communication-thread-reply-shared.ts +++ b/apps/api/src/handlers/mcp/communication-thread-reply-shared.ts @@ -26,6 +26,12 @@ export type CommunicationReplyTaskRun = { export type ParsedThreadReplyBody = { text?: string; images: Array<{ artifactId: string }>; + /** + * Caller-minted per-invocation send id: every HTTP retry of one tool call + * carries the same value, so providers with idempotent sends (email) can + * dedupe the logical send without depending on mutable route state. + */ + clientSendId?: string; }; type CommunicationThreadReplyProvider = 'discord' | 'telegram' | 'teams'; diff --git a/apps/api/src/handlers/mcp/slack.ts b/apps/api/src/handlers/mcp/slack.ts index cf0538d62..747a61330 100644 --- a/apps/api/src/handlers/mcp/slack.ts +++ b/apps/api/src/handlers/mcp/slack.ts @@ -504,6 +504,7 @@ function parseRequestBody(body: unknown): { text?: string; blocks?: unknown[]; images: Array<{ artifactId: string }>; + clientSendId?: string; } { if (!body || typeof body !== 'object' || Array.isArray(body)) { throw new Error('Invalid request body'); @@ -514,6 +515,11 @@ function parseRequestBody(body: unknown): { typeof record.text === 'string' && record.text.trim().length > 0 ? record.text.trim() : undefined; + const clientSendId = + typeof record.clientSendId === 'string' && + /^[A-Za-z0-9_-]{8,64}$/.test(record.clientSendId) + ? record.clientSendId + : undefined; const blocks = record.blocks === undefined ? undefined @@ -551,7 +557,7 @@ function parseRequestBody(body: unknown): { throw new Error('At least one of text, blocks, or images is required'); } - return { text, blocks, images }; + return { text, blocks, images, clientSendId }; } /** diff --git a/apps/api/src/route-policies.ts b/apps/api/src/route-policies.ts index 15a3059cc..6ad579254 100644 --- a/apps/api/src/route-policies.ts +++ b/apps/api/src/route-policies.ts @@ -224,6 +224,12 @@ export const ROUTE_POLICY_RULES: readonly RoutePolicyRule[] = [ policy: 'webhook', rateLimits: WEBHOOK_RATE_LIMITS, }, + { + name: 'webhook-agentmail', + match: { type: 'prefix', path: '/api/webhooks/agentmail' }, + policy: 'webhook', + rateLimits: WEBHOOK_RATE_LIMITS, + }, { // The BullMQ worker authenticates this route with the Discord gateway // secret. It has no client IP, so applying webhook limits would make every diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index cde48eee4..30e9f5945 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -45,6 +45,7 @@ import { linear, teams, telegram, + agentmail, discord, cloudDeploymentAccess, brainInference, @@ -77,6 +78,12 @@ const PUBLIC_OIDC_PATHS = new Set([ const SELF_AUTHENTICATING_WEBHOOK_PATHS = new Set([ '/api/webhooks/teams', '/api/webhooks/telegram', + '/api/webhooks/agentmail', + // Signed one-click answer links from question emails authenticate via + // their own token; see handlers/agentmail. + '/api/webhooks/agentmail/answer', + // Signed List-Unsubscribe links/one-click posts, same token trust model. + '/api/webhooks/agentmail/unsubscribe', '/api/internal/discord/events', '/api/internal/discord/events/process', '/api/internal/cloud/deployment-access', @@ -211,6 +218,7 @@ export function createApiApp(): ApiApp { app.route('/api/webhooks/linear', linear); app.route('/api/webhooks/teams', teams); app.route('/api/webhooks/telegram', telegram); + app.route('/api/webhooks/agentmail', agentmail); app.route('/api/internal/discord', discord); app.route('/api/internal/cloud', cloudDeploymentAccess); app.route('/api/inference', inference); diff --git a/apps/bullmq/src/agentmail-webhook-events-queue.ts b/apps/bullmq/src/agentmail-webhook-events-queue.ts new file mode 100644 index 000000000..b1b5d5a49 --- /dev/null +++ b/apps/bullmq/src/agentmail-webhook-events-queue.ts @@ -0,0 +1,95 @@ +import { DelayedError, Queue, QueueEvents, Worker, type Job } from 'bullmq'; + +import { + AGENTMAIL_WEBHOOK_EVENT_QUEUE_NAME, + AgentMailConversationBusyError, + drainAgentMailInboundTurns, + processAgentMailWebhookEvent, + recoverPendingAgentMailWork, + type AgentMailWebhookEventJob, +} from '@roomote/sdk/server'; + +import { getRedis } from './redis'; + +const RECOVERY_JOB_NAME = 'recover-pending'; +const RECOVERY_SCHEDULER_ID = 'agentmail-webhook-event-recovery'; +const RECOVERY_INTERVAL_MS = 60_000; +const BUSY_CONVERSATION_RETRY_DELAY_MS = 1_000; + +type AgentMailQueueJob = AgentMailWebhookEventJob | { recovery: true }; + +async function processJob(job: Job) { + if (job.name === RECOVERY_JOB_NAME || 'recovery' in job.data) { + await recoverPendingAgentMailWork(); + return; + } + + if (job.data.kind === 'process') { + await processAgentMailWebhookEvent(job.data.deliveryId); + return; + } + + try { + await drainAgentMailInboundTurns(job.data.conversationId); + } catch (error) { + if (!(error instanceof AgentMailConversationBusyError) || !job.token) { + throw error; + } + await job.moveToDelayed( + Date.now() + BUSY_CONVERSATION_RETRY_DELAY_MS, + job.token, + ); + throw new DelayedError(); + } +} + +export async function startAgentMailWebhookEventsQueue() { + const connection = getRedis(); + const queue = new Queue( + AGENTMAIL_WEBHOOK_EVENT_QUEUE_NAME, + { + connection, + defaultJobOptions: { + attempts: 5, + backoff: { type: 'exponential', delay: 2_000 }, + removeOnComplete: true, + removeOnFail: true, + }, + }, + ); + + await queue.upsertJobScheduler( + RECOVERY_SCHEDULER_ID, + { every: RECOVERY_INTERVAL_MS }, + { name: RECOVERY_JOB_NAME, data: { recovery: true } }, + ); + await recoverPendingAgentMailWork(); + + const worker = new Worker( + AGENTMAIL_WEBHOOK_EVENT_QUEUE_NAME, + processJob, + // Busy conversations immediately move back to delayed; no email thread + // can park the worker pool while it owns an active Fast turn. + { connection, concurrency: 10, autorun: true }, + ); + + worker.on('failed', (job, error) => + console.error( + `[AgentMailWebhookEventsQueue] job ${job?.id} failed: ${error.message}`, + ), + ); + worker.on('error', (error) => + console.error('[AgentMailWebhookEventsQueue] worker error:', error), + ); + + const queueEvents = new QueueEvents(AGENTMAIL_WEBHOOK_EVENT_QUEUE_NAME, { + connection, + }); + queueEvents.on('failed', ({ jobId, failedReason }) => + console.error( + `[AgentMailWebhookEventsQueue] job ${jobId} failed: ${failedReason}`, + ), + ); + + return { queue, worker, queueEvents }; +} diff --git a/apps/bullmq/src/index.ts b/apps/bullmq/src/index.ts index dd8fd03bc..26f60eed2 100644 --- a/apps/bullmq/src/index.ts +++ b/apps/bullmq/src/index.ts @@ -51,6 +51,7 @@ import { startPullRequestMergeabilityCheckQueue } from './pull-request-mergeabil import { startTaskSleepQueue } from './task-sleep-queue'; import { startAutomationRecommendationsQueue } from './automation-recommendations-queue'; import { startFastAgentParentEventQueue } from './fast-agent-parent-event-queue'; +import { startAgentMailWebhookEventsQueue } from './agentmail-webhook-events-queue'; // Resolve auto-generated auth keypairs before any queue worker starts so // scheduled jobs that sign tokens observe the resolved keys. @@ -194,6 +195,12 @@ const { queueEvents: fastAgentParentEventQueueEvents, } = await startFastAgentParentEventQueue(); +const { + queue: agentMailWebhookEventsQueue, + worker: agentMailWebhookEventsWorker, + queueEvents: agentMailWebhookEventsQueueEvents, +} = await startAgentMailWebhookEventsQueue(); + const serverAdapter = new HonoAdapter(serveStatic); createBullBoard({ @@ -233,6 +240,7 @@ createBullBoard({ readOnlyMode: false, }), new BullMQAdapter(fastAgentParentEventQueue, { readOnlyMode: false }), + new BullMQAdapter(agentMailWebhookEventsQueue, { readOnlyMode: false }), ], serverAdapter, }); @@ -409,6 +417,9 @@ async function gracefulShutdown() { await fastAgentParentEventWorker.close(); await fastAgentParentEventQueueEvents.close(); await fastAgentParentEventQueue.close(); + await agentMailWebhookEventsWorker.close(); + await agentMailWebhookEventsQueueEvents.close(); + await agentMailWebhookEventsQueue.close(); await discordGatewaySupervisor.stop(); await closeRedis(); } catch (error) { diff --git a/apps/docs/communications.mdx b/apps/docs/communications.mdx index 8c1e481a2..0850fe89c 100644 --- a/apps/docs/communications.mdx +++ b/apps/docs/communications.mdx @@ -1,7 +1,7 @@ --- title: Communications Overview icon: messages-square -description: Connect Slack, Microsoft Teams, Telegram, or Discord so Roomote can start, continue, and summarize work from chat. +description: Connect Slack, Microsoft Teams, Telegram, Discord, or email so Roomote can start, continue, and summarize work from chat. --- import { IntegrationName } from '/snippets/integration-name.jsx'; @@ -23,6 +23,7 @@ attention. | | Teams that use Microsoft 365, Teams channels, and Microsoft Entra accounts | Microsoft can also be used for sign-in and account linking. | | | Lightweight personal, group, or forum-topic chat access | Telegram is a messaging provider, not a sign-in provider. | | | Communities and teams that organize work in channels, threads, and forums | Discord is a messaging provider, not a sign-in provider. | +| | Low-frequency task requests and results over email | Email conversations are replyable in both directions; not a sign-in provider. | Configure communications providers from **Settings > Communications**. @@ -77,6 +78,7 @@ silent; it does not react to the reaction event itself. | Discord | Supported | Discord Gateway sends standard and server custom reaction-add events, but the event does not include the reacted-to message text. | | Microsoft Teams | Supported | Bot Framework sends native `messageReaction` activities only for messages posted by Roomote. Reaction removals are ignored. | | Telegram | Supported | Roomote uses user-attributed `message_reaction` updates. Anonymous aggregate `message_reaction_count` updates are not supported because they do not identify the reacting user. | +| Email (AgentMail) | Not supported | Email has no reaction mechanism; reply in the thread instead. | These reaction paths apply only to Roomote replies that were recorded for the current Fast conversation. Reactions on older or unrelated messages do not gain @@ -108,6 +110,10 @@ Telegram and Discord use one-time link codes instead of acting as sign-in providers. Generate a code under **Settings > Personal > Linked Accounts** and send the corresponding link command to the bot. +Email needs no separate linking step: Roomote recognizes senders by the +verified email addresses on their Roomote accounts. See +[Email (AgentMail)](/providers/communications/agentmail). + ## Related setup Communications providers are only one part of a working Roomote deployment. diff --git a/apps/docs/docs.json b/apps/docs/docs.json index fe4ebda6b..593d7a890 100644 --- a/apps/docs/docs.json +++ b/apps/docs/docs.json @@ -108,6 +108,7 @@ "expanded": false, "pages": [ "communications", + "providers/communications/agentmail", "providers/communications/discord", "providers/communications/microsoft-teams", "providers/communications/slack", diff --git a/apps/docs/environment-variables.mdx b/apps/docs/environment-variables.mdx index d77a14dab..e48a93324 100644 --- a/apps/docs/environment-variables.mdx +++ b/apps/docs/environment-variables.mdx @@ -357,6 +357,11 @@ as per-task auth tokens or workspace paths. | `R_DISCORD_BOT_TOKEN` | Discord | Discord bot token. The bot and application identity are read from this token. | | `R_DISCORD_GATEWAY_SECRET` | Discord | Shared internal secret for Discord event delivery between BullMQ and API. Auto-generated when Discord is saved in the UI if unset, and auto-healed when Discord is already configured without one. | | `DISCORD_API_BASE_URL` | Optional | Discord REST API base URL override, primarily for testing. | +| `R_EMAIL_CHANNEL_ENABLED` | Optional | Set to `true` to enable the email (AgentMail) channel. Without it, email is absent from settings, inbound webhooks are ignored, and Roomote never sends email. Enabling it also turns on account email verification. | +| `R_AGENTMAIL_API_KEY` | Optional | AgentMail API key for email. Overrides the value saved in the settings UI. | +| `R_AGENTMAIL_WEBHOOK_SECRET` | Optional | AgentMail webhook secret. Overrides the value managed by the settings UI. | +| `R_AGENTMAIL_INBOX_ID` | Optional | AgentMail deployment inbox. Overrides the inbox connected in the settings UI. | +| `AGENTMAIL_API_BASE_URL` | Optional | AgentMail API base URL override, primarily for testing. Defaults to `https://api.agentmail.to`. | | `R_MICROSOFT_CLIENT_ID` | Microsoft sign-in | Microsoft OAuth client ID. | | `R_MICROSOFT_CLIENT_SECRET` | Microsoft sign-in | Microsoft OAuth client secret. | | `R_MICROSOFT_TENANT_ID` | Microsoft sign-in | Microsoft tenant ID. Also accepted as the Azure DevOps tenant ID fallback. | diff --git a/apps/docs/how-roomote-works.mdx b/apps/docs/how-roomote-works.mdx index 3348ea1ae..078eaf8c3 100644 --- a/apps/docs/how-roomote-works.mdx +++ b/apps/docs/how-roomote-works.mdx @@ -46,6 +46,7 @@ manage these in the web app under **Automations** — see | Teams | Mention the bot in a channel or group chat, or message it directly in a personal chat | Teams thread and task view | | Telegram | Mention the bot in a group chat, or message it directly in a private chat | Telegram chat and task view | | Discord | Mention the bot in a server or send it a direct message | Discord thread, forum post, or DM | +| Email | Email the deployment's AgentMail inbox from a verified account address | Replies in the same email thread and task view | | Web dashboard | Submit a prompt from **Home** | Task view in Roomote | | Linear | Start an agent session or mention Roomote on an issue | Linear activity and task view | | GitHub | Pull request events or `@`-mentions in PR and issue comments | GitHub comments, reviews, and task view | diff --git a/apps/docs/index.mdx b/apps/docs/index.mdx index 4b7cdd590..2689930d3 100644 --- a/apps/docs/index.mdx +++ b/apps/docs/index.mdx @@ -7,7 +7,8 @@ description: Roomote is an open, self-hostable platform for cloud coding agents. Roomote is an open, self-hostable platform for cloud coding agents. It runs Roomote agents in isolated sandboxes, connects them to your repositories, and lets your team start, follow, and review agent work from the web dashboard, -Slack, Microsoft Teams, Telegram, Discord, and your source-control provider. +Slack, Microsoft Teams, Telegram, Discord, email, and your source-control +provider. Use Roomote when agent work should be shared, reviewable, and available outside one developer's editor. You keep control of the deployment, @@ -27,7 +28,8 @@ repositories, inference provider, sandbox provider, and collaboration surfaces. and other inference providers. - **Source control integrations** — GitHub, GitLab, Gitea, Bitbucket Cloud, and Azure DevOps. -- **Conversation surfaces** — Slack, Microsoft Teams, Telegram, and Discord. +- **Conversation surfaces** — Slack, Microsoft Teams, Telegram, Discord, and + email through AgentMail. - **Choose your own sandbox provider** — including Docker-in-Docker on your own host. diff --git a/apps/docs/logo/integrations/agentmail.svg b/apps/docs/logo/integrations/agentmail.svg new file mode 100644 index 000000000..5a462aa4a --- /dev/null +++ b/apps/docs/logo/integrations/agentmail.svg @@ -0,0 +1,14 @@ + + + + diff --git a/apps/docs/providers/communications/agentmail.mdx b/apps/docs/providers/communications/agentmail.mdx new file mode 100644 index 000000000..af87a3e11 --- /dev/null +++ b/apps/docs/providers/communications/agentmail.mdx @@ -0,0 +1,139 @@ +--- +title: Email (AgentMail) +icon: '/logo/integrations/agentmail.svg' +description: Connect an AgentMail inbox so Roomote can receive tasks and reply over email. +--- + +Email is a deployment-owned communications provider backed by +[AgentMail](https://agentmail.to), an email API. It is not a Roomote sign-in +provider. + +Roomote can receive email sent to a dedicated deployment inbox, start tasks +from those messages, and reply in the same email thread. AgentMail delivers +inbound mail through a `message.received` webhook, so Roomote must be reachable +at a stable public HTTPS URL. Each deployment brings its own AgentMail account +and API key. + +## Enable the email channel + +Email is off by default. Set `R_EMAIL_CHANNEL_ENABLED=true` in the +deployment's environment and restart. Until then the provider does not +appear in settings, inbound webhook deliveries are acknowledged and dropped, +and Roomote never sends email. On Roomote Cloud this is enabled per +deployment by the Roomote team. + +Enabling the channel gives the deployment an email sender for the first +time, so it also turns on account email verification (see +[Account email verification](#account-email-verification) below). + +## Connect an AgentMail inbox + +Create an AgentMail account and API key at +[console.agentmail.to/dashboard/api-keys](https://console.agentmail.to/dashboard/api-keys). +The API key must carry these AgentMail permissions (or be a full-access +key): `inbox_read`, `inbox_create`, `inbox_update`, `webhook_read`, +`webhook_create`, `webhook_update`, `webhook_delete`, `message_read`, and +`message_send`. Missing permissions fail at save time with an error naming +the refused step, except `message_send`, which has no side-effect-free +check and is exercised on the first reply. + +In the Roomote UI (**Settings > Communications > Email (AgentMail)**), +paste the API key, then save. Roomote proposes a deployment inbox address such as +`roomote-yourhost-a1b2c3@agentmail.to`; edit the address before creation, or +supply the address of an existing AgentMail inbox instead. On save, Roomote +creates the inbox and registers a webhook for `message.received`, +`message.bounced`, and `message.complained` events automatically. The +connected inbox address and the registered webhook URL are shown in +settings. + +For self-hosted env-var configuration instead of the UI, all values are +optional overrides of the settings UI: + +```sh +# Optional — configure email entirely from settings when unset: +# R_AGENTMAIL_API_KEY= +# R_AGENTMAIL_WEBHOOK_SECRET= +# R_AGENTMAIL_INBOX_ID= +``` + +AgentMail's free tier allows 3 inboxes and 100 emails per day and adds a +"Sent via AgentMail" footer to outbound mail. Custom domains, with +AgentMail-managed SPF/DKIM/DMARC, require a paid AgentMail plan. For +production use, a paid plan with a custom domain is recommended. + +## Email Roomote + +Send an email to the deployment inbox from an email address on your Roomote +account. The address must be verified. Roomote replies in the same email +thread, and follow-up emails in that thread continue the same conversation; a +running delegated task receives them as follow-ups. Start a new request with a +new email so it opens a new thread. + +Only verified account email addresses are recognized automatically. Mail from +an unknown address receives one polite refusal per thread and is otherwise +ignored — the refusal includes a link to connect the address to your +Roomote account, and once linked, the emails you already sent are +processed automatically. Forwarding a Roomote email thread to someone +else does not give them access to the original conversation or task; +their message starts a separate conversation of their own. + +Email is deliberately low-frequency: expect roughly two emails per task, an +acknowledgment and the result, plus questions when the agent genuinely needs +input. Roomote does not send play-by-play progress messages over email. + +When the agent asks a structured question, the question email includes a +button for each option. Clicking a button opens a one-tap confirmation +page; confirming records your answer and the agent continues. (The extra +tap exists so corporate mail link scanners cannot answer on your behalf.) +You can always answer by replying to the email instead. Multi-question +prompts are answered by reply, one answer per line. + +## Roomote-initiated email + +Roomote can also initiate email — for example, notifying you that a GitHub +App installation you requested was approved when you have no chat +integration linked. Strict consent rules are enforced in code: + +- Roomote only initiates email to your own verified account address, or to + an address you explicitly linked to your account through the email-link + flow. It never emails an address just because someone typed it in. +- Every Roomote-initiated email carries a one-click unsubscribe link + (RFC 8058 `List-Unsubscribe`). Unsubscribing stops Roomote from initiating + new email to that address; replies to email you send Roomote are + unaffected. +- Addresses that bounce permanently or file a spam complaint are suppressed + automatically and never emailed again. +- If you also have a chat integration linked (Slack, Teams, Telegram, or + Discord), broadcast-style notices go there instead of email — email is the + fallback reach, not another copy. + +Replying to a Roomote-initiated email works like any other thread: the reply +routes back into the same conversation. + +## Account email verification + +Roomote only initiates email to an address it has verified. With the email +channel enabled, that guarantee starts at the account: + +- New email-and-password sign-ups receive a verification email and must + confirm the address before signing in. +- Existing accounts that never verified (sign-ups from before the channel + was enabled) receive a verification email the next time they sign in. +- Sign-ins through Slack, Microsoft, GitHub, and the other OAuth providers + are unaffected; those providers assert a verified email. +- Password reset links are emailed to the user as well as shown to the + admin who requested them. + +Verification and password-reset emails carry no unsubscribe link (the user +asked for them) and are sent even to an address that unsubscribed from +Roomote notifications. Addresses that bounced or filed a spam complaint are +never emailed. + +## Limitations + +In this first version: + +- automation reports and channel-style posting are not delivered over email +- reactions are not supported +- attachments from inbound email are not yet processed +- each deployment has one inbox diff --git a/apps/web/src/app/(authenticated)/link-email/LinkEmail.client.test.tsx b/apps/web/src/app/(authenticated)/link-email/LinkEmail.client.test.tsx new file mode 100644 index 000000000..3c004e240 --- /dev/null +++ b/apps/web/src/app/(authenticated)/link-email/LinkEmail.client.test.tsx @@ -0,0 +1,116 @@ +import { fireEvent, render, screen } from '@testing-library/react'; + +const { previewMock, linkMock, mutateMock, searchParamsMock } = vi.hoisted( + () => ({ + previewMock: vi.fn(), + linkMock: vi.fn(), + mutateMock: vi.fn(), + searchParamsMock: vi.fn(), + }), +); + +vi.mock('next/navigation', () => ({ + useSearchParams: searchParamsMock, +})); + +vi.mock('@/hooks/linked-accounts', () => ({ + useEmailLinkPreview: previewMock, + useLinkEmailAddress: linkMock, +})); + +import { LinkEmail } from './LinkEmail'; + +describe('LinkEmail', () => { + beforeEach(() => { + vi.clearAllMocks(); + searchParamsMock.mockReturnValue(new URLSearchParams('token=link-token')); + previewMock.mockReturnValue({ + isPending: false, + isError: false, + data: { emailAddress: 'sender@example.com' }, + }); + linkMock.mockReturnValue({ + isPending: false, + isError: false, + isSuccess: false, + mutate: mutateMock, + }); + }); + + it('asks for confirmation and links with the token from the URL', () => { + render(); + + expect(screen.getByText(/to your Roomote account\?/)).toBeInTheDocument(); + expect(screen.getByText('sender@example.com')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Link email address' })); + + expect(mutateMock).toHaveBeenCalledWith({ token: 'link-token' }); + }); + + it('shows the invalid-link copy when the token is missing', () => { + searchParamsMock.mockReturnValue(new URLSearchParams()); + + render(); + + expect( + screen.getByText( + 'This link is invalid or has expired. Send another email to get a fresh link.', + ), + ).toBeInTheDocument(); + }); + + it('surfaces the preview error for an invalid token', () => { + previewMock.mockReturnValue({ + isPending: false, + isError: true, + error: new Error( + 'This link is invalid or has expired. Send another email to get a fresh link.', + ), + }); + + render(); + + expect( + screen.getByText( + 'This link is invalid or has expired. Send another email to get a fresh link.', + ), + ).toBeInTheDocument(); + }); + + it('reports how many earlier emails were redispatched after linking', () => { + linkMock.mockReturnValue({ + isPending: false, + isError: false, + isSuccess: true, + mutate: mutateMock, + data: { emailAddress: 'sender@example.com', redispatchedCount: 2 }, + }); + + render(); + + expect( + screen.getByText( + 'sender@example.com is linked. 2 earlier emails are being processed now — replies will arrive in your inbox.', + ), + ).toBeInTheDocument(); + }); + + it('uses the plain success copy when nothing was redispatched', () => { + linkMock.mockReturnValue({ + isPending: false, + isError: false, + isSuccess: true, + mutate: mutateMock, + data: { emailAddress: 'sender@example.com', redispatchedCount: 0 }, + }); + + render(); + + expect( + screen.getByText( + 'Linked. Emails from this address will now reach Roomote.', + ), + ).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/app/(authenticated)/link-email/LinkEmail.tsx b/apps/web/src/app/(authenticated)/link-email/LinkEmail.tsx new file mode 100644 index 000000000..c909a65f1 --- /dev/null +++ b/apps/web/src/app/(authenticated)/link-email/LinkEmail.tsx @@ -0,0 +1,133 @@ +'use client'; + +import { useSearchParams } from 'next/navigation'; + +import { + Button, + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle, + Check, + Mail, + Skeleton, +} from '@/components/system'; +import { + useEmailLinkPreview, + useLinkEmailAddress, +} from '@/hooks/linked-accounts'; + +const INVALID_LINK_MESSAGE = + 'This link is invalid or has expired. Send another email to get a fresh link.'; + +function LinkEmailShell({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function LinkEmailError({ message }: { message: string }) { + return ( + + + + + Link email address + + + +

{message}

+
+
+ ); +} + +export function LinkEmail() { + const searchParams = useSearchParams(); + const token = searchParams.get('token'); + + const preview = useEmailLinkPreview(token); + const linkEmailAddress = useLinkEmailAddress(); + + if (!token) { + return ; + } + + if (preview.isError) { + return ; + } + + if (preview.isPending) { + return ( + + + + + + + + + + + + + + ); + } + + if (linkEmailAddress.isSuccess) { + const { emailAddress, redispatchedCount } = linkEmailAddress.data; + + return ( + + + + + Email address linked + + + +

+ {redispatchedCount > 0 + ? `${emailAddress} is linked. ${redispatchedCount} earlier ${ + redispatchedCount === 1 ? 'email is' : 'emails are' + } being processed now — replies will arrive in your inbox.` + : 'Linked. Emails from this address will now reach Roomote.'} +

+
+
+ ); + } + + return ( + + + + + Link email address + + + +

+ Link {preview.data.emailAddress}{' '} + to your Roomote account? Emails you send from this address will start + tasks attributed to you. +

+ {linkEmailAddress.isError && ( +

{linkEmailAddress.error.message}

+ )} +
+ + + +
+ ); +} diff --git a/apps/web/src/app/(authenticated)/link-email/page.tsx b/apps/web/src/app/(authenticated)/link-email/page.tsx new file mode 100644 index 000000000..532d50df2 --- /dev/null +++ b/apps/web/src/app/(authenticated)/link-email/page.tsx @@ -0,0 +1,7 @@ +'use client'; + +import { LinkEmail } from './LinkEmail'; + +export default function Page() { + return ; +} diff --git a/apps/web/src/app/(onboarding)/setup/ProviderSetupExperience.tsx b/apps/web/src/app/(onboarding)/setup/ProviderSetupExperience.tsx index 5a7f53a1e..0d96691f4 100644 --- a/apps/web/src/app/(onboarding)/setup/ProviderSetupExperience.tsx +++ b/apps/web/src/app/(onboarding)/setup/ProviderSetupExperience.tsx @@ -29,7 +29,11 @@ import { getProviderSetupCopy } from './providerSetupCopy'; type ProviderSetupExperienceProvider = | SetupAuthStatus['providers'][number] | { - id: SetupAuthStatus['providers'][number]['id'] | 'telegram' | 'discord'; + id: + | SetupAuthStatus['providers'][number]['id'] + | 'telegram' + | 'discord' + | 'agentmail'; label: string; fields: SetupAuthStatus['providers'][number]['fields']; runtimeSatisfied: boolean; @@ -829,16 +833,18 @@ function GenericSetupExperience(props: ProviderSetupExperienceProps) {

-

- If you need our logo,{' '} - - download here - - . -

+ {props.provider.id !== 'agentmail' && ( +

+ If you need our logo,{' '} + + download here + + . +

+ )} diff --git a/apps/web/src/app/(onboarding)/setup/ProviderSetupInstructions.tsx b/apps/web/src/app/(onboarding)/setup/ProviderSetupInstructions.tsx index 16a13404f..5123b74d1 100644 --- a/apps/web/src/app/(onboarding)/setup/ProviderSetupInstructions.tsx +++ b/apps/web/src/app/(onboarding)/setup/ProviderSetupInstructions.tsx @@ -13,7 +13,8 @@ import { cn } from '@/lib/utils'; type ProviderSetupInstructionsProviderId = | SetupAuthProviderId | 'telegram' - | 'discord'; + | 'discord' + | 'agentmail'; function InstructionText({ heading, @@ -181,5 +182,25 @@ export function ProviderSetupInstructions({ ); } + if (providerId === 'agentmail') { + return ( +
+ + In the AgentMail console, create an API key and paste it below. + + + Leave the address blank and Roomote provisions an inbox for this + deployment automatically, or enter an existing AgentMail inbox address + to use it instead. + + + Roomote registers the AgentMail webhook for incoming mail + automatically when you save — there is nothing to configure at + AgentMail. + +
+ ); + } + return null; } diff --git a/apps/web/src/app/(onboarding)/setup/providerSetupCopy.ts b/apps/web/src/app/(onboarding)/setup/providerSetupCopy.ts index 3bd059c83..cf2c1e909 100644 --- a/apps/web/src/app/(onboarding)/setup/providerSetupCopy.ts +++ b/apps/web/src/app/(onboarding)/setup/providerSetupCopy.ts @@ -1,6 +1,10 @@ import type { SetupAuthProviderId } from '@roomote/types'; -type ProviderSetupCopyId = SetupAuthProviderId | 'telegram' | 'discord'; +type ProviderSetupCopyId = + | SetupAuthProviderId + | 'telegram' + | 'discord' + | 'agentmail'; type ProviderSetupCopy = { creationHref: string; @@ -25,6 +29,10 @@ const PROVIDER_SETUP_COPY: Record = { creationHref: 'https://discord.com/developers/applications', setupLabel: 'Discord bot', }, + agentmail: { + creationHref: 'https://console.agentmail.to/dashboard/api-keys', + setupLabel: 'AgentMail API key', + }, }; export function getProviderSetupCopy( diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx index d81d219bb..cabce1ff5 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx @@ -55,6 +55,7 @@ import { LayoutGrid, Loader2Icon, LocalDateTime, + Mail, Popover, PopoverContent, PopoverTrigger, @@ -624,6 +625,8 @@ function SessionInfoPanel({ {session.surface === 'slack' ? ( + ) : session.surface === 'agentmail' ? ( + ) : surfaceBrandIcon ? ( = { teams: surface('teams', 'teams'), telegram: surface('telegram', 'telegram'), discord: surface('discord', 'discord'), + agentmail: surface('agentmail'), linear: surface('linear', 'linear'), github: surface('github', 'github'), gitlab: surface('gitlab', 'gitlab'), diff --git a/apps/web/src/components/settings/CommsProviderSection.tsx b/apps/web/src/components/settings/CommsProviderSection.tsx index 92b4be749..7eedbbad8 100644 --- a/apps/web/src/components/settings/CommsProviderSection.tsx +++ b/apps/web/src/components/settings/CommsProviderSection.tsx @@ -5,6 +5,7 @@ import Link from 'next/link'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import type { SetupAuthProviderStatus } from '@roomote/types'; +import type { AgentMailCommsStatus } from '@/trpc/commands/comms'; import { useTRPC } from '@/trpc/client'; import { @@ -23,7 +24,11 @@ import { ProviderSetupExperience, } from '@/app/(onboarding)/setup/ProviderSetupExperience'; -type CommsProviderId = SetupAuthProviderStatus['id'] | 'telegram' | 'discord'; +type CommsProviderId = + | SetupAuthProviderStatus['id'] + | 'telegram' + | 'discord' + | 'agentmail'; type TelegramWebhookStatus = { status: 'connected' | 'mismatch' | 'stale_updates' | 'unregistered' | 'error'; registeredUrl: string | null; @@ -37,6 +42,7 @@ type CommsProviderStatus = Omit & { telegramWebhook?: TelegramWebhookStatus | null; telegramBotUsername?: string | null; discord?: import('@/trpc/commands/comms').DiscordCommsStatus | null; + agentmail?: AgentMailCommsStatus | null; }; const TELEGRAM_WEBHOOK_STATUS_COPY: Record< @@ -77,8 +83,15 @@ import { DialogTitle, ExternalLink, Info, + Input, + Mail, Plug, RefreshCw, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, Spinner, Trash2, TriangleAlert, @@ -259,6 +272,310 @@ function TeamsBotStatus() { ); } +const AGENTMAIL_WEBHOOK_STATUS_COPY: Record< + 'connected' | 'mismatch' | 'unregistered' | 'error', + { label: string; tone: 'ok' | 'warn' } +> = { + connected: { label: 'Webhook connected', tone: 'ok' }, + mismatch: { + label: + 'Webhook points at a different URL — save again to re-register it for this deployment', + tone: 'warn', + }, + unregistered: { + label: + 'Webhook not registered yet — it is registered automatically when you save', + tone: 'warn', + }, + error: { + label: 'Could not check the AgentMail webhook status', + tone: 'warn', + }, +}; + +function AgentMailSetupStatus({ + status, +}: { + status: NonNullable; +}) { + const webhookCopy = AGENTMAIL_WEBHOOK_STATUS_COPY[status.webhook.status]; + + return ( +
+ {status.inboxAddress ? ( +
+ +

+ Inbox:{' '} + + {status.inboxEmail ?? status.inboxAddress} + + {status.inboxEmail && status.inboxEmail !== status.inboxAddress ? ( + + {' '} + (id: {status.inboxAddress}) + + ) : null} +

+
+ ) : null} +
+ {webhookCopy.tone === 'ok' ? ( + + ) : ( + + )} +

+ {status.webhook.status === 'error' + ? (status.webhook.errorMessage ?? + AGENTMAIL_WEBHOOK_STATUS_COPY.error.label) + : webhookCopy.label} + {status.webhook.registeredUrl ? ( + <> + {' '} + + {status.webhook.registeredUrl} + + + ) : null} +

+
+
+ ); +} + +const AGENTMAIL_CREATE_NEW_INBOX_OPTION = '__agentmail_create_new__'; +const AGENTMAIL_MANUAL_INBOX_OPTION = '__agentmail_manual__'; + +/** + * Chooser for the AgentMail inbox: lists the inboxes the API key can see plus + * a "create new" option for this deployment's proposed address, instead of a + * free-text field. Manual entry stays available for inboxes the key cannot + * list yet (e.g. custom domains). Writes the chosen address into the + * R_AGENTMAIL_INBOX_ID form value; the save reconcile does the rest. + */ +function AgentMailInboxChooser({ + enteredApiKey, + keyConfigured, + value, + savedSatisfied, + disabled, + onChange, +}: { + /** API key currently typed into the form (already trimmed). */ + enteredApiKey: string; + /** The API key field is satisfied by a saved or runtime value. */ + keyConfigured: boolean; + value: string; + savedSatisfied: boolean; + disabled: boolean; + onChange: (address: string) => void; +}) { + const trpc = useTRPC(); + // A key typed into the form only loads on request, so AgentMail is not + // called on every keystroke. A saved-and-connected config never loads + // automatically either — the status block already names the inbox, so + // AgentMail is only called when the operator actually opens the chooser. + const [loadedEnteredKey, setLoadedEnteredKey] = useState(null); + const [manualEntry, setManualEntry] = useState(false); + const [chooserRequested, setChooserRequested] = useState(false); + + const hasEnteredKey = enteredApiKey.length > 0; + const keyAvailable = hasEnteredKey || keyConfigured; + const loadWanted = chooserRequested || (keyAvailable && !savedSatisfied); + const loadEnabled = + loadWanted && + keyAvailable && + (!hasEnteredKey || loadedEnteredKey === enteredApiKey); + + // A mutation rather than a query: the typed API key travels in the POST + // body instead of being serialized into a GET URL (browser history, proxy + // logs, tracing). + const loadInboxes = useMutation( + trpc.comms.listAgentMailInboxes.mutationOptions(), + ); + const requestedKeyRef = useRef(null); + const requestKey = hasEnteredKey ? enteredApiKey : ''; + // savedSatisfied joins the signature so a save that just created the + // inbox refreshes the list (mutations have no query cache to invalidate). + const requestSignature = `${savedSatisfied ? 'saved' : 'unsaved'}:${requestKey}`; + const { mutate: loadInboxesMutate } = loadInboxes; + + useEffect(() => { + if (!loadEnabled || requestedKeyRef.current === requestSignature) { + return; + } + requestedKeyRef.current = requestSignature; + loadInboxesMutate(requestKey ? { apiKey: requestKey } : {}); + }, [loadEnabled, requestKey, requestSignature, loadInboxesMutate]); + + const inboxesLoading = + loadInboxes.isPending || + (loadEnabled && !loadInboxes.isSuccess && !loadInboxes.isError); + + // Entries pair the routed inbox_id (the submitted value) with the + // deliverable email (the label); they are equal today but may diverge. + const inboxes = loadInboxes.data?.inboxes ?? []; + const inboxIds = inboxes.map((inbox) => inbox.inboxId); + const proposedNewAddress = loadInboxes.data?.proposedNewAddress ?? null; + const proposalAlreadyExists = Boolean( + proposedNewAddress && inboxIds.includes(proposedNewAddress), + ); + const normalizedValue = value.trim().toLowerCase(); + const selectValue = !normalizedValue + ? undefined + : inboxIds.includes(normalizedValue) + ? normalizedValue + : normalizedValue === proposedNewAddress && !proposalAlreadyExists + ? AGENTMAIL_CREATE_NEW_INBOX_OPTION + : undefined; + // A value the account listing does not contain (custom domain, older + // config) keeps the manual input visible so it is never silently hidden. + // A saved config also rests on the input until the chooser is opened. + const showManualInput = + manualEntry || + !keyAvailable || + (savedSatisfied && !chooserRequested) || + (loadInboxes.isSuccess && + normalizedValue.length > 0 && + selectValue === undefined); + + const manualEntryLink = ( + + ); + + return ( +
+
Inbox Email Address (optional)
+

+ The inbox Roomote receives mail at. Leave it unset to let Roomote adopt + the account's only inbox or create one when you save. +

+ {showManualInput ? ( +
+
+ onChange(event.target.value)} + placeholder="Inbox Email Address" + disabled={disabled} + data-1p-ignore + /> + {savedSatisfied && } +
+ {keyAvailable ? ( + + ) : ( +

+ Enter the AgentMail API key above to choose from the + account's inboxes. +

+ )} +
+ ) : !loadEnabled ? ( +
+ + {manualEntryLink} +
+ ) : inboxesLoading ? ( +
+ + Loading inboxes… +
+ ) : loadInboxes.isError ? ( +
+

+ {loadInboxes.error.message} +

+
+ + {manualEntryLink} +
+
+ ) : ( +
+ + {savedSatisfied && } +
+ )} +
+ ); +} + type CommsProviderSectionProps = { provider: CommsProviderStatus; onSave: (provider: CommsProviderId, values: Record) => void; @@ -517,6 +834,39 @@ export function CommsProviderSection({ ? getTeamsAppPackageUnavailableReason(enteredTeamsBotAppId) : null; + // AgentMail replaces the free-text inbox field with a chooser fed by the + // account's inbox list, so that field is pulled out of the generic setup + // fields and rendered below (unless an env var pins it at runtime). + const agentMailInboxField = + provider.id === 'agentmail' + ? provider.fields.find( + (field) => field.envVarName === 'R_AGENTMAIL_INBOX_ID', + ) + : undefined; + const agentMailChooserActive = Boolean( + agentMailInboxField && !agentMailInboxField.runtimeSatisfied, + ); + const setupExperienceProvider = agentMailChooserActive + ? { + ...provider, + fields: provider.fields.filter( + (field) => field.envVarName !== 'R_AGENTMAIL_INBOX_ID', + ), + } + : provider; + const agentMailApiKeyField = + provider.id === 'agentmail' + ? provider.fields.find( + (field) => field.envVarName === 'R_AGENTMAIL_API_KEY', + ) + : undefined; + const agentMailKeyConfigured = Boolean( + agentMailApiKeyField && + (agentMailApiKeyField.runtimeSatisfied || + (agentMailApiKeyField.savedSatisfied && + !clearedSavedValues['R_AGENTMAIL_API_KEY'])), + ); + const handleSave = () => { onSave(provider.id, getSetupSubmitValues({ provider, values })); }; @@ -529,11 +879,15 @@ export function CommsProviderSection({ <>
+ provider.id === 'agentmail' ? ( + + ) : ( + + ) } title={provider.label} action={ @@ -559,7 +913,7 @@ export function CommsProviderSection({ ) : (
createSlackApp.mutate({ configToken }) @@ -602,6 +958,28 @@ export function CommsProviderSection({ } /> + {agentMailChooserActive && agentMailInboxField ? ( + { + setValues((current) => ({ + ...current, + R_AGENTMAIL_INBOX_ID: address, + })); + if (agentMailInboxField.savedSatisfied) { + setClearedSavedValues((current) => ({ + ...current, + R_AGENTMAIL_INBOX_ID: address.length === 0, + })); + } + }} + /> + ) : null} +
{provider.id === 'telegram' && provider.telegramWebhook && (
@@ -649,6 +1027,9 @@ export function CommsProviderSection({ {provider.id === 'discord' && provider.discord && ( )} + {provider.id === 'agentmail' && provider.agentmail && ( + + )} {provider.id === 'microsoft' && (hasConfiguredValues || teamsBotConfigured) && ( diff --git a/apps/web/src/components/settings/CommsProviders.tsx b/apps/web/src/components/settings/CommsProviders.tsx index 16a67a0ae..2143d0000 100644 --- a/apps/web/src/components/settings/CommsProviders.tsx +++ b/apps/web/src/components/settings/CommsProviders.tsx @@ -41,6 +41,13 @@ export function CommsProviders() { return; } + if (result?.agentmail) { + toast.success( + `Email connected. Roomote receives mail at ${result.agentmail.inboxEmail ?? result.agentmail.inboxAddress}.`, + ); + return; + } + if (result?.discord) { if (result.discord.registered) { toast.success( diff --git a/apps/web/src/components/settings/automations/AutomationDestinationPicker.tsx b/apps/web/src/components/settings/automations/AutomationDestinationPicker.tsx index 86ab593fa..3ae9b04bf 100644 --- a/apps/web/src/components/settings/automations/AutomationDestinationPicker.tsx +++ b/apps/web/src/components/settings/automations/AutomationDestinationPicker.tsx @@ -1,6 +1,6 @@ 'use client'; -import type { CommunicationProvider } from '@roomote/types'; +import type { AutomationCapableCommunicationProvider } from '@roomote/types'; import { Input, @@ -14,7 +14,9 @@ import { import { SlackChannelSelect } from './SlackChannelSelect'; -export type AutomationDestinationProvider = 'none' | CommunicationProvider; +export type AutomationDestinationProvider = + | 'none' + | AutomationCapableCommunicationProvider; type AutomationDestinationMode = 'channel' | 'direct_message'; type AutomationDestinationValue = { provider: AutomationDestinationProvider; @@ -33,7 +35,7 @@ const PROVIDER_LABELS = { discord: 'Discord', teams: 'Teams', telegram: 'Telegram', -} as const satisfies Record; +} as const satisfies Record; export function AutomationDestinationPicker({ id, @@ -52,7 +54,7 @@ export function AutomationDestinationPicker({ id: string; label?: string; value: AutomationDestinationValue; - availableProviders: readonly CommunicationProvider[]; + availableProviders: readonly AutomationCapableCommunicationProvider[]; slackOptions: DestinationOption[]; discordOptions: DestinationOption[]; defaultSlackChannelId?: string; @@ -63,7 +65,7 @@ export function AutomationDestinationPicker({ onChange: (value: AutomationDestinationValue) => void; }) { const visibleProviders = availableProviders.includes( - value.provider as CommunicationProvider, + value.provider as AutomationCapableCommunicationProvider, ) ? availableProviders : value.provider === 'none' diff --git a/apps/web/src/components/settings/automations/AutomationsSettings.tsx b/apps/web/src/components/settings/automations/AutomationsSettings.tsx index 463be5cf6..c46e345c5 100644 --- a/apps/web/src/components/settings/automations/AutomationsSettings.tsx +++ b/apps/web/src/components/settings/automations/AutomationsSettings.tsx @@ -7,6 +7,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { AUTOMATION_DESTINATION_DESCRIPTORS, + type AutomationCapableCommunicationProvider, type BackgroundAutomationKey, type CommunicationProvider, communicationProviders, @@ -367,8 +368,13 @@ function getAutomationCapabilityBadges( const comms: readonly CommunicationProvider[] = descriptor.supportedCommunicationProviders; + // Email (agentmail) never receives automation posts, so full coverage is + // measured against the automation-capable providers only. + const automationCapableProviderCount = communicationProviders.filter( + (provider) => provider !== 'agentmail', + ).length; const commsLimited = - comms.length > 0 && comms.length < communicationProviders.length; + comms.length > 0 && comms.length < automationCapableProviderCount; const commsBadge = commsLimited ? comms.length === 1 && comms[0] === 'slack' ? 'Slack only' @@ -836,7 +842,7 @@ function mapSettingsToFormState( ciFailureTriageSlackChannelId: string | null; ciFailureTriageSlackChannelName?: string | null; ciFailureTriageDiscordChannelId: string | null; - mergeAnnouncerTargetProvider: CommunicationProvider | null; + mergeAnnouncerTargetProvider: AutomationCapableCommunicationProvider | null; mergeAnnouncerTargetMode: 'channel' | 'direct_message' | null; mergeAnnouncerTargetChannelId: string | null; } & ScheduleOnlyAutomationFrequencyState & { @@ -3355,7 +3361,12 @@ export function AutomationsSettings() { channelId: formState.mergeAnnouncerTargetChannelId, }} availableProviders={communicationProviders.filter( - (provider) => + ( + provider, + ): provider is AutomationCapableCommunicationProvider => + // Email (agentmail) never receives automation + // posts, so it is not offered as a destination. + provider !== 'agentmail' && settingsQuery.data?.capabilities[ `${provider}Connected` as keyof typeof settingsQuery.data.capabilities ] === true, diff --git a/apps/web/src/components/settings/automations/formState.ts b/apps/web/src/components/settings/automations/formState.ts index 6c5bb30d7..f6ae3a923 100644 --- a/apps/web/src/components/settings/automations/formState.ts +++ b/apps/web/src/components/settings/automations/formState.ts @@ -1,8 +1,8 @@ import { AUTOMATION_DESTINATION_DESCRIPTORS, SCHEDULE_ONLY_BACKGROUND_AUTOMATION_LIST, + type AutomationCapableCommunicationProvider, type ChannelAutoStartLaunchMode, - type CommunicationProvider, type ConflictResolverMaxPrAgeDays, type ScheduleOnlyBackgroundAutomationFrequency, type ScheduleOnlyBackgroundAutomationFrequencyField, @@ -113,7 +113,7 @@ export type FormState = { announcerFrequency: AnnouncerFrequency; announcerInstructions: string; platformIssueAlertsEnabled: boolean; - mergeAnnouncerTargetProvider: 'none' | CommunicationProvider; + mergeAnnouncerTargetProvider: 'none' | AutomationCapableCommunicationProvider; mergeAnnouncerTargetMode: 'channel' | 'direct_message'; mergeAnnouncerTargetChannelId: string; } & DestinationChannelFormFields & diff --git a/apps/web/src/components/settings/router-diagnostics-destination.ts b/apps/web/src/components/settings/router-diagnostics-destination.ts index e4bf51257..8c66d0929 100644 --- a/apps/web/src/components/settings/router-diagnostics-destination.ts +++ b/apps/web/src/components/settings/router-diagnostics-destination.ts @@ -1,16 +1,19 @@ import type { CommunicationProvider } from '@roomote/types'; +/** Router diagnostics post to a chat channel; email has none. */ +type RouterDebugProvider = Exclude; + export const ROUTER_DEBUG_NONE = '__none__'; export const ROUTER_DEBUG_ENV_FALLBACK = '__env_fallback__'; export type RouterDebugDestinationSelection = - | CommunicationProvider + | RouterDebugProvider | typeof ROUTER_DEBUG_NONE | typeof ROUTER_DEBUG_ENV_FALLBACK; export function getRouterDebugDestinationSelection(settings: { destination: { - provider: CommunicationProvider; + provider: RouterDebugProvider; channelId: string; } | null; disabled: boolean; @@ -31,7 +34,7 @@ export function buildRouterDebugSettingsInput( selection: RouterDebugDestinationSelection, channelId: string, ): { - provider: CommunicationProvider | null; + provider: RouterDebugProvider | null; channelId: string | null; disabled: boolean; } { diff --git a/apps/web/src/hooks/linked-accounts/index.ts b/apps/web/src/hooks/linked-accounts/index.ts index 91e711f9d..bee2e15f7 100644 --- a/apps/web/src/hooks/linked-accounts/index.ts +++ b/apps/web/src/hooks/linked-accounts/index.ts @@ -30,3 +30,6 @@ export * from './useCreateTelegramLinkCode'; export * from './useDiscordLinkedAccount'; export * from './useUnlinkDiscordLinkedAccount'; export * from './useCreateDiscordLinkCode'; + +export * from './useEmailLinkPreview'; +export * from './useLinkEmailAddress'; diff --git a/apps/web/src/hooks/linked-accounts/useEmailLinkPreview.ts b/apps/web/src/hooks/linked-accounts/useEmailLinkPreview.ts new file mode 100644 index 000000000..2f8c0234a --- /dev/null +++ b/apps/web/src/hooks/linked-accounts/useEmailLinkPreview.ts @@ -0,0 +1,14 @@ +import { useQuery } from '@tanstack/react-query'; + +import { useTRPC } from '@/trpc/client'; + +export const useEmailLinkPreview = (token: string | null) => { + const trpc = useTRPC(); + + return useQuery( + trpc.linkedAccounts.previewEmailLink.queryOptions( + { token: token ?? '' }, + { enabled: Boolean(token), retry: false }, + ), + ); +}; diff --git a/apps/web/src/hooks/linked-accounts/useLinkEmailAddress.ts b/apps/web/src/hooks/linked-accounts/useLinkEmailAddress.ts new file mode 100644 index 000000000..54d922011 --- /dev/null +++ b/apps/web/src/hooks/linked-accounts/useLinkEmailAddress.ts @@ -0,0 +1,9 @@ +import { useMutation } from '@tanstack/react-query'; + +import { useTRPC } from '@/trpc/client'; + +export const useLinkEmailAddress = () => { + const trpc = useTRPC(); + + return useMutation(trpc.linkedAccounts.linkEmailAddress.mutationOptions()); +}; diff --git a/apps/web/src/lib/server/auth.test.ts b/apps/web/src/lib/server/auth.test.ts index 8e9c27874..9b599a9f4 100644 --- a/apps/web/src/lib/server/auth.test.ts +++ b/apps/web/src/lib/server/auth.test.ts @@ -99,6 +99,7 @@ vi.mock('./env', () => ({ R_ALLOWED_EMAILS: undefined, R_APP_URL: 'http://localhost:3000', }, + isEmailChannelEnabled: () => false, getEncryptionKey: () => 'test-encryption-key', getBetterAuthSecret: () => 'test-better-auth-secret', })); diff --git a/apps/web/src/lib/server/auth.ts b/apps/web/src/lib/server/auth.ts index 6382408f9..2d5e9b3ce 100644 --- a/apps/web/src/lib/server/auth.ts +++ b/apps/web/src/lib/server/auth.ts @@ -6,6 +6,9 @@ import { nextCookies } from 'better-auth/next-js'; import { genericOAuth, microsoftEntraId, slack } from 'better-auth/plugins'; import { drizzleAdapter } from '@better-auth/drizzle-adapter'; import { normalizeAdoLinkedAccountKey } from '@roomote/ado'; +// Subpath import on purpose: the SDK barrel drags the whole server graph +// into auth, which the auth unit tests mock only partially. +import { sendAgentMailSystemEmail } from '@roomote/sdk/server/agentmail-outbound'; import type { SourceControlTokenBackedProvider } from '@roomote/types'; import { @@ -20,7 +23,7 @@ import { } from '@roomote/db/server'; import * as dbSchema from '@roomote/db/server'; -import { Env, getBetterAuthSecret } from './env'; +import { Env, getBetterAuthSecret, isEmailChannelEnabled } from './env'; import { getBetterAuthBaseUrlConfig } from './better-auth-base-url'; import { withCanonicalForwardedProto } from './canonical-forwarded-proto'; import { bootstrapWebRuntimeEnv } from './bootstrap-runtime-env'; @@ -1025,6 +1028,8 @@ async function createAuth(authProviderConfig: ResolvedAuthProviderConfig) { : []), ]; + const emailChannelEnabled = isEmailChannelEnabled(); + return betterAuth({ appName: 'Roomote', baseURL: getBetterAuthBaseUrlConfig({ @@ -1062,18 +1067,70 @@ async function createAuth(authProviderConfig: ResolvedAuthProviderConfig) { modelName: 'authVerifications', }, // Sign-up is gated by the invite/access checks in the database hooks - // below; password sign-in for existing accounts is always available. + // below. Password sign-in for existing accounts is always available, + // except that once the email channel is enabled the deployment has an + // email sender for the first time, so account emails become verifiable + // and verification is required: Roomote only ever initiates email to an + // address it has verified, and this is where that guarantee starts. emailAndPassword: { enabled: true, + requireEmailVerification: emailChannelEnabled, resetPasswordTokenExpiresIn: PASSWORD_RESET_TOKEN_EXPIRES_IN_SECONDS, revokeSessionsOnPasswordReset: true, - sendResetPassword: async ({ url }) => { + sendResetPassword: async ({ user, url }) => { + // Admin-initiated resets capture the link for the settings UI; with + // the email channel on, the user also gets it by email. const capture = resetPasswordLinkCapture.getStore(); if (capture) { capture.url = url; } + if (emailChannelEnabled) { + await sendAgentMailSystemEmail({ + to: user.email, + subject: 'Reset your Roomote password', + text: [ + 'A password reset was requested for your Roomote account.', + '', + `[Reset your password](${url})`, + '', + `This link expires in ${Math.round(PASSWORD_RESET_TOKEN_EXPIRES_IN_SECONDS / 60)} minutes. If you did not request a reset, you can ignore this email.`, + ].join('\n'), + logContext: 'auth.sendResetPassword', + }); + } }, }, + ...(emailChannelEnabled + ? { + emailVerification: { + sendOnSignUp: true, + // An unverified account that signs in gets a fresh verification + // email instead of a dead-end 403 — this is how accounts created + // before the channel was enabled get verified. + sendOnSignIn: true, + autoSignInAfterVerification: true, + sendVerificationEmail: async ({ user, url }) => { + const result = await sendAgentMailSystemEmail({ + to: user.email, + subject: 'Verify your email for Roomote', + text: [ + 'Confirm this address to finish setting up your Roomote account.', + '', + `[Verify your email](${url})`, + '', + 'If you did not create a Roomote account, you can ignore this email.', + ].join('\n'), + logContext: 'auth.sendVerificationEmail', + }); + if (!result.sent) { + throw new Error( + `Could not send the verification email (${result.reason}).`, + ); + } + }, + }, + } + : {}), databaseHooks: { account: { create: { diff --git a/apps/web/src/lib/server/env.ts b/apps/web/src/lib/server/env.ts index dcf197410..ed59ce4ed 100644 --- a/apps/web/src/lib/server/env.ts +++ b/apps/web/src/lib/server/env.ts @@ -15,6 +15,7 @@ import { getWebBundledEnvFilePaths as getSharedWebBundledEnvFilePaths, getWebEnvFilePaths as getSharedWebEnvFilePaths, isBrainConfigured, + isEmailChannelEnabled, isEnvFlagEnabled, isExposedBindHost, isRoomoteCloudEnabled, @@ -204,6 +205,7 @@ export { getArtifactSigningKeyPrevious, getBetterAuthSecret, isBrainConfigured, + isEmailChannelEnabled, isEnvFlagEnabled, isRoomoteCloudEnabled, resolveAppEnv, diff --git a/apps/web/src/lib/task-surface-label.ts b/apps/web/src/lib/task-surface-label.ts index 8d725aad9..bb74dfd4f 100644 --- a/apps/web/src/lib/task-surface-label.ts +++ b/apps/web/src/lib/task-surface-label.ts @@ -5,6 +5,7 @@ export const TASK_SOURCE_ORDER: readonly string[] = [ 'Teams', 'Telegram', 'Discord', + 'Email', 'GitHub', 'GitLab', 'Gitea', @@ -29,6 +30,8 @@ export function getTaskSurfaceLabel( return 'Telegram'; case 'discord': return 'Discord'; + case 'agentmail': + return 'Email'; case 'github': return 'GitHub'; case 'gitlab': diff --git a/apps/web/src/trpc/commands/automations/settings-update.ts b/apps/web/src/trpc/commands/automations/settings-update.ts index c1e594642..d34125465 100644 --- a/apps/web/src/trpc/commands/automations/settings-update.ts +++ b/apps/web/src/trpc/commands/automations/settings-update.ts @@ -9,6 +9,7 @@ import { isConflictResolverMaxPrAgeDays, isProviderUsageLimitThreshold, type AutomationTarget, + type CommunicationProvider, type PrReviewSettings, type TriggerableBackgroundAutomationKey, } from '@roomote/types'; @@ -1118,7 +1119,7 @@ export async function updateBackgroundAgentSettingsCommand( const descriptor = getTriggerableBackgroundAutomationDescriptorByKey( validation.key, ); - const nonSlackProviders = + const nonSlackProviders: readonly CommunicationProvider[] = descriptor?.supportedCommunicationProviders.filter( (provider) => provider !== 'slack', ) ?? []; diff --git a/apps/web/src/trpc/commands/automations/types.ts b/apps/web/src/trpc/commands/automations/types.ts index 4d912ff48..845688b78 100644 --- a/apps/web/src/trpc/commands/automations/types.ts +++ b/apps/web/src/trpc/commands/automations/types.ts @@ -1,5 +1,6 @@ import type { AnnouncerFrequency, + AutomationCapableCommunicationProvider, BackgroundAutomationKey, ChannelAutoStartLaunchMode, CommunicationProvider, @@ -314,7 +315,7 @@ export interface UpdateBackgroundAgentSettingsInput extends ScheduleOnlyAutomati codeQualityAuditorDiscordChannel?: string | null; ciFailureTriageSlackChannel?: string | null; ciFailureTriageDiscordChannel?: string | null; - mergeAnnouncerTargetProvider?: CommunicationProvider | null; + mergeAnnouncerTargetProvider?: AutomationCapableCommunicationProvider | null; mergeAnnouncerTargetMode?: 'channel' | 'direct_message'; mergeAnnouncerTargetChannelId?: string | null; } diff --git a/apps/web/src/trpc/commands/comms/index.test.ts b/apps/web/src/trpc/commands/comms/index.test.ts index 418f27450..7841cd3ae 100644 --- a/apps/web/src/trpc/commands/comms/index.test.ts +++ b/apps/web/src/trpc/commands/comms/index.test.ts @@ -1,9 +1,21 @@ +import { createHash } from 'node:crypto'; + import type { UserAuthSuccess } from '@/types'; const { mockDbDelete, mockTxSelect, mockDbTransaction, + mockResolveAgentMailRuntimeCredentials, + mockAgentMailClientConstructor, + mockAgentMailListInboxes, + mockAgentMailCreateInbox, + mockAgentMailGetInbox, + mockAgentMailGetMessage, + mockAgentMailListWebhooks, + mockAgentMailCreateWebhook, + mockAgentMailUpdateWebhook, + mockAgentMailDeleteWebhook, mockUpsertDeploymentEnvironmentVariables, mockGetPersistedEnvironmentVariableNames, mockGetPersistedEnvironmentVariableValues, @@ -30,6 +42,20 @@ const { })), mockTxSelect: vi.fn(), mockDbTransaction: vi.fn(), + mockResolveAgentMailRuntimeCredentials: vi.fn(async () => ({ + apiKey: null as string | null, + webhookSecret: null as string | null, + inboxId: null as string | null, + })), + mockAgentMailClientConstructor: vi.fn(), + mockAgentMailListInboxes: vi.fn(), + mockAgentMailCreateInbox: vi.fn(), + mockAgentMailGetInbox: vi.fn(), + mockAgentMailGetMessage: vi.fn(), + mockAgentMailListWebhooks: vi.fn(), + mockAgentMailCreateWebhook: vi.fn(), + mockAgentMailUpdateWebhook: vi.fn(), + mockAgentMailDeleteWebhook: vi.fn(), mockUpsertDeploymentEnvironmentVariables: vi.fn(), mockGetPersistedEnvironmentVariableNames: vi.fn().mockResolvedValue([]), mockGetPersistedEnvironmentVariableValues: vi.fn().mockResolvedValue({}), @@ -112,6 +138,8 @@ vi.mock('@roomote/db/server', () => ({ inArray: vi.fn(), isNull: vi.fn(), like: vi.fn(), + resolveAgentMailRuntimeCredentials: mockResolveAgentMailRuntimeCredentials, + invalidateAgentMailRuntimeCredentialsCache: vi.fn(), resolveEffectiveDeploymentEnvVars: mockResolveEffectiveDeploymentEnvVars, resolveInvocationIdentities: mockResolveInvocationIdentities, resolveTelegramRuntimeCredentials: mockResolveTelegramRuntimeCredentials, @@ -161,6 +189,31 @@ vi.mock('@roomote/sdk/server', () => ({ }), })); +vi.mock('@roomote/communication/agentmail-provider', () => ({ + AgentMailApiClient: class { + constructor(options: { apiKey: string }) { + mockAgentMailClientConstructor(options); + } + listInboxes = mockAgentMailListInboxes; + createInbox = mockAgentMailCreateInbox; + getInbox = mockAgentMailGetInbox; + getMessage = mockAgentMailGetMessage; + listWebhooks = mockAgentMailListWebhooks; + createWebhook = mockAgentMailCreateWebhook; + updateWebhook = mockAgentMailUpdateWebhook; + deleteWebhook = mockAgentMailDeleteWebhook; + }, + AgentMailApiError: class extends Error { + constructor( + message: string, + public readonly status: number, + ) { + super(message); + this.name = 'AgentMailApiError'; + } + }, +})); + vi.mock('@roomote/communication/discord-provider', () => ({ DiscordCommunicationProvider: class { registerCommands = mockDiscordRegisterCommands; @@ -192,6 +245,7 @@ vi.mock('@roomote/communication/teams-credential-validation', () => ({ vi.mock('@/lib/server/env', () => ({ Env: { R_APP_URL: 'https://app.example.com' }, + isEmailChannelEnabled: () => process.env.R_EMAIL_CHANNEL_ENABLED === 'true', })); vi.mock('../environment-variables', () => ({ @@ -209,11 +263,13 @@ vi.mock('../environment-variables', () => ({ })); import { TeamsBotCredentialValidationError } from '@roomote/communication/teams-credential-validation'; +import { AgentMailApiError } from '@roomote/communication/agentmail-provider'; import { classifyTelegramWebhookCheckError, clearCommsAuthConfigCommand, getCommsStatusCommand, + listAgentMailInboxesCommand, listDiscordChannelsCommand, listDiscordGuildsCommand, repairTelegramWebhookCommand, @@ -246,6 +302,11 @@ function buildMockAuth( } describe('comms commands', () => { + beforeAll(() => { + // The email channel is gated; the suite exercises it enabled. + process.env.R_EMAIL_CHANNEL_ENABLED = 'true'; + }); + beforeEach(() => { vi.clearAllMocks(); mockTxSelect.mockReset(); @@ -257,6 +318,18 @@ describe('comms commands', () => { botUsername: null, }); mockTelegramGetWebhookInfo.mockReset(); + mockResolveAgentMailRuntimeCredentials.mockResolvedValue({ + apiKey: null, + webhookSecret: null, + inboxId: null, + }); + mockAgentMailListInboxes.mockResolvedValue({ inboxes: [] }); + mockAgentMailListWebhooks.mockResolvedValue({ webhooks: [] }); + mockAgentMailCreateInbox.mockReset(); + mockAgentMailGetInbox.mockReset(); + mockAgentMailCreateWebhook.mockReset(); + mockAgentMailUpdateWebhook.mockReset(); + mockAgentMailDeleteWebhook.mockReset(); mockValidateTeamsBotCredentials.mockResolvedValue(undefined); mockDiscordListGuilds.mockResolvedValue([]); mockDiscordListGuildChannels.mockResolvedValue([]); @@ -723,6 +796,770 @@ describe('comms commands', () => { }); }); + describe('agentmail channel gate', () => { + it('keeps email out of the settings surface and refuses saves when disabled', async () => { + process.env.R_EMAIL_CHANNEL_ENABLED = 'false'; + try { + const status = await getCommsStatusCommand(buildMockAuth()); + expect( + status.providers.find((p) => p.id === 'agentmail'), + ).toBeUndefined(); + + await expect( + saveCommsAuthConfigCommand(buildMockAuth(), { + provider: 'agentmail', + values: { R_AGENTMAIL_API_KEY: 'am-key' }, + }), + ).rejects.toThrow(/R_EMAIL_CHANNEL_ENABLED/); + expect(mockAgentMailListInboxes).not.toHaveBeenCalled(); + } finally { + process.env.R_EMAIL_CHANNEL_ENABLED = 'true'; + } + }); + }); + + describe('agentmail save reconcile', () => { + const hostHash = createHash('sha256') + .update('app.example.com') + .digest('hex') + .slice(0, 6); + const expectedUsername = `roomote-app-example-com-${hostHash}`; + const expectedWebhookUrl = 'https://app.example.com/api/webhooks/agentmail'; + + beforeEach(() => { + mockDbTransaction.mockImplementation(async (callback) => + callback({} as never), + ); + // The message_read capability probe fetches a sentinel message id; + // 404 is the with-permission answer. + mockAgentMailGetMessage.mockRejectedValue( + new AgentMailApiError('AgentMail GET failed (404): Not Found', 404), + ); + }); + + it("adopts the org's only existing inbox instead of creating a second", async () => { + mockAgentMailListInboxes.mockResolvedValue({ + inboxes: [{ inbox_id: 'existing@agentmail.to' }], + }); + mockAgentMailCreateWebhook.mockResolvedValue({ + webhook_id: 'wh-1', + url: expectedWebhookUrl, + secret: 'whsec_adopted', + }); + + await expect( + saveCommsAuthConfigCommand(buildMockAuth(), { + provider: 'agentmail', + values: { R_AGENTMAIL_API_KEY: 'am-key' }, + }), + ).resolves.toMatchObject({ + agentmail: { inboxAddress: 'existing@agentmail.to' }, + }); + + expect(mockAgentMailCreateInbox).not.toHaveBeenCalled(); + }); + + it('routes by inbox_id, not the email field, when adopting', async () => { + // inbox_id is the API key (paths, webhook inbox_ids filters); it must + // win over the display email if the fields ever diverge. + mockAgentMailListInboxes.mockResolvedValue({ + inboxes: [ + { + inbox_id: 'Existing@agentmail.to', + email: 'display-alias@agentmail.to', + }, + ], + }); + mockAgentMailCreateWebhook.mockResolvedValue({ + webhook_id: 'wh-1', + url: expectedWebhookUrl, + secret: 'whsec_adopted', + }); + + await expect( + saveCommsAuthConfigCommand(buildMockAuth(), { + provider: 'agentmail', + values: { R_AGENTMAIL_API_KEY: 'am-key' }, + }), + ).resolves.toMatchObject({ + agentmail: { + inboxAddress: 'existing@agentmail.to', + inboxEmail: 'display-alias@agentmail.to', + }, + }); + }); + + it('fails the save when the key lacks message_read', async () => { + mockAgentMailListInboxes.mockResolvedValue({ + inboxes: [{ inbox_id: 'existing@agentmail.to' }], + }); + mockAgentMailGetMessage.mockRejectedValue( + new AgentMailApiError('AgentMail GET failed (403): Forbidden', 403), + ); + + await expect( + saveCommsAuthConfigCommand(buildMockAuth(), { + provider: 'agentmail', + values: { R_AGENTMAIL_API_KEY: 'am-key' }, + }), + ).rejects.toThrow(/permission|403/i); + expect(mockAgentMailCreateWebhook).not.toHaveBeenCalled(); + }); + + it('fails the save when the message_read probe cannot complete (network error)', async () => { + mockAgentMailListInboxes.mockResolvedValue({ + inboxes: [{ inbox_id: 'existing@agentmail.to' }], + }); + mockAgentMailGetMessage.mockRejectedValue( + new Error('fetch failed: socket hang up'), + ); + + await expect( + saveCommsAuthConfigCommand(buildMockAuth(), { + provider: 'agentmail', + values: { R_AGENTMAIL_API_KEY: 'am-key' }, + }), + ).rejects.toThrow(/Could not reach the AgentMail API/); + expect(mockAgentMailCreateWebhook).not.toHaveBeenCalled(); + }); + + it('uses the created inbox email in the result when it differs from the id', async () => { + mockAgentMailListInboxes.mockResolvedValue({ inboxes: [] }); + mockAgentMailCreateInbox.mockResolvedValue({ + inbox_id: `${expectedUsername}@agentmail.to`, + email: `${expectedUsername}-alias@agentmail.to`, + }); + mockAgentMailCreateWebhook.mockResolvedValue({ + webhook_id: 'wh-1', + url: expectedWebhookUrl, + secret: 'whsec_created', + }); + + await expect( + saveCommsAuthConfigCommand(buildMockAuth(), { + provider: 'agentmail', + values: { R_AGENTMAIL_API_KEY: 'am-key' }, + }), + ).resolves.toMatchObject({ + agentmail: { + inboxAddress: `${expectedUsername}@agentmail.to`, + inboxEmail: `${expectedUsername}-alias@agentmail.to`, + }, + }); + }); + + it('asks the operator to choose when the org has several inboxes', async () => { + mockAgentMailListInboxes.mockResolvedValue({ + inboxes: [ + { inbox_id: 'one@agentmail.to' }, + { inbox_id: 'two@agentmail.to' }, + ], + }); + + await expect( + saveCommsAuthConfigCommand(buildMockAuth(), { + provider: 'agentmail', + values: { R_AGENTMAIL_API_KEY: 'am-key' }, + }), + ).rejects.toThrow(/2 inboxes.*one@agentmail\.to, two@agentmail\.to/s); + expect(mockAgentMailCreateInbox).not.toHaveBeenCalled(); + }); + + it('names the failing step when a later call is refused', async () => { + mockAgentMailCreateInbox.mockRejectedValue( + new Error( + 'AgentMail POST /v0/inboxes failed (403): {"message":"Forbidden"}', + ), + ); + + await expect( + saveCommsAuthConfigCommand(buildMockAuth(), { + provider: 'agentmail', + values: { R_AGENTMAIL_API_KEY: 'am-key' }, + }), + ).rejects.toThrow( + /refused permission while creating an inbox \(403 Forbidden\)/, + ); + }); + + it('validates the key, provisions an inbox and webhook, and persists the result', async () => { + mockAgentMailCreateInbox.mockResolvedValue({ + inbox_id: `${expectedUsername}@agentmail.to`, + }); + mockAgentMailCreateWebhook.mockResolvedValue({ + webhook_id: 'wh-1', + url: expectedWebhookUrl, + secret: 'whsec_test', + }); + + await expect( + saveCommsAuthConfigCommand(buildMockAuth(), { + provider: 'agentmail', + values: { R_AGENTMAIL_API_KEY: 'am-key' }, + }), + ).resolves.toMatchObject({ + agentmail: { + inboxAddress: `${expectedUsername}@agentmail.to`, + webhookUrl: expectedWebhookUrl, + }, + }); + + expect(mockAgentMailListInboxes).toHaveBeenCalledOnce(); + expect(mockAgentMailCreateInbox).toHaveBeenCalledWith({ + username: expectedUsername, + clientId: `roomote-${hostHash}`, + displayName: 'Roomote', + }); + expect(mockAgentMailCreateWebhook).toHaveBeenCalledWith({ + url: expectedWebhookUrl, + // The client id embeds the deployment host hash so deployments + // sharing one AgentMail account never adopt each other's webhook. + clientId: `roomote-agentmail-webhook-${hostHash}`, + inboxIds: [`${expectedUsername}@agentmail.to`], + eventTypes: [ + 'message.received', + 'message.bounced', + 'message.complained', + ], + }); + expect(mockUpsertDeploymentEnvironmentVariables).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + values: expect.arrayContaining([ + { name: 'R_AGENTMAIL_API_KEY', value: 'am-key' }, + { + name: 'R_AGENTMAIL_INBOX_ID', + value: `${expectedUsername}@agentmail.to`, + }, + { name: 'R_AGENTMAIL_WEBHOOK_SECRET', value: 'whsec_test' }, + ]), + }), + ); + }); + + it('rejects a bad API key with clear copy and persists nothing', async () => { + mockAgentMailListInboxes.mockRejectedValue( + new Error('AgentMail GET /v0/inboxes failed (401): Unauthorized'), + ); + + await expect( + saveCommsAuthConfigCommand(buildMockAuth(), { + provider: 'agentmail', + values: { R_AGENTMAIL_API_KEY: 'bad-key' }, + }), + ).rejects.toThrow( + /AgentMail rejected this API key\. Create a key in the AgentMail console with these permissions .* webhook_create/, + ); + + expect(mockUpsertDeploymentEnvironmentVariables).not.toHaveBeenCalled(); + }); + + it('distinguishes network failures from rejected keys', async () => { + const timeout = new Error('The operation was aborted due to timeout'); + timeout.name = 'TimeoutError'; + mockAgentMailListInboxes.mockRejectedValue(timeout); + + await expect( + saveCommsAuthConfigCommand(buildMockAuth(), { + provider: 'agentmail', + values: { R_AGENTMAIL_API_KEY: 'am-key' }, + }), + ).rejects.toThrow( + 'Could not reach the AgentMail API (timed out). Check connectivity and save again.', + ); + + expect(mockUpsertDeploymentEnvironmentVariables).not.toHaveBeenCalled(); + }); + + it('adopts an operator-supplied inbox after verifying the key can see it', async () => { + mockAgentMailGetInbox.mockResolvedValue({ + inbox_id: 'support@agentmail.to', + }); + mockAgentMailCreateWebhook.mockResolvedValue({ + webhook_id: 'wh-1', + url: expectedWebhookUrl, + secret: 'whsec_test', + }); + + await saveCommsAuthConfigCommand(buildMockAuth(), { + provider: 'agentmail', + values: { + R_AGENTMAIL_API_KEY: 'am-key', + R_AGENTMAIL_INBOX_ID: 'Support@AgentMail.to', + }, + }); + + expect(mockAgentMailGetInbox).toHaveBeenCalledWith( + 'support@agentmail.to', + ); + expect(mockAgentMailCreateInbox).not.toHaveBeenCalled(); + expect(mockUpsertDeploymentEnvironmentVariables).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + values: expect.arrayContaining([ + { name: 'R_AGENTMAIL_INBOX_ID', value: 'support@agentmail.to' }, + ]), + }), + ); + }); + + it('adopts a legacy client-id webhook and re-points it without recreating when a secret is stored', async () => { + mockResolveAgentMailRuntimeCredentials.mockResolvedValue({ + apiKey: 'am-key', + webhookSecret: 'whsec_existing', + inboxId: 'support@agentmail.to', + }); + mockGetPersistedEnvironmentVariableNames.mockResolvedValue([ + 'R_AGENTMAIL_API_KEY', + 'R_AGENTMAIL_INBOX_ID', + 'R_AGENTMAIL_WEBHOOK_SECRET', + ]); + mockAgentMailGetInbox.mockResolvedValue({ + inbox_id: 'support@agentmail.to', + }); + mockAgentMailListWebhooks.mockResolvedValue({ + webhooks: [ + { + webhook_id: 'wh-1', + url: 'https://old-deployment.example.com/api/webhooks/agentmail', + // Pre-hash client id from an earlier release. + client_id: 'roomote-agentmail-webhook', + inbox_ids: ['support@agentmail.to'], + }, + ], + }); + + await saveCommsAuthConfigCommand(buildMockAuth(), { + provider: 'agentmail', + values: { R_AGENTMAIL_INBOX_ID: 'support@agentmail.to' }, + }); + + expect(mockAgentMailUpdateWebhook).toHaveBeenCalledWith('wh-1', { + url: expectedWebhookUrl, + inboxIds: ['support@agentmail.to'], + eventTypes: [ + 'message.received', + 'message.bounced', + 'message.complained', + ], + }); + expect(mockAgentMailCreateWebhook).not.toHaveBeenCalled(); + expect(mockAgentMailDeleteWebhook).not.toHaveBeenCalled(); + }); + + it('re-scopes the webhook inbox_ids when the configured inbox changes', async () => { + mockResolveAgentMailRuntimeCredentials.mockResolvedValue({ + apiKey: 'am-key', + webhookSecret: 'whsec_existing', + inboxId: 'old-inbox@agentmail.to', + }); + mockGetPersistedEnvironmentVariableNames.mockResolvedValue([ + 'R_AGENTMAIL_API_KEY', + 'R_AGENTMAIL_INBOX_ID', + 'R_AGENTMAIL_WEBHOOK_SECRET', + ]); + mockAgentMailGetInbox.mockResolvedValue({ + inbox_id: 'new-inbox@agentmail.to', + }); + mockAgentMailListWebhooks.mockResolvedValue({ + webhooks: [ + { + webhook_id: 'wh-1', + // URL already matches; only the inbox scoping drifted. + url: expectedWebhookUrl, + client_id: `roomote-agentmail-webhook-${hostHash}`, + inbox_ids: ['old-inbox@agentmail.to'], + }, + ], + }); + + await saveCommsAuthConfigCommand(buildMockAuth(), { + provider: 'agentmail', + values: { R_AGENTMAIL_INBOX_ID: 'new-inbox@agentmail.to' }, + }); + + expect(mockAgentMailUpdateWebhook).toHaveBeenCalledWith('wh-1', { + url: expectedWebhookUrl, + inboxIds: ['new-inbox@agentmail.to'], + eventTypes: [ + 'message.received', + 'message.bounced', + 'message.complained', + ], + }); + expect(mockAgentMailCreateWebhook).not.toHaveBeenCalled(); + expect(mockAgentMailDeleteWebhook).not.toHaveBeenCalled(); + }); + + it('leaves a fully converged webhook untouched', async () => { + mockResolveAgentMailRuntimeCredentials.mockResolvedValue({ + apiKey: 'am-key', + webhookSecret: 'whsec_existing', + inboxId: 'support@agentmail.to', + }); + mockGetPersistedEnvironmentVariableNames.mockResolvedValue([ + 'R_AGENTMAIL_API_KEY', + 'R_AGENTMAIL_INBOX_ID', + 'R_AGENTMAIL_WEBHOOK_SECRET', + ]); + mockAgentMailGetInbox.mockResolvedValue({ + inbox_id: 'support@agentmail.to', + }); + mockAgentMailListWebhooks.mockResolvedValue({ + webhooks: [ + { + webhook_id: 'wh-1', + url: expectedWebhookUrl, + client_id: `roomote-agentmail-webhook-${hostHash}`, + inbox_ids: ['support@agentmail.to'], + event_types: [ + 'message.received', + 'message.bounced', + 'message.complained', + ], + }, + ], + }); + + await saveCommsAuthConfigCommand(buildMockAuth(), { + provider: 'agentmail', + values: { R_AGENTMAIL_INBOX_ID: 'support@agentmail.to' }, + }); + + expect(mockAgentMailUpdateWebhook).not.toHaveBeenCalled(); + expect(mockAgentMailCreateWebhook).not.toHaveBeenCalled(); + expect(mockAgentMailDeleteWebhook).not.toHaveBeenCalled(); + }); + + it("never adopts another deployment's webhook with a different host hash", async () => { + mockAgentMailGetInbox.mockResolvedValue({ + inbox_id: 'support@agentmail.to', + }); + mockAgentMailListWebhooks.mockResolvedValue({ + webhooks: [ + { + webhook_id: 'wh-other', + url: 'https://other-deployment.example.com/api/webhooks/agentmail', + client_id: 'roomote-agentmail-webhook-ffffff', + inbox_ids: ['other@agentmail.to'], + }, + ], + }); + mockAgentMailCreateWebhook.mockResolvedValue({ + webhook_id: 'wh-mine', + url: expectedWebhookUrl, + secret: 'whsec_mine', + }); + + await saveCommsAuthConfigCommand(buildMockAuth(), { + provider: 'agentmail', + values: { + R_AGENTMAIL_API_KEY: 'am-key', + R_AGENTMAIL_INBOX_ID: 'support@agentmail.to', + }, + }); + + expect(mockAgentMailUpdateWebhook).not.toHaveBeenCalled(); + expect(mockAgentMailDeleteWebhook).not.toHaveBeenCalled(); + expect(mockAgentMailCreateWebhook).toHaveBeenCalledWith({ + url: expectedWebhookUrl, + clientId: `roomote-agentmail-webhook-${hostHash}`, + inboxIds: ['support@agentmail.to'], + eventTypes: [ + 'message.received', + 'message.bounced', + 'message.complained', + ], + }); + }); + + it('creates the proposal inbox when the chooser requests it and it is missing', async () => { + mockAgentMailGetInbox.mockRejectedValue( + new Error('AgentMail GET /v0/inboxes/x failed (404): Not Found'), + ); + mockAgentMailCreateInbox.mockResolvedValue({ + inbox_id: `${expectedUsername}@agentmail.to`, + }); + mockAgentMailCreateWebhook.mockResolvedValue({ + webhook_id: 'wh-1', + url: expectedWebhookUrl, + secret: 'whsec_test', + }); + + await expect( + saveCommsAuthConfigCommand(buildMockAuth(), { + provider: 'agentmail', + values: { + R_AGENTMAIL_API_KEY: 'am-key', + R_AGENTMAIL_INBOX_ID: `${expectedUsername}@agentmail.to`, + }, + }), + ).resolves.toMatchObject({ + agentmail: { inboxAddress: `${expectedUsername}@agentmail.to` }, + }); + + expect(mockAgentMailCreateInbox).toHaveBeenCalledWith({ + username: expectedUsername, + clientId: `roomote-${hostHash}`, + displayName: 'Roomote', + }); + expect(mockUpsertDeploymentEnvironmentVariables).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + values: expect.arrayContaining([ + { + name: 'R_AGENTMAIL_INBOX_ID', + value: `${expectedUsername}@agentmail.to`, + }, + ]), + }), + ); + }); + + it('still rejects a missing inbox that is not the deployment proposal', async () => { + mockAgentMailGetInbox.mockRejectedValue( + new Error('AgentMail GET /v0/inboxes/x failed (404): Not Found'), + ); + + await expect( + saveCommsAuthConfigCommand(buildMockAuth(), { + provider: 'agentmail', + values: { + R_AGENTMAIL_API_KEY: 'am-key', + R_AGENTMAIL_INBOX_ID: 'missing@agentmail.to', + }, + }), + ).rejects.toThrow( + /could not find the inbox missing@agentmail\.to with this API key/u, + ); + + expect(mockAgentMailCreateInbox).not.toHaveBeenCalled(); + expect(mockUpsertDeploymentEnvironmentVariables).not.toHaveBeenCalled(); + }); + + it('surfaces a taken username inline with guidance to pick an address', async () => { + mockAgentMailCreateInbox.mockRejectedValue( + new Error( + 'AgentMail POST /v0/inboxes failed (409): Inbox already exists', + ), + ); + + await expect( + saveCommsAuthConfigCommand(buildMockAuth(), { + provider: 'agentmail', + values: { R_AGENTMAIL_API_KEY: 'am-key' }, + }), + ).rejects.toThrow(/already taken at AgentMail/u); + + expect(mockUpsertDeploymentEnvironmentVariables).not.toHaveBeenCalled(); + }); + }); + + describe('listAgentMailInboxesCommand', () => { + const hostHash = createHash('sha256') + .update('app.example.com') + .digest('hex') + .slice(0, 6); + const proposedNewAddress = `roomote-app-example-com-${hostHash}@agentmail.to`; + + it('rejects non-admin users', async () => { + await expect( + listAgentMailInboxesCommand(buildMockAuth({ isAdmin: false }), {}), + ).rejects.toThrow('Unauthorized'); + }); + + it('lists normalized inboxes with the entered key even when one is saved', async () => { + mockResolveAgentMailRuntimeCredentials.mockResolvedValue({ + apiKey: 'saved-key', + webhookSecret: null, + inboxId: null, + }); + mockAgentMailListInboxes.mockResolvedValue({ + inboxes: [ + { inbox_id: 'One@AgentMail.to' }, + { inbox_id: 'two@agentmail.to' }, + ], + }); + + await expect( + listAgentMailInboxesCommand(buildMockAuth(), { + apiKey: ' typed-key ', + }), + ).resolves.toEqual({ + inboxes: [ + { inboxId: 'one@agentmail.to', email: 'one@agentmail.to' }, + { inboxId: 'two@agentmail.to', email: 'two@agentmail.to' }, + ], + proposedNewAddress, + }); + + expect(mockAgentMailClientConstructor).toHaveBeenCalledWith( + expect.objectContaining({ apiKey: 'typed-key' }), + ); + }); + + it('falls back to the saved API key when none is entered', async () => { + mockResolveAgentMailRuntimeCredentials.mockResolvedValue({ + apiKey: 'saved-key', + webhookSecret: null, + inboxId: null, + }); + mockAgentMailListInboxes.mockResolvedValue({ + inboxes: [{ inbox_id: 'existing@agentmail.to' }], + }); + + await expect( + listAgentMailInboxesCommand(buildMockAuth(), {}), + ).resolves.toEqual({ + inboxes: [ + { + inboxId: 'existing@agentmail.to', + email: 'existing@agentmail.to', + }, + ], + proposedNewAddress, + }); + + expect(mockAgentMailClientConstructor).toHaveBeenCalledWith( + expect.objectContaining({ apiKey: 'saved-key' }), + ); + }); + + it('errors clearly when no API key is entered or saved', async () => { + await expect( + listAgentMailInboxesCommand(buildMockAuth(), {}), + ).rejects.toThrow( + 'Enter an AgentMail API key to load the account inboxes.', + ); + + expect(mockAgentMailListInboxes).not.toHaveBeenCalled(); + }); + + it('classifies a refused key with the required permissions copy', async () => { + mockAgentMailListInboxes.mockRejectedValue( + new Error('AgentMail GET /v0/inboxes failed (403): Forbidden'), + ); + + await expect( + listAgentMailInboxesCommand(buildMockAuth(), { apiKey: 'bad-key' }), + ).rejects.toThrow( + /AgentMail rejected this API key\. Create a key in the AgentMail console with these permissions .* webhook_create/, + ); + }); + }); + + describe('agentmail clear', () => { + it('best-effort deletes the reconciled webhook and removes the secret', async () => { + const txDelete = vi.fn(() => ({ + where: vi.fn(async () => undefined), + })); + const txInArray = (await import('@roomote/db/server')).inArray; + mockDbTransaction.mockImplementation(async (callback) => + callback({ delete: txDelete } as never), + ); + mockResolveAgentMailRuntimeCredentials.mockResolvedValue({ + apiKey: 'am-key', + webhookSecret: 'whsec_existing', + inboxId: 'support@agentmail.to', + }); + mockAgentMailListWebhooks.mockResolvedValue({ + webhooks: [ + { + webhook_id: 'wh-1', + url: 'https://app.example.com/api/webhooks/agentmail', + client_id: 'roomote-agentmail-webhook', + }, + ], + }); + + await clearCommsAuthConfigCommand(buildMockAuth(), { + provider: 'agentmail', + }); + + expect(mockAgentMailDeleteWebhook).toHaveBeenCalledWith('wh-1'); + expect(txInArray).toHaveBeenCalledWith( + 'env.name', + expect.arrayContaining([ + 'R_AGENTMAIL_API_KEY', + 'R_AGENTMAIL_INBOX_ID', + 'R_AGENTMAIL_WEBHOOK_SECRET', + ]), + ); + }); + + it('never fails the disconnect when the webhook delete errors', async () => { + const txDelete = vi.fn(() => ({ + where: vi.fn(async () => undefined), + })); + mockDbTransaction.mockImplementation(async (callback) => + callback({ delete: txDelete } as never), + ); + mockResolveAgentMailRuntimeCredentials.mockResolvedValue({ + apiKey: 'am-key', + webhookSecret: 'whsec_existing', + inboxId: 'support@agentmail.to', + }); + mockAgentMailListWebhooks.mockRejectedValue( + new Error('AgentMail GET /v0/webhooks failed (500)'), + ); + + await expect( + clearCommsAuthConfigCommand(buildMockAuth(), { + provider: 'agentmail', + }), + ).resolves.toBeUndefined(); + expect(txDelete).toHaveBeenCalled(); + }); + }); + + describe('agentmail status', () => { + const expectedWebhookUrl = 'https://app.example.com/api/webhooks/agentmail'; + + beforeEach(() => { + mockResolveAgentMailRuntimeCredentials.mockResolvedValue({ + apiKey: 'am-key', + webhookSecret: 'whsec_existing', + inboxId: 'support@agentmail.to', + }); + }); + + it('reports connected when the webhook covers the configured inbox', async () => { + mockAgentMailListWebhooks.mockResolvedValue({ + webhooks: [ + { + webhook_id: 'wh-1', + url: expectedWebhookUrl, + client_id: 'roomote-agentmail-webhook', + inbox_ids: ['support@agentmail.to'], + }, + ], + }); + + const status = await getCommsStatusCommand(buildMockAuth()); + const agentmail = status.providers.find((p) => p.id === 'agentmail'); + + expect(agentmail?.agentmail?.webhook.status).toBe('connected'); + }); + + it('reports mismatch when the webhook is scoped to a different inbox', async () => { + mockAgentMailListWebhooks.mockResolvedValue({ + webhooks: [ + { + webhook_id: 'wh-1', + url: expectedWebhookUrl, + client_id: 'roomote-agentmail-webhook', + inbox_ids: ['someone-else@agentmail.to'], + }, + ], + }); + + const status = await getCommsStatusCommand(buildMockAuth()); + const agentmail = status.providers.find((p) => p.id === 'agentmail'); + + expect(agentmail?.agentmail?.webhook.status).toBe('mismatch'); + }); + }); + describe('telegram status', () => { it('reflects persisted Telegram values as saved', async () => { mockGetPersistedEnvironmentVariableNames.mockResolvedValue([ diff --git a/apps/web/src/trpc/commands/comms/index.ts b/apps/web/src/trpc/commands/comms/index.ts index 18a4d1c76..fa286b89f 100644 --- a/apps/web/src/trpc/commands/comms/index.ts +++ b/apps/web/src/trpc/commands/comms/index.ts @@ -3,8 +3,10 @@ import { createHash } from 'node:crypto'; import { DiscordBotTokenValidationError, discordGatewaySessions, + invalidateAgentMailRuntimeCredentialsCache, invalidateDiscordRuntimeCredentialsCache, normalizeDiscordBotToken, + resolveAgentMailRuntimeCredentials, resolveDiscordGatewaySecret, resolveDiscordRuntimeCredentials, validateDiscordBotToken, @@ -23,6 +25,11 @@ import { like, type DatabaseOrTransaction, } from '@roomote/db/server'; +import { + AgentMailApiClient, + AgentMailApiError, + type AgentMailWebhook, +} from '@roomote/communication/agentmail-provider'; import { discordChannelRequiresTag, DiscordCommunicationProvider, @@ -38,9 +45,11 @@ import { syncDiscordInstallationChannels, } from '@roomote/sdk/server'; -import { Env } from '@/lib/server/env'; +import { Env, isEmailChannelEnabled } from '@/lib/server/env'; +import { buildDeploymentAppName } from '@/lib/server/deployment-app-name'; import { DISCORD_INSTALL_PERMISSIONS } from '@/lib/discord-install'; import { + PRODUCT_NAME, buildSetupAuthStatus, getSetupAuthProvider, NON_SECRET_AUTH_ENV_VAR_NAMES, @@ -64,12 +73,13 @@ import { invalidateTeamsBotCredentialCheckCache, } from '../teams/bot-credential-check'; -type AdditionalCommsProviderId = 'telegram' | 'discord'; +type AdditionalCommsProviderId = 'telegram' | 'discord' | 'agentmail'; type CommsProviderId = SetupAuthProviderId | AdditionalCommsProviderId; export const COMMS_PROVIDER_IDS = [ ...SETUP_AUTH_PROVIDER_IDS, 'telegram', 'discord', + 'agentmail', ] as const; type AdditionalCommsProviderDefinition = { @@ -119,12 +129,34 @@ const ADDITIONAL_COMMS_PROVIDERS: Record< }, ], }, + agentmail: { + id: 'agentmail', + label: 'Email (AgentMail)', + fields: [ + { + envVarName: 'R_AGENTMAIL_API_KEY', + acceptedEnvVarNames: ['R_AGENTMAIL_API_KEY'], + label: 'AgentMail API Key', + secret: true, + }, + { + envVarName: 'R_AGENTMAIL_INBOX_ID', + acceptedEnvVarNames: ['R_AGENTMAIL_INBOX_ID'], + label: 'Inbox Email Address', + required: false, + }, + ], + }, }; function isAdditionalCommsProviderId( provider: CommsProviderId, ): provider is AdditionalCommsProviderId { - return provider === 'telegram' || provider === 'discord'; + return ( + provider === 'telegram' || + provider === 'discord' || + provider === 'agentmail' + ); } function getCommsProviderDefinition(provider: CommsProviderId) { @@ -149,6 +181,7 @@ export type CommsProviderStatus = Omit< telegramWebhook?: TelegramWebhookStatus | null; telegramBotUsername?: string | null; discord?: DiscordCommsStatus | null; + agentmail?: AgentMailCommsStatus | null; }; export type CommsStatus = Omit & { @@ -165,6 +198,21 @@ type TelegramWebhookStatus = { lastErrorAtMs: number | null; }; +type AgentMailWebhookStatus = { + status: 'connected' | 'mismatch' | 'unregistered' | 'error'; + registeredUrl: string | null; + expectedUrl: string; + errorMessage: string | null; +}; + +export type AgentMailCommsStatus = { + /** The routed inbox_id (the persisted configuration value). */ + inboxAddress: string | null; + /** The deliverable address for display, resolved live from AgentMail. */ + inboxEmail: string | null; + webhook: AgentMailWebhookStatus; +}; + type DiscordGatewayPhase = | 'starting' | 'standby' @@ -579,6 +627,612 @@ export async function repairTelegramWebhookCommand(auth: UserAuthSuccess) { return { repaired: true }; } +const AGENTMAIL_API_TIMEOUT_MS = 5_000; +/** + * Client id used before webhook ids became deployment-specific. Still matched + * on lookup so existing registrations are adopted and converged instead of + * orphaned. + */ +const AGENTMAIL_LEGACY_WEBHOOK_CLIENT_ID = 'roomote-agentmail-webhook'; +const AGENTMAIL_INBOX_HASH_LENGTH = 6; + +function buildExpectedAgentMailWebhookUrl(): string { + return new URL('/api/webhooks/agentmail', Env.R_APP_URL).toString(); +} + +function createAgentMailApiClient(apiKey: string) { + return new AgentMailApiClient({ + apiKey, + timeoutMs: AGENTMAIL_API_TIMEOUT_MS, + }); +} + +const EMAIL_CHANNEL_DISABLED_MESSAGE = + 'Email is not enabled for this deployment. Set R_EMAIL_CHANNEL_ENABLED=true and restart to configure it.'; + +function assertEmailChannelEnabled(): void { + if (!isEmailChannelEnabled()) { + throw new Error(EMAIL_CHANNEL_DISABLED_MESSAGE); + } +} + +/** Map AgentMail API / network failures into admin-facing setup copy. */ +/** + * AgentMail keys carry fine-grained permissions + * (https://docs.agentmail.to/core-concepts/permissions); this is the full set + * the channel needs across setup, inbound processing, and replies. + */ +const AGENTMAIL_REQUIRED_PERMISSIONS = + 'inbox_read, inbox_create, inbox_update, webhook_read, webhook_create, webhook_update, webhook_delete, message_read, message_send'; + +function classifyAgentMailSetupError( + error: unknown, + operation: + | 'validating the API key' + | 'reading the inbox' + | 'reading inbox messages' + | 'creating an inbox' + | 'configuring the webhook' = 'validating the API key', +): string { + const message = error instanceof Error ? error.message : String(error); + const lower = message.toLowerCase(); + const errorName = error instanceof Error ? error.name : ''; + + if ( + errorName === 'TimeoutError' || + errorName === 'AbortError' || + lower.includes('aborted') || + lower.includes('timeout') || + lower.includes('timed out') + ) { + return 'Could not reach the AgentMail API (timed out). Check connectivity and save again.'; + } + + if ( + lower.includes('fetch failed') || + lower.includes('econnrefused') || + lower.includes('enotfound') || + lower.includes('econnreset') || + lower.includes('network') || + lower.includes('certificate') || + lower.includes('getaddrinfo') + ) { + return 'Could not reach the AgentMail API. Check connectivity and save again.'; + } + + if ( + lower.includes('(401)') || + lower.includes('(403)') || + lower.includes('unauthorized') || + lower.includes('forbidden') || + lower.includes('invalid api key') + ) { + return operation === 'validating the API key' + ? `AgentMail rejected this API key. Create a key in the AgentMail console with these permissions (or full access) and save again: ${AGENTMAIL_REQUIRED_PERMISSIONS}.` + : `AgentMail refused permission while ${operation} (${message.includes('(403)') ? '403 Forbidden' : '401 Unauthorized'}). Create a key with these permissions (or full access) and save again: ${AGENTMAIL_REQUIRED_PERMISSIONS}.`; + } + + return `AgentMail failed while ${operation}: ${message.trim() || 'could not connect.'}`; +} + +/** + * Short stable hash of the deployment's public hostname. Keys both the inbox + * proposal and the webhook client id, so two deployments sharing one + * AgentMail account never adopt (or delete) each other's resources. + */ +function buildAgentMailHostHash(publicAppUrl: string): string { + return createHash('sha256') + .update(new URL(publicAppUrl).hostname) + .digest('hex') + .slice(0, AGENTMAIL_INBOX_HASH_LENGTH); +} + +function buildAgentMailWebhookClientId(publicAppUrl: string): string { + return `${AGENTMAIL_LEGACY_WEBHOOK_CLIENT_ID}-${buildAgentMailHostHash(publicAppUrl)}`; +} + +function readAgentMailWebhookInboxIds(webhook: AgentMailWebhook): string[] { + return Array.isArray(webhook.inbox_ids) ? webhook.inbox_ids.map(String) : []; +} + +function readAgentMailWebhookEventTypes(webhook: AgentMailWebhook): string[] { + return Array.isArray(webhook.event_types) + ? webhook.event_types.map(String) + : []; +} + +function findRoomoteAgentMailWebhook( + webhooks: readonly AgentMailWebhook[] | undefined, +): AgentMailWebhook | null { + const deploymentClientId = buildAgentMailWebhookClientId(Env.R_APP_URL); + return ( + webhooks?.find((webhook) => webhook['client_id'] === deploymentClientId) ?? + webhooks?.find( + (webhook) => webhook['client_id'] === AGENTMAIL_LEGACY_WEBHOOK_CLIENT_ID, + ) ?? + null + ); +} + +/** + * Propose a deterministic inbox username for this deployment: the shared + * deployment app name plus a short hash of the full public hostname so + * truncated app names cannot collide across deployments. The same hash keys + * the createInbox client id, which makes inbox creation idempotent across + * re-saves. + */ +function buildAgentMailInboxProposal(publicAppUrl: string): { + username: string; + clientId: string; +} { + const hostHash = buildAgentMailHostHash(publicAppUrl); + const username = `${buildDeploymentAppName(publicAppUrl).toLowerCase()}-${hostHash}`; + + return { username, clientId: `roomote-${hostHash}` }; +} + +/** Default domain AgentMail assigns to inboxes created without a domain. */ +const AGENTMAIL_DEFAULT_INBOX_DOMAIN = 'agentmail.to'; + +function buildAgentMailProposedInboxAddress(proposal: { + username: string; +}): string { + return `${proposal.username}@${AGENTMAIL_DEFAULT_INBOX_DOMAIN}`; +} + +function normalizeAgentMailInboxAddress( + value: string | null | undefined, +): string | null { + const normalized = value?.trim().toLowerCase(); + return normalized || null; +} + +/** + * The value setup persists and every later call routes by. AgentMail's + * schema carries both `inbox_id` and `email` (equal today), but `inbox_id` + * is the key the API contract requires in request paths and webhook + * `inbox_ids` filters — so it must win if the fields ever diverge. `email` + * is only a display fallback for a hypothetical object without an id. + */ +function readAgentMailInboxAddress( + inbox: Record, +): string | null { + const inboxId = typeof inbox.inbox_id === 'string' ? inbox.inbox_id : null; + const email = typeof inbox.email === 'string' ? inbox.email : null; + return normalizeAgentMailInboxAddress(inboxId ?? email); +} + +/** + * The deliverable address of an inbox object, for operator-facing display + * only — never persisted and never used to route (see + * readAgentMailInboxAddress). Deriving it live from the API each time means + * a stored value can never drift from AgentMail's record. + */ +function readAgentMailInboxEmail( + inbox: Record, +): string | null { + const email = typeof inbox.email === 'string' ? inbox.email : null; + return ( + normalizeAgentMailInboxAddress(email) ?? readAgentMailInboxAddress(inbox) + ); +} + +/** `email (inbox_id)` when the fields differ, else just the address. */ +function formatAgentMailInboxLabel( + inboxId: string, + email: string | null, +): string { + return email && email !== inboxId ? `${email} (${inboxId})` : inboxId; +} + +async function getAgentMailCommsStatus(): Promise { + const credentials = await resolveAgentMailRuntimeCredentials(); + if (!credentials.apiKey) return null; + + const expectedUrl = buildExpectedAgentMailWebhookUrl(); + const client = createAgentMailApiClient(credentials.apiKey); + + try { + // Display only: the deliverable address may differ from the routed + // inbox_id, and resolving it live means it can never drift. Best-effort; + // the webhook status below is the load-bearing part. + let inboxEmail: string | null = null; + if (credentials.inboxId) { + try { + const inbox = await client.getInbox(credentials.inboxId); + inboxEmail = inbox ? readAgentMailInboxEmail(inbox) : null; + } catch { + inboxEmail = null; + } + } + + const { webhooks } = await client.listWebhooks(); + const webhook = findRoomoteAgentMailWebhook(webhooks); + const registeredUrl = webhook?.url ?? null; + // A webhook without inbox_ids receives every inbox's events, so only an + // explicit scope that omits the configured inbox counts as drift. + const registeredInboxIds = webhook + ? readAgentMailWebhookInboxIds(webhook) + : []; + const inboxScopeMatches = + !credentials.inboxId || + registeredInboxIds.length === 0 || + registeredInboxIds.includes(credentials.inboxId); + + return { + inboxAddress: credentials.inboxId, + inboxEmail, + webhook: { + status: !webhook + ? 'unregistered' + : registeredUrl === expectedUrl && inboxScopeMatches + ? 'connected' + : 'mismatch', + registeredUrl, + expectedUrl, + errorMessage: null, + }, + }; + } catch (error) { + return { + inboxAddress: credentials.inboxId, + inboxEmail: null, + webhook: { + status: 'error', + registeredUrl: null, + expectedUrl, + errorMessage: classifyAgentMailSetupError(error), + }, + }; + } +} + +/** + * List the AgentMail inboxes the given (or saved) API key can see, plus the + * deployment's proposed new-inbox address, so the settings UI can offer a + * chooser instead of a free-text inbox field. Read-only: nothing is created + * or persisted here. + */ +export async function listAgentMailInboxesCommand( + auth: UserAuthSuccess, + input: { apiKey?: string } = {}, +): Promise<{ + inboxes: Array<{ inboxId: string; email: string }>; + proposedNewAddress: string; +}> { + assertAdmin(auth); + assertEmailChannelEnabled(); + + invalidateAgentMailRuntimeCredentialsCache(); + const existing = await resolveAgentMailRuntimeCredentials(); + const apiKey = input.apiKey?.trim() || existing.apiKey; + + if (!apiKey) { + throw new Error('Enter an AgentMail API key to load the account inboxes.'); + } + + const client = createAgentMailApiClient(apiKey); + + try { + const listed = await client.listInboxes(); + const inboxes = (listed.inboxes ?? []) + .map((inbox) => { + const inboxId = readAgentMailInboxAddress(inbox); + return inboxId + ? { inboxId, email: readAgentMailInboxEmail(inbox) ?? inboxId } + : null; + }) + .filter((entry): entry is { inboxId: string; email: string } => + Boolean(entry), + ); + + return { + inboxes, + proposedNewAddress: buildAgentMailProposedInboxAddress( + buildAgentMailInboxProposal(Env.R_APP_URL), + ), + }; + } catch (error) { + throw new Error( + classifyAgentMailSetupError(error, 'validating the API key'), + ); + } +} + +type AgentMailReconcileResult = { + /** The routed inbox_id — persisted and used in API paths/webhook scoping. */ + inboxAddress: string; + /** The deliverable address, display only. */ + inboxEmail: string; + webhookUrl: string; + webhookSecret: string | null; +}; + +/** + * Create the deployment's proposed inbox (idempotent via the proposal client + * id) and return its normalized routing address plus the deliverable email. + * Shared by the blank-inbox provision path and the chooser's explicit + * "create new" path. + */ +async function createProposedAgentMailInbox( + client: AgentMailApiClient, + proposal: { username: string; clientId: string }, +): Promise<{ inboxAddress: string; inboxEmail: string }> { + try { + const inbox = await client.createInbox({ + username: proposal.username, + clientId: proposal.clientId, + displayName: PRODUCT_NAME, + }); + const createdAddress = readAgentMailInboxAddress(inbox); + if (!createdAddress) { + throw new Error('AgentMail created an inbox but returned no inbox id.'); + } + return { + inboxAddress: createdAddress, + inboxEmail: readAgentMailInboxEmail(inbox) ?? createdAddress, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (/\(409\)|already exists|already taken/iu.test(message)) { + throw new Error( + `The email address ${proposal.username} is already taken at AgentMail. Enter an inbox email address of your own in the Inbox Email Address field and save again.`, + ); + } + throw new Error(classifyAgentMailSetupError(error, 'creating an inbox')); + } +} + +/** + * Reconcile the AgentMail account against this deployment before persisting + * anything: validate the API key, adopt or provision the inbox, and converge + * the webhook registration on this deployment's URL. Every step is idempotent + * (inbox and webhook creation are keyed by client id), so a partial failure + * is fixed by saving again. Failures throw with admin-facing copy and abort + * the save so credentials are never persisted half-configured. + */ +async function reconcileAgentMailSetup(input: { + enteredApiKey: string | null; + enteredInboxId: string | null; +}): Promise { + assertEmailChannelEnabled(); + invalidateAgentMailRuntimeCredentialsCache(); + const existing = await resolveAgentMailRuntimeCredentials(); + const apiKey = input.enteredApiKey ?? existing.apiKey; + + if (!apiKey) { + throw new Error( + 'Enter the required Email (AgentMail) configuration values to continue.', + ); + } + + const client = createAgentMailApiClient(apiKey); + + // Prove the key authenticates with the cheapest read before touching + // anything else, so a bad key fails with a clear message instead of a + // confusing inbox or webhook error. + const orgInboxes: string[] = []; + const inboxDisplayNames = new Map(); + const inboxEmails = new Map(); + try { + const listed = await client.listInboxes(); + for (const inbox of listed.inboxes ?? []) { + const address = readAgentMailInboxAddress(inbox); + if (!address) continue; + orgInboxes.push(address); + inboxDisplayNames.set( + address, + typeof inbox.display_name === 'string' ? inbox.display_name : null, + ); + const email = readAgentMailInboxEmail(inbox); + if (email) { + inboxEmails.set(address, email); + } + } + } catch (error) { + throw new Error( + classifyAgentMailSetupError(error, 'validating the API key'), + ); + } + + // Webhook permissions are the ones default console keys most often lack; + // prove them during validation so the failure names the missing permission + // before any inbox work happens. + try { + await client.listWebhooks(); + } catch (error) { + throw new Error( + classifyAgentMailSetupError(error, 'configuring the webhook'), + ); + } + + const requestedInboxId = + normalizeAgentMailInboxAddress(input.enteredInboxId) ?? existing.inboxId; + let inboxAddress: string; + + if (requestedInboxId) { + try { + const inbox = await client.getInbox(requestedInboxId); + inboxAddress = readAgentMailInboxAddress(inbox) ?? requestedInboxId; + const email = readAgentMailInboxEmail(inbox); + if (email) { + inboxEmails.set(inboxAddress, email); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (/\(404\)|not found/iu.test(message)) { + const proposal = buildAgentMailInboxProposal(Env.R_APP_URL); + if (requestedInboxId === buildAgentMailProposedInboxAddress(proposal)) { + // The inbox chooser's "create new" option submits the deployment's + // proposal address explicitly, so a 404 here means it does not + // exist yet: create it instead of erroring. + const created = await createProposedAgentMailInbox(client, proposal); + inboxAddress = created.inboxAddress; + inboxEmails.set(created.inboxAddress, created.inboxEmail); + } else { + throw new Error( + `AgentMail could not find the inbox ${requestedInboxId} with this API key. Check the inbox email address, or clear it to let Roomote create one.`, + ); + } + } else { + throw new Error( + classifyAgentMailSetupError(error, 'reading the inbox'), + ); + } + } + } else if (orgInboxes.length === 1) { + // The org already has exactly one inbox (the console provisions one at + // signup): adopt it instead of trying to create a second — free-tier + // plans often cannot, and a surprise extra inbox helps nobody. + inboxAddress = orgInboxes[0]!; + } else if (orgInboxes.length > 1) { + throw new Error( + `This AgentMail account has ${orgInboxes.length} inboxes. Enter the one Roomote should use in the Inbox Email Address field: ${orgInboxes + .map((address) => + formatAgentMailInboxLabel(address, inboxEmails.get(address) ?? null), + ) + .join(', ')}`, + ); + } else { + const created = await createProposedAgentMailInbox( + client, + buildAgentMailInboxProposal(Env.R_APP_URL), + ); + inboxAddress = created.inboxAddress; + inboxEmails.set(created.inboxAddress, created.inboxEmail); + } + + // Prove message_read without side effects: fetching a sentinel message id + // returns 404 when the permission exists and 403 when it does not. An + // already-converged inbox/webhook would otherwise let a key without + // message permissions reach the successful save path, deferring the + // failure to runtime (oversize-body re-fetches and every reply). + // message_send has no side-effect-free probe; it is exercised on the + // first reply. + try { + await client.getMessage(inboxAddress, 'roomote-permission-probe'); + } catch (error) { + // Only the expected 404 proves the permission; anything else — 403, but + // also timeouts and network failures — means validation never completed + // and must fail the save rather than persist an unvalidated key. + if (!(error instanceof AgentMailApiError) || error.status !== 404) { + throw new Error( + classifyAgentMailSetupError(error, 'reading inbox messages'), + ); + } + } + + // Recipients see the inbox display name as the sender ("Roomote + //
"); AgentMail's own default reads as "AgentMail". Converging is + // cosmetic, so a failure logs instead of aborting the save. + if (inboxDisplayNames.get(inboxAddress) !== PRODUCT_NAME) { + try { + await client.updateInbox(inboxAddress, { displayName: PRODUCT_NAME }); + } catch (error) { + console.warn( + `[comms] Failed to set the AgentMail inbox display name: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + // Converge the deployment's webhook (found by client id) on the current + // URL and inbox scope, so pointing the config at a different inbox re-scopes + // delivery instead of silently keeping the old inbox. The webhook secret + // only exists where AgentMail returns it, so a registration we can no + // longer verify deliveries for is recreated. + const webhookUrl = buildExpectedAgentMailWebhookUrl(); + const desiredInboxIds = [inboxAddress]; + // Bounce/complaint events feed the outbound suppression list; a webhook + // created by an earlier release only carries message.received, so event + // types are converged like the URL and inbox scope. + const desiredEventTypes = [ + 'message.received', + 'message.bounced', + 'message.complained', + ]; + let webhookSecret = existing.webhookSecret; + + try { + const { webhooks } = await client.listWebhooks(); + const existingWebhook = findRoomoteAgentMailWebhook(webhooks); + const createDeploymentWebhook = async (): Promise => { + const created = await client.createWebhook({ + url: webhookUrl, + clientId: buildAgentMailWebhookClientId(Env.R_APP_URL), + inboxIds: desiredInboxIds, + eventTypes: desiredEventTypes, + }); + return typeof created.secret === 'string' && created.secret.trim() + ? created.secret.trim() + : null; + }; + + if (existingWebhook) { + const registeredInboxIds = readAgentMailWebhookInboxIds(existingWebhook); + const inboxScopeMatches = + registeredInboxIds.length === desiredInboxIds.length && + desiredInboxIds.every((id) => registeredInboxIds.includes(id)); + const registeredEventTypes = + readAgentMailWebhookEventTypes(existingWebhook); + const eventTypesMatch = + registeredEventTypes.length === desiredEventTypes.length && + desiredEventTypes.every((type) => registeredEventTypes.includes(type)); + if ( + existingWebhook.url !== webhookUrl || + !inboxScopeMatches || + !eventTypesMatch + ) { + await client.updateWebhook(existingWebhook.webhook_id, { + url: webhookUrl, + inboxIds: desiredInboxIds, + eventTypes: desiredEventTypes, + }); + } + const apiSecret = + typeof existingWebhook.secret === 'string' && + existingWebhook.secret.trim() + ? existingWebhook.secret.trim() + : null; + if (apiSecret) { + webhookSecret = apiSecret; + } + if (!webhookSecret) { + await client.deleteWebhook(existingWebhook.webhook_id); + webhookSecret = await createDeploymentWebhook(); + } + } else { + webhookSecret = (await createDeploymentWebhook()) ?? webhookSecret; + } + } catch (error) { + throw new Error( + classifyAgentMailSetupError(error, 'configuring the webhook'), + ); + } + + return { + inboxAddress, + inboxEmail: inboxEmails.get(inboxAddress) ?? inboxAddress, + webhookUrl, + webhookSecret, + }; +} + +/** Deleting the webhook must never block a disconnect. */ +async function deleteAgentMailWebhookBestEffort(): Promise { + try { + const credentials = await resolveAgentMailRuntimeCredentials(); + if (!credentials.apiKey) return; + const client = createAgentMailApiClient(credentials.apiKey); + const { webhooks } = await client.listWebhooks(); + const webhook = findRoomoteAgentMailWebhook(webhooks); + if (webhook) { + await client.deleteWebhook(webhook.webhook_id); + } + } catch { + // Best effort only. + } +} + type DiscordRegistrationResult = { registered: boolean; guildCount: number; @@ -814,15 +1468,19 @@ function withAdditionalCommsProviders( status: SetupAuthStatus, options: { persistedEnvVarNames: string[]; + persistedEnvVarValues: Record; telegramWebhook: TelegramWebhookStatus | null; discord: DiscordCommsStatus | null; + agentmail: AgentMailCommsStatus | null; invocationIdentities: InvocationIdentity[]; }, ): CommsStatus { const { persistedEnvVarNames, + persistedEnvVarValues, telegramWebhook, discord, + agentmail, invocationIdentities, } = options; const telegramBotUsername = @@ -838,7 +1496,10 @@ function withAdditionalCommsProviders( ...field, runtimeSatisfied: isRuntime(field.envVarName), savedSatisfied: isSaved(field.envVarName), - savedValue: null, + savedValue: + field.secret === true + ? null + : (persistedEnvVarValues[field.envVarName]?.trim() ?? null), satisfiedByEnvVarName: isSatisfied(field.envVarName) ? field.envVarName : null, @@ -861,6 +1522,7 @@ function withAdditionalCommsProviders( ? { telegramWebhook, telegramBotUsername } : {}), ...(definition.id === 'discord' ? { discord } : {}), + ...(definition.id === 'agentmail' ? { agentmail } : {}), }; }; @@ -871,6 +1533,11 @@ function withAdditionalCommsProviders( ...status.providers, buildProviderStatus(ADDITIONAL_COMMS_PROVIDERS.telegram), buildProviderStatus(ADDITIONAL_COMMS_PROVIDERS.discord), + // Email is gated by R_EMAIL_CHANNEL_ENABLED (see isEmailChannelEnabled) + // and stays out of the settings surface entirely until it is set. + ...(isEmailChannelEnabled() + ? [buildProviderStatus(ADDITIONAL_COMMS_PROVIDERS.agentmail)] + : []), ], }; } @@ -882,15 +1549,20 @@ export async function getCommsStatusCommand( const [ persistedEnvVarNames, - nonSecretAuthEnvValues, + nonSecretEnvValues, telegramWebhook, discord, + agentmail, invocationIdentities, ] = await Promise.all([ getPersistedEnvironmentVariableNames(), - getPersistedEnvironmentVariableValues([...NON_SECRET_AUTH_ENV_VAR_NAMES]), + getPersistedEnvironmentVariableValues([ + ...NON_SECRET_AUTH_ENV_VAR_NAMES, + 'R_AGENTMAIL_INBOX_ID', + ]), getTelegramWebhookStatus(), getDiscordCommsStatus(), + getAgentMailCommsStatus(), resolveInvocationIdentities(), ]); @@ -898,12 +1570,14 @@ export async function getCommsStatusCommand( buildSetupAuthStatus({ runtimeEnv: process.env, persistedEnvVarNames, - persistedEnvVarValues: nonSecretAuthEnvValues, + persistedEnvVarValues: nonSecretEnvValues, }), { persistedEnvVarNames, + persistedEnvVarValues: nonSecretEnvValues, telegramWebhook, discord, + agentmail, invocationIdentities, }, ); @@ -941,6 +1615,18 @@ export async function saveCommsAuthConfigCommand( await assertTeamsBotCredentialsAuthenticate(input.values); } + // AgentMail saves are a reconcile: validate the key, adopt or provision the + // inbox, and converge the webhook before anything is persisted, so the + // stored configuration always includes the final inbox address and the + // webhook secret AgentMail issued. + const agentmailSetup = + input.provider === 'agentmail' + ? await reconcileAgentMailSetup({ + enteredApiKey: input.values?.R_AGENTMAIL_API_KEY?.trim() || null, + enteredInboxId: input.values?.R_AGENTMAIL_INBOX_ID?.trim() || null, + }) + : null; + await db.transaction(async (tx) => { const persistedEnvVarNames = await getPersistedEnvironmentVariableNames(tx); const authSetup = buildSetupAuthStatus({ @@ -1054,6 +1740,28 @@ export async function saveCommsAuthConfigCommand( } } + if (input.provider === 'agentmail' && agentmailSetup) { + // Persist the reconciled inbox address (which may have just been + // provisioned) instead of whatever was typed, plus the webhook secret + // AgentMail issued for delivery verification. + const inboxIndex = valuesToSave.findIndex( + (value) => value.name === 'R_AGENTMAIL_INBOX_ID', + ); + if (inboxIndex >= 0) { + valuesToSave.splice(inboxIndex, 1); + } + valuesToSave.push({ + name: 'R_AGENTMAIL_INBOX_ID', + value: agentmailSetup.inboxAddress, + }); + if (agentmailSetup.webhookSecret) { + valuesToSave.push({ + name: 'R_AGENTMAIL_WEBHOOK_SECRET', + value: agentmailSetup.webhookSecret, + }); + } + } + const hasConfiguredAuthEnvVar = (name: string) => Boolean(process.env[name]?.trim()) || persistedEnvVarNames.includes(name) || @@ -1119,6 +1827,10 @@ export async function saveCommsAuthConfigCommand( invalidateDiscordRuntimeCredentialsCache(); } + if (input.provider === 'agentmail') { + invalidateAgentMailRuntimeCredentialsCache(); + } + // Registration talks to the Telegram Bot API, so it runs after the // transaction commits; a registration failure must not roll back the // saved configuration. @@ -1135,6 +1847,15 @@ export async function saveCommsAuthConfigCommand( return { telegramWebhook, ...(discord ? { discord } : {}), + ...(agentmailSetup + ? { + agentmail: { + inboxAddress: agentmailSetup.inboxAddress, + inboxEmail: agentmailSetup.inboxEmail, + webhookUrl: agentmailSetup.webhookUrl, + }, + } + : {}), }; } @@ -1153,6 +1874,14 @@ export async function clearCommsAuthConfigCommand( fieldEnvVarNames.push('R_TELEGRAM_BOT_USERNAME'); } + if (input.provider === 'agentmail') { + // The webhook secret is provisioned server-side rather than entered, so + // it is not a field; remove it with the credentials, and best-effort + // unregister the webhook while the API key is still available. + fieldEnvVarNames.push('R_AGENTMAIL_WEBHOOK_SECRET'); + await deleteAgentMailWebhookBestEffort(); + } + if (fieldEnvVarNames.length === 0) { return; } @@ -1177,6 +1906,10 @@ export async function clearCommsAuthConfigCommand( if (input.provider === 'discord') { invalidateDiscordRuntimeCredentialsCache(); } + + if (input.provider === 'agentmail') { + invalidateAgentMailRuntimeCredentialsCache(); + } } async function deleteDeploymentEnvVarsByNames( diff --git a/apps/web/src/trpc/commands/linked-accounts/email-link.test.ts b/apps/web/src/trpc/commands/linked-accounts/email-link.test.ts new file mode 100644 index 000000000..cfcc42698 --- /dev/null +++ b/apps/web/src/trpc/commands/linked-accounts/email-link.test.ts @@ -0,0 +1,151 @@ +import type { UserAuthSuccess } from '@/types'; + +const { + mockVerifyAgentMailEmailLinkToken, + mockRedispatchAgentMailEventsForSender, + mockFindFirst, + mockInsert, + mockValues, + mockOnConflictDoNothing, + mockReturning, +} = vi.hoisted(() => { + const mockReturning = vi.fn(); + const mockOnConflictDoNothing = vi.fn(() => ({ returning: mockReturning })); + const mockValues = vi.fn(() => ({ + onConflictDoNothing: mockOnConflictDoNothing, + })); + const mockInsert = vi.fn(() => ({ values: mockValues })); + + return { + mockVerifyAgentMailEmailLinkToken: vi.fn(), + mockRedispatchAgentMailEventsForSender: vi.fn(), + mockFindFirst: vi.fn(), + mockInsert, + mockValues, + mockOnConflictDoNothing, + mockReturning, + }; +}); + +vi.mock('@roomote/db/server', () => ({ + db: { + insert: mockInsert, + query: { agentmailUserMappings: { findFirst: mockFindFirst } }, + }, + agentmailUserMappings: { + id: 'agentmail_user_mappings.id', + emailAddress: 'agentmail_user_mappings.email_address', + }, + eq: vi.fn(), +})); + +vi.mock('@roomote/sdk/server', () => ({ + verifyAgentMailEmailLinkToken: mockVerifyAgentMailEmailLinkToken, + redispatchAgentMailEventsForSender: mockRedispatchAgentMailEventsForSender, +})); + +import { linkEmailAddressCommand, previewEmailLinkCommand } from './email-link'; + +const mockAuth = { userId: 'user-1' } as UserAuthSuccess; + +describe('previewEmailLinkCommand', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns the email address for a valid token', async () => { + mockVerifyAgentMailEmailLinkToken.mockReturnValue({ + emailAddress: 'sender@example.com', + }); + + await expect( + previewEmailLinkCommand(mockAuth, 'valid-token'), + ).resolves.toEqual({ emailAddress: 'sender@example.com' }); + }); + + it('rejects an invalid or expired token with a clear message', async () => { + mockVerifyAgentMailEmailLinkToken.mockReturnValue(null); + + await expect( + previewEmailLinkCommand(mockAuth, 'bad-token'), + ).rejects.toThrow( + 'This link is invalid or has expired. Send another email to get a fresh link.', + ); + }); +}); + +describe('linkEmailAddressCommand', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockVerifyAgentMailEmailLinkToken.mockReturnValue({ + emailAddress: 'sender@example.com', + }); + mockReturning.mockResolvedValue([{ id: 'mapping-1' }]); + mockRedispatchAgentMailEventsForSender.mockResolvedValue(2); + }); + + it('links the address and redispatches the refused emails', async () => { + await expect( + linkEmailAddressCommand(mockAuth, 'valid-token'), + ).resolves.toEqual({ + emailAddress: 'sender@example.com', + redispatchedCount: 2, + }); + + expect(mockValues).toHaveBeenCalledWith({ + emailAddress: 'sender@example.com', + userId: 'user-1', + source: 'link_code', + }); + expect(mockOnConflictDoNothing).toHaveBeenCalledWith({ + target: 'agentmail_user_mappings.email_address', + }); + expect(mockRedispatchAgentMailEventsForSender).toHaveBeenCalledWith( + 'sender@example.com', + ); + expect(mockFindFirst).not.toHaveBeenCalled(); + }); + + it('treats relinking by the same user as success', async () => { + mockReturning.mockResolvedValue([]); + mockFindFirst.mockResolvedValue({ userId: 'user-1' }); + mockRedispatchAgentMailEventsForSender.mockResolvedValue(0); + + await expect( + linkEmailAddressCommand(mockAuth, 'valid-token'), + ).resolves.toEqual({ + emailAddress: 'sender@example.com', + redispatchedCount: 0, + }); + + expect(mockRedispatchAgentMailEventsForSender).toHaveBeenCalledWith( + 'sender@example.com', + ); + }); + + it('rejects when the address is already linked to a different account', async () => { + mockReturning.mockResolvedValue([]); + mockFindFirst.mockResolvedValue({ userId: 'user-2' }); + + await expect( + linkEmailAddressCommand(mockAuth, 'valid-token'), + ).rejects.toThrow( + 'This email address is already linked to a different Roomote account.', + ); + + expect(mockRedispatchAgentMailEventsForSender).not.toHaveBeenCalled(); + }); + + it('rejects an invalid token without touching the database', async () => { + mockVerifyAgentMailEmailLinkToken.mockReturnValue(null); + + await expect( + linkEmailAddressCommand(mockAuth, 'bad-token'), + ).rejects.toThrow( + 'This link is invalid or has expired. Send another email to get a fresh link.', + ); + + expect(mockInsert).not.toHaveBeenCalled(); + expect(mockRedispatchAgentMailEventsForSender).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/trpc/commands/linked-accounts/email-link.ts b/apps/web/src/trpc/commands/linked-accounts/email-link.ts new file mode 100644 index 000000000..917edbd3a --- /dev/null +++ b/apps/web/src/trpc/commands/linked-accounts/email-link.ts @@ -0,0 +1,70 @@ +import { agentmailUserMappings, db, eq } from '@roomote/db/server'; +import { + redispatchAgentMailEventsForSender, + verifyAgentMailEmailLinkToken, +} from '@roomote/sdk/server'; +import { TRPCError } from '@trpc/server'; + +import type { UserAuthSuccess } from '@/types'; + +const INVALID_EMAIL_LINK_TOKEN_MESSAGE = + 'This link is invalid or has expired. Send another email to get a fresh link.'; + +function verifyEmailLinkTokenOrThrow(token: string) { + const verified = verifyAgentMailEmailLinkToken(token); + + if (!verified) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: INVALID_EMAIL_LINK_TOKEN_MESSAGE, + }); + } + + return verified; +} + +export async function previewEmailLinkCommand( + _auth: UserAuthSuccess, + token: string, +) { + const { emailAddress } = verifyEmailLinkTokenOrThrow(token); + + return { emailAddress }; +} + +export async function linkEmailAddressCommand( + auth: UserAuthSuccess, + token: string, +) { + const { emailAddress } = verifyEmailLinkTokenOrThrow(token); + + const inserted = await db + .insert(agentmailUserMappings) + .values({ + emailAddress, + userId: auth.userId, + source: 'link_code', + }) + .onConflictDoNothing({ target: agentmailUserMappings.emailAddress }) + .returning({ id: agentmailUserMappings.id }); + + if (inserted.length === 0) { + const existing = await db.query.agentmailUserMappings.findFirst({ + where: eq(agentmailUserMappings.emailAddress, emailAddress), + columns: { userId: true }, + }); + + if (existing && existing.userId !== auth.userId) { + throw new TRPCError({ + code: 'CONFLICT', + message: + 'This email address is already linked to a different Roomote account.', + }); + } + } + + const redispatchedCount = + await redispatchAgentMailEventsForSender(emailAddress); + + return { emailAddress, redispatchedCount }; +} diff --git a/apps/web/src/trpc/commands/linked-accounts/index.ts b/apps/web/src/trpc/commands/linked-accounts/index.ts index bb8368682..b051c548e 100644 --- a/apps/web/src/trpc/commands/linked-accounts/index.ts +++ b/apps/web/src/trpc/commands/linked-accounts/index.ts @@ -28,6 +28,8 @@ import { Env } from '@/lib/server/env'; import { resolveAuthProviderConfig } from '@/lib/server/auth-provider-config'; import { captureIntegrationLifecycleEvent } from '@/lib/server/integration-telemetry'; +export * from './email-link'; + const MICROSOFT_ENTRA_PROVIDER_ID = 'microsoft-entra-id'; function formatGitLabLinkedAccountDisplayName(accountId: string) { diff --git a/apps/web/src/trpc/commands/router-debug/index.ts b/apps/web/src/trpc/commands/router-debug/index.ts index 828aeabb0..2f983cd10 100644 --- a/apps/web/src/trpc/commands/router-debug/index.ts +++ b/apps/web/src/trpc/commands/router-debug/index.ts @@ -8,7 +8,7 @@ import { teamsInstallations, updateRouterDebugSettings, } from '@roomote/db/server'; -import type { CommunicationProvider } from '@roomote/types'; +import type { AutomationCapableCommunicationProvider } from '@roomote/types'; import { TelegramCommunicationProvider } from '@roomote/communication/telegram-provider'; import { getCommunicationProviderAdapter } from '@roomote/sdk/server'; import { SlackNotifier } from '@roomote/slack'; @@ -27,7 +27,9 @@ export async function getRouterDebugSettingsCommand(auth: UserAuthSuccess) { export async function updateRouterDebugSettingsCommand( auth: UserAuthSuccess, input: { - provider: CommunicationProvider | null; + // Router diagnostics post outbound messages, which email (agentmail) + // never receives, so the destination is limited to chat providers. + provider: AutomationCapableCommunicationProvider | null; channelId: string | null; disabled: boolean; }, diff --git a/apps/web/src/trpc/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts index 2739f4d20..30c499630 100644 --- a/apps/web/src/trpc/routers/_app.ts +++ b/apps/web/src/trpc/routers/_app.ts @@ -175,6 +175,8 @@ import { createDiscordLinkCodeCommand, unlinkLinkedDiscordAccountCommand, getLinkedMicrosoftTeamsAccountCommand, + previewEmailLinkCommand, + linkEmailAddressCommand, } from '../commands/linked-accounts'; import { getPersonalAccountCapabilitiesCommand, @@ -340,6 +342,7 @@ import { saveCommsAuthConfigCommand, clearCommsAuthConfigCommand, diagnoseDiscordPermissionsCommand, + listAgentMailInboxesCommand, listDiscordChannelsCommand, listDiscordGuildsCommand, registerDiscordCommandsCommand, @@ -1467,6 +1470,18 @@ export const appRouter = createRouter({ unlinkDiscord: protectedProcedure.mutation(({ ctx: { auth } }) => unlinkLinkedDiscordAccountCommand(auth), ), + + previewEmailLink: protectedProcedure + .input(z.object({ token: z.string().min(1) })) + .query(({ ctx: { auth }, input }) => + previewEmailLinkCommand(auth, input.token), + ), + + linkEmailAddress: protectedProcedure + .input(z.object({ token: z.string().min(1) })) + .mutation(({ ctx: { auth }, input }) => + linkEmailAddressCommand(auth, input.token), + ), }), preferences: createRouter({ @@ -2016,6 +2031,15 @@ export const appRouter = createRouter({ repairTelegramWebhookCommand(auth), ), + // A mutation, not a query: the input can carry a freshly typed API key, + // and query inputs serialize into the GET URL (browser history, proxy + // and access logs, tracing). Mutations POST the input in the body. + listAgentMailInboxes: protectedProcedure + .input(z.object({ apiKey: z.string().trim().optional() })) + .mutation(({ ctx: { auth }, input }) => + listAgentMailInboxesCommand(auth, input), + ), + listDiscordGuilds: protectedProcedure.query(({ ctx: { auth } }) => listDiscordGuildsCommand(auth), ), diff --git a/apps/worker/src/callbacks/communication.ts b/apps/worker/src/callbacks/communication.ts index 3cc8cbdd3..00da9979d 100644 --- a/apps/worker/src/callbacks/communication.ts +++ b/apps/worker/src/callbacks/communication.ts @@ -19,10 +19,14 @@ import { supportsIntegrationRequestUserInput, } from './request-user-input'; +// AgentMail (email) renders request_user_input as a question email whose +// options are signed one-click answer links, with free-text reply as the +// fallback; the other providers use interactive chat prompts. const COMMUNICATION_RUI_PROVIDERS = new Set([ 'discord', 'telegram', 'teams', + 'agentmail', ]); function supportsCommunicationRequestUserInput( diff --git a/apps/worker/src/mcp/roomote-mcp-server/__tests__/slack-api-client.test.ts b/apps/worker/src/mcp/roomote-mcp-server/__tests__/slack-api-client.test.ts index 5f04e915f..9e9d4806f 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/__tests__/slack-api-client.test.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/__tests__/slack-api-client.test.ts @@ -40,7 +40,9 @@ describe('replyToSlackThread', () => { headers: expect.objectContaining({ Authorization: 'Bearer test-token', }), - body: JSON.stringify({ text: 'hello from worker' }), + body: expect.stringMatching( + /^\{"text":"hello from worker","clientSendId":"[0-9a-f-]{36}"\}$/, + ), }), ); }); @@ -111,10 +113,9 @@ describe('replyToSlackThread', () => { expect(fetch).toHaveBeenCalledWith( 'https://platform.example.com/api/mcp/slack/thread_reply', expect.objectContaining({ - body: JSON.stringify({ - text: 'with screenshot', - images: [{ artifactId: 'art-1' }, { artifactId: 'art-2' }], - }), + body: expect.stringMatching( + /^\{"text":"with screenshot","images":\[\{"artifactId":"art-1"\},\{"artifactId":"art-2"\}\],"clientSendId":"[0-9a-f-]{36}"\}$/, + ), }), ); }); diff --git a/apps/worker/src/mcp/roomote-mcp-server/chat-api-client.ts b/apps/worker/src/mcp/roomote-mcp-server/chat-api-client.ts index e84c4ad76..bec598d3b 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/chat-api-client.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/chat-api-client.ts @@ -1,3 +1,5 @@ +import { randomUUID } from 'node:crypto'; + import { buildApiHeaders, fetchWithTimeout } from './api-client.js'; import { ChatDeliveryError } from './chat-delivery-error.js'; import type { @@ -121,7 +123,9 @@ export async function replyToChatThread( return postToChatEndpoint( config, 'thread_reply', - input, + // One send id per tool invocation: HTTP retries of this call carry the + // same value, so idempotent providers (email) dedupe the logical send. + { ...input, clientSendId: randomUUID() }, 'Failed to reply to chat thread', ); } @@ -137,7 +141,7 @@ export async function replyToSlackThread( return postToChatEndpoint( config, 'thread_reply', - input, + { ...input, clientSendId: randomUUID() }, 'Failed to reply to Slack thread', ); } diff --git a/apps/worker/src/mcp/roomote-mcp-server/index.ts b/apps/worker/src/mcp/roomote-mcp-server/index.ts index aa65b3273..de39c4623 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/index.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/index.ts @@ -476,11 +476,19 @@ function hasDiscordChatContext(): boolean { ); } +function hasAgentMailChatContext(): boolean { + return ( + process.env.ROOMOTE_COMMUNICATION_PROVIDER?.trim() === 'agentmail' && + Boolean(process.env.ROOMOTE_COMMUNICATION_CHANNEL_ID?.trim()) + ); +} + function getChatReplySurfaceLabel(): | 'Slack' | 'Teams' | 'Telegram' | 'Discord' + | 'email thread' | 'chat' { const provider = process.env.ROOMOTE_COMMUNICATION_PROVIDER?.trim(); @@ -496,6 +504,10 @@ function getChatReplySurfaceLabel(): return 'Discord'; } + if (provider === 'agentmail') { + return 'email thread'; + } + return process.env.ROOMOTE_SLACK_CHANNEL?.trim() ? 'Slack' : 'chat'; } @@ -1423,12 +1435,17 @@ if (shouldRegisterSlackThreadReplyTool() || isFastAgentChild()) { chatReplySurfaceLabel === 'Slack' ? 'Use the modern Slack Markdown contract from the Slack instructions; tables, headings, blockquotes, and fenced code blocks are allowed when they make the reply clearer.' : 'Use Markdown when it makes the reply clearer.'; + // Email is a low-frequency surface: no play-by-play progress posting. + const chatReplyEmailCadenceGuidance = + chatReplySurfaceLabel === 'email thread' + ? 'Email is a low-frequency surface: batch updates instead of posting play-by-play progress, and aim for roughly two emails per task — an initial ack and the final result. ' + : ''; const chatReplySuggestionGuidance = supportsChatReplySuggestions ? 'Use the optional suggestions parameter when the automation prompt explicitly asks for task suggestions, launchable follow-ups, or help taking concrete actions. Do not infer suggested-task intent from a request that only asks for a summary or action-item list. Suggestions are posted inside the originating conversation. Do not use suggestions for ordinary summary bullets, status updates, questions, speculative ideas, or work explicitly identified in the conversation as already underway. When suggestions are present, the tool automatically adds the surface-specific instruction for starting one; do not write a separate launch instruction. ' : ''; const chatReplyDescription = relaysThroughFastParent ? 'Fast-internal: sends a lifecycle update privately to the Fast parent, which owns any user-visible reply. The raw message is never posted directly to the user. The kickoff already acknowledged the request, so do not send another generic ack. Use progress to pass concrete findings, blockers, meaningful work milestones, required input, or a brief note after roughly 10 minutes of silence. Describe the work itself without labeling the message as a progress update or using policy vocabulary such as phase transition, checkpoint, lifecycle, or user-facing. Use closeout for the final result or blocker and clarification when user input is needed. Ack and progress keep the coding task active.' - : `${chatReplySurfaceLabel}-visible: posts a lifecycle reply in the originating ${chatReplySurfaceLabel} thread. Choose the current ${chatReplySurfaceLabel} turn purpose before writing: ack, progress, closeout, or clarification. Use ack for the first visible response when work will continue; use progress only when the message adds new decision-useful state or prevents a 10-minute silence gap; use closeout for the answer, result, blocker, or handoff; use clarification for lightweight non-secret questions. Use closeout to finish a turn with an outcome; a clarification also ends the turn when the next step depends on the user's answer — do not follow it with a separate "waiting on your answer" message. Ack and progress keep the ${chatReplySurfaceLabel} turn open. Use it again on later ${chatReplySurfaceLabel} turns when they need another direct reply; an earlier thread reply does not count as the reply for the current turn. For routine successful closeouts, focus on the shipped change and any blocker or delivery outcome that changes the user's next step; do not include exact validation commands, passed-check ledgers, or proof-applicability narration unless the user asked or that detail materially changes what they should do next. ${chatReplyMarkdownGuidance}${chatReplySourceLinkingGuidance}${chatReplySuggestionGuidance}Write the message so its content clearly matches the selected purpose.`; + : `${chatReplySurfaceLabel}-visible: posts a lifecycle reply in the originating ${chatReplySurfaceLabel} thread. Choose the current ${chatReplySurfaceLabel} turn purpose before writing: ack, progress, closeout, or clarification. Use ack for the first visible response when work will continue; use progress only when the message adds new decision-useful state or prevents a 10-minute silence gap; use closeout for the answer, result, blocker, or handoff; use clarification for lightweight non-secret questions. Use closeout to finish a turn with an outcome; a clarification also ends the turn when the next step depends on the user's answer — do not follow it with a separate "waiting on your answer" message. Ack and progress keep the ${chatReplySurfaceLabel} turn open. Use it again on later ${chatReplySurfaceLabel} turns when they need another direct reply; an earlier thread reply does not count as the reply for the current turn. For routine successful closeouts, focus on the shipped change and any blocker or delivery outcome that changes the user's next step; do not include exact validation commands, passed-check ledgers, or proof-applicability narration unless the user asked or that detail materially changes what they should do next. ${chatReplyMarkdownGuidance}${chatReplySourceLinkingGuidance}${chatReplyEmailCadenceGuidance}${chatReplySuggestionGuidance}Write the message so its content clearly matches the selected purpose.`; roomoteMcpServer.registerTool( 'send_chat_reply', { @@ -1759,7 +1776,10 @@ if (shouldRegisterChannelPostTool()) { (hasSlackChatContext() || hasTelegramChatContext() || hasTeamsChatContext() || - hasDiscordChatContext()) + hasDiscordChatContext() || + // Registered for email tasks too so a call gets the structured + // "email has no reactions" result instead of an unknown-tool error. + hasAgentMailChatContext()) ) { const reactionSurface = getChatReplySurfaceLabel(); @@ -1781,6 +1801,11 @@ if (shouldRegisterChannelPostTool()) { 'Teams reactions post a plain Teams message containing only the emoji and support a limited set; prefer common names like eyes, thumbsup, heart, laugh, or tada.', ] : []), + ...(reactionSurface === 'email thread' + ? [ + 'Email has no reactions, so this tool always reports unsupported for email tasks; reply with send_chat_reply when a response is needed.', + ] + : []), ].join(' '), inputSchema: { name: nonEmptyStringSchema.describe( diff --git a/apps/worker/src/mcp/roomote-mcp-server/post-to-channel.ts b/apps/worker/src/mcp/roomote-mcp-server/post-to-channel.ts index 6c7940487..8fac91ae7 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/post-to-channel.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/post-to-channel.ts @@ -74,6 +74,12 @@ export async function handlePostToChannel( ): Promise { const provider = process.env.ROOMOTE_COMMUNICATION_PROVIDER?.trim().toLowerCase(); + if (provider === 'agentmail') { + // Email is inbound-initiated: tasks reply in the originating thread only. + return errorResult( + 'Channel posts are not supported for email; email tasks are inbound-initiated. Reply in the originating email thread with send_chat_reply instead.', + ); + } const rawChannel = input.channel.trim(); if (!rawChannel) { return errorResult('channel is required'); diff --git a/apps/worker/src/mcp/roomote-mcp-server/send-chat-reaction-emoji.ts b/apps/worker/src/mcp/roomote-mcp-server/send-chat-reaction-emoji.ts index 08bf0d2c3..08f83c90f 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/send-chat-reaction-emoji.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/send-chat-reaction-emoji.ts @@ -68,6 +68,11 @@ export async function handleSendChatReactionEmoji( ): Promise { const communicationProvider = process.env.ROOMOTE_COMMUNICATION_PROVIDER?.trim(); + if (communicationProvider === 'agentmail') { + return errorResult( + 'Email has no reactions; reply with send_chat_reply when a response is needed', + ); + } const isCommunicationReactionContext = communicationProvider === 'telegram' || communicationProvider === 'teams' || diff --git a/apps/worker/src/mcp/roomote-mcp-server/send-chat-reply.ts b/apps/worker/src/mcp/roomote-mcp-server/send-chat-reply.ts index 95ecd9368..6cd0c3423 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/send-chat-reply.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/send-chat-reply.ts @@ -14,7 +14,13 @@ import { } from './tasks-api-client.js'; import type { ArtifactConfig, RoomoteConfig, ToolResult } from './types.js'; -type ChatReplySurface = 'Slack' | 'Teams' | 'Telegram' | 'Discord' | 'chat'; +type ChatReplySurface = + | 'Slack' + | 'Teams' + | 'Telegram' + | 'Discord' + | 'email thread' + | 'chat'; const SUGGESTION_START_INSTRUCTIONS: Record = { Slack: @@ -25,6 +31,8 @@ const SUGGESTION_START_INSTRUCTIONS: Record = { "Want me to take one of these on? React with a 👍 on a suggested task below and I'll start it.", Teams: "Want me to take one of these on? React with a 👍 on a suggested task below and I'll start it.", + 'email thread': + "Want me to take one of these on? Reply to this email naming the suggested task and I'll start it.", chat: 'Want me to take one of these on? Use the Start action on a suggested task below.', }; diff --git a/apps/worker/src/run-task/mcp-task-env.ts b/apps/worker/src/run-task/mcp-task-env.ts index 765d6b3c3..e3c4451d1 100644 --- a/apps/worker/src/run-task/mcp-task-env.ts +++ b/apps/worker/src/run-task/mcp-task-env.ts @@ -140,7 +140,9 @@ export function buildMcpTaskEnv(input: { ) { // Telegram, Teams, and Discord tasks use the same turn-satisfaction // machinery as Slack (ack/closeout enforcement and current-turn emoji - // reactions). + // reactions). AgentMail (email) is deliberately excluded: email is a + // low-frequency surface with no reactions, and it must never get the + // silence-reminder heartbeats this state file drives. mcpTaskEnv.ROOMOTE_SLACK_REPLY_SATISFACTION_STATE_FILE ??= [ input.runtimeEnv.HOME ?? '/tmp', '.config', diff --git a/apps/worker/src/run-task/polling/communication.ts b/apps/worker/src/run-task/polling/communication.ts index 0ccf28ce9..38fbcb4ff 100644 --- a/apps/worker/src/run-task/polling/communication.ts +++ b/apps/worker/src/run-task/polling/communication.ts @@ -79,10 +79,14 @@ export function createCommunicationMessageInterval({ } try { + // AgentMail (email) answers arrive from one-click answer links or + // parsed reply emails; they queue through the same pending-input state + // as the chat providers. if ( provider === 'discord' || provider === 'telegram' || - provider === 'teams' + provider === 'teams' || + provider === 'agentmail' ) { const queuedAnswers = await runPollingSdkCall({ execute: () => @@ -286,7 +290,9 @@ export function createCommunicationMessageInterval({ ) { // Telegram, Teams, and Discord turns feed the same satisfaction // machinery as Slack so ack/closeout enforcement and current-turn - // reactions work. + // reactions work. AgentMail (email) is deliberately excluded: + // email must never get the ack/silence heartbeats that machinery + // enforces, and it has no reactions. recordChatTurnStart({ turnMessageTs: message.ts, allowReaction: message.turnPolicy?.reactionsAllowed, diff --git a/apps/worker/src/run-task/run-task.ts b/apps/worker/src/run-task/run-task.ts index e76b93b66..9bc2ccae5 100644 --- a/apps/worker/src/run-task/run-task.ts +++ b/apps/worker/src/run-task/run-task.ts @@ -247,6 +247,8 @@ function getInitialSlackTurnMessageTs(taskRun: { // Non-Slack communication tasks track the launch message so turn-satisfaction // machinery (ack/closeout enforcement, current-turn reactions) applies. + // AgentMail (email) is deliberately excluded from that machinery: email is + // low-frequency and must never get ack/silence heartbeats. if ( (payload.communicationProvider === 'telegram' || payload.communicationProvider === 'teams' || @@ -2043,6 +2045,8 @@ export const runTask = async ({ return; } + // AgentMail (email) is deliberately excluded: email turns never feed + // the turn-satisfaction machinery (no ack/silence heartbeats). if ( message.provider === 'slack' || message.provider === 'telegram' || diff --git a/apps/worker/src/run-task/slack-silence-hook-script.ts b/apps/worker/src/run-task/slack-silence-hook-script.ts index dc613931c..71e61bc62 100644 --- a/apps/worker/src/run-task/slack-silence-hook-script.ts +++ b/apps/worker/src/run-task/slack-silence-hook-script.ts @@ -24,6 +24,8 @@ function getChatSurfaceLabel() { if (provider === 'discord') { return 'Discord'; } + // agentmail deliberately has no arm here: email tasks never configure the + // reply-satisfaction state file, so these hooks exit before labeling. return (process.env.ROOMOTE_SLACK_CHANNEL || '').trim() ? 'Slack' : 'chat'; } diff --git a/apps/worker/src/run-task/slack-stop-hook-script.ts b/apps/worker/src/run-task/slack-stop-hook-script.ts index 9ddcc5c85..085c35fac 100644 --- a/apps/worker/src/run-task/slack-stop-hook-script.ts +++ b/apps/worker/src/run-task/slack-stop-hook-script.ts @@ -15,6 +15,8 @@ function getChatSurfaceLabel() { if (provider === 'discord') { return 'Discord'; } + // agentmail deliberately has no arm here: email tasks never configure the + // reply-satisfaction state file, so these hooks exit before labeling. return (process.env.ROOMOTE_SLACK_CHANNEL || '').trim() ? 'Slack' : 'chat'; } diff --git a/packages/cloud-agents/src/server/cloud-agent-workflow.ts b/packages/cloud-agents/src/server/cloud-agent-workflow.ts index 7a14e1c0b..ac5d888fb 100644 --- a/packages/cloud-agents/src/server/cloud-agent-workflow.ts +++ b/packages/cloud-agents/src/server/cloud-agent-workflow.ts @@ -44,6 +44,7 @@ import { githubPrReviewSync } from './workflows/githubPrReviewSync'; import { githubPrReviewFollowUp } from './workflows/githubPrReviewFollowUp'; import { standardTask } from './workflows/standardTask'; import { + buildAgentMailMessageInstructions, buildChatProviderMessageInstructions, buildSlackMessageInstructions, buildTeamsMessageInstructions, @@ -492,7 +493,9 @@ export async function generatePrompt({ const chatInstructions = nonSlackChatProvider === 'teams' ? buildTeamsMessageInstructions() - : buildChatProviderMessageInstructions(nonSlackChatProvider); + : nonSlackChatProvider === 'agentmail' + ? buildAgentMailMessageInstructions() + : buildChatProviderMessageInstructions(nonSlackChatProvider); result.harnessInstructions = result.harnessInstructions ? `${chatInstructions}\n\n${result.harnessInstructions}` : chatInstructions; diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-prompt.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-prompt.ts index a1e77fecb..26e66e5bb 100644 --- a/packages/cloud-agents/src/server/fast-agent/fast-agent-prompt.ts +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-prompt.ts @@ -140,9 +140,15 @@ export function buildFastAgentSystemPrompt({ ? 'Microsoft Teams' : surface === 'telegram' ? 'Telegram' - : surface === 'web' - ? 'the Roomote web app' - : 'a stored automation conversation'; + : surface === 'agentmail' + ? 'an email thread' + : surface === 'web' + ? 'the Roomote web app' + : 'a stored automation conversation'; + const emailCadenceGuidance = + surface === 'agentmail' + ? "- Every reply you send becomes a new email in the sender's inbox. Email is low-frequency: send one substantive, self-contained reply per turn — no play-by-play, no separate acknowledgement followed by the answer moments later. When you delegate a task, one brief confirmation reply is enough; the task result will arrive in the thread on its own.\n" + : ''; const reactionGuidance = surface === 'slack' && currentMessageReactable ? '- Use `send_chat_reaction` only for a lightweight acknowledgement or an emoji-only answer. Put the Slack emoji name without colons in `name`. Reserve "eyes" for actively looking, use "thumbsup" for acknowledgement or agreement, and "white_check_mark" for completion.' @@ -213,7 +219,7 @@ ${formatIntegrationsForPrompt(availableIntegrations)} - Set "includeAttachments" on "launch_task" to true only when supported attachments from the active conversation turn are relevant to the coding task. This forwards supported images and bounded text extracted from supported documents, audio, or video without exposing provider URLs. Omit it otherwise; attachments are not forwarded by default. - If the answer is immediate, call the closeout tool directly. ${reactionGuidance} -- Prefer one direct closeout over an acknowledgement followed immediately by the same answer. +${emailCadenceGuidance}- Prefer one direct closeout over an acknowledgement followed immediately by the same answer. - After a closeout, clarification, closeout reaction, or ignored event, do not call another tool and do not add user-facing prose. ## User-Facing Communication diff --git a/packages/cloud-agents/src/server/workflows/slackAppMention.ts b/packages/cloud-agents/src/server/workflows/slackAppMention.ts index 27a7821de..5df1944da 100644 --- a/packages/cloud-agents/src/server/workflows/slackAppMention.ts +++ b/packages/cloud-agents/src/server/workflows/slackAppMention.ts @@ -231,6 +231,38 @@ export function buildTeamsMessageInstructions(): string { return buildChatProviderMessageInstructions('teams'); } +/** + * Email cadence differs deliberately from the chat providers: every reply + * lands in someone's inbox and spends the deployment's daily send quota, so + * the default is roughly two emails per task (an acknowledgement and the + * result), batched updates, and no reactions or play-by-play. + */ +export function buildAgentMailMessageInstructions(): string { + return ` + + + This task originates from an email thread. Incoming follow-ups arrive as provider-neutral chat message blocks containing the sender's new message text with quoted history already stripped. + When present, a \`...\` block contains earlier messages from the email thread for conversational context. Treat it as background, not as the latest instruction. + + + + Email is a low-frequency surface, not chat. Every \`send_chat_reply\` becomes a new email in the recipient's inbox. + Aim for two emails per task: one brief acknowledgement that work has started (with the task link), then one reply carrying the result. Send nothing in between unless the work is genuinely blocked on the user's input. + Never send progress updates, phase transitions, heartbeat messages, or internal-milestone narration over email. Task UI commentary covers those. + Batch related content into one reply instead of sending several small emails in a burst. + Emoji reactions are not available on email; never attempt \`send_chat_reaction_emoji\`. + Questions are acceptable email: when the next step genuinely depends on the user's answer, one clear reply asking the question (or \`request_user_input\` for structured/private input, paired with a brief reply saying work is paused) is correct. + The closeout reply leads with the answer or result, links the PR or task where relevant, and reads as a complete, self-contained email: the recipient may open it hours later without surrounding context. + + + + Write like a considerate colleague's email: a short opening line with the outcome, then only the detail the reader needs. Standard Markdown renders as formatted email HTML. + Do not include greetings/signatures boilerplate; the thread carries identity. Keep subject continuity by replying in-thread (automatic). + + +`.trim(); +} + function formatWorkspaceReadinessContext({ workspaceReadiness, readinessMessage, diff --git a/packages/cloud-agents/src/server/workflows/utils.ts b/packages/cloud-agents/src/server/workflows/utils.ts index b89e8d03f..675a79701 100644 --- a/packages/cloud-agents/src/server/workflows/utils.ts +++ b/packages/cloud-agents/src/server/workflows/utils.ts @@ -73,6 +73,7 @@ export function getPrBodyAttributionLine({ | 'teams' | 'telegram' | 'discord' + | 'agentmail' | 'linear' | 'github' | 'gitlab' @@ -170,6 +171,7 @@ function buildPrBodyAttributionLine({ | 'teams' | 'telegram' | 'discord' + | 'agentmail' | 'linear' | 'github' | 'gitlab' @@ -220,7 +222,10 @@ function buildPrBodyAttributionLine({ taskSurface === 'slack' || taskSurface === 'teams' || taskSurface === 'telegram' || - taskSurface === 'discord'; + taskSurface === 'discord' || + // Email threads have no public permalink, so agentmail gets the chat + // phrasing with no conversation link. + taskSurface === 'agentmail'; const taskLinkLabel = isChatSurface ? 'the web UI' : 'View the task'; const taskLink = safeTaskUrl ? `[${taskLinkLabel}](${safeTaskUrl})` diff --git a/packages/communication/package.json b/packages/communication/package.json index 61b3e6e25..230df2bf1 100644 --- a/packages/communication/package.json +++ b/packages/communication/package.json @@ -5,6 +5,8 @@ "type": "module", "exports": { ".": "./src/index.ts", + "./agentmail-event": "./src/agentmail-event.ts", + "./agentmail-provider": "./src/agentmail-provider.ts", "./chat-messages": "./src/chat-messages.ts", "./discord-event": "./src/discord-event.ts", "./discord-provider": "./src/discord-provider.ts", @@ -33,6 +35,7 @@ "check-types:fast": "tsgo --noEmit", "mock:telegram": "dotenvx run -f ../../.env.local -- tsx scripts/run-mock-telegram.ts", "mock:discord": "dotenvx run -f ../../.env.local -- tsx scripts/run-mock-discord.ts", + "mock:agentmail": "dotenvx run -f ../../.env.local -- tsx scripts/run-mock-agentmail.ts", "test": "dotenvx run -f ../../.env.test -- vitest", "clean": "rimraf .turbo", "eval:telegram-scenario": "dotenvx run -f ../../.env.local -- tsx evals/run-telegram-scenario.ts", diff --git a/packages/communication/scripts/mock-agentmail.example.json b/packages/communication/scripts/mock-agentmail.example.json new file mode 100644 index 000000000..71487fd63 --- /dev/null +++ b/packages/communication/scripts/mock-agentmail.example.json @@ -0,0 +1,30 @@ +{ + "port": 3015, + "state": { + "inboxes": [ + { + "inbox_id": "roomote@agentmail.to", + "display_name": "Roomote", + "client_id": "roomote-mock-inbox" + } + ], + "webhooks": [ + { + "webhook_id": "wh_seeded_1", + "url": "http://localhost:4000/api/webhooks/agentmail", + "secret": "whsec_bW9jay1hZ2VudG1haWwtc2VlZGVkLXNlY3JldC0x", + "client_id": "roomote-mock-webhook", + "event_types": ["message.received"] + } + ] + }, + "replay": [ + { + "kind": "message", + "inboxId": "roomote@agentmail.to", + "from": "grace@example.com", + "subject": "Flaky login test", + "text": "Hi Roomote — can you look into the flaky login test?" + } + ] +} diff --git a/packages/communication/scripts/run-mock-agentmail.ts b/packages/communication/scripts/run-mock-agentmail.ts new file mode 100644 index 000000000..6166ab27e --- /dev/null +++ b/packages/communication/scripts/run-mock-agentmail.ts @@ -0,0 +1,194 @@ +#!/usr/bin/env npx tsx + +import { readFile } from 'node:fs/promises'; + +import { z } from 'zod'; + +import { + MockAgentMailServer, + type MockAgentMailReplayEvent, + type MockAgentMailState, +} from '../src/mock-agentmail-server'; + +const inboundEmailSchema = z + .object({ + kind: z.literal('message').optional(), + inboxId: z.string().min(1), + from: z.string().min(1), + to: z.array(z.string().min(1)).optional(), + cc: z.array(z.string().min(1)).optional(), + subject: z.string().optional(), + text: z.string().optional(), + html: z.string().optional(), + threadId: z.string().optional(), + timestamp: z.string().optional(), + autoSubmitted: z.boolean().optional(), + oversize: z.boolean().optional(), + duplicate: z.boolean().optional(), + }) + .passthrough(); + +const configSchema = z.object({ + port: z.number().int().positive().optional(), + state: z.object({ + acceptedApiKeys: z.array(z.string().min(1)).optional(), + inboxes: z.array( + z + .object({ + inbox_id: z.string().min(1), + display_name: z.string().optional(), + client_id: z.string().optional(), + }) + .passthrough(), + ), + webhooks: z + .array( + z + .object({ + webhook_id: z.string().min(1), + url: z.string().url(), + secret: z.string().min(1).optional(), + client_id: z.string().optional(), + inbox_ids: z.array(z.string().min(1)).optional(), + event_types: z.array(z.string().min(1)).optional(), + enabled: z.boolean().optional(), + }) + .passthrough(), + ) + .optional(), + messages: z.array(z.record(z.unknown())).optional(), + }), + replay: z + .array( + z.union([ + z.object({ + kind: z.literal('redeliver'), + eventId: z.string().min(1), + }), + inboundEmailSchema, + ]), + ) + .optional(), +}); + +type HarnessConfig = z.infer; + +type ParsedOptions = { + statePath: string; + port?: number; + exitAfterReplay: boolean; +}; + +function parseArgs(argv: string[]): ParsedOptions { + const args = [...argv]; + let statePath = ''; + let port: number | undefined; + let exitAfterReplay = false; + + while (args.length > 0) { + const current = args.shift(); + + switch (current) { + case '--state': { + statePath = args.shift() ?? ''; + break; + } + case '--port': { + const rawPort = args.shift(); + port = rawPort ? Number.parseInt(rawPort, 10) : undefined; + break; + } + case '--exit-after-replay': { + exitAfterReplay = true; + break; + } + case '--help': { + printHelp(); + process.exit(0); + return { + statePath, + port, + exitAfterReplay, + }; + } + default: { + throw new Error(`Unknown argument: ${current}`); + } + } + } + + if (!statePath) { + throw new Error('Missing required --state argument.'); + } + + if (typeof port === 'number' && (!Number.isInteger(port) || port <= 0)) { + throw new Error(`Invalid --port value: ${port}`); + } + + return { statePath, port, exitAfterReplay }; +} + +function printHelp(): void { + console.info(`Usage: + pnpm --filter @roomote/communication mock:agentmail --state scripts/mock-agentmail.example.json + pnpm --filter @roomote/communication mock:agentmail --state scripts/mock-agentmail.example.json --port 3015 --exit-after-replay +`); +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + const rawConfig = await readFile(options.statePath, 'utf8'); + const config = configSchema.parse(JSON.parse(rawConfig)) as HarnessConfig; + + const server = new MockAgentMailServer({ + state: config.state as MockAgentMailState, + }); + + const baseUrl = await server.start(options.port ?? config.port ?? 0); + + console.info(`Mock AgentMail API listening at ${baseUrl}`); + console.info( + `Set AGENTMAIL_API_BASE_URL=${baseUrl} in the Roomote services you want to point at the harness.`, + ); + + for (const webhook of server.getState().webhooks ?? []) { + console.info( + `Seeded webhook ${webhook.webhook_id} → ${webhook.url} (secret ${webhook.secret})`, + ); + } + + if (config.replay?.length) { + for (const [index, event] of config.replay.entries()) { + const result = await server.dispatch(event as MockAgentMailReplayEvent); + const statuses = + result.deliveries + .map((delivery) => `${delivery.url} ${delivery.status}`) + .join(', ') || 'no matching webhooks'; + console.info( + `Replayed ${result.eventId} ${index + 1}/${config.replay.length}: ${statuses}`, + ); + } + } + + if (options.exitAfterReplay) { + await server.stop(); + return; + } + + process.on('SIGINT', async () => { + await server.stop(); + process.exit(0); + }); + + process.on('SIGTERM', async () => { + await server.stop(); + process.exit(0); + }); + + await new Promise(() => undefined); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/packages/communication/src/__tests__/agentmail-api-client.test.ts b/packages/communication/src/__tests__/agentmail-api-client.test.ts new file mode 100644 index 000000000..b1404c1a2 --- /dev/null +++ b/packages/communication/src/__tests__/agentmail-api-client.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; + +import { AgentMailApiClient, AgentMailApiError } from '../agentmail-provider'; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +describe('AgentMailApiClient.listInboxes', () => { + it('follows next_page_token pagination and aggregates every page', async () => { + const urls: string[] = []; + const pages = [ + { + inboxes: [ + { inbox_id: 'a@agentmail.to' }, + { inbox_id: 'b@agentmail.to' }, + ], + next_page_token: 'page-2', + }, + { + inboxes: [{ inbox_id: 'c@agentmail.to' }], + next_page_token: '', + }, + ]; + const client = new AgentMailApiClient({ + apiKey: 'am_test', + apiBaseUrl: 'https://agentmail.test', + fetch: (async (input: RequestInfo | URL) => { + urls.push(String(input)); + return jsonResponse(pages[urls.length - 1]); + }) as typeof fetch, + }); + + const listed = await client.listInboxes(); + + expect(urls).toEqual([ + 'https://agentmail.test/v0/inboxes', + 'https://agentmail.test/v0/inboxes?page_token=page-2', + ]); + expect((listed.inboxes ?? []).map((inbox) => inbox.inbox_id)).toEqual([ + 'a@agentmail.to', + 'b@agentmail.to', + 'c@agentmail.to', + ]); + }); + + it('returns a single page unchanged when no token is present', async () => { + let calls = 0; + const client = new AgentMailApiClient({ + apiKey: 'am_test', + apiBaseUrl: 'https://agentmail.test', + fetch: (async () => { + calls += 1; + return jsonResponse({ inboxes: [{ inbox_id: 'only@agentmail.to' }] }); + }) as typeof fetch, + }); + + const listed = await client.listInboxes(); + expect(calls).toBe(1); + expect(listed.inboxes).toHaveLength(1); + }); +}); + +describe('AgentMailApiError', () => { + it('carries the HTTP status of non-2xx responses', async () => { + const client = new AgentMailApiClient({ + apiKey: 'am_test', + apiBaseUrl: 'https://agentmail.test', + fetch: (async () => + jsonResponse({ error: 'forbidden' }, 403)) as typeof fetch, + }); + + const error = await client + .getMessage('inbox@agentmail.to', 'missing') + .then(() => null) + .catch((thrown: unknown) => thrown); + + expect(error).toBeInstanceOf(AgentMailApiError); + expect((error as AgentMailApiError).status).toBe(403); + }); +}); diff --git a/packages/communication/src/__tests__/agentmail-buttons.test.ts b/packages/communication/src/__tests__/agentmail-buttons.test.ts new file mode 100644 index 000000000..a000b4020 --- /dev/null +++ b/packages/communication/src/__tests__/agentmail-buttons.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; + +import { buildAgentMailButtonSections } from '../agentmail-format'; + +describe('buildAgentMailButtonSections', () => { + it('renders url buttons as anchors and a plain-text link list', () => { + const sections = buildAgentMailButtonSections([ + [ + { text: 'Quick fix', url: 'https://app.example/answer?token=a' }, + { text: 'Full refactor', url: 'https://app.example/answer?token=b' }, + ], + ]); + + expect(sections.html).toContain( + ''); + expect(sections.text).toContain( + 'Quick fix: https://app.example/answer?token=a', + ); + }); + + it('escapes labels and urls in the html output', () => { + const sections = buildAgentMailButtonSections([ + [{ text: 'Yes & no', url: 'https://x.example/?a=1&b=2' }], + ]); + + expect(sections.html).toContain('<b>Yes</b> & no'); + expect(sections.html).toContain('https://x.example/?a=1&b=2'); + expect(sections.html).not.toContain('Yes'); + }); + + it('returns empty sections when no button has a url', () => { + expect( + buildAgentMailButtonSections([[{ text: 'No link', url: '' }]]), + ).toEqual({ html: '', text: '' }); + }); +}); diff --git a/packages/communication/src/__tests__/agentmail-event.test.ts b/packages/communication/src/__tests__/agentmail-event.test.ts new file mode 100644 index 000000000..ffb6a7358 --- /dev/null +++ b/packages/communication/src/__tests__/agentmail-event.test.ts @@ -0,0 +1,295 @@ +import { describe, expect, it } from 'vitest'; + +import type { AgentMailMessage } from '../agentmail-event'; +import { + getAgentMailDeliveryFailureRecipients, + getAgentMailMessageBodyText, + getAgentMailSenderAddress, + isAgentMailAutoGeneratedMessage, + isAgentMailMessageBouncedEvent, + isAgentMailMessageComplainedEvent, + isAgentMailMessageReceivedEvent, + isAgentMailPermanentBounce, + parseAgentMailWebhookEvent, +} from '../agentmail-event'; + +function buildMessage( + overrides: Record = {}, +): AgentMailMessage { + return { + message_id: 'msg_1', + thread_id: 'thread_1', + inbox_id: 'inbox_1', + from: 'sender@example.test', + timestamp: '2026-08-31T12:00:00Z', + ...overrides, + } as AgentMailMessage; +} + +describe('parseAgentMailWebhookEvent', () => { + it('parses a message.received event and keeps unknown fields', () => { + const event = parseAgentMailWebhookEvent({ + type: 'event', + event_type: 'message.received', + event_id: 'evt_1', + message: { + message_id: 'msg_1', + thread_id: 'thread_1', + inbox_id: 'inbox_1', + from: 'Sender ', + to: ['agent@inbox.example.test'], + subject: 'Hello', + text: 'Hi there', + timestamp: '2026-08-31T12:00:00Z', + some_future_field: 'kept', + }, + }); + + expect(event).not.toBeNull(); + expect(event?.event_type).toBe('message.received'); + expect(event && isAgentMailMessageReceivedEvent(event)).toBe(true); + expect( + (event?.message as Record | undefined) + ?.some_future_field, + ).toBe('kept'); + }); + + it('returns null for payloads missing required fields', () => { + expect(parseAgentMailWebhookEvent({ message: {} })).toBeNull(); + expect(parseAgentMailWebhookEvent('not an object')).toBeNull(); + }); + + it('accepts array and object address forms', () => { + const event = parseAgentMailWebhookEvent({ + event_type: 'message.received', + message: { + message_id: 'msg_1', + thread_id: 'thread_1', + inbox_id: 'inbox_1', + from: [{ address: 'sender@example.test', name: 'Sender' }], + timestamp: '2026-08-31T12:00:00Z', + }, + }); + + expect(event?.message).toBeDefined(); + }); +}); + +describe('getAgentMailSenderAddress', () => { + it('normalizes the angle-bracket display form to a lowercased address', () => { + expect( + getAgentMailSenderAddress(buildMessage({ from: 'Name ' })), + ).toBe('a@b.example'); + }); + + it('accepts bare addresses and arrays', () => { + expect( + getAgentMailSenderAddress(buildMessage({ from: 'A@Example.Test' })), + ).toBe('a@example.test'); + expect( + getAgentMailSenderAddress( + buildMessage({ from: ['First ', 'x@y.test'] }), + ), + ).toBe('first@example.test'); + }); + + it('reads address objects and returns null when no address exists', () => { + expect( + getAgentMailSenderAddress( + buildMessage({ from: { email: 'Obj@Example.Test', name: 'Obj' } }), + ), + ).toBe('obj@example.test'); + expect(getAgentMailSenderAddress(buildMessage({ from: [] }))).toBeNull(); + expect( + getAgentMailSenderAddress(buildMessage({ from: 'not-an-address' })), + ).toBeNull(); + }); +}); + +describe('getAgentMailMessageBodyText', () => { + it('ignores close tags embedded in the opening tag attributes', () => { + expect( + getAgentMailMessageBodyText( + buildMessage({ + html: 'visible', + }), + ), + ).toBe('visible'); + }); + + it('ignores a ">" inside a quoted attribute before an embedded close tag', () => { + expect( + getAgentMailMessageBodyText( + buildMessage({ + html: 'visible', + }), + ), + ).toBe('visible'); + expect( + getAgentMailMessageBodyText( + buildMessage({ + html: '">.a{}after', + }), + ), + ).toBe('after'); + }); + + it('does not treat tags that merely start with script/style as blocks', () => { + expect( + getAgentMailMessageBodyText( + buildMessage({ + html: 'keep me and done', + }), + ), + ).toBe('keep me and done'); + }); + + it('keeps neighboring text separated when script/style blocks are removed', () => { + expect( + getAgentMailMessageBodyText( + buildMessage({ + html: 'Pleasereviewthis', + }), + ), + ).toBe('Please review this'); + }); + + it('prefers extracted_text over text and html', () => { + expect( + getAgentMailMessageBodyText( + buildMessage({ + extracted_text: ' extracted ', + text: 'raw text', + html: '

html

', + }), + ), + ).toBe('extracted'); + }); + + it('falls back to text before html', () => { + expect( + getAgentMailMessageBodyText( + buildMessage({ text: 'raw text', html: '

html

' }), + ), + ).toBe('raw text'); + }); + + it('strips tags from the html fallback', () => { + expect( + getAgentMailMessageBodyText( + buildMessage({ html: '

Hello world

' }), + ), + ).toBe('Hello world'); + expect(getAgentMailMessageBodyText(buildMessage({}))).toBe(''); + }); +}); + +describe('isAgentMailAutoGeneratedMessage', () => { + it('detects Auto-Submitted headers case-insensitively', () => { + expect( + isAgentMailAutoGeneratedMessage( + buildMessage({ headers: { 'auto-submitted': 'auto-replied' } }), + ), + ).toBe(true); + expect( + isAgentMailAutoGeneratedMessage( + buildMessage({ headers: { 'Auto-Submitted': 'no' } }), + ), + ).toBe(false); + }); + + it('detects bulk/list/junk precedence', () => { + expect( + isAgentMailAutoGeneratedMessage( + buildMessage({ headers: { Precedence: 'Bulk' } }), + ), + ).toBe(true); + expect( + isAgentMailAutoGeneratedMessage( + buildMessage({ headers: { Precedence: 'first-class' } }), + ), + ).toBe(false); + }); + + it('tolerates missing or malformed headers', () => { + expect(isAgentMailAutoGeneratedMessage(buildMessage({}))).toBe(false); + expect( + isAgentMailAutoGeneratedMessage(buildMessage({ headers: 'nope' })), + ).toBe(false); + expect( + isAgentMailAutoGeneratedMessage( + buildMessage({ labels: ['Auto-Generated'] }), + ), + ).toBe(true); + }); +}); + +describe('delivery-failure events', () => { + it('parses a message.bounced payload with object recipients', () => { + const event = parseAgentMailWebhookEvent({ + type: 'event', + event_type: 'message.bounced', + event_id: 'evt_1', + bounce: { + inbox_id: 'inbox_1', + thread_id: 'thread_1', + message_id: '', + timestamp: '2026-08-31T12:00:00Z', + type: 'Permanent', + sub_type: 'General', + recipients: [ + { address: 'Invalid@Example.test', status: 'bounced' }, + { address: 'invalid@example.test', status: 'bounced' }, + ], + }, + }); + + expect(event).not.toBeNull(); + expect(isAgentMailMessageBouncedEvent(event!)).toBe(true); + expect(isAgentMailMessageComplainedEvent(event!)).toBe(false); + expect(isAgentMailPermanentBounce(event!.bounce!)).toBe(true); + expect(getAgentMailDeliveryFailureRecipients(event!.bounce!)).toEqual([ + 'invalid@example.test', + ]); + }); + + it('parses a message.complained payload with string recipients', () => { + const event = parseAgentMailWebhookEvent({ + type: 'event', + event_type: 'message.complained', + event_id: 'evt_2', + complaint: { + inbox_id: 'inbox_1', + message_id: '', + type: 'abuse', + sub_type: 'spam', + recipients: ['Complainer@Example.test'], + }, + }); + + expect(event).not.toBeNull(); + expect(isAgentMailMessageComplainedEvent(event!)).toBe(true); + expect(getAgentMailDeliveryFailureRecipients(event!.complaint!)).toEqual([ + 'complainer@example.test', + ]); + }); + + it('treats only Permanent bounces as permanent', () => { + expect(isAgentMailPermanentBounce({ inbox_id: 'i' })).toBe(false); + expect( + isAgentMailPermanentBounce({ inbox_id: 'i', type: 'Transient' }), + ).toBe(false); + expect( + isAgentMailPermanentBounce({ inbox_id: 'i', type: 'permanent' }), + ).toBe(true); + }); + + it('drops recipients without a parseable address', () => { + expect( + getAgentMailDeliveryFailureRecipients({ + inbox_id: 'i', + recipients: ['not-an-address', { status: 'bounced' }], + }), + ).toEqual([]); + }); +}); diff --git a/packages/communication/src/__tests__/agentmail-format.test.ts b/packages/communication/src/__tests__/agentmail-format.test.ts new file mode 100644 index 000000000..1059b47eb --- /dev/null +++ b/packages/communication/src/__tests__/agentmail-format.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest'; + +import { + AGENTMAIL_MAX_TEXT_LENGTH, + buildAgentMailEmailBody, + renderAgentMailHtml, + renderAgentMailPlainText, +} from '../agentmail-format'; + +describe('renderAgentMailHtml', () => { + it('converts bold, italic, and inline code inside a paragraph', () => { + expect(renderAgentMailHtml('**bold** and *italic* and `code`')).toBe( + '

bold and italic and code

', + ); + }); + + it('escapes HTML so script injection stays inert', () => { + expect(renderAgentMailHtml('')).toBe( + '

<script>alert("x")</script>

', + ); + }); + + it('converts http and mailto links to anchors', () => { + expect( + renderAgentMailHtml( + '[the task](https://example.test/t/1) or [mail us](mailto:hi@example.test)', + ), + ).toBe( + '

the task or mail us

', + ); + }); + + it('leaves unsafe link protocols as escaped text', () => { + expect(renderAgentMailHtml('[click](javascript:alert(1))')).toBe( + '

[click](javascript:alert(1))

', + ); + }); + + it('renders headings one size down as h3-h5', () => { + expect(renderAgentMailHtml('# One\n\n## Two\n\n### Three')).toBe( + '

One

Two

Three
', + ); + }); + + it('renders unordered and ordered lists', () => { + expect(renderAgentMailHtml('- a\n- b\n\n1. x\n2. y')).toBe( + '
  • a
  • b
  1. x
  2. y
', + ); + }); + + it('renders blockquotes', () => { + expect(renderAgentMailHtml('> quoted line')).toBe( + '

quoted line

', + ); + }); + + it('renders fenced code blocks with escaped content', () => { + expect(renderAgentMailHtml('```ts\nconst a = 1 < 2;\n```')).toBe( + '
const a = 1 < 2;
', + ); + }); + + it('joins adjacent paragraph lines with line breaks', () => { + expect(renderAgentMailHtml('line one\nline two')).toBe( + '

line one
line two

', + ); + }); + + it('leaves emphasis markers inside code spans untouched', () => { + expect(renderAgentMailHtml('`**not bold**` but **bold**')).toBe( + '

**not bold** but bold

', + ); + }); + + it('does not italicize snake_case identifiers', () => { + expect(renderAgentMailHtml('set R_AGENTMAIL_API_KEY and my_var_name')).toBe( + '

set R_AGENTMAIL_API_KEY and my_var_name

', + ); + }); +}); + +describe('renderAgentMailPlainText', () => { + it('renders links as label (url)', () => { + expect( + renderAgentMailPlainText('see [the task](https://example.test/t/1)'), + ).toBe('see the task (https://example.test/t/1)'); + }); + + it('strips emphasis, code, and heading markers', () => { + expect( + renderAgentMailPlainText('## Summary\n**done** with `it` and *more*'), + ).toBe('Summary\ndone with it and more'); + }); + + it('keeps fenced code content verbatim', () => { + expect(renderAgentMailPlainText('```\nconst a = 1;\n```')).toBe( + 'const a = 1;', + ); + }); + + it('strips blockquote markers', () => { + expect(renderAgentMailPlainText('> quoted line')).toBe('quoted line'); + }); +}); + +describe('buildAgentMailEmailBody', () => { + it('wraps the html body in a minimal div and pairs it with plain text', () => { + expect(buildAgentMailEmailBody('**hi** there')).toEqual({ + html: '

hi there

', + text: 'hi there', + }); + }); + + it('truncates oversized messages with a suffix', () => { + const body = buildAgentMailEmailBody( + 'a'.repeat(AGENTMAIL_MAX_TEXT_LENGTH + 100), + ); + + expect(body.text.endsWith('[message truncated]')).toBe(true); + expect(body.text.length).toBeLessThanOrEqual(AGENTMAIL_MAX_TEXT_LENGTH); + expect(body.html.endsWith('[message truncated]

')).toBe(true); + }); +}); diff --git a/packages/communication/src/__tests__/mock-agentmail-server.test.ts b/packages/communication/src/__tests__/mock-agentmail-server.test.ts new file mode 100644 index 000000000..f22e38f69 --- /dev/null +++ b/packages/communication/src/__tests__/mock-agentmail-server.test.ts @@ -0,0 +1,648 @@ +import { createHmac } from 'node:crypto'; +import { + createServer, + type IncomingMessage, + type Server, + type ServerResponse, +} from 'node:http'; +import { AddressInfo } from 'node:net'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { + MockAgentMailServer, + signSvixPayload, + type MockAgentMailState, + type MockAgentMailWebhook, +} from '../mock-agentmail-server'; + +const API_KEY = 'mock-agentmail-api-key'; +const INBOX_ID = 'roomote@agentmail.to'; + +function baseState(): MockAgentMailState { + return { + inboxes: [ + { + inbox_id: INBOX_ID, + username: 'roomote', + domain: 'agentmail.to', + display_name: 'Roomote', + created_at: '2026-08-01T00:00:00.000Z', + }, + ], + }; +} + +async function startServer(state: MockAgentMailState = baseState()) { + const server = new MockAgentMailServer({ state }); + const baseUrl = await server.start(); + return { server, baseUrl }; +} + +async function api( + baseUrl: string, + method: string, + path: string, + body?: unknown, + headers: Record = {}, +): Promise<{ status: number; body: Record }> { + const response = await fetch(`${baseUrl}${path}`, { + method, + headers: { + authorization: `Bearer ${API_KEY}`, + 'content-type': 'application/json', + ...headers, + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + + return { + status: response.status, + body: (await response.json()) as Record, + }; +} + +type ReceivedDelivery = { + headers: IncomingMessage['headers']; + body: string; +}; + +describe('MockAgentMailServer', () => { + const cleanups: Array<() => Promise> = []; + + afterEach(async () => { + while (cleanups.length > 0) { + await cleanups.pop()?.(); + } + }); + + function onCleanup(fn: () => Promise) { + cleanups.push(fn); + } + + it('rejects API requests without a bearer token', async () => { + const { server, baseUrl } = await startServer(); + onCleanup(() => server.stop()); + + const response = await fetch(`${baseUrl}/v0/inboxes`); + expect(response.status).toBe(401); + }); + + it('rejects requests carrying an API key outside acceptedApiKeys', async () => { + const state = baseState(); + state.acceptedApiKeys = ['some-other-key']; + const { server, baseUrl } = await startServer(state); + onCleanup(() => server.stop()); + + const listing = await api(baseUrl, 'GET', '/v0/inboxes'); + expect(listing.status).toBe(401); + }); + + it('creates inboxes idempotently per client_id', async () => { + const { server, baseUrl } = await startServer(); + onCleanup(() => server.stop()); + + const first = await api(baseUrl, 'POST', '/v0/inboxes', { + username: 'support', + client_id: 'support-inbox', + }); + expect(first.status).toBe(200); + expect(first.body.inbox_id).toBe('support@agentmail.to'); + + const second = await api(baseUrl, 'POST', '/v0/inboxes', { + username: 'support', + client_id: 'support-inbox', + }); + expect(second.status).toBe(200); + expect(second.body.inbox_id).toBe('support@agentmail.to'); + + const listing = await api(baseUrl, 'GET', '/v0/inboxes'); + expect(listing.body.inboxes).toHaveLength(2); + + const fetched = await api( + baseUrl, + 'GET', + '/v0/inboxes/support%40agentmail.to', + ); + expect(fetched.status).toBe(200); + expect(fetched.body.username).toBe('support'); + }); + + it('registers webhooks idempotently per client_id and supports PATCH/DELETE', async () => { + const { server, baseUrl } = await startServer(); + onCleanup(() => server.stop()); + + const first = await api(baseUrl, 'POST', '/v0/webhooks', { + url: 'https://roomote.example.test/api/webhooks/agentmail', + client_id: 'roomote-webhook', + event_types: ['message.received'], + }); + expect(first.status).toBe(200); + expect(String(first.body.secret)).toMatch(/^whsec_/); + + const second = await api(baseUrl, 'POST', '/v0/webhooks', { + url: 'https://roomote.example.test/api/webhooks/agentmail', + client_id: 'roomote-webhook', + }); + expect(second.body.webhook_id).toBe(first.body.webhook_id); + expect(second.body.secret).toBe(first.body.secret); + + const patched = await api( + baseUrl, + 'PATCH', + `/v0/webhooks/${first.body.webhook_id}`, + { url: 'https://roomote.example.test/api/webhooks/agentmail-v2' }, + ); + expect(patched.body.url).toBe( + 'https://roomote.example.test/api/webhooks/agentmail-v2', + ); + + const deleted = await api( + baseUrl, + 'DELETE', + `/v0/webhooks/${first.body.webhook_id}`, + ); + expect(deleted.status).toBe(200); + expect((await api(baseUrl, 'GET', '/v0/webhooks')).body.webhooks).toEqual( + [], + ); + }); + + it('delivers message.received events with a valid Svix signature', async () => { + const received: ReceivedDelivery[] = []; + const listener = await startStubWebhook(received); + onCleanup(() => listener.stop()); + + const { server, baseUrl } = await startServer(); + onCleanup(() => server.stop()); + + const registered = await api(baseUrl, 'POST', '/v0/webhooks', { + url: listener.url, + inbox_ids: [INBOX_ID], + event_types: ['message.received'], + }); + const secret = String(registered.body.secret); + + const result = await server.dispatch({ + inboxId: INBOX_ID, + from: 'grace@example.com', + subject: 'Flaky login test', + text: 'Hi Roomote — can you look into the flaky login test?', + }); + + expect(result.deliveries).toHaveLength(1); + expect(result.deliveries[0]?.status).toBe(200); + expect(received).toHaveLength(1); + + const delivery = received[0]!; + const svixId = String(delivery.headers['svix-id']); + const timestamp = String(delivery.headers['svix-timestamp']); + const signature = String(delivery.headers['svix-signature']); + + // Verify the way the `svix` package does: HMAC-SHA256 over + // `${id}.${timestamp}.${body}` keyed with the base64-decoded secret. + const expected = createHmac( + 'sha256', + Buffer.from(secret.slice('whsec_'.length), 'base64'), + ) + .update(`${svixId}.${timestamp}.${delivery.body}`) + .digest('base64'); + expect(signature).toBe(`v1,${expected}`); + expect(signature).toBe( + signSvixPayload({ + secret, + svixId, + timestamp, + payload: delivery.body, + }), + ); + + const payload = JSON.parse(delivery.body) as { + type: string; + event_type: string; + event_id: string; + message: Record; + thread: Record; + }; + expect(payload.type).toBe('event'); + expect(payload.event_type).toBe('message.received'); + expect(payload.event_id).toBe(result.eventId); + expect(payload.message).toMatchObject({ + message_id: result.messageId, + thread_id: result.threadId, + inbox_id: INBOX_ID, + from: 'grace@example.com', + to: [INBOX_ID], + subject: 'Flaky login test', + text: 'Hi Roomote — can you look into the flaky login test?', + extracted_text: 'Hi Roomote — can you look into the flaky login test?', + attachments: [], + }); + expect(payload.thread).toEqual({ + thread_id: result.threadId, + last_message_id: result.messageId, + message_count: 1, + }); + }); + + it('delivers bounce and complaint events with real payload shapes', async () => { + const received: ReceivedDelivery[] = []; + const listener = await startStubWebhook(received); + onCleanup(() => listener.stop()); + + const { server, baseUrl } = await startServer(); + onCleanup(() => server.stop()); + + await api(baseUrl, 'POST', '/v0/webhooks', { + url: listener.url, + inbox_ids: [INBOX_ID], + event_types: ['message.bounced', 'message.complained'], + }); + + const bounce = await server.dispatch({ + kind: 'bounce', + inboxId: INBOX_ID, + recipients: ['gone@example.com'], + }); + expect(bounce.deliveries).toHaveLength(1); + expect(bounce.deliveries[0]?.status).toBe(200); + + const complaint = await server.dispatch({ + kind: 'complaint', + inboxId: INBOX_ID, + recipients: ['angry@example.com'], + }); + expect(complaint.deliveries).toHaveLength(1); + + const bouncePayload = JSON.parse(received[0]!.body) as Record< + string, + unknown + >; + expect(bouncePayload.event_type).toBe('message.bounced'); + // Bounce recipients are objects; complaint recipients are bare strings. + expect(bouncePayload.bounce).toMatchObject({ + inbox_id: INBOX_ID, + type: 'Permanent', + recipients: [{ address: 'gone@example.com', status: 'bounced' }], + }); + + const complaintPayload = JSON.parse(received[1]!.body) as Record< + string, + unknown + >; + expect(complaintPayload.event_type).toBe('message.complained'); + expect(complaintPayload.complaint).toMatchObject({ + inbox_id: INBOX_ID, + recipients: ['angry@example.com'], + }); + }); + + it('redelivers duplicates and explicit redeliveries with the same svix-id', async () => { + const received: ReceivedDelivery[] = []; + const listener = await startStubWebhook(received); + onCleanup(() => listener.stop()); + + const { server, baseUrl } = await startServer(); + onCleanup(() => server.stop()); + + await api(baseUrl, 'POST', '/v0/webhooks', { url: listener.url }); + + const original = await server.dispatch({ + inboxId: INBOX_ID, + from: 'grace@example.com', + text: 'first delivery', + }); + + const duplicate = await server.dispatch({ + inboxId: INBOX_ID, + from: 'grace@example.com', + duplicate: true, + }); + expect(duplicate.eventId).toBe(original.eventId); + expect(duplicate.svixId).toBe(original.svixId); + expect(duplicate.messageId).toBe(original.messageId); + + const redelivered = await server.dispatch({ + kind: 'redeliver', + eventId: original.eventId, + }); + expect(redelivered.svixId).toBe(original.svixId); + + expect(received).toHaveLength(3); + expect(new Set(received.map((d) => d.headers['svix-id']))).toEqual( + new Set([original.svixId]), + ); + expect(received[1]!.body).toBe(received[0]!.body); + expect(received[2]!.body).toBe(received[0]!.body); + + // No second inbound message was stored for the duplicate delivery. + const inbound = (server.getState().messages ?? []).filter( + (m) => m.direction === 'inbound', + ); + expect(inbound).toHaveLength(1); + }); + + it('omits text and html from oversize deliveries but keeps the stored message intact', async () => { + const received: ReceivedDelivery[] = []; + const listener = await startStubWebhook(received); + onCleanup(() => listener.stop()); + + const { server, baseUrl } = await startServer(); + onCleanup(() => server.stop()); + + await api(baseUrl, 'POST', '/v0/webhooks', { url: listener.url }); + + const result = await server.dispatch({ + inboxId: INBOX_ID, + from: 'grace@example.com', + subject: 'Huge attachment recap', + text: 'pretend this is 2MB of text', + html: '

pretend this is 2MB of html

', + oversize: true, + }); + + const payload = JSON.parse(received[0]!.body) as { + message: Record; + }; + expect(payload.message.text).toBeUndefined(); + expect(payload.message.extracted_text).toBeUndefined(); + expect(payload.message.html).toBeUndefined(); + expect(payload.message.subject).toBe('Huge attachment recap'); + + // Re-fetching the message by id still returns the full content. + const fetched = await api( + baseUrl, + 'GET', + `/v0/inboxes/${encodeURIComponent(INBOX_ID)}/messages/${result.messageId}`, + ); + expect(fetched.body.text).toBe('pretend this is 2MB of text'); + expect(fetched.body.html).toBe('

pretend this is 2MB of html

'); + }); + + it('stamps an Auto-Submitted header on autoSubmitted events', async () => { + const received: ReceivedDelivery[] = []; + const listener = await startStubWebhook(received); + onCleanup(() => listener.stop()); + + const { server, baseUrl } = await startServer(); + onCleanup(() => server.stop()); + + await api(baseUrl, 'POST', '/v0/webhooks', { url: listener.url }); + + await server.dispatch({ + inboxId: INBOX_ID, + from: 'noreply@example.com', + text: 'Your build failed.', + autoSubmitted: true, + }); + + const payload = JSON.parse(received[0]!.body) as { + message: { headers?: Record }; + }; + expect(payload.message.headers).toEqual({ + 'auto-submitted': 'auto-generated', + }); + }); + + it('scopes deliveries to matching inbox_ids and event_types', async () => { + const received: ReceivedDelivery[] = []; + const listener = await startStubWebhook(received); + onCleanup(() => listener.stop()); + + const state = baseState(); + state.inboxes.push({ + inbox_id: 'other@agentmail.to', + username: 'other', + domain: 'agentmail.to', + created_at: '2026-08-01T00:00:00.000Z', + }); + const { server, baseUrl } = await startServer(state); + onCleanup(() => server.stop()); + + await api(baseUrl, 'POST', '/v0/webhooks', { + url: listener.url, + inbox_ids: ['other@agentmail.to'], + }); + await api(baseUrl, 'POST', '/v0/webhooks', { + url: listener.url, + event_types: ['message.bounced'], + }); + + const result = await server.dispatch({ + inboxId: INBOX_ID, + from: 'grace@example.com', + text: 'nobody should hear about this', + }); + + expect(result.deliveries).toHaveLength(0); + expect(received).toHaveLength(0); + }); + + it('threads inbound follow-ups and replies through the same thread', async () => { + const { server, baseUrl } = await startServer(); + onCleanup(() => server.stop()); + + const first = await server.dispatch({ + inboxId: INBOX_ID, + from: 'grace@example.com', + subject: 'Flaky login test', + text: 'Can you take a look?', + }); + + const reply = await api( + baseUrl, + 'POST', + `/v0/inboxes/${encodeURIComponent(INBOX_ID)}/messages/${first.messageId}/reply`, + { text: 'On it — taking a look now.' }, + ); + expect(reply.status).toBe(200); + expect(reply.body.thread_id).toBe(first.threadId); + + const followUp = await server.dispatch({ + inboxId: INBOX_ID, + from: 'grace@example.com', + text: 'thanks!', + threadId: first.threadId, + }); + expect(followUp.threadId).toBe(first.threadId); + + const messages = server.getState().messages ?? []; + const outbound = messages.filter((m) => m.direction === 'outbound'); + expect(outbound).toHaveLength(1); + expect(outbound[0]).toMatchObject({ + thread_id: first.threadId, + from: INBOX_ID, + to: ['grace@example.com'], + subject: 'Re: Flaky login test', + text: 'On it — taking a look now.', + in_reply_to: first.messageId, + references: [first.messageId], + }); + + const followUpMessage = messages.find( + (m) => m.message_id === followUp.messageId, + ); + expect(followUpMessage?.in_reply_to).toBe(outbound[0]?.message_id); + }); + + it('dedupes replies on the Idempotency-Key header', async () => { + const { server, baseUrl } = await startServer(); + onCleanup(() => server.stop()); + + const inbound = await server.dispatch({ + inboxId: INBOX_ID, + from: 'grace@example.com', + text: 'ping', + }); + + const replyPath = `/v0/inboxes/${encodeURIComponent(INBOX_ID)}/messages/${inbound.messageId}/reply`; + const first = await api( + baseUrl, + 'POST', + replyPath, + { text: 'pong' }, + { 'idempotency-key': 'reply-attempt-1' }, + ); + const retry = await api( + baseUrl, + 'POST', + replyPath, + { text: 'pong' }, + { 'idempotency-key': 'reply-attempt-1' }, + ); + + expect(retry.body.message_id).toBe(first.body.message_id); + expect(retry.body.thread_id).toBe(first.body.thread_id); + expect( + (server.getState().messages ?? []).filter( + (m) => m.direction === 'outbound', + ), + ).toHaveLength(1); + }); + + it('starts a new thread on send and records it as outbound', async () => { + const { server, baseUrl } = await startServer(); + onCleanup(() => server.stop()); + + const sent = await api( + baseUrl, + 'POST', + `/v0/inboxes/${encodeURIComponent(INBOX_ID)}/messages/send`, + { + to: ['grace@example.com'], + subject: 'Task complete', + text: 'All done — the fix is on develop.', + }, + ); + expect(sent.status).toBe(200); + expect(String(sent.body.message_id)).toMatch(/^msg_/); + expect(String(sent.body.thread_id)).toMatch(/^thread_/); + + const message = (server.getState().messages ?? []).find( + (m) => m.message_id === sent.body.message_id, + ); + expect(message).toMatchObject({ + direction: 'outbound', + from: INBOX_ID, + to: ['grace@example.com'], + subject: 'Task complete', + }); + expect(message?.in_reply_to).toBeUndefined(); + }); + + it('exposes state and accepts inbound events through the /mock endpoints', async () => { + const received: ReceivedDelivery[] = []; + const listener = await startStubWebhook(received); + onCleanup(() => listener.stop()); + + const { server, baseUrl } = await startServer(); + onCleanup(() => server.stop()); + + await api(baseUrl, 'POST', '/v0/webhooks', { url: listener.url }); + + const eventResponse = await fetch(`${baseUrl}/mock/events`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + inboxId: INBOX_ID, + from: 'grace@example.com', + subject: 'Hello', + text: 'via the control surface', + }), + }); + expect(eventResponse.status).toBe(200); + const dispatched = (await eventResponse.json()) as { + ok: boolean; + dispatchResult: { deliveries: Array<{ status: number }> }; + }; + expect(dispatched.ok).toBe(true); + expect(dispatched.dispatchResult.deliveries[0]?.status).toBe(200); + + const stateResponse = await fetch(`${baseUrl}/mock/state`); + const state = (await stateResponse.json()) as MockAgentMailState; + expect(state.inboxes).toHaveLength(1); + expect(state.webhooks).toHaveLength(1); + expect(state.messages).toHaveLength(1); + expect(state.events).toHaveLength(1); + + // POST /mock/state resets the harness in place. + const resetResponse = await fetch(`${baseUrl}/mock/state`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(baseState()), + }); + expect(resetResponse.status).toBe(200); + expect(server.getState().messages ?? []).toHaveLength(0); + expect(server.getState().webhooks ?? []).toHaveLength(0); + }); + + it('mints signing secrets for seeded webhooks that omit one', async () => { + const state = baseState(); + state.webhooks = [ + { + webhook_id: 'wh_seeded_1', + url: 'https://roomote.example.test/api/webhooks/agentmail', + secret: '', + enabled: true, + created_at: '2026-08-01T00:00:00.000Z', + } as MockAgentMailWebhook, + ]; + const { server } = await startServer(state); + onCleanup(() => server.stop()); + + expect(server.getState().webhooks?.[0]?.secret).toMatch(/^whsec_/); + }); +}); + +async function startStubWebhook( + received: ReceivedDelivery[], +): Promise<{ url: string; stop: () => Promise }> { + const server: Server = createServer( + async (request: IncomingMessage, response: ServerResponse) => { + const chunks: Buffer[] = []; + for await (const chunk of request) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + received.push({ + headers: request.headers, + body: Buffer.concat(chunks).toString('utf8'), + }); + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ ok: true })); + }, + ); + + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => resolve()); + }); + + const address = server.address() as AddressInfo; + + return { + url: `http://127.0.0.1:${address.port}/api/webhooks/agentmail`, + stop: () => + new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }), + }; +} diff --git a/packages/communication/src/agentmail-api-base-url.ts b/packages/communication/src/agentmail-api-base-url.ts new file mode 100644 index 000000000..9bd08840d --- /dev/null +++ b/packages/communication/src/agentmail-api-base-url.ts @@ -0,0 +1,14 @@ +const DEFAULT_AGENTMAIL_API_BASE_URL = 'https://api.agentmail.to'; + +/** + * Resolves the AgentMail API host, mirroring `getTelegramApiBaseUrl`: + * `AGENTMAIL_API_BASE_URL` lets tests and a mock AgentMail harness reroute + * every outbound API call without touching call sites. + */ +export function getAgentMailApiBaseUrl(): string { + const configuredUrl = ( + process.env.AGENTMAIL_API_BASE_URL ?? DEFAULT_AGENTMAIL_API_BASE_URL + ).trim(); + + return configuredUrl.replace(/\/+$/, ''); +} diff --git a/packages/communication/src/agentmail-event.ts b/packages/communication/src/agentmail-event.ts new file mode 100644 index 000000000..5c148134f --- /dev/null +++ b/packages/communication/src/agentmail-event.ts @@ -0,0 +1,397 @@ +import { z } from 'zod'; + +/** + * AgentMail webhook payload schemas (https://docs.agentmail.to). Every object + * uses `.passthrough()` so unknown fields survive parsing — the pipeline + * stores raw events and later readers may need fields we do not model yet. + */ + +export const agentMailAddressSchema = z.union([ + z.string(), + z + .object({ + address: z.string().optional(), + email: z.string().optional(), + name: z.string().optional(), + }) + .passthrough(), +]); + +export const agentMailAttachmentSchema = z + .object({ + attachment_id: z.string(), + filename: z.string().optional(), + content_type: z.string().optional(), + size: z.number().optional(), + }) + .passthrough(); + +export const agentMailMessageSchema = z + .object({ + message_id: z.string(), + thread_id: z.string(), + inbox_id: z.string(), + organization_id: z.string().optional(), + from: z.union([agentMailAddressSchema, z.array(agentMailAddressSchema)]), + to: z.array(agentMailAddressSchema).optional(), + cc: z.array(agentMailAddressSchema).optional(), + bcc: z.array(agentMailAddressSchema).optional(), + subject: z.string().optional(), + text: z.string().optional(), + html: z.string().optional(), + extracted_text: z.string().optional(), + extracted_html: z.string().optional(), + /** Provider timestamp (ISO string) used for message ordering. */ + timestamp: z.string(), + in_reply_to: z.string().optional(), + references: z.array(z.string()).optional(), + labels: z.array(z.string()).optional(), + attachments: z.array(agentMailAttachmentSchema).optional(), + }) + .passthrough(); + +export const agentMailThreadSummarySchema = z + .object({ + thread_id: z.string(), + last_message_id: z.string().optional(), + message_count: z.number().optional(), + }) + .passthrough(); + +/** + * `message.bounced` recipients arrive as `{address, status}` objects; + * `message.complained` recipients arrive as bare strings. Accept both shapes + * for both events. + */ +const agentMailDeliveryFailureRecipientSchema = z.union([ + z.string(), + z + .object({ + address: z.string().optional(), + status: z.string().optional(), + }) + .passthrough(), +]); + +export const agentMailBounceSchema = z + .object({ + inbox_id: z.string(), + thread_id: z.string().optional(), + message_id: z.string().optional(), + timestamp: z.string().optional(), + /** 'Permanent' or 'Transient'. */ + type: z.string().optional(), + sub_type: z.string().optional(), + recipients: z.array(agentMailDeliveryFailureRecipientSchema).optional(), + }) + .passthrough(); + +export const agentMailComplaintSchema = z + .object({ + inbox_id: z.string(), + thread_id: z.string().optional(), + message_id: z.string().optional(), + timestamp: z.string().optional(), + type: z.string().optional(), + sub_type: z.string().optional(), + recipients: z.array(agentMailDeliveryFailureRecipientSchema).optional(), + }) + .passthrough(); + +export const agentMailWebhookEventSchema = z + .object({ + type: z.literal('event').optional(), + event_type: z.string(), + event_id: z.string().optional(), + message: agentMailMessageSchema.optional(), + thread: agentMailThreadSummarySchema.optional(), + bounce: agentMailBounceSchema.optional(), + complaint: agentMailComplaintSchema.optional(), + }) + .passthrough(); + +export type AgentMailAddress = z.infer; +export type AgentMailAttachment = z.infer; +export type AgentMailMessage = z.infer; +export type AgentMailThreadSummary = z.infer< + typeof agentMailThreadSummarySchema +>; +export type AgentMailWebhookEvent = z.infer; + +export function parseAgentMailWebhookEvent( + value: unknown, +): AgentMailWebhookEvent | null { + const parsed = agentMailWebhookEventSchema.safeParse(value); + + return parsed.success ? parsed.data : null; +} + +export type AgentMailDeliveryFailure = z.infer; + +export function isAgentMailMessageReceivedEvent( + event: AgentMailWebhookEvent, +): boolean { + return event.event_type === 'message.received'; +} + +export function isAgentMailMessageBouncedEvent( + event: AgentMailWebhookEvent, +): boolean { + return event.event_type === 'message.bounced'; +} + +export function isAgentMailMessageComplainedEvent( + event: AgentMailWebhookEvent, +): boolean { + return event.event_type === 'message.complained'; +} + +/** + * Only a permanent bounce proves the address is undeliverable; transient + * bounces (full mailbox, greylisting) must not poison the address forever. + */ +export function isAgentMailPermanentBounce( + bounce: AgentMailDeliveryFailure, +): boolean { + return (bounce.type ?? '').trim().toLowerCase() === 'permanent'; +} + +/** Normalized recipient addresses of a bounce/complaint payload. */ +export function getAgentMailDeliveryFailureRecipients( + failure: AgentMailDeliveryFailure, +): string[] { + const addresses = (failure.recipients ?? []) + .map((recipient) => + normalizeAgentMailAddress( + typeof recipient === 'string' ? recipient : (recipient.address ?? ''), + ), + ) + .filter((address): address is string => Boolean(address)); + return [...new Set(addresses)]; +} + +/** + * Index of the '>' that terminates the tag starting at `openAt`, skipping + * '>' characters inside single- or double-quoted attribute values, or -1 + * when the tag never terminates. + */ +function findTagEnd(html: string, openAt: number): number { + let quote: '"' | "'" | null = null; + for (let index = openAt; index < html.length; index += 1) { + const char = html.charAt(index); + if (quote) { + if (char === quote) { + quote = null; + } + continue; + } + if (char === '"' || char === "'") { + quote = char; + continue; + } + if (char === '>') { + return index; + } + } + return -1; +} + +/** + * Crude tag stripper for the HTML fallback body. Real sanitization happens + * later in the pipeline — this only recovers readable text for routing. + */ +function stripElementWithContent(html: string, tagName: string): string { + // Linear scan instead of a backtracking regex (CodeQL js/polynomial-redos, + // js/bad-tag-filter): find each opening tag, then the matching close tag + // allowing whitespace before '>', and cut the whole block. + const lower = html.toLowerCase(); + const openToken = `<${tagName}`; + let result = ''; + let cursor = 0; + + const isTagNameBoundary = (index: number): boolean => { + const next = lower.charAt(index); + // '' must not match '', whitespace, or '/'. + return next === '>' || next === '/' || /\s/.test(next); + }; + + for (;;) { + const openAt = lower.indexOf(openToken, cursor); + if (openAt === -1) { + result += html.slice(cursor); + return result; + } + if (!isTagNameBoundary(openAt + openToken.length)) { + result += html.slice(cursor, openAt + openToken.length); + cursor = openAt + openToken.length; + continue; + } + // Replace the removed block with a space so neighboring text does not + // merge ("Pleasereview" must not become + // "Pleasereview"); later whitespace normalization collapses it. + result += `${html.slice(cursor, openAt)} `; + + // Scan for the close tag only AFTER the opening tag ends; a literal + // '' inside the opening tag's attribute values must not + // terminate the block early and leak its content. Finding where the + // opening tag ends must itself respect quoted attribute values — a '>' + // inside `data-note="> "` is attribute text, not the tag end. + const openTagEnd = findTagEnd(html, openAt); + if (openTagEnd === -1) { + // Unterminated opening tag: drop the rest, matching sanitizer behavior. + return result; + } + + const closePattern = `', closeAt); + if (closeEnd === -1) { + return result; + } + cursor = closeEnd + 1; + } +} + +function stripHtmlTags(html: string): string { + return stripElementWithContent( + stripElementWithContent(html, 'script'), + 'style', + ) + .replace(//gi, '\n') + .replace(/<\/(?:p|div|li|blockquote|h[1-6]|tr)>/gi, '\n') + .replace(/<[^>]+>/g, ' ') + .replaceAll(' ', ' ') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll('&', '&') + .replace(/[^\S\n]+/g, ' ') + .replace(/[ \t]*\n[ \t]*/g, '\n') + .replace(/\n{3,}/g, '\n\n'); +} + +/** Prefer the provider's extracted plain text, then raw text, then HTML. */ +export function getAgentMailMessageBodyText(message: AgentMailMessage): string { + const extractedText = message.extracted_text?.trim(); + + if (extractedText) { + return extractedText; + } + + const text = message.text?.trim(); + + if (text) { + return text; + } + + const html = message.extracted_html ?? message.html; + + return html ? stripHtmlTags(html).trim() : ''; +} + +function readAddressString(address: AgentMailAddress): string | undefined { + if (typeof address === 'string') { + return address; + } + + return address.address ?? address.email; +} + +/** + * Normalize the `from` field — a bare address, a `Name ` display + * string, an address object, or an array of any of those — to a lowercased + * bare email address. + */ +export function getAgentMailSenderAddress( + message: AgentMailMessage, +): string | null { + const from = Array.isArray(message.from) ? message.from[0] : message.from; + + if (from === undefined) { + return null; + } + + return normalizeAgentMailAddress(from); +} + +/** + * Normalize one address value — a bare address, a `Name ` display + * string, or an address object — to a lowercased bare email address. + */ +export function normalizeAgentMailAddress( + value: AgentMailAddress, +): string | null { + const raw = readAddressString(value)?.trim(); + + if (!raw) { + return null; + } + + const angleMatch = /<([^<>]+)>/.exec(raw); + const candidate = (angleMatch?.[1] ?? raw).trim().toLowerCase(); + + return candidate.includes('@') ? candidate : null; +} + +function readHeader( + headers: Record, + name: string, +): string | undefined { + const lowered = name.toLowerCase(); + + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === lowered && typeof value === 'string') { + return value; + } + } + + return undefined; +} + +/** + * Loop protection: detect auto-generated mail (vacation responders, bounce + * notices, list traffic) so the pipeline never auto-replies to it. The + * webhook payload may carry raw headers in a passthrough `headers` field — + * check defensively since the shape is not guaranteed. + */ +export function isAgentMailAutoGeneratedMessage( + message: AgentMailMessage, +): boolean { + const headersValue = (message as Record).headers; + const headers = + headersValue && + typeof headersValue === 'object' && + !Array.isArray(headersValue) + ? (headersValue as Record) + : {}; + + const autoSubmitted = readHeader(headers, 'Auto-Submitted') + ?.trim() + .toLowerCase(); + + if (autoSubmitted && autoSubmitted !== 'no') { + return true; + } + + const precedence = readHeader(headers, 'Precedence')?.trim().toLowerCase(); + + if (precedence && ['bulk', 'list', 'junk'].includes(precedence)) { + return true; + } + + const labels = (message.labels ?? []).map((label) => label.toLowerCase()); + + return labels.includes('auto-generated') || labels.includes('auto-submitted'); +} diff --git a/packages/communication/src/agentmail-format.ts b/packages/communication/src/agentmail-format.ts new file mode 100644 index 000000000..710ced7bb --- /dev/null +++ b/packages/communication/src/agentmail-format.ts @@ -0,0 +1,361 @@ +/** + * AgentMail email body formatting helpers. + * + * Converts the agent-authored markdown used across Roomote chat surfaces into + * a conservative HTML email body plus a plain-text alternative. All source + * text is HTML-escaped first — raw HTML never passes through, so an email + * body can never carry injected markup. + */ + +/** + * Email size limits are generous, so no chunking — just a defensive cap so a + * runaway agent reply cannot produce a multi-megabyte email. + */ +export const AGENTMAIL_MAX_TEXT_LENGTH = 100_000; + +const TRUNCATION_SUFFIX = '\n\n[message truncated]'; + +function truncateAgentMailMarkdown(markdown: string): string { + if (markdown.length <= AGENTMAIL_MAX_TEXT_LENGTH) { + return markdown; + } + + return ( + markdown.slice(0, AGENTMAIL_MAX_TEXT_LENGTH - TRUNCATION_SUFFIX.length) + + TRUNCATION_SUFFIX + ); +} + +export function escapeAgentMailHtml(text: string): string { + return text + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); +} + +/** + * Only link protocols that are safe in an email client. Anything else + * (javascript:, data:, file:, …) stays literal escaped text. + */ +const SAFE_LINK_PATTERN = /^(https?:\/\/|mailto:)/i; + +function convertInlineMarkdown(escaped: string): string { + return ( + escaped + // Links first so their URLs are not touched by emphasis rules. The + // text was already escaped, so `&` inside URLs appears as `&`, + // which is the correct encoding for an href attribute. + .replace( + /\[([^\]\n]+)\]\(([^\s)]+)\)/g, + (match, label: string, url: string) => + SAFE_LINK_PATTERN.test(url) ? `${label}` : match, + ) + .replace(/\*\*([^*\n]+)\*\*/g, '$1') + .replace(/(?$1') + // Underscore italics only when they wrap a whole line, so snake_case + // identifiers inside prose are never touched. + .replace(/^_([^_\n](?:[^\n]*[^_\n])?)_$/gm, '$1') + ); +} + +function convertInlineText(line: string): string { + const escaped = escapeAgentMailHtml(line); + + // Convert inline code spans before emphasis so their contents stay + // verbatim, then apply emphasis/link conversion outside spans only. + return escaped + .replace(/`([^`\n]+)`/g, '$1') + .split(/([^<]*<\/code>)/g) + .map((part) => + part.startsWith('') ? part : convertInlineMarkdown(part), + ) + .join(''); +} + +type MarkdownSegment = + | { kind: 'text'; content: string } + | { kind: 'code'; content: string; language?: string }; + +function splitCodeFences(markdown: string): MarkdownSegment[] { + const segments: MarkdownSegment[] = []; + const fencePattern = /^```([^\n`]*)\n([\s\S]*?)^```[ \t]*$/gm; + let lastIndex = 0; + + for (const match of markdown.matchAll(fencePattern)) { + const index = match.index ?? 0; + + if (index > lastIndex) { + segments.push({ + kind: 'text', + content: markdown.slice(lastIndex, index), + }); + } + + segments.push({ + kind: 'code', + content: match[2] ?? '', + ...(match[1]?.trim() ? { language: match[1].trim() } : {}), + }); + lastIndex = index + match[0].length; + } + + if (lastIndex < markdown.length) { + segments.push({ kind: 'text', content: markdown.slice(lastIndex) }); + } + + return segments; +} + +type MarkdownBlock = + | { kind: 'heading'; level: number; text: string } + | { kind: 'blockquote'; lines: string[] } + | { kind: 'unordered-list'; items: string[] } + | { kind: 'ordered-list'; items: string[] } + | { kind: 'paragraph'; lines: string[] }; + +const HEADING_PATTERN = /^(#{1,6})\s+(.*)$/; +const UNORDERED_ITEM_PATTERN = /^[-*+]\s+(.*)$/; +const ORDERED_ITEM_PATTERN = /^\d+[.)]\s+(.*)$/; +const BLOCKQUOTE_PATTERN = /^>\s?(.*)$/; + +function splitBlocks(text: string): MarkdownBlock[] { + const blocks: MarkdownBlock[] = []; + let current: MarkdownBlock | null = null; + + const flush = () => { + if (current) { + blocks.push(current); + current = null; + } + }; + + for (const line of text.split('\n')) { + if (!line.trim()) { + flush(); + continue; + } + + const heading = HEADING_PATTERN.exec(line); + + if (heading?.[1] && heading[2] !== undefined) { + flush(); + blocks.push({ + kind: 'heading', + level: heading[1].length, + text: heading[2], + }); + continue; + } + + const blockquote = BLOCKQUOTE_PATTERN.exec(line); + + if (blockquote) { + if (current?.kind === 'blockquote') { + current.lines.push(blockquote[1] ?? ''); + } else { + flush(); + current = { kind: 'blockquote', lines: [blockquote[1] ?? ''] }; + } + continue; + } + + const unordered = UNORDERED_ITEM_PATTERN.exec(line); + + if (unordered) { + if (current?.kind === 'unordered-list') { + current.items.push(unordered[1] ?? ''); + } else { + flush(); + current = { kind: 'unordered-list', items: [unordered[1] ?? ''] }; + } + continue; + } + + const ordered = ORDERED_ITEM_PATTERN.exec(line); + + if (ordered) { + if (current?.kind === 'ordered-list') { + current.items.push(ordered[1] ?? ''); + } else { + flush(); + current = { kind: 'ordered-list', items: [ordered[1] ?? ''] }; + } + continue; + } + + if (current?.kind === 'paragraph') { + current.lines.push(line); + } else { + flush(); + current = { kind: 'paragraph', lines: [line] }; + } + } + + flush(); + + return blocks; +} + +/** Headings render one size down (h3–h5) to stay email-friendly. */ +function headingTag(level: number): string { + return level <= 1 ? 'h3' : level === 2 ? 'h4' : 'h5'; +} + +function renderBlock(block: MarkdownBlock): string { + switch (block.kind) { + case 'heading': { + const tag = headingTag(block.level); + + return `<${tag}>${convertInlineText(block.text)}`; + } + case 'blockquote': + return `

${block.lines + .map((line) => convertInlineText(line)) + .join('
')}

`; + case 'unordered-list': + return `
    ${block.items + .map((item) => `
  • ${convertInlineText(item)}
  • `) + .join('')}
`; + case 'ordered-list': + return `
    ${block.items + .map((item) => `
  1. ${convertInlineText(item)}
  2. `) + .join('')}
`; + case 'paragraph': + return `

${block.lines + .map((line) => convertInlineText(line)) + .join('
')}

`; + } +} + +/** + * Convert Roomote markdown to a conservative HTML email body. Supports + * paragraphs, bold, italic, inline code, fenced code blocks, safe links + * (http/https/mailto only), unordered/ordered lists, headings (rendered + * h3–h5), blockquotes, and line breaks. Everything else passes through as + * escaped text. + */ +export function renderAgentMailHtml(markdown: string): string { + return splitCodeFences(truncateAgentMailMarkdown(markdown)) + .map((segment) => { + if (segment.kind === 'code') { + const escaped = escapeAgentMailHtml(segment.content.replace(/\n$/, '')); + + return segment.language + ? `
${escaped}
` + : `
${escaped}
`; + } + + return splitBlocks(segment.content).map(renderBlock).join(''); + }) + .join(''); +} + +function stripInlineMarkdown(text: string): string { + return text + .replace(/\[([^\]\n]+)\]\(([^\s)]+)\)/g, '$1 ($2)') + .replace(/\*\*([^*\n]+)\*\*/g, '$1') + .replace(/(? { + if (segment.kind === 'code') { + return segment.content.replace(/\n$/, ''); + } + + return segment.content + .split('\n') + .map((line) => { + const heading = HEADING_PATTERN.exec(line); + const blockquote = BLOCKQUOTE_PATTERN.exec(line); + const source = heading?.[2] ?? blockquote?.[1] ?? line; + + return stripInlineMarkdown(source); + }) + .join('\n'); + }) + .join('\n') + .trim(); +} + +/** + * Build the HTML body plus plain-text alternative for one outbound email. + * The HTML is wrapped in a minimal `
` — email clients supply the + * surrounding ``/`` themselves. + */ +export function buildAgentMailEmailBody(markdown: string): { + html: string; + text: string; +} { + const truncated = truncateAgentMailMarkdown(markdown); + + return { + html: `
${renderAgentMailHtml(truncated)}
`, + text: renderAgentMailPlainText(truncated), + }; +} + +export type AgentMailEmailButton = { + text: string; + url: string; +}; + +const AGENTMAIL_BUTTON_STYLE = [ + 'display:inline-block', + 'padding:8px 16px', + 'margin:4px 8px 4px 0', + 'border:1px solid #c4c9d4', + 'border-radius:6px', + 'text-decoration:none', + 'color:#1b2430', + 'background:#f4f6f9', + 'font-family:inherit', +].join(';'); + +/** + * Render action buttons for an outbound email: button-styled anchors in the + * HTML body and a `label: url` list in the plain-text alternative. Email has + * no callback intake, so only URL buttons render; callback-only buttons are + * the caller's mistake and are skipped. + */ +export function buildAgentMailButtonSections(rows: AgentMailEmailButton[][]): { + html: string; + text: string; +} { + const usableRows = rows + .map((row) => row.filter((button) => button.text.trim() && button.url)) + .filter((row) => row.length > 0); + + if (usableRows.length === 0) { + return { html: '', text: '' }; + } + + const html = usableRows + .map( + (row) => + `
${row + .map( + (button) => + `${escapeAgentMailHtml(button.text)}`, + ) + .join('')}
`, + ) + .join(''); + + const text = usableRows + .flat() + .map((button) => `${button.text}: ${button.url}`) + .join('\n'); + + return { html, text }; +} diff --git a/packages/communication/src/agentmail-provider.ts b/packages/communication/src/agentmail-provider.ts new file mode 100644 index 000000000..02211cc50 --- /dev/null +++ b/packages/communication/src/agentmail-provider.ts @@ -0,0 +1,618 @@ +import { createHash } from 'node:crypto'; + +import type { + CommunicationChannelMessagesResult, + CommunicationPostMessageInput, + CommunicationPostMessageResult, + CommunicationProviderAdapter, + CommunicationReactionResult, + CommunicationThreadLookupResult, +} from './provider'; +import { UnsupportedCommunicationOperationError } from './provider'; +import { readBoundedResponseBody } from './bounded-response-body'; +import { getAgentMailApiBaseUrl } from './agentmail-api-base-url'; +import { + buildAgentMailButtonSections, + buildAgentMailEmailBody, + escapeAgentMailHtml, +} from './agentmail-format'; + +const DEFAULT_AGENTMAIL_TIMEOUT_MS = 10_000; +const DEFAULT_AGENTMAIL_MAX_RETRIES = 2; +const AGENTMAIL_RETRY_BASE_DELAY_MS = 250; +const AGENTMAIL_ERROR_BODY_MAX_BYTES = 4_096; + +/** + * AgentMail restricts Idempotency-Key to `A-Z a-z 0-9 - . _ ~`. Logical keys + * elsewhere in the codebase are colon-delimited (and may embed RFC822 + * message ids), so the header value is the hex digest of the logical key: + * same input, same header, guaranteed charset. + */ +function toAgentMailIdempotencyHeader(logicalKey: string): string { + return createHash('sha256').update(logicalKey).digest('hex'); +} + +/** + * A non-2xx AgentMail response. The `status` lets callers distinguish + * definite rejections (4xx: the request was not processed) from ambiguous + * failures (5xx/network: the provider may have acted before failing), which + * matters for send-adjacent bookkeeping like once-per-thread claims. + */ +export class AgentMailApiError extends Error { + constructor( + message: string, + public readonly status: number, + ) { + super(message); + this.name = 'AgentMailApiError'; + } +} + +/** Loop instead of a suffix regex (CodeQL js/polynomial-redos). */ +function trimTrailingSlashes(value: string): string { + let end = value.length; + while (end > 0 && value.charCodeAt(end - 1) === 0x2f) { + end -= 1; + } + return value.slice(0, end); +} + +/** + * The durable reply anchor for one AgentMail conversation, resolved from + * storage at send time. The adapter never trusts caller-supplied reply + * targets — email threading must always come from the recorded route. + */ +export type AgentMailReplyRoute = { + inboxId: string; + replyToMessageId: string | null; + recipientEmail: string | null; + subject?: string | null; +}; + +export type AgentMailCommunicationProviderOptions = { + apiKey: string; + apiBaseUrl?: string; + fetch?: typeof fetch; + timeoutMs?: number; + maxRetries?: number; + /** + * Resolves the internal conversation id carried in `input.threadId` to the + * stored reply route (inbox, anchor message, recipient). + */ + resolveRoute: (conversationId: string) => Promise; + /** + * Invoked after a successful send. Write-back of the conversation's + * `latestOutboundMessageId` happens here, not in the adapter. + */ + onMessageSent?: (update: { + conversationId: string; + messageId: string; + threadId?: string; + }) => Promise; +}; + +type AgentMailSendResponse = { + message_id?: string; + thread_id?: string; +}; + +export class AgentMailCommunicationProvider implements CommunicationProviderAdapter { + readonly provider = 'agentmail' as const; + + private readonly apiBaseUrl: string; + private readonly fetchImpl: typeof fetch; + private readonly timeoutMs: number; + private readonly maxRetries: number; + + constructor(private readonly options: AgentMailCommunicationProviderOptions) { + this.apiBaseUrl = trimTrailingSlashes( + options.apiBaseUrl ?? getAgentMailApiBaseUrl(), + ); + this.fetchImpl = options.fetch ?? fetch; + this.timeoutMs = options.timeoutMs ?? DEFAULT_AGENTMAIL_TIMEOUT_MS; + this.maxRetries = options.maxRetries ?? DEFAULT_AGENTMAIL_MAX_RETRIES; + } + + /** + * `input.channelId` carries the AgentMail inbox id; `input.threadId` + * carries the INTERNAL conversation id (never the provider thread id). + * Every send resolves the stored reply route — caller-supplied reply + * anchors are ignored by design so a reply can never target the wrong + * message or leak to the wrong recipient. + */ + async postMessage( + input: CommunicationPostMessageInput, + ): Promise { + const text = input.text?.trim(); + + if (!text) { + throw new Error('AgentMail postMessage requires text.'); + } + + if (!input.threadId) { + throw new UnsupportedCommunicationOperationError({ + provider: 'agentmail', + operation: 'postMessage', + message: 'AgentMail does not support unsolicited outbound email.', + help: 'Adapter sends must reply within an existing conversation (pass the internal conversation id as threadId). Roomote-initiated email goes through the consent-checked startAgentMailConversation entry point instead.', + }); + } + + const conversationId = input.threadId; + const route = await this.options.resolveRoute(conversationId); + + if (!route || !route.replyToMessageId) { + // A reply without a durable route is a bug upstream, not a fallback. + throw new Error( + `AgentMail conversation ${conversationId} has no stored reply route; refusing to send without a reply anchor.`, + ); + } + + const useMarkdown = + input.textFormat !== 'plain' && input.textFormat !== 'xml'; + const body = useMarkdown + ? buildAgentMailEmailBody(text) + : { + text, + html: `
${escapeAgentMailHtml(text).replaceAll('\n', '
')}
`, + }; + + // Email has no callback intake, so only URL buttons render (one-click + // answer links); callback-data buttons are silently skipped. + const buttonRows = (input.buttons ?? []).map((row) => + row + .filter((button) => button.url) + .map((button) => ({ text: button.text, url: button.url! })), + ); + const buttonSections = buildAgentMailButtonSections(buttonRows); + if (buttonSections.html) { + body.html = `${body.html}${buttonSections.html}`; + body.text = `${body.text}\n\n${buttonSections.text}`; + } + + const response = await this.request( + 'POST', + `/v0/inboxes/${encodeURIComponent(route.inboxId)}/messages/${encodeURIComponent(route.replyToMessageId)}/reply`, + { + text: body.text, + html: body.html, + // Reply only to the recorded correspondent — never reply-all, never + // cc. Omitting `to` lets AgentMail default to the original sender. + ...(route.recipientEmail ? { to: [route.recipientEmail] } : {}), + }, + { + ...(input.idempotencyKey + ? { idempotencyKey: input.idempotencyKey } + : {}), + }, + ); + const messageId = response.message_id; + + if (!messageId) { + throw new Error('AgentMail reply returned no message_id.'); + } + + await this.options.onMessageSent?.({ + conversationId, + messageId, + ...(response.thread_id ? { threadId: response.thread_id } : {}), + }); + + return { + provider: 'agentmail', + channelId: input.channelId, + messageId, + lastTextMessageId: messageId, + threadId: conversationId, + }; + } + + async fetchThreadMessages(_input: { + channelId: string; + messageId: string; + }): Promise { + throw new UnsupportedCommunicationOperationError({ + provider: 'agentmail', + operation: 'fetchThreadMessages', + message: + 'AgentMail thread history reads are not supported by this adapter.', + help: 'Use stored conversation messages for active task context.', + }); + } + + async fetchChannelMessages(_input: { + channelId: string; + oldest?: string; + latest?: string; + }): Promise { + throw new UnsupportedCommunicationOperationError({ + provider: 'agentmail', + operation: 'fetchChannelMessages', + message: + 'AgentMail inbox history reads are not supported by this adapter.', + help: 'Use stored conversation messages for active task context.', + }); + } + + async addReaction(_input: { + channelId: string; + messageId: string; + name: string; + }): Promise { + throw new UnsupportedCommunicationOperationError({ + provider: 'agentmail', + operation: 'addReaction', + message: 'AgentMail does not support reactions.', + help: 'Email has no reactions; send a reply instead.', + }); + } + + async removeReaction(_input: { + channelId: string; + messageId: string; + name: string; + }): Promise { + throw new UnsupportedCommunicationOperationError({ + provider: 'agentmail', + operation: 'removeReaction', + message: 'AgentMail does not support reactions.', + help: 'Email has no reactions; send a reply instead.', + }); + } + + private async request( + method: 'GET' | 'POST' | 'PATCH' | 'DELETE', + path: string, + body?: Record, + options: { idempotencyKey?: string } = {}, + ): Promise { + return callAgentMailApi({ + fetchImpl: this.fetchImpl, + apiBaseUrl: this.apiBaseUrl, + apiKey: this.options.apiKey, + timeoutMs: this.timeoutMs, + maxRetries: this.maxRetries, + method, + path, + ...(body !== undefined ? { body } : {}), + ...(options.idempotencyKey + ? { idempotencyKey: options.idempotencyKey } + : {}), + }); + } +} + +/** + * Shared REST call with the same retry/timeout discipline as the Telegram + * provider: AbortSignal timeout per attempt, bounded retries on 429/5xx + * honoring `Retry-After`, and bounded error-body reads. + */ +async function callAgentMailApi(params: { + fetchImpl: typeof fetch; + apiBaseUrl: string; + apiKey: string; + timeoutMs: number; + maxRetries: number; + method: 'GET' | 'POST' | 'PATCH' | 'DELETE'; + path: string; + body?: Record; + idempotencyKey?: string; +}): Promise { + const url = `${params.apiBaseUrl}${params.path}`; + let lastError: unknown; + + for (let attempt = 0; attempt <= params.maxRetries; attempt += 1) { + let response: Response; + + try { + response = await params.fetchImpl(url, { + method: params.method, + headers: { + authorization: `Bearer ${params.apiKey}`, + ...(params.body !== undefined + ? { 'content-type': 'application/json' } + : {}), + ...(params.idempotencyKey + ? { + 'idempotency-key': toAgentMailIdempotencyHeader( + params.idempotencyKey, + ), + } + : {}), + }, + ...(params.body !== undefined + ? { body: JSON.stringify(params.body) } + : {}), + signal: AbortSignal.timeout(params.timeoutMs), + }); + } catch (error) { + lastError = error; + + // Only idempotent-by-contract calls retry network errors: GET always, + // and writes only when the caller supplied an Idempotency-Key. + const retryNetworkErrors = + params.method === 'GET' || Boolean(params.idempotencyKey); + + if (!retryNetworkErrors || attempt >= params.maxRetries) { + throw error; + } + + await delay(AGENTMAIL_RETRY_BASE_DELAY_MS * 2 ** attempt); + continue; + } + + // 429 means the request was rejected before processing, so it is always + // safe to retry. A 5xx is ambiguous — the provider may have sent the + // email before failing — so mutating calls retry it only when the caller + // supplied an Idempotency-Key that makes the replay a no-op. + const retryableStatus = + response.status === 429 || + (response.status >= 500 && + (params.method === 'GET' || Boolean(params.idempotencyKey))); + + if (attempt < params.maxRetries && retryableStatus) { + const retryAfterSeconds = Number.parseFloat( + response.headers.get('retry-after') ?? '', + ); + const delayMs = + Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0 + ? retryAfterSeconds * 1000 + : AGENTMAIL_RETRY_BASE_DELAY_MS * 2 ** attempt; + + await response.body?.cancel().catch(() => undefined); + await delay(delayMs); + continue; + } + + if (!response.ok) { + const bodyBytes = await readBoundedResponseBody( + response, + AGENTMAIL_ERROR_BODY_MAX_BYTES, + `AgentMail ${params.method} ${params.path} error body exceeded ${AGENTMAIL_ERROR_BODY_MAX_BYTES} bytes.`, + ).catch(() => new Uint8Array()); + const bodyText = new TextDecoder().decode(bodyBytes).trim(); + + throw new AgentMailApiError( + `AgentMail ${params.method} ${params.path} failed (${response.status})${ + bodyText ? `: ${bodyText}` : '' + }`, + response.status, + ); + } + + if (response.status === 204) { + return undefined as T; + } + + return (await response.json().catch(() => ({}))) as T; + } + + throw lastError instanceof Error + ? lastError + : new Error(`AgentMail ${params.method} ${params.path} failed.`); +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export type AgentMailInbox = { + inbox_id: string; + /** Deliverable address. In practice equal to inbox_id today. */ + email?: string; + display_name?: string; +} & Record; + +export type AgentMailWebhook = { + webhook_id: string; + url: string; + secret?: string; + inbox_ids?: string[]; + event_types?: string[]; +} & Record; + +export type AgentMailApiClientOptions = { + apiKey: string; + apiBaseUrl?: string; + fetch?: typeof fetch; + timeoutMs?: number; +}; + +export type AgentMailOutboundBody = { + to?: string[]; + cc?: string[]; + bcc?: string[]; + subject?: string; + text?: string; + html?: string; + /** Extra RFC 5322 headers on the sent message (e.g. List-Unsubscribe). */ + headers?: Record; +}; + +/** + * Standalone AgentMail REST client for setup and reconcile flows (inbox and + * webhook provisioning), separate from the message-sending adapter above. + */ +export class AgentMailApiClient { + private readonly apiBaseUrl: string; + private readonly fetchImpl: typeof fetch; + private readonly timeoutMs: number; + + constructor(private readonly options: AgentMailApiClientOptions) { + this.apiBaseUrl = trimTrailingSlashes( + options.apiBaseUrl ?? getAgentMailApiBaseUrl(), + ); + this.fetchImpl = options.fetch ?? fetch; + this.timeoutMs = options.timeoutMs ?? DEFAULT_AGENTMAIL_TIMEOUT_MS; + } + + /** + * Lists ALL inboxes, following `next_page_token` pagination — a first-page + * read can make a many-inbox account look like it has exactly one, which + * would let setup silently adopt the wrong inbox. The page cap is a + * runaway guard far above any realistic account. + */ + async listInboxes(): Promise< + { inboxes?: AgentMailInbox[] } & Record + > { + const inboxes: AgentMailInbox[] = []; + let pageToken: string | undefined; + + for (let page = 0; page < 50; page += 1) { + const query = pageToken + ? `?page_token=${encodeURIComponent(pageToken)}` + : ''; + const result = await this.request< + { + inboxes?: AgentMailInbox[]; + next_page_token?: string; + } & Record + >('GET', `/v0/inboxes${query}`); + inboxes.push(...(result.inboxes ?? [])); + + pageToken = + typeof result.next_page_token === 'string' && + result.next_page_token.trim() + ? result.next_page_token + : undefined; + if (!pageToken) { + return { ...result, inboxes }; + } + } + + throw new Error( + 'AgentMail inbox listing did not terminate within 50 pages.', + ); + } + + createInbox(input: { + username?: string; + domain?: string; + clientId?: string; + displayName?: string; + }): Promise { + return this.request('POST', '/v0/inboxes', { + ...(input.username ? { username: input.username } : {}), + ...(input.domain ? { domain: input.domain } : {}), + ...(input.clientId ? { client_id: input.clientId } : {}), + ...(input.displayName ? { display_name: input.displayName } : {}), + }); + } + + getInbox(inboxId: string): Promise { + return this.request('GET', `/v0/inboxes/${encodeURIComponent(inboxId)}`); + } + + updateInbox( + inboxId: string, + input: { displayName?: string }, + ): Promise { + return this.request('PATCH', `/v0/inboxes/${encodeURIComponent(inboxId)}`, { + ...(input.displayName ? { display_name: input.displayName } : {}), + }); + } + + listWebhooks(): Promise< + { webhooks?: AgentMailWebhook[] } & Record + > { + return this.request('GET', '/v0/webhooks'); + } + + createWebhook(input: { + url: string; + clientId?: string; + inboxIds?: string[]; + eventTypes?: string[]; + }): Promise { + return this.request('POST', '/v0/webhooks', { + url: input.url, + ...(input.clientId ? { client_id: input.clientId } : {}), + ...(input.inboxIds ? { inbox_ids: input.inboxIds } : {}), + ...(input.eventTypes ? { event_types: input.eventTypes } : {}), + }); + } + + getWebhook(webhookId: string): Promise { + return this.request('GET', `/v0/webhooks/${encodeURIComponent(webhookId)}`); + } + + updateWebhook( + webhookId: string, + input: { url?: string; inboxIds?: string[]; eventTypes?: string[] }, + ): Promise { + return this.request( + 'PATCH', + `/v0/webhooks/${encodeURIComponent(webhookId)}`, + { + ...(input.url ? { url: input.url } : {}), + ...(input.inboxIds ? { inbox_ids: input.inboxIds } : {}), + // A non-empty list REPLACES the subscription in full (AgentMail + // semantics); an omitted/empty list leaves it unchanged. + ...(input.eventTypes?.length ? { event_types: input.eventTypes } : {}), + }, + ); + } + + deleteWebhook(webhookId: string): Promise { + return this.request( + 'DELETE', + `/v0/webhooks/${encodeURIComponent(webhookId)}`, + ); + } + + getMessage( + inboxId: string, + messageId: string, + ): Promise> { + return this.request( + 'GET', + `/v0/inboxes/${encodeURIComponent(inboxId)}/messages/${encodeURIComponent(messageId)}`, + ); + } + + replyToMessage( + inboxId: string, + messageId: string, + body: AgentMailOutboundBody, + opts: { idempotencyKey?: string } = {}, + ): Promise { + return this.request( + 'POST', + `/v0/inboxes/${encodeURIComponent(inboxId)}/messages/${encodeURIComponent(messageId)}/reply`, + { ...body }, + opts, + ); + } + + sendMessage( + inboxId: string, + body: AgentMailOutboundBody, + opts: { idempotencyKey?: string } = {}, + ): Promise { + return this.request( + 'POST', + `/v0/inboxes/${encodeURIComponent(inboxId)}/messages/send`, + { ...body }, + opts, + ); + } + + private request( + method: 'GET' | 'POST' | 'PATCH' | 'DELETE', + path: string, + body?: Record, + opts: { idempotencyKey?: string } = {}, + ): Promise { + return callAgentMailApi({ + fetchImpl: this.fetchImpl, + apiBaseUrl: this.apiBaseUrl, + apiKey: this.options.apiKey, + timeoutMs: this.timeoutMs, + maxRetries: DEFAULT_AGENTMAIL_MAX_RETRIES, + method, + path, + ...(body !== undefined ? { body } : {}), + ...(opts.idempotencyKey ? { idempotencyKey: opts.idempotencyKey } : {}), + }); + } +} diff --git a/packages/communication/src/fast-session-footer.ts b/packages/communication/src/fast-session-footer.ts index 0c7c22959..23ff7f885 100644 --- a/packages/communication/src/fast-session-footer.ts +++ b/packages/communication/src/fast-session-footer.ts @@ -21,7 +21,8 @@ export type FastSessionFooterProvider = | 'slack' | 'discord' | 'teams' - | 'telegram'; + | 'telegram' + | 'agentmail'; export type FastSessionPullRequestReference = { number: number | null; diff --git a/packages/communication/src/index.ts b/packages/communication/src/index.ts index 0b0748ea9..3b4d082b5 100644 --- a/packages/communication/src/index.ts +++ b/packages/communication/src/index.ts @@ -1,3 +1,7 @@ +export * from './agentmail-api-base-url'; +export * from './agentmail-event'; +export * from './agentmail-format'; +export * from './agentmail-provider'; export * from './chat-messages'; export * from './discord-event'; export * from './discord-provider'; diff --git a/packages/communication/src/mock-agentmail-server.ts b/packages/communication/src/mock-agentmail-server.ts new file mode 100644 index 000000000..bb21c5db5 --- /dev/null +++ b/packages/communication/src/mock-agentmail-server.ts @@ -0,0 +1,1088 @@ +import { createHmac, randomBytes } from 'node:crypto'; +import { + createServer, + type IncomingMessage, + type Server, + type ServerResponse, +} from 'node:http'; +import { AddressInfo } from 'node:net'; + +type JsonRecord = Record; + +export type MockAgentMailInbox = { + /** Canonical id, `@` — also the inbox email address. */ + inbox_id: string; + username: string; + domain: string; + display_name?: string; + /** Creation is idempotent per client_id, matching real AgentMail. */ + client_id?: string; + created_at: string; +}; + +export type MockAgentMailWebhook = { + webhook_id: string; + url: string; + /** Svix-style signing secret: `whsec_`. */ + secret: string; + client_id?: string; + /** Omitted means the webhook receives events for every inbox. */ + inbox_ids?: string[]; + /** Omitted means the webhook receives every event type. */ + event_types?: string[]; + enabled: boolean; + created_at: string; +}; + +export type MockAgentMailStoredMessage = { + message_id: string; + thread_id: string; + inbox_id: string; + /** + * `inbound` messages arrive via `/mock/events`; `outbound` messages were + * sent by the system under test through the send/reply endpoints — evals + * assert replies by filtering on this. + */ + direction: 'inbound' | 'outbound'; + from: string; + to: string[]; + cc?: string[]; + subject?: string; + text?: string; + html?: string; + timestamp: string; + in_reply_to?: string; + references?: string[]; + /** Extra RFC 5322 headers, e.g. `auto-submitted` for automated senders. */ + headers?: Record; +}; + +type MockAgentMailWebhookDelivery = { + webhook_id: string; + url: string; + status: number; + body: string; +}; + +export type MockAgentMailDeliveredEvent = { + event_id: string; + /** + * Svix delivery id. Redeliveries reuse it — that is how the production + * verifier recognizes a duplicate delivery of the same event. + */ + svix_id: string; + event_type: string; + inbox_id: string; + message_id: string; + /** Raw JSON body signed and delivered — redeliveries resend it verbatim. */ + payload: string; + deliveries: MockAgentMailWebhookDelivery[]; +}; + +export type MockAgentMailState = { + /** + * API keys accepted as `Authorization: Bearer `. Empty or omitted + * accepts any non-empty bearer token — convenient for exploratory runs; + * set it to catch requests built with the wrong credential. + */ + acceptedApiKeys?: string[]; + inboxes: MockAgentMailInbox[]; + webhooks?: MockAgentMailWebhook[]; + messages?: MockAgentMailStoredMessage[]; + events?: MockAgentMailDeliveredEvent[]; +}; + +/** + * An inbound email as a scenario author writes it. `threadId` continues an + * existing thread (minting reply headers the way a real mail chain would); + * omit it to start a fresh thread. The flags exercise edge cases the real + * AgentMail pipeline produces: `autoSubmitted` stamps an `Auto-Submitted` + * header, `oversize` delivers the webhook with `text`/`html` omitted (the + * 1MB payload cap), and `duplicate` redelivers the previous event with the + * SAME svix-id instead of creating a new message. + */ +export type MockAgentMailInboundEmail = { + kind?: 'message'; + inboxId: string; + from: string; + to?: string[]; + cc?: string[]; + subject?: string; + text?: string; + html?: string; + threadId?: string; + timestamp?: string; + autoSubmitted?: boolean; + oversize?: boolean; + duplicate?: boolean; +}; + +/** + * A delivery-failure notification (`message.bounced` / `message.complained`) + * as a scenario author writes it, for exercising the outbound suppression + * pipeline. Bounce recipients deliver as `{address, status}` objects and + * complaint recipients as bare strings, matching real AgentMail payloads. + */ +export type MockAgentMailDeliveryFailure = { + kind: 'bounce' | 'complaint'; + inboxId: string; + recipients: string[]; + messageId?: string; + threadId?: string; + /** Bounce only; defaults to 'Permanent'. */ + bounceType?: string; + subType?: string; +}; + +export type MockAgentMailReplayEvent = + | MockAgentMailInboundEmail + | MockAgentMailDeliveryFailure + | { + /** Redeliver a past event verbatim, reusing its original svix-id. */ + kind: 'redeliver'; + eventId: string; + }; + +type MockAgentMailDispatchResult = { + eventId: string; + svixId: string; + messageId: string; + threadId: string; + deliveries: MockAgentMailWebhookDelivery[]; +}; + +const AGENTMAIL_DEFAULT_DOMAIN = 'agentmail.to'; +const AGENTMAIL_MESSAGE_RECEIVED_EVENT = 'message.received'; + +/** + * `kind` is optional on inbound emails, so the union is not a discriminated + * union TypeScript narrows on its own; this guard does the narrowing. + */ +function isMockDeliveryFailure( + event: MockAgentMailReplayEvent, +): event is MockAgentMailDeliveryFailure { + return event.kind === 'bounce' || event.kind === 'complaint'; +} + +function cloneState(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function splitInboxId(inboxId: string): { username: string; domain: string } { + const separator = inboxId.indexOf('@'); + + if (separator <= 0) { + return { username: inboxId, domain: AGENTMAIL_DEFAULT_DOMAIN }; + } + + return { + username: inboxId.slice(0, separator), + domain: inboxId.slice(separator + 1), + }; +} + +function mintWebhookSecret(): string { + return `whsec_${randomBytes(24).toString('base64')}`; +} + +/** + * Sign a delivery exactly the way Svix does, so the production verifier + * (the `svix` npm package) accepts mock deliveries: HMAC-SHA256 over + * `${svixId}.${timestamp}.${rawBody}` keyed with the base64-decoded portion + * of the secret after the `whsec_` prefix. + */ +export function signSvixPayload({ + secret, + svixId, + timestamp, + payload, +}: { + secret: string; + svixId: string; + timestamp: string; + payload: string; +}): string { + const encodedKey = secret.startsWith('whsec_') + ? secret.slice('whsec_'.length) + : secret; + + const signature = createHmac('sha256', Buffer.from(encodedKey, 'base64')) + .update(`${svixId}.${timestamp}.${payload}`) + .digest('base64'); + + return `v1,${signature}`; +} + +function normalizeState(state: MockAgentMailState): MockAgentMailState { + return { + ...cloneState(state), + // Seeded scenarios may omit derivable fields; fill them in here. + inboxes: state.inboxes.map((inbox) => ({ + ...splitInboxId(inbox.inbox_id), + ...inbox, + created_at: inbox.created_at ?? new Date(0).toISOString(), + })), + webhooks: (state.webhooks ?? []).map((webhook) => ({ + ...webhook, + secret: webhook.secret || mintWebhookSecret(), + enabled: webhook.enabled ?? true, + created_at: webhook.created_at ?? new Date(0).toISOString(), + })), + messages: (state.messages ?? []).map((message) => ({ ...message })), + events: (state.events ?? []).map((event) => ({ ...event })), + }; +} + +async function readRequestBody(request: IncomingMessage): Promise { + const chunks: Buffer[] = []; + + for await (const chunk of request) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + + return Buffer.concat(chunks).toString('utf8'); +} + +function json(response: ServerResponse, status: number, body: unknown) { + response.writeHead(status, { + 'content-type': 'application/json', + }); + response.end(JSON.stringify(body)); +} + +function text(response: ServerResponse, status: number, body: string) { + response.writeHead(status, { + 'content-type': 'text/plain; charset=utf-8', + }); + response.end(body); +} + +function apiError(response: ServerResponse, status: number, message: string) { + json(response, status, { message }); +} + +export class MockAgentMailServer { + private server: Server | null = null; + private state: MockAgentMailState; + private port: number | null = null; + // Seed from the clock so minted ids never repeat across harness runs — + // consumers may dedupe messages and svix deliveries by id. + private idSequence = Math.floor(Date.now() / 1000); + /** Idempotency-Key header → prior send/reply result, per endpoint. */ + private idempotencyResults = new Map< + string, + { message_id: string; thread_id: string } + >(); + + constructor({ state }: { state: MockAgentMailState }) { + this.state = normalizeState(state); + } + + public get baseUrl(): string { + if (this.port === null) { + throw new Error('Mock AgentMail server is not running.'); + } + + return `http://127.0.0.1:${this.port}`; + } + + public getState(): MockAgentMailState { + return cloneState(this.state); + } + + public setState(state: MockAgentMailState): void { + this.state = normalizeState(state); + this.idempotencyResults.clear(); + } + + public async start(port = 0): Promise { + if (this.server) { + return this.baseUrl; + } + + this.server = createServer(async (request, response) => { + try { + await this.handleRequest(request, response); + } catch (error) { + text( + response, + 500, + error instanceof Error + ? error.message + : 'Unknown mock AgentMail error', + ); + } + }); + + await new Promise((resolve, reject) => { + this.server?.once('error', reject); + this.server?.listen(port, '127.0.0.1', () => { + this.server?.off('error', reject); + resolve(); + }); + }); + + const address = this.server.address(); + if (!address || typeof address === 'string') { + throw new Error('Failed to resolve mock AgentMail server address.'); + } + + this.port = (address as AddressInfo).port; + return this.baseUrl; + } + + public async stop(): Promise { + if (!this.server) { + return; + } + + const server = this.server; + this.server = null; + this.port = null; + + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + + resolve(); + }); + }); + } + + private nextId(prefix: string): string { + this.idSequence += 1; + return `${prefix}_${this.idSequence}`; + } + + /** + * Store an inbound email and deliver the signed `message.received` webhook + * to every matching registration. `duplicate` and `kind: 'redeliver'` + * resend a past event's payload verbatim with its original svix-id. + */ + public async dispatch( + event: MockAgentMailReplayEvent, + ): Promise { + if (event.kind === 'redeliver') { + const stored = (this.state.events ?? []).find( + (entry) => entry.event_id === event.eventId, + ); + + if (!stored) { + throw new Error(`Unknown eventId for redelivery: ${event.eventId}`); + } + + return this.redeliver(stored); + } + + if (isMockDeliveryFailure(event)) { + return this.dispatchDeliveryFailure(event); + } + + if (event.duplicate) { + const previous = (this.state.events ?? []).at(-1); + + if (!previous) { + throw new Error('No previous event to redeliver as a duplicate.'); + } + + return this.redeliver(previous); + } + + const inbox = this.findInbox(event.inboxId); + + if (!inbox) { + throw new Error(`Unknown inboxId: ${event.inboxId}`); + } + + const message = this.storeInboundMessage(inbox, event); + const payload = this.buildMessageReceivedPayload(message, { + oversize: event.oversize === true, + }); + + const stored: MockAgentMailDeliveredEvent = { + event_id: String(payload.event_id), + svix_id: this.nextId('svix'), + event_type: AGENTMAIL_MESSAGE_RECEIVED_EVENT, + inbox_id: message.inbox_id, + message_id: message.message_id, + payload: JSON.stringify(payload), + deliveries: [], + }; + this.state.events = [...(this.state.events ?? []), stored]; + + return this.redeliver(stored); + } + + private async dispatchDeliveryFailure( + event: MockAgentMailDeliveryFailure, + ): Promise { + const inbox = this.findInbox(event.inboxId); + + if (!inbox) { + throw new Error(`Unknown inboxId: ${event.inboxId}`); + } + + const bounce = event.kind === 'bounce'; + const eventId = this.nextId('evt'); + const messageId = event.messageId ?? `<${this.nextId('msg')}@mock>`; + const threadId = event.threadId ?? this.nextId('thread'); + const failure = { + inbox_id: inbox.inbox_id, + thread_id: threadId, + message_id: messageId, + timestamp: new Date().toISOString(), + type: bounce ? (event.bounceType ?? 'Permanent') : 'abuse', + sub_type: event.subType ?? (bounce ? 'General' : 'spam'), + // Real payload shapes differ: bounce recipients are objects, + // complaint recipients are bare strings. + recipients: bounce + ? event.recipients.map((address) => ({ address, status: 'bounced' })) + : event.recipients, + }; + const payload = { + type: 'event', + event_type: bounce ? 'message.bounced' : 'message.complained', + event_id: eventId, + ...(bounce ? { bounce: failure } : { complaint: failure }), + }; + + const stored: MockAgentMailDeliveredEvent = { + event_id: eventId, + svix_id: this.nextId('svix'), + event_type: String(payload.event_type), + inbox_id: inbox.inbox_id, + message_id: messageId, + payload: JSON.stringify(payload), + deliveries: [], + }; + this.state.events = [...(this.state.events ?? []), stored]; + + return this.redeliver(stored); + } + + /** + * Deliver an event's raw payload with its original svix-id. Timestamp and + * signature are computed fresh per delivery, matching real Svix retries. + */ + private async redeliver( + event: MockAgentMailDeliveredEvent, + ): Promise { + const timestamp = String(Math.floor(Date.now() / 1000)); + const deliveries: MockAgentMailWebhookDelivery[] = []; + + for (const webhook of this.state.webhooks ?? []) { + if (!this.webhookMatches(webhook, event)) { + continue; + } + + const response = await fetch(webhook.url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'svix-id': event.svix_id, + 'svix-timestamp': timestamp, + 'svix-signature': signSvixPayload({ + secret: webhook.secret, + svixId: event.svix_id, + timestamp, + payload: event.payload, + }), + }, + body: event.payload, + }); + + deliveries.push({ + webhook_id: webhook.webhook_id, + url: webhook.url, + status: response.status, + body: await response.text(), + }); + } + + event.deliveries = [...event.deliveries, ...deliveries]; + + const parsed = JSON.parse(event.payload) as { + message?: { thread_id: string }; + bounce?: { thread_id?: string }; + complaint?: { thread_id?: string }; + }; + + return { + eventId: event.event_id, + svixId: event.svix_id, + messageId: event.message_id, + threadId: + parsed.message?.thread_id ?? + parsed.bounce?.thread_id ?? + parsed.complaint?.thread_id ?? + '', + deliveries, + }; + } + + private webhookMatches( + webhook: MockAgentMailWebhook, + event: MockAgentMailDeliveredEvent, + ): boolean { + if (!webhook.enabled) { + return false; + } + + if ( + webhook.inbox_ids?.length && + !webhook.inbox_ids.includes(event.inbox_id) + ) { + return false; + } + + if ( + webhook.event_types?.length && + !webhook.event_types.includes(event.event_type) + ) { + return false; + } + + return true; + } + + private storeInboundMessage( + inbox: MockAgentMailInbox, + email: MockAgentMailInboundEmail, + ): MockAgentMailStoredMessage { + const threadId = email.threadId ?? this.nextId('thread'); + const threadMessages = (this.state.messages ?? []).filter( + (entry) => entry.thread_id === threadId, + ); + const previous = threadMessages.at(-1); + + const stored: MockAgentMailStoredMessage = { + message_id: this.nextId('msg'), + thread_id: threadId, + inbox_id: inbox.inbox_id, + direction: 'inbound', + from: email.from, + to: email.to ?? [inbox.inbox_id], + ...(email.cc ? { cc: email.cc } : {}), + ...(email.subject !== undefined ? { subject: email.subject } : {}), + ...(email.text !== undefined ? { text: email.text } : {}), + ...(email.html !== undefined ? { html: email.html } : {}), + timestamp: email.timestamp ?? new Date().toISOString(), + ...(previous + ? { + in_reply_to: previous.message_id, + references: [...(previous.references ?? []), previous.message_id], + } + : {}), + ...(email.autoSubmitted + ? { headers: { 'auto-submitted': 'auto-generated' } } + : {}), + }; + + this.state.messages = [...(this.state.messages ?? []), stored]; + return stored; + } + + private buildMessageReceivedPayload( + message: MockAgentMailStoredMessage, + { oversize }: { oversize: boolean }, + ): JsonRecord { + const threadMessages = (this.state.messages ?? []).filter( + (entry) => entry.thread_id === message.thread_id, + ); + + return { + type: 'event', + event_type: AGENTMAIL_MESSAGE_RECEIVED_EVENT, + event_id: this.nextId('evt'), + message: { + message_id: message.message_id, + thread_id: message.thread_id, + inbox_id: message.inbox_id, + from: message.from, + to: message.to, + cc: message.cc ?? [], + ...(message.subject !== undefined ? { subject: message.subject } : {}), + // The real pipeline drops text/html when the payload would exceed + // the 1MB cap; consumers must re-fetch the message by id. + ...(oversize + ? {} + : { + ...(message.text !== undefined + ? { text: message.text, extracted_text: message.text } + : {}), + ...(message.html !== undefined ? { html: message.html } : {}), + }), + timestamp: message.timestamp, + ...(message.in_reply_to !== undefined + ? { in_reply_to: message.in_reply_to } + : {}), + ...(message.references !== undefined + ? { references: message.references } + : {}), + ...(message.headers !== undefined ? { headers: message.headers } : {}), + attachments: [], + }, + thread: { + thread_id: message.thread_id, + last_message_id: + threadMessages.at(-1)?.message_id ?? message.message_id, + message_count: threadMessages.length, + }, + }; + } + + private async handleRequest( + request: IncomingMessage, + response: ServerResponse, + ): Promise { + const url = new URL(request.url ?? '/', this.baseUrl); + + if (url.pathname.startsWith('/mock/')) { + await this.handleControlRequest(request, response, url); + return; + } + + if (!url.pathname.startsWith('/v0/')) { + text(response, 404, 'Not Found'); + return; + } + + if (!this.isAuthorized(request)) { + apiError(response, 401, 'Unauthorized'); + return; + } + + await this.handleApiRequest(request, response, url); + } + + private isAuthorized(request: IncomingMessage): boolean { + const header = request.headers.authorization ?? ''; + + if (!header.startsWith('Bearer ')) { + return false; + } + + const apiKey = header.slice('Bearer '.length).trim(); + + if (!apiKey) { + return false; + } + + const acceptedApiKeys = this.state.acceptedApiKeys; + + if (!acceptedApiKeys?.length) { + return true; + } + + return acceptedApiKeys.includes(apiKey); + } + + private async handleControlRequest( + request: IncomingMessage, + response: ServerResponse, + url: URL, + ): Promise { + if (request.method === 'GET' && url.pathname === '/mock/state') { + json(response, 200, this.getState() as unknown as JsonRecord); + return; + } + + if (request.method === 'POST' && url.pathname === '/mock/state') { + const body = JSON.parse( + await readRequestBody(request), + ) as MockAgentMailState; + this.setState(body); + json(response, 200, { ok: true }); + return; + } + + if (request.method === 'POST' && url.pathname === '/mock/events') { + const body = JSON.parse( + await readRequestBody(request), + ) as MockAgentMailReplayEvent; + const dispatchResult = await this.dispatch(body); + json(response, 200, { + ok: true, + dispatchResult, + }); + return; + } + + text(response, 404, 'Not Found'); + } + + private async handleApiRequest( + request: IncomingMessage, + response: ServerResponse, + url: URL, + ): Promise { + const segments = url.pathname + .split('/') + .filter(Boolean) + .map((segment) => decodeURIComponent(segment)); + const method = request.method ?? 'GET'; + const bodyText = method === 'GET' ? '' : await readRequestBody(request); + const body: JsonRecord = bodyText + ? (JSON.parse(bodyText) as JsonRecord) + : {}; + + // segments[0] is 'v0'. + if (segments[1] === 'inboxes') { + if (segments.length === 2) { + if (method === 'GET') { + json(response, 200, { inboxes: this.state.inboxes }); + return; + } + + if (method === 'POST') { + this.handleCreateInbox(response, body); + return; + } + } + + const inbox = this.findInbox(segments[2] ?? ''); + + if (segments.length === 3 && method === 'GET') { + if (!inbox) { + apiError(response, 404, 'Inbox not found'); + return; + } + + json(response, 200, inbox); + return; + } + + if (segments[3] === 'messages') { + if (!inbox) { + apiError(response, 404, 'Inbox not found'); + return; + } + + if ( + segments.length === 5 && + segments[4] === 'send' && + method === 'POST' + ) { + this.handleSendMessage(request, response, inbox, body); + return; + } + + const message = (this.state.messages ?? []).find( + (entry) => + entry.inbox_id === inbox.inbox_id && + entry.message_id === segments[4], + ); + + if (segments.length === 5 && method === 'GET') { + if (!message) { + apiError(response, 404, 'Message not found'); + return; + } + + json(response, 200, message); + return; + } + + if ( + segments.length === 6 && + segments[5] === 'reply' && + method === 'POST' + ) { + if (!message) { + apiError(response, 404, 'Message not found'); + return; + } + + this.handleReplyToMessage(request, response, inbox, message, body); + return; + } + } + } + + if (segments[1] === 'webhooks') { + if (segments.length === 2) { + if (method === 'GET') { + json(response, 200, { webhooks: this.state.webhooks ?? [] }); + return; + } + + if (method === 'POST') { + this.handleCreateWebhook(response, body); + return; + } + } + + if (segments.length === 3) { + const webhook = (this.state.webhooks ?? []).find( + (entry) => entry.webhook_id === segments[2], + ); + + if (!webhook) { + apiError(response, 404, 'Webhook not found'); + return; + } + + if (method === 'GET') { + json(response, 200, webhook); + return; + } + + if (method === 'PATCH') { + if (typeof body.url === 'string') { + webhook.url = body.url; + } + if (Array.isArray(body.inbox_ids)) { + webhook.inbox_ids = body.inbox_ids.map(String); + } + if (Array.isArray(body.event_types)) { + webhook.event_types = body.event_types.map(String); + } + if (typeof body.enabled === 'boolean') { + webhook.enabled = body.enabled; + } + json(response, 200, webhook); + return; + } + + if (method === 'DELETE') { + this.state.webhooks = (this.state.webhooks ?? []).filter( + (entry) => entry !== webhook, + ); + json(response, 200, { ok: true }); + return; + } + } + } + + apiError( + response, + 404, + `Not Found: unhandled mock AgentMail route "${method} ${url.pathname}"`, + ); + } + + private handleCreateInbox(response: ServerResponse, body: JsonRecord): void { + const clientId = + typeof body.client_id === 'string' ? body.client_id : undefined; + + if (clientId) { + const existing = this.state.inboxes.find( + (entry) => entry.client_id === clientId, + ); + + if (existing) { + json(response, 200, existing); + return; + } + } + + const username = + typeof body.username === 'string' && body.username + ? body.username + : this.nextId('inbox'); + const domain = + typeof body.domain === 'string' && body.domain + ? body.domain + : AGENTMAIL_DEFAULT_DOMAIN; + const inboxId = `${username}@${domain}`; + + if (this.findInbox(inboxId)) { + apiError(response, 409, 'Inbox already exists'); + return; + } + + const inbox: MockAgentMailInbox = { + inbox_id: inboxId, + username, + domain, + ...(typeof body.display_name === 'string' + ? { display_name: body.display_name } + : {}), + ...(clientId ? { client_id: clientId } : {}), + created_at: new Date().toISOString(), + }; + + this.state.inboxes = [...this.state.inboxes, inbox]; + json(response, 200, inbox); + } + + private handleCreateWebhook( + response: ServerResponse, + body: JsonRecord, + ): void { + const webhookUrl = typeof body.url === 'string' ? body.url : ''; + + if (!webhookUrl) { + apiError(response, 400, 'url is required'); + return; + } + + const clientId = + typeof body.client_id === 'string' ? body.client_id : undefined; + + if (clientId) { + const existing = (this.state.webhooks ?? []).find( + (entry) => entry.client_id === clientId, + ); + + if (existing) { + json(response, 200, existing); + return; + } + } + + const webhook: MockAgentMailWebhook = { + webhook_id: this.nextId('wh'), + url: webhookUrl, + secret: mintWebhookSecret(), + ...(clientId ? { client_id: clientId } : {}), + ...(Array.isArray(body.inbox_ids) + ? { inbox_ids: body.inbox_ids.map(String) } + : {}), + ...(Array.isArray(body.event_types) + ? { event_types: body.event_types.map(String) } + : {}), + enabled: true, + created_at: new Date().toISOString(), + }; + + this.state.webhooks = [...(this.state.webhooks ?? []), webhook]; + json(response, 200, webhook); + } + + private handleSendMessage( + request: IncomingMessage, + response: ServerResponse, + inbox: MockAgentMailInbox, + body: JsonRecord, + ): void { + this.handleOutboundMessage(request, response, inbox, body, undefined); + } + + private handleReplyToMessage( + request: IncomingMessage, + response: ServerResponse, + inbox: MockAgentMailInbox, + target: MockAgentMailStoredMessage, + body: JsonRecord, + ): void { + this.handleOutboundMessage(request, response, inbox, body, target); + } + + /** + * Shared send/reply path. Honors the `Idempotency-Key` header: repeating a + * key returns the original `{message_id, thread_id}` without storing a + * second message, matching real AgentMail retry semantics. + */ + private handleOutboundMessage( + request: IncomingMessage, + response: ServerResponse, + inbox: MockAgentMailInbox, + body: JsonRecord, + replyTarget: MockAgentMailStoredMessage | undefined, + ): void { + const idempotencyKey = request.headers['idempotency-key']; + // Mirror the real API's charset restriction so parity bugs surface in + // tests instead of production (validation_error, path headers/Idempotency-Key). + if ( + typeof idempotencyKey === 'string' && + idempotencyKey && + !/^[A-Za-z0-9\-._~]+$/.test(idempotencyKey) + ) { + json(response, 400, { + name: 'ValidationError', + code: 'validation_error', + message: 'Request validation failed', + errors: [ + { + code: 'invalid_format', + format: 'custom', + path: ['headers', 'Idempotency-Key'], + message: + 'Idempotency-Key must contain only the following characters: A-Z a-z 0-9 - . _ ~', + }, + ], + }); + return; + } + const idempotencyMapKey = + typeof idempotencyKey === 'string' && idempotencyKey + ? [ + inbox.inbox_id, + replyTarget ? `reply:${replyTarget.message_id}` : 'send', + idempotencyKey, + ].join(':') + : undefined; + + if (idempotencyMapKey) { + const previous = this.idempotencyResults.get(idempotencyMapKey); + + if (previous) { + json(response, 200, previous); + return; + } + } + + const to = Array.isArray(body.to) + ? body.to.map(String) + : typeof body.to === 'string' + ? [body.to] + : replyTarget + ? [replyTarget.from] + : []; + + if (to.length === 0) { + apiError(response, 400, 'to is required'); + return; + } + + const cc = Array.isArray(body.cc) ? body.cc.map(String) : undefined; + + const stored: MockAgentMailStoredMessage = { + message_id: this.nextId('msg'), + thread_id: replyTarget?.thread_id ?? this.nextId('thread'), + inbox_id: inbox.inbox_id, + direction: 'outbound', + from: inbox.inbox_id, + to, + ...(cc ? { cc } : {}), + ...(typeof body.subject === 'string' + ? { subject: body.subject } + : replyTarget?.subject !== undefined + ? { subject: `Re: ${replyTarget.subject}` } + : {}), + ...(typeof body.text === 'string' ? { text: body.text } : {}), + ...(typeof body.html === 'string' ? { html: body.html } : {}), + timestamp: new Date().toISOString(), + ...(replyTarget + ? { + in_reply_to: replyTarget.message_id, + references: [ + ...(replyTarget.references ?? []), + replyTarget.message_id, + ], + } + : {}), + }; + + this.state.messages = [...(this.state.messages ?? []), stored]; + + const result = { + message_id: stored.message_id, + thread_id: stored.thread_id, + }; + + if (idempotencyMapKey) { + this.idempotencyResults.set(idempotencyMapKey, result); + } + + json(response, 200, result); + } + + private findInbox(inboxId: string): MockAgentMailInbox | undefined { + return this.state.inboxes.find((entry) => entry.inbox_id === inboxId); + } +} diff --git a/packages/communication/src/provider.ts b/packages/communication/src/provider.ts index 5ef5960e0..7f67d6b98 100644 --- a/packages/communication/src/provider.ts +++ b/packages/communication/src/provider.ts @@ -92,7 +92,8 @@ export type CommunicationOperation = | 'postMessage' | 'fetchThreadMessages' | 'fetchChannelMessages' - | 'addReaction'; + | 'addReaction' + | 'removeReaction'; export class UnsupportedCommunicationOperationError extends Error { readonly code = 'communication_operation_unsupported' as const; diff --git a/packages/db/drizzle/0071_milky_the_fallen.sql b/packages/db/drizzle/0071_milky_the_fallen.sql new file mode 100644 index 000000000..5c936b4b7 --- /dev/null +++ b/packages/db/drizzle/0071_milky_the_fallen.sql @@ -0,0 +1,94 @@ +CREATE TABLE "agentmail_conversation_participants" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "conversation_id" uuid NOT NULL, + "inbox_id" text NOT NULL, + "provider_thread_id" text NOT NULL, + "user_id" text NOT NULL, + "role" text NOT NULL, + "source" text NOT NULL, + "added_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "agentmail_conversation_participants_conversation_user_unique" UNIQUE("conversation_id","user_id"), + CONSTRAINT "agentmail_conversation_participants_thread_user_unique" UNIQUE("inbox_id","provider_thread_id","user_id"), + CONSTRAINT "agentmail_conversation_participants_role_check" CHECK ("agentmail_conversation_participants"."role" in ('owner', 'participant')), + CONSTRAINT "agentmail_conversation_participants_source_check" CHECK ("agentmail_conversation_participants"."source" in ('initiator', 'cc', 'link_code')) +); +--> statement-breakpoint +CREATE TABLE "agentmail_conversations" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "inbox_id" text NOT NULL, + "provider_thread_id" text NOT NULL, + "owner_user_id" text NOT NULL, + "subject" text, + "latest_inbound_message_id" text, + "latest_inbound_at" timestamp, + "latest_inbound_sender_email" text, + "latest_inbound_user_id" text, + "latest_outbound_message_id" text, + "version" integer DEFAULT 0 NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "agentmail_conversations_id_thread_unique" UNIQUE("id","inbox_id","provider_thread_id") +); +--> statement-breakpoint +CREATE TABLE "agentmail_inbound_turns" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "conversation_id" uuid NOT NULL, + "webhook_event_id" uuid NOT NULL, + "provider_message_id" text NOT NULL, + "provider_timestamp" timestamp NOT NULL, + "sender_email" text NOT NULL, + "sender_user_id" text NOT NULL, + "body_text" text DEFAULT '' NOT NULL, + "state" text DEFAULT 'pending' NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "consumed_at" timestamp, + CONSTRAINT "agentmail_inbound_turns_webhook_event_unique" UNIQUE("webhook_event_id"), + CONSTRAINT "agentmail_inbound_turns_state_check" CHECK ("agentmail_inbound_turns"."state" in ('pending', 'consumed')) +); +--> statement-breakpoint +CREATE TABLE "agentmail_user_mappings" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "email_address" text NOT NULL, + "user_id" text NOT NULL, + "source" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "agentmail_user_mappings_unique" UNIQUE("email_address"), + CONSTRAINT "agentmail_user_mappings_source_check" CHECK ("agentmail_user_mappings"."source" in ('verified_match', 'link_code')) +); +--> statement-breakpoint +CREATE TABLE "agentmail_webhook_events" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "delivery_id" text NOT NULL, + "event_id" text, + "event_type" text NOT NULL, + "payload" jsonb NOT NULL, + "state" text DEFAULT 'received' NOT NULL, + "attempts" integer DEFAULT 0 NOT NULL, + "last_error" text, + "received_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "agentmail_webhook_events_delivery_unique" UNIQUE("delivery_id"), + CONSTRAINT "agentmail_webhook_events_state_check" CHECK ("agentmail_webhook_events"."state" in ('received', 'queued', 'processing', 'processed', 'failed')) +); +--> statement-breakpoint +ALTER TABLE "fast_agent_provider_messages" DROP CONSTRAINT "fast_agent_provider_messages_provider_v3_check";--> statement-breakpoint +ALTER TABLE "sessions" DROP CONSTRAINT "sessions_source_surface_check";--> statement-breakpoint +ALTER TABLE "tasks" DROP CONSTRAINT "tasks_surface_check";--> statement-breakpoint +ALTER TABLE "agentmail_conversation_participants" ADD CONSTRAINT "agentmail_conversation_participants_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "agentmail_conversation_participants" ADD CONSTRAINT "agentmail_conversation_participants_conversation_fk" FOREIGN KEY ("conversation_id","inbox_id","provider_thread_id") REFERENCES "public"."agentmail_conversations"("id","inbox_id","provider_thread_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "agentmail_conversations" ADD CONSTRAINT "agentmail_conversations_owner_user_id_users_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "agentmail_conversations" ADD CONSTRAINT "agentmail_conversations_latest_inbound_user_id_users_id_fk" FOREIGN KEY ("latest_inbound_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "agentmail_inbound_turns" ADD CONSTRAINT "agentmail_inbound_turns_conversation_id_agentmail_conversations_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."agentmail_conversations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "agentmail_inbound_turns" ADD CONSTRAINT "agentmail_inbound_turns_webhook_event_id_agentmail_webhook_events_id_fk" FOREIGN KEY ("webhook_event_id") REFERENCES "public"."agentmail_webhook_events"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "agentmail_inbound_turns" ADD CONSTRAINT "agentmail_inbound_turns_sender_user_id_users_id_fk" FOREIGN KEY ("sender_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "agentmail_user_mappings" ADD CONSTRAINT "agentmail_user_mappings_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "agentmail_conversation_participants_user_idx" ON "agentmail_conversation_participants" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "agentmail_conversations_thread_idx" ON "agentmail_conversations" USING btree ("inbox_id","provider_thread_id");--> statement-breakpoint +CREATE INDEX "agentmail_conversations_owner_idx" ON "agentmail_conversations" USING btree ("owner_user_id");--> statement-breakpoint +CREATE INDEX "agentmail_inbound_turns_drain_idx" ON "agentmail_inbound_turns" USING btree ("conversation_id","state","provider_timestamp","provider_message_id");--> statement-breakpoint +CREATE INDEX "agentmail_user_mappings_user_id_idx" ON "agentmail_user_mappings" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "agentmail_webhook_events_state_idx" ON "agentmail_webhook_events" USING btree ("state","received_at");--> statement-breakpoint +ALTER TABLE "fast_agent_provider_messages" ADD CONSTRAINT "fast_agent_provider_messages_provider_v3_check" CHECK ("fast_agent_provider_messages"."provider" in ('discord', 'slack', 'teams', 'telegram', 'agentmail'));--> statement-breakpoint +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_source_surface_check" CHECK ("sessions"."source_surface" in ('web', 'api', 'slack', 'teams', 'telegram', 'discord', 'agentmail', 'linear', 'github', 'gitlab', 'gitea', 'ado', 'bitbucket', 'system', 'automation'));--> statement-breakpoint +ALTER TABLE "tasks" ADD CONSTRAINT "tasks_surface_check" CHECK ("tasks"."surface" in ('web', 'api', 'slack', 'teams', 'telegram', 'discord', 'agentmail', 'linear', 'github', 'gitlab', 'gitea', 'ado', 'bitbucket', 'system')); \ No newline at end of file diff --git a/packages/db/drizzle/0072_lethal_iron_fist.sql b/packages/db/drizzle/0072_lethal_iron_fist.sql new file mode 100644 index 000000000..e327e380c --- /dev/null +++ b/packages/db/drizzle/0072_lethal_iron_fist.sql @@ -0,0 +1,13 @@ +CREATE TABLE "agentmail_suppressions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "email_address" text NOT NULL, + "reason" text NOT NULL, + "details" text, + "provider_message_id" text, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "agentmail_suppressions_email_unique" UNIQUE("email_address"), + CONSTRAINT "agentmail_suppressions_reason_check" CHECK ("agentmail_suppressions"."reason" in ('bounce', 'complaint', 'unsubscribe')) +); +--> statement-breakpoint +ALTER TABLE "agentmail_conversation_participants" DROP CONSTRAINT "agentmail_conversation_participants_source_check";--> statement-breakpoint +ALTER TABLE "agentmail_conversation_participants" ADD CONSTRAINT "agentmail_conversation_participants_source_check" CHECK ("agentmail_conversation_participants"."source" in ('initiator', 'cc', 'link_code', 'outbound')); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0071_snapshot.json b/packages/db/drizzle/meta/0071_snapshot.json new file mode 100644 index 000000000..65c419b95 --- /dev/null +++ b/packages/db/drizzle/meta/0071_snapshot.json @@ -0,0 +1,14709 @@ +{ + "id": "4f685bf8-37eb-4bf0-8f1b-36dd98d90593", + "prevId": "6a2acb2b-02f4-4a18-88fa-0a56e33481c0", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.agentmail_conversation_participants": { + "name": "agentmail_conversation_participants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "inbox_id": { + "name": "inbox_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_thread_id": { + "name": "provider_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "added_at": { + "name": "added_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agentmail_conversation_participants_user_idx": { + "name": "agentmail_conversation_participants_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agentmail_conversation_participants_user_id_users_id_fk": { + "name": "agentmail_conversation_participants_user_id_users_id_fk", + "tableFrom": "agentmail_conversation_participants", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agentmail_conversation_participants_conversation_fk": { + "name": "agentmail_conversation_participants_conversation_fk", + "tableFrom": "agentmail_conversation_participants", + "tableTo": "agentmail_conversations", + "columnsFrom": ["conversation_id", "inbox_id", "provider_thread_id"], + "columnsTo": ["id", "inbox_id", "provider_thread_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "agentmail_conversation_participants_conversation_user_unique": { + "name": "agentmail_conversation_participants_conversation_user_unique", + "nullsNotDistinct": false, + "columns": ["conversation_id", "user_id"] + }, + "agentmail_conversation_participants_thread_user_unique": { + "name": "agentmail_conversation_participants_thread_user_unique", + "nullsNotDistinct": false, + "columns": ["inbox_id", "provider_thread_id", "user_id"] + } + }, + "policies": {}, + "checkConstraints": { + "agentmail_conversation_participants_role_check": { + "name": "agentmail_conversation_participants_role_check", + "value": "\"agentmail_conversation_participants\".\"role\" in ('owner', 'participant')" + }, + "agentmail_conversation_participants_source_check": { + "name": "agentmail_conversation_participants_source_check", + "value": "\"agentmail_conversation_participants\".\"source\" in ('initiator', 'cc', 'link_code')" + } + }, + "isRLSEnabled": false + }, + "public.agentmail_conversations": { + "name": "agentmail_conversations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "inbox_id": { + "name": "inbox_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_thread_id": { + "name": "provider_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latest_inbound_message_id": { + "name": "latest_inbound_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latest_inbound_at": { + "name": "latest_inbound_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "latest_inbound_sender_email": { + "name": "latest_inbound_sender_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latest_inbound_user_id": { + "name": "latest_inbound_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latest_outbound_message_id": { + "name": "latest_outbound_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agentmail_conversations_thread_idx": { + "name": "agentmail_conversations_thread_idx", + "columns": [ + { + "expression": "inbox_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentmail_conversations_owner_idx": { + "name": "agentmail_conversations_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agentmail_conversations_owner_user_id_users_id_fk": { + "name": "agentmail_conversations_owner_user_id_users_id_fk", + "tableFrom": "agentmail_conversations", + "tableTo": "users", + "columnsFrom": ["owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agentmail_conversations_latest_inbound_user_id_users_id_fk": { + "name": "agentmail_conversations_latest_inbound_user_id_users_id_fk", + "tableFrom": "agentmail_conversations", + "tableTo": "users", + "columnsFrom": ["latest_inbound_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "agentmail_conversations_id_thread_unique": { + "name": "agentmail_conversations_id_thread_unique", + "nullsNotDistinct": false, + "columns": ["id", "inbox_id", "provider_thread_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agentmail_inbound_turns": { + "name": "agentmail_inbound_turns", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "webhook_event_id": { + "name": "webhook_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider_message_id": { + "name": "provider_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_timestamp": { + "name": "provider_timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "sender_email": { + "name": "sender_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sender_user_id": { + "name": "sender_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "agentmail_inbound_turns_drain_idx": { + "name": "agentmail_inbound_turns_drain_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agentmail_inbound_turns_conversation_id_agentmail_conversations_id_fk": { + "name": "agentmail_inbound_turns_conversation_id_agentmail_conversations_id_fk", + "tableFrom": "agentmail_inbound_turns", + "tableTo": "agentmail_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agentmail_inbound_turns_webhook_event_id_agentmail_webhook_events_id_fk": { + "name": "agentmail_inbound_turns_webhook_event_id_agentmail_webhook_events_id_fk", + "tableFrom": "agentmail_inbound_turns", + "tableTo": "agentmail_webhook_events", + "columnsFrom": ["webhook_event_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agentmail_inbound_turns_sender_user_id_users_id_fk": { + "name": "agentmail_inbound_turns_sender_user_id_users_id_fk", + "tableFrom": "agentmail_inbound_turns", + "tableTo": "users", + "columnsFrom": ["sender_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "agentmail_inbound_turns_webhook_event_unique": { + "name": "agentmail_inbound_turns_webhook_event_unique", + "nullsNotDistinct": false, + "columns": ["webhook_event_id"] + } + }, + "policies": {}, + "checkConstraints": { + "agentmail_inbound_turns_state_check": { + "name": "agentmail_inbound_turns_state_check", + "value": "\"agentmail_inbound_turns\".\"state\" in ('pending', 'consumed')" + } + }, + "isRLSEnabled": false + }, + "public.agentmail_user_mappings": { + "name": "agentmail_user_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email_address": { + "name": "email_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agentmail_user_mappings_user_id_idx": { + "name": "agentmail_user_mappings_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agentmail_user_mappings_user_id_users_id_fk": { + "name": "agentmail_user_mappings_user_id_users_id_fk", + "tableFrom": "agentmail_user_mappings", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "agentmail_user_mappings_unique": { + "name": "agentmail_user_mappings_unique", + "nullsNotDistinct": false, + "columns": ["email_address"] + } + }, + "policies": {}, + "checkConstraints": { + "agentmail_user_mappings_source_check": { + "name": "agentmail_user_mappings_source_check", + "value": "\"agentmail_user_mappings\".\"source\" in ('verified_match', 'link_code')" + } + }, + "isRLSEnabled": false + }, + "public.agentmail_webhook_events": { + "name": "agentmail_webhook_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "delivery_id": { + "name": "delivery_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "received_at": { + "name": "received_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agentmail_webhook_events_state_idx": { + "name": "agentmail_webhook_events_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "received_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "agentmail_webhook_events_delivery_unique": { + "name": "agentmail_webhook_events_delivery_unique", + "nullsNotDistinct": false, + "columns": ["delivery_id"] + } + }, + "policies": {}, + "checkConstraints": { + "agentmail_webhook_events_state_check": { + "name": "agentmail_webhook_events_state_check", + "value": "\"agentmail_webhook_events\".\"state\" in ('received', 'queued', 'processing', 'processed', 'failed')" + } + }, + "isRLSEnabled": false + }, + "public.auth_accounts": { + "name": "auth_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "auth_accounts_user_id_idx": { + "name": "auth_accounts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_accounts_provider_account_unique": { + "name": "auth_accounts_provider_account_unique", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auth_accounts_user_id_auth_users_id_fk": { + "name": "auth_accounts_user_id_auth_users_id_fk", + "tableFrom": "auth_accounts", + "tableTo": "auth_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_sessions": { + "name": "auth_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "auth_sessions_token_unique": { + "name": "auth_sessions_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_sessions_user_id_idx": { + "name": "auth_sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auth_sessions_user_id_auth_users_id_fk": { + "name": "auth_sessions_user_id_auth_users_id_fk", + "tableFrom": "auth_sessions", + "tableTo": "auth_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_users": { + "name": "auth_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "auth_users_email_unique": { + "name": "auth_users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_users_created_at_idx": { + "name": "auth_users_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_verifications": { + "name": "auth_verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "auth_verifications_identifier_idx": { + "name": "auth_verifications_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automations": { + "name": "automations", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "internal": { + "name": "internal", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "schedule": { + "name": "schedule", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "targets": { + "name": "targets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_succeeded_at": { + "name": "last_succeeded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scan_cursor": { + "name": "scan_cursor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brain_collector_items": { + "name": "brain_collector_items", + "schema": "", + "columns": { + "collector_id": { + "name": "collector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brain_collector_items_collector_seen_idx": { + "name": "brain_collector_items_collector_seen_idx", + "columns": [ + { + "expression": "collector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_seen_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "brain_collector_items_collector_item_pk": { + "name": "brain_collector_items_collector_item_pk", + "columns": ["collector_id", "item_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brain_memory_events": { + "name": "brain_memory_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "agent_summary": { + "name": "agent_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brain_memory_events_status_created_idx": { + "name": "brain_memory_events_status_created_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "brain_memory_events_run_id_task_runs_id_fk": { + "name": "brain_memory_events_run_id_task_runs_id_fk", + "tableFrom": "brain_memory_events", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "brain_memory_events_run_unique": { + "name": "brain_memory_events_run_unique", + "nullsNotDistinct": false, + "columns": ["run_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brain_sync_state": { + "name": "brain_sync_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "collector_id": { + "name": "collector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "watermark": { + "name": "watermark", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "backfill_cursor": { + "name": "backfill_cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "backfill_completed_at": { + "name": "backfill_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "brain_sync_state_collector_id_unique": { + "name": "brain_sync_state_collector_id_unique", + "nullsNotDistinct": false, + "columns": ["collector_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.compute_provider_usage": { + "name": "compute_provider_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_usage_id": { + "name": "provider_usage_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth_kind": { + "name": "auth_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "launch_mode": { + "name": "launch_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle_action": { + "name": "lifecycle_action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "measurement_source": { + "name": "measurement_source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "configured_vcpus": { + "name": "configured_vcpus", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "configured_cpu_cores": { + "name": "configured_cpu_cores", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "configured_memory_mib": { + "name": "configured_memory_mib", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "wall_clock_duration_ms": { + "name": "wall_clock_duration_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "active_cpu_duration_ms": { + "name": "active_cpu_duration_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "observed_memory_mib_milliseconds": { + "name": "observed_memory_mib_milliseconds", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "network_ingress_bytes": { + "name": "network_ingress_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "network_egress_bytes": { + "name": "network_egress_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "compute_provider_usage_provider_usage_id_unique": { + "name": "compute_provider_usage_provider_usage_id_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_usage_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "compute_provider_usage_run_id_idx": { + "name": "compute_provider_usage_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "compute_provider_usage_task_id_idx": { + "name": "compute_provider_usage_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "compute_provider_usage_created_at_idx": { + "name": "compute_provider_usage_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "compute_provider_usage_run_id_task_runs_id_fk": { + "name": "compute_provider_usage_run_id_task_runs_id_fk", + "tableFrom": "compute_provider_usage", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "compute_provider_usage_task_id_tasks_id_fk": { + "name": "compute_provider_usage_task_id_tasks_id_fk", + "tableFrom": "compute_provider_usage", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.compute_provider_usage_samples": { + "name": "compute_provider_usage_samples", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_usage_id": { + "name": "provider_usage_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sampled_at": { + "name": "sampled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "cpu_usage_ns_total": { + "name": "cpu_usage_ns_total", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "memory_usage_bytes": { + "name": "memory_usage_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "memory_peak_usage_bytes": { + "name": "memory_peak_usage_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "compute_provider_usage_samples_provider_usage_sampled_at_unique": { + "name": "compute_provider_usage_samples_provider_usage_sampled_at_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_usage_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sampled_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "compute_provider_usage_samples_run_id_idx": { + "name": "compute_provider_usage_samples_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "compute_provider_usage_samples_task_id_idx": { + "name": "compute_provider_usage_samples_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "compute_provider_usage_samples_created_at_idx": { + "name": "compute_provider_usage_samples_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "compute_provider_usage_samples_run_id_task_runs_id_fk": { + "name": "compute_provider_usage_samples_run_id_task_runs_id_fk", + "tableFrom": "compute_provider_usage_samples", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "compute_provider_usage_samples_task_id_tasks_id_fk": { + "name": "compute_provider_usage_samples_task_id_tasks_id_fk", + "tableFrom": "compute_provider_usage_samples", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_automations": { + "name": "custom_automations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "schedule_mode": { + "name": "schedule_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'off'" + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "all_repositories": { + "name": "all_repositories", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "execution_mode": { + "name": "execution_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'sandbox_task'" + }, + "target": { + "name": "target", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_succeeded_at": { + "name": "last_succeeded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_launched_task_id": { + "name": "last_launched_task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "launch_claimed_at": { + "name": "launch_claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_automations_name_unique_idx": { + "name": "custom_automations_name_unique_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_automations_enabled_idx": { + "name": "custom_automations_enabled_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_automations_environment_id_idx": { + "name": "custom_automations_environment_id_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_automations_environment_id_environments_id_fk": { + "name": "custom_automations_environment_id_environments_id_fk", + "tableFrom": "custom_automations", + "tableTo": "environments", + "columnsFrom": ["environment_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "custom_automations_created_by_user_id_users_id_fk": { + "name": "custom_automations_created_by_user_id_users_id_fk", + "tableFrom": "custom_automations", + "tableTo": "users", + "columnsFrom": ["created_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "custom_automations_last_launched_task_id_tasks_id_fk": { + "name": "custom_automations_last_launched_task_id_tasks_id_fk", + "tableFrom": "custom_automations", + "tableTo": "tasks", + "columnsFrom": ["last_launched_task_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_mcp_servers": { + "name": "custom_mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "headers": { + "name": "headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "stdio": { + "name": "stdio", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "disabled_tools": { + "name": "disabled_tools", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "manual_client_id": { + "name": "manual_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "manual_client_secret": { + "name": "manual_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_server_metadata": { + "name": "oauth_server_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "oauth_server_metadata_fetched_at": { + "name": "oauth_server_metadata_fetched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "oauth_resource_indicator_disabled": { + "name": "oauth_resource_indicator_disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "custom_mcp_servers_created_by_user_id_users_id_fk": { + "name": "custom_mcp_servers_created_by_user_id_users_id_fk", + "tableFrom": "custom_mcp_servers", + "tableTo": "users", + "columnsFrom": ["created_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "custom_mcp_servers_name_unique": { + "name": "custom_mcp_servers_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_mcp_enablements": { + "name": "deployment_mcp_enablements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "mcp_id": { + "name": "mcp_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enabled_by_user_id": { + "name": "enabled_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled_tools": { + "name": "disabled_tools", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tool_access_mode": { + "name": "tool_access_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "deployment_mcp_enablements_enabled_by_user_id_users_id_fk": { + "name": "deployment_mcp_enablements_enabled_by_user_id_users_id_fk", + "tableFrom": "deployment_mcp_enablements", + "tableTo": "users", + "columnsFrom": ["enabled_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "deployment_mcp_enablements_mcp_unique": { + "name": "deployment_mcp_enablements_mcp_unique", + "nullsNotDistinct": false, + "columns": ["mcp_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_secrets": { + "name": "deployment_secrets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deployment_secrets_name_unique": { + "name": "deployment_secrets_name_unique", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_settings": { + "name": "deployment_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "default": "'default'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "task_model_settings": { + "name": "task_model_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "workspace_routing_settings": { + "name": "workspace_routing_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "router_debug_provider": { + "name": "router_debug_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "router_debug_channel_id": { + "name": "router_debug_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "router_debug_disabled": { + "name": "router_debug_disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "router_debug_slack_channel_id": { + "name": "router_debug_slack_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "runtime_model_config": { + "name": "runtime_model_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "runtime_compute_config": { + "name": "runtime_compute_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "access_policy": { + "name": "access_policy", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "brain_enabled": { + "name": "brain_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "license_key": { + "name": "license_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "license_cloud_state": { + "name": "license_cloud_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "instance_analytics_id": { + "name": "instance_analytics_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latest_known_version": { + "name": "latest_known_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latest_version_checked_at": { + "name": "latest_version_checked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "setup_completed_at": { + "name": "setup_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "setup_new_state": { + "name": "setup_new_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "slack_onboarding_stage": { + "name": "slack_onboarding_stage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "manager_slack_channel_id": { + "name": "manager_slack_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "manager_discord_channel_id": { + "name": "manager_discord_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "global_agent_instructions": { + "name": "global_agent_instructions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time_zone": { + "name": "time_zone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time_zone_updated_at": { + "name": "time_zone_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "authorship_instructions": { + "name": "authorship_instructions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "compiled_authorship_rules": { + "name": "compiled_authorship_rules", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "compiled_authorship_issues": { + "name": "compiled_authorship_issues", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "compiled_authorship_at": { + "name": "compiled_authorship_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "style_guidance": { + "name": "style_guidance", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_summon_emoji": { + "name": "slack_summon_emoji", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_ack_emoji": { + "name": "slack_ack_emoji", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'eyes'" + }, + "slack_completion_emoji": { + "name": "slack_completion_emoji", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'white_check_mark'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.discord_gateway_sessions": { + "name": "discord_gateway_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resume_gateway_url": { + "name": "resume_gateway_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sequence": { + "name": "sequence", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "shard_count": { + "name": "shard_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_connected_at": { + "name": "last_connected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_heartbeat_ack_at": { + "name": "last_heartbeat_ack_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "disconnected_at": { + "name": "disconnected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.discord_installation_channels": { + "name": "discord_installation_channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "discord_installation_id": { + "name": "discord_installation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_name": { + "name": "channel_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "channel_type": { + "name": "channel_type", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_available": { + "name": "is_available", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "discord_installation_channels_installation_id_idx": { + "name": "discord_installation_channels_installation_id_idx", + "columns": [ + { + "expression": "discord_installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "discord_installation_channels_unique": { + "name": "discord_installation_channels_unique", + "columns": [ + { + "expression": "discord_installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "discord_installation_channels_discord_installation_id_discord_installations_id_fk": { + "name": "discord_installation_channels_discord_installation_id_discord_installations_id_fk", + "tableFrom": "discord_installation_channels", + "tableTo": "discord_installations", + "columnsFrom": ["discord_installation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.discord_installations": { + "name": "discord_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "guild_name": { + "name": "guild_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "application_id": { + "name": "application_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_user_id": { + "name": "bot_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "installed_by_user_id": { + "name": "installed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_channel_id": { + "name": "default_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_channel_name": { + "name": "default_channel_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_channel_type": { + "name": "default_channel_type", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "discord_installations_guild_id_unique": { + "name": "discord_installations_guild_id_unique", + "columns": [ + { + "expression": "guild_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "discord_installations_active_idx": { + "name": "discord_installations_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "discord_installations_default_channel_idx": { + "name": "discord_installations_default_channel_idx", + "columns": [ + { + "expression": "default_channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "discord_installations_installed_by_user_id_users_id_fk": { + "name": "discord_installations_installed_by_user_id_users_id_fk", + "tableFrom": "discord_installations", + "tableTo": "users", + "columnsFrom": ["installed_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.discord_user_mappings": { + "name": "discord_user_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "discord_user_id": { + "name": "discord_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "discord_username": { + "name": "discord_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "discord_global_name": { + "name": "discord_global_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "discord_dm_channel_id": { + "name": "discord_dm_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "discord_user_mappings_user_id_idx": { + "name": "discord_user_mappings_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "discord_user_mappings_discord_user_id_unique": { + "name": "discord_user_mappings_discord_user_id_unique", + "columns": [ + { + "expression": "discord_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "discord_user_mappings_user_id_users_id_fk": { + "name": "discord_user_mappings_user_id_users_id_fk", + "tableFrom": "discord_user_mappings", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environment_config_versions": { + "name": "environment_config_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "environment_config_versions_environment_id_idx": { + "name": "environment_config_versions_environment_id_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environment_config_versions_environment_version_unique": { + "name": "environment_config_versions_environment_version_unique", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "environment_config_versions_environment_id_environments_id_fk": { + "name": "environment_config_versions_environment_id_environments_id_fk", + "tableFrom": "environment_config_versions", + "tableTo": "environments", + "columnsFrom": ["environment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "environment_config_versions_created_by_user_id_users_id_fk": { + "name": "environment_config_versions_created_by_user_id_users_id_fk", + "tableFrom": "environment_config_versions", + "tableTo": "users", + "columnsFrom": ["created_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environment_repository_mappings": { + "name": "environment_repository_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "env_repo_mappings_env_id_idx": { + "name": "env_repo_mappings_env_id_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "env_repo_mappings_repo_id_idx": { + "name": "env_repo_mappings_repo_id_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "environment_repository_mappings_environment_id_environments_id_fk": { + "name": "environment_repository_mappings_environment_id_environments_id_fk", + "tableFrom": "environment_repository_mappings", + "tableTo": "environments", + "columnsFrom": ["environment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "environment_repository_mappings_repository_id_repositories_id_fk": { + "name": "environment_repository_mappings_repository_id_repositories_id_fk", + "tableFrom": "environment_repository_mappings", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "env_repo_mappings_unique": { + "name": "env_repo_mappings_unique", + "nullsNotDistinct": false, + "columns": ["environment_id", "repository_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environment_snapshots": { + "name": "environment_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "snapshot_created_at": { + "name": "snapshot_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "snapshot_expires_at": { + "name": "snapshot_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "snapshot_status": { + "name": "snapshot_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "environment_snapshots_environment_id_idx": { + "name": "environment_snapshots_environment_id_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environment_snapshots_env_provider_unique": { + "name": "environment_snapshots_env_provider_unique", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"environment_snapshots\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "environment_snapshots_environment_id_environments_id_fk": { + "name": "environment_snapshots_environment_id_environments_id_fk", + "tableFrom": "environment_snapshots", + "tableTo": "environments", + "columnsFrom": ["environment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environment_variables": { + "name": "environment_variables", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_updated_by_user_id": { + "name": "last_updated_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "environment_variables_user_id_idx": { + "name": "environment_variables_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environment_variables_name_unique": { + "name": "environment_variables_name_unique", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "environment_variables_user_id_users_id_fk": { + "name": "environment_variables_user_id_users_id_fk", + "tableFrom": "environment_variables", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "environment_variables_created_by_user_id_users_id_fk": { + "name": "environment_variables_created_by_user_id_users_id_fk", + "tableFrom": "environment_variables", + "tableTo": "users", + "columnsFrom": ["created_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "environment_variables_last_updated_by_user_id_users_id_fk": { + "name": "environment_variables_last_updated_by_user_id_users_id_fk", + "tableFrom": "environment_variables", + "tableTo": "users", + "columnsFrom": ["last_updated_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environments": { + "name": "environments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "is_eval": { + "name": "is_eval", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "declarative_source": { + "name": "declarative_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_verified": { + "name": "is_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verification_task_id": { + "name": "verification_task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "verification_error": { + "name": "verification_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "snapshot_created_at": { + "name": "snapshot_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "snapshot_expires_at": { + "name": "snapshot_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "snapshot_status": { + "name": "snapshot_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "environments_user_id_idx": { + "name": "environments_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environments_created_by_user_id_idx": { + "name": "environments_created_by_user_id_idx", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environments_snapshot_expires_at_idx": { + "name": "environments_snapshot_expires_at_idx", + "columns": [ + { + "expression": "snapshot_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environments_name_unique": { + "name": "environments_name_unique", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "environments_user_id_users_id_fk": { + "name": "environments_user_id_users_id_fk", + "tableFrom": "environments", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "environments_created_by_user_id_users_id_fk": { + "name": "environments_created_by_user_id_users_id_fk", + "tableFrom": "environments", + "tableTo": "users", + "columnsFrom": ["created_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.fast_agent_conversations": { + "name": "fast_agent_conversations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_reply_channel_id": { + "name": "current_reply_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_reply_thread_id": { + "name": "current_reply_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_reply_service_url": { + "name": "current_reply_service_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reply_target_verified": { + "name": "reply_target_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "compatibility_messages": { + "name": "compatibility_messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "opencode_session_id": { + "name": "opencode_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reasoning_effort": { + "name": "reasoning_effort", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title_edited_by_user_at": { + "name": "title_edited_by_user_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "llm_title_checkpoint": { + "name": "llm_title_checkpoint", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "legacy_conversation_ids": { + "name": "legacy_conversation_ids", + "type": "uuid[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::uuid[]" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "fast_agent_conversations_identity_unique": { + "name": "fast_agent_conversations_identity_unique", + "columns": [ + { + "expression": "surface", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "fast_agent_conversations_user_idx": { + "name": "fast_agent_conversations_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "fast_agent_conversations_legacy_ids_idx": { + "name": "fast_agent_conversations_legacy_ids_idx", + "columns": [ + { + "expression": "legacy_conversation_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "fast_agent_conversations_user_id_users_id_fk": { + "name": "fast_agent_conversations_user_id_users_id_fk", + "tableFrom": "fast_agent_conversations", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.fast_agent_memory_events": { + "name": "fast_agent_memory_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "memory": { + "name": "memory", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "fast_agent_memory_events_status_created_idx": { + "name": "fast_agent_memory_events_status_created_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "fast_agent_memory_events_conversation_id_fast_agent_conversations_id_fk": { + "name": "fast_agent_memory_events_conversation_id_fast_agent_conversations_id_fk", + "tableFrom": "fast_agent_memory_events", + "tableTo": "fast_agent_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "fast_agent_memory_events_conversation_unique": { + "name": "fast_agent_memory_events_conversation_unique", + "nullsNotDistinct": false, + "columns": ["conversation_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.fast_agent_messages": { + "name": "fast_agent_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "turn_seq": { + "name": "turn_seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "ts": { + "name": "ts", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_blocks": { + "name": "content_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "native_session_id": { + "name": "native_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "native_message_id": { + "name": "native_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "fast_agent_messages_conversation_event_unique": { + "name": "fast_agent_messages_conversation_event_unique", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "fast_agent_messages_conversation_order_idx": { + "name": "fast_agent_messages_conversation_order_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ts", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "turn_seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "fast_agent_messages_conversation_id_fast_agent_conversations_id_fk": { + "name": "fast_agent_messages_conversation_id_fast_agent_conversations_id_fk", + "tableFrom": "fast_agent_messages", + "tableTo": "fast_agent_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.fast_agent_parent_events": { + "name": "fast_agent_parent_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent": { + "name": "parent", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "event": { + "name": "event", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "retry_task_start_run_id": { + "name": "retry_task_start_run_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "discarded_at": { + "name": "discarded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "fast_agent_parent_events_pending_idx": { + "name": "fast_agent_parent_events_pending_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "delivered_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "discarded_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "fast_agent_parent_events_retry_run_idx": { + "name": "fast_agent_parent_events_retry_run_idx", + "columns": [ + { + "expression": "retry_task_start_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "fast_agent_parent_events_conversation_id_fast_agent_conversations_id_fk": { + "name": "fast_agent_parent_events_conversation_id_fast_agent_conversations_id_fk", + "tableFrom": "fast_agent_parent_events", + "tableTo": "fast_agent_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "fast_agent_parent_events_retry_task_start_run_id_task_runs_id_fk": { + "name": "fast_agent_parent_events_retry_task_start_run_id_task_runs_id_fk", + "tableFrom": "fast_agent_parent_events", + "tableTo": "task_runs", + "columnsFrom": ["retry_task_start_run_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "fast_agent_parent_events_event_key_unique": { + "name": "fast_agent_parent_events_event_key_unique", + "nullsNotDistinct": false, + "columns": ["event_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.fast_agent_pr_feedback_deliveries": { + "name": "fast_agent_pr_feedback_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "feedback_id": { + "name": "feedback_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_token": { + "name": "lease_token", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "fast_agent_pr_feedback_deliveries_identity_unique": { + "name": "fast_agent_pr_feedback_deliveries_identity_unique", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "feedback_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "fast_agent_pr_feedback_deliveries_task_idx": { + "name": "fast_agent_pr_feedback_deliveries_task_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "fast_agent_pr_feedback_deliveries_conversation_id_fast_agent_conversations_id_fk": { + "name": "fast_agent_pr_feedback_deliveries_conversation_id_fast_agent_conversations_id_fk", + "tableFrom": "fast_agent_pr_feedback_deliveries", + "tableTo": "fast_agent_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "fast_agent_pr_feedback_deliveries_task_id_tasks_id_fk": { + "name": "fast_agent_pr_feedback_deliveries_task_id_tasks_id_fk", + "tableFrom": "fast_agent_pr_feedback_deliveries", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.fast_agent_provider_messages": { + "name": "fast_agent_provider_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "fast_agent_provider_messages_route_unique": { + "name": "fast_agent_provider_messages_route_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "fast_agent_provider_messages_conversation_idx": { + "name": "fast_agent_provider_messages_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "fast_agent_provider_messages_thread_idx": { + "name": "fast_agent_provider_messages_thread_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "fast_agent_provider_messages_conversation_id_fast_agent_conversations_id_fk": { + "name": "fast_agent_provider_messages_conversation_id_fast_agent_conversations_id_fk", + "tableFrom": "fast_agent_provider_messages", + "tableTo": "fast_agent_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "fast_agent_provider_messages_provider_v3_check": { + "name": "fast_agent_provider_messages_provider_v3_check", + "value": "\"fast_agent_provider_messages\".\"provider\" in ('discord', 'slack', 'teams', 'telegram', 'agentmail')" + } + }, + "isRLSEnabled": false + }, + "public.github_installations": { + "name": "github_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installation_id": { + "name": "installation_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "app_id": { + "name": "app_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "account_login": { + "name": "account_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_type": { + "name": "account_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "installed_by_user_id": { + "name": "installed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "members_count": { + "name": "members_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_installations_account_login_idx": { + "name": "github_installations_account_login_idx", + "columns": [ + { + "expression": "account_login", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_installations_deployment_installation_unique": { + "name": "github_installations_deployment_installation_unique", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_installations_user_id_users_id_fk": { + "name": "github_installations_user_id_users_id_fk", + "tableFrom": "github_installations", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_installations_installed_by_user_id_users_id_fk": { + "name": "github_installations_installed_by_user_id_users_id_fk", + "tableFrom": "github_installations", + "tableTo": "users", + "columnsFrom": ["installed_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_pending_installations": { + "name": "github_pending_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_by_user_id": { + "name": "requested_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app_id": { + "name": "app_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_pending_installations_requested_by_user_id_idx": { + "name": "github_pending_installations_requested_by_user_id_idx", + "columns": [ + { + "expression": "requested_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_pending_installations_user_id_users_id_fk": { + "name": "github_pending_installations_user_id_users_id_fk", + "tableFrom": "github_pending_installations", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_pending_installations_requested_by_user_id_users_id_fk": { + "name": "github_pending_installations_requested_by_user_id_users_id_fk", + "tableFrom": "github_pending_installations", + "tableTo": "users", + "columnsFrom": ["requested_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_user_mappings": { + "name": "github_user_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "github_login": { + "name": "github_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_user_id": { + "name": "github_user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_expires_at": { + "name": "token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_user_mappings_github_login_idx": { + "name": "github_user_mappings_github_login_idx", + "columns": [ + { + "expression": "github_login", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_user_mappings_user_id_idx": { + "name": "github_user_mappings_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_user_mappings_user_id_users_id_fk": { + "name": "github_user_mappings_user_id_users_id_fk", + "tableFrom": "github_user_mappings", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_user_mappings_unique": { + "name": "github_user_mappings_unique", + "nullsNotDistinct": false, + "columns": ["github_user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invites": { + "name": "invites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "invited_by_user_id": { + "name": "invited_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "max_uses": { + "name": "max_uses", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "used_count": { + "name": "used_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invites_token_hash_unique": { + "name": "invites_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invites_created_at_idx": { + "name": "invites_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invites_invited_by_user_id_users_id_fk": { + "name": "invites_invited_by_user_id_users_id_fk", + "tableFrom": "invites", + "tableTo": "users", + "columnsFrom": ["invited_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.license_usage_observations": { + "name": "license_usage_observations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "observed_at": { + "name": "observed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "active_users": { + "name": "active_users", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempted_at": { + "name": "last_attempted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "license_usage_observations_pending_idx": { + "name": "license_usage_observations_pending_idx", + "columns": [ + { + "expression": "delivered_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "observed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.linear_pending_selections": { + "name": "linear_pending_selections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "linear_organization_id": { + "name": "linear_organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "step": { + "name": "step", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'awaiting_workspace'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "selected_repo": { + "name": "selected_repo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_options": { + "name": "workspace_options", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "linear_pending_selections_expires_at_idx": { + "name": "linear_pending_selections_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "linear_pending_selections_step_idx": { + "name": "linear_pending_selections_step_idx", + "columns": [ + { + "expression": "step", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "linear_pending_selections_user_id_users_id_fk": { + "name": "linear_pending_selections_user_id_users_id_fk", + "tableFrom": "linear_pending_selections", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "linear_pending_selections_session_id_unique": { + "name": "linear_pending_selections_session_id_unique", + "nullsNotDistinct": false, + "columns": ["session_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_inference_usage_events": { + "name": "task_inference_usage_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'opencode'" + }, + "usage_type": { + "name": "usage_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'inference'" + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "harness_session_id": { + "name": "harness_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reasoning_tokens": { + "name": "reasoning_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_read_tokens": { + "name": "cache_read_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_write_tokens": { + "name": "cache_write_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens": { + "name": "total_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "context_tokens": { + "name": "context_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_micro_usd": { + "name": "cost_micro_usd", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_source": { + "name": "cost_source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pricing_metadata": { + "name": "pricing_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "message_created_at": { + "name": "message_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "message_completed_at": { + "name": "message_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_inference_usage_events_session_message_unique": { + "name": "task_inference_usage_events_session_message_unique", + "columns": [ + { + "expression": "harness_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_inference_usage_events_event_key_unique": { + "name": "task_inference_usage_events_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_inference_usage_events_task_id_idx": { + "name": "task_inference_usage_events_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_inference_usage_events_run_id_idx": { + "name": "task_inference_usage_events_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_inference_usage_events_user_id_idx": { + "name": "task_inference_usage_events_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_inference_usage_events_environment_id_idx": { + "name": "task_inference_usage_events_environment_id_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_inference_usage_events_session_id_idx": { + "name": "task_inference_usage_events_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_inference_usage_events_provider_model_idx": { + "name": "task_inference_usage_events_provider_model_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "model_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_inference_usage_events_created_at_idx": { + "name": "task_inference_usage_events_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_inference_usage_events_task_id_tasks_id_fk": { + "name": "task_inference_usage_events_task_id_tasks_id_fk", + "tableFrom": "task_inference_usage_events", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_inference_usage_events_run_id_task_runs_id_fk": { + "name": "task_inference_usage_events_run_id_task_runs_id_fk", + "tableFrom": "task_inference_usage_events", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "task_inference_usage_events_user_id_users_id_fk": { + "name": "task_inference_usage_events_user_id_users_id_fk", + "tableFrom": "task_inference_usage_events", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "task_inference_usage_events_environment_id_environments_id_fk": { + "name": "task_inference_usage_events_environment_id_environments_id_fk", + "tableFrom": "task_inference_usage_events", + "tableTo": "environments", + "columnsFrom": ["environment_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "task_inference_usage_events_session_id_sessions_id_fk": { + "name": "task_inference_usage_events_session_id_sessions_id_fk", + "tableFrom": "task_inference_usage_events", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_connections": { + "name": "mcp_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_id": { + "name": "mcp_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_role": { + "name": "connection_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "auth_config": { + "name": "auth_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_status": { + "name": "auth_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_expires_at": { + "name": "token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_connections_user_id_idx": { + "name": "mcp_connections_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_connections_role_idx": { + "name": "mcp_connections_role_idx", + "columns": [ + { + "expression": "mcp_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection_role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_connections_user_id_users_id_fk": { + "name": "mcp_connections_user_id_users_id_fk", + "tableFrom": "mcp_connections", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mcp_connections_user_mcp_id_unique": { + "name": "mcp_connections_user_mcp_id_unique", + "nullsNotDistinct": true, + "columns": ["user_id", "mcp_id", "connection_role"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_oauth_replays": { + "name": "mcp_oauth_replays", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_id": { + "name": "mcp_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_role": { + "name": "connection_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "redirect_to": { + "name": "redirect_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_oauth_replays_connection_id_idx": { + "name": "mcp_oauth_replays_connection_id_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_oauth_replays_user_id_idx": { + "name": "mcp_oauth_replays_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_oauth_replays_expires_at_idx": { + "name": "mcp_oauth_replays_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_oauth_replays_connection_id_mcp_connections_id_fk": { + "name": "mcp_oauth_replays_connection_id_mcp_connections_id_fk", + "tableFrom": "mcp_oauth_replays", + "tableTo": "mcp_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_oauth_replays_user_id_users_id_fk": { + "name": "mcp_oauth_replays_user_id_users_id_fk", + "tableFrom": "mcp_oauth_replays", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mcp_oauth_replays_token_unique": { + "name": "mcp_oauth_replays_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.microsoft_auth_user_mappings": { + "name": "microsoft_auth_user_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "auth_account_id": { + "name": "auth_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "microsoft_tenant_id": { + "name": "microsoft_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "microsoft_aad_object_id": { + "name": "microsoft_aad_object_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "microsoft_auth_user_mappings_user_id_idx": { + "name": "microsoft_auth_user_mappings_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "microsoft_auth_user_mappings_account_id_idx": { + "name": "microsoft_auth_user_mappings_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "microsoft_auth_user_mappings_auth_account_idx": { + "name": "microsoft_auth_user_mappings_auth_account_idx", + "columns": [ + { + "expression": "auth_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "microsoft_auth_user_mappings_aad_object_unique": { + "name": "microsoft_auth_user_mappings_aad_object_unique", + "columns": [ + { + "expression": "microsoft_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "microsoft_aad_object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "microsoft_auth_user_mappings_auth_account_id_auth_accounts_id_fk": { + "name": "microsoft_auth_user_mappings_auth_account_id_auth_accounts_id_fk", + "tableFrom": "microsoft_auth_user_mappings", + "tableTo": "auth_accounts", + "columnsFrom": ["auth_account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "microsoft_auth_user_mappings_user_id_auth_users_id_fk": { + "name": "microsoft_auth_user_mappings_user_id_auth_users_id_fk", + "tableFrom": "microsoft_auth_user_mappings", + "tableTo": "auth_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notion_directory_users": { + "name": "notion_directory_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "notion_user_id": { + "name": "notion_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_deleted": { + "name": "is_deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "notion_directory_users_unique": { + "name": "notion_directory_users_unique", + "nullsNotDistinct": false, + "columns": ["notion_user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_state": { + "name": "oauth_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "replay_token": { + "name": "replay_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "oauth_state_connection_id_idx": { + "name": "oauth_state_connection_id_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_state_replay_token_idx": { + "name": "oauth_state_replay_token_idx", + "columns": [ + { + "expression": "replay_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_state_expires_at_idx": { + "name": "oauth_state_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_state_connection_id_mcp_connections_id_fk": { + "name": "oauth_state_connection_id_mcp_connections_id_fk", + "tableFrom": "oauth_state", + "tableTo": "mcp_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pr_review_auto_preferences": { + "name": "pr_review_auto_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "source_control_provider": { + "name": "source_control_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "repository_identity_key": { + "name": "repository_identity_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "enabled_by_user_id": { + "name": "enabled_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled_at": { + "name": "enabled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "source_task_id": { + "name": "source_task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_destination_key": { + "name": "source_destination_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pr_review_auto_preferences_identity_unique": { + "name": "pr_review_auto_preferences_identity_unique", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repository_identity_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pr_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pr_review_auto_preferences_repository_idx": { + "name": "pr_review_auto_preferences_repository_idx", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repository", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pr_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pr_review_auto_preferences_repository_id_repositories_id_fk": { + "name": "pr_review_auto_preferences_repository_id_repositories_id_fk", + "tableFrom": "pr_review_auto_preferences", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "pr_review_auto_preferences_enabled_by_user_id_users_id_fk": { + "name": "pr_review_auto_preferences_enabled_by_user_id_users_id_fk", + "tableFrom": "pr_review_auto_preferences", + "tableTo": "users", + "columnsFrom": ["enabled_by_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pr_review_auto_preferences_source_task_id_tasks_id_fk": { + "name": "pr_review_auto_preferences_source_task_id_tasks_id_fk", + "tableFrom": "pr_review_auto_preferences", + "tableTo": "tasks", + "columnsFrom": ["source_task_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pr_review_cycles": { + "name": "pr_review_cycles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "source_control_provider": { + "name": "source_control_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "review_head_sha": { + "name": "review_head_sha", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cycle_id": { + "name": "cycle_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "pr_review_cycles_source_unique": { + "name": "pr_review_cycles_source_unique", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repository", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pr_number", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "review_head_sha", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pr_review_event_deliveries": { + "name": "pr_review_event_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "due_at": { + "name": "due_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "deferrals": { + "name": "deferrals", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lease_token": { + "name": "lease_token", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pr_review_event_deliveries_event_task_unique": { + "name": "pr_review_event_deliveries_event_task_unique", + "columns": [ + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pr_review_event_deliveries_due_idx": { + "name": "pr_review_event_deliveries_due_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "due_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lease_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pr_review_event_deliveries_event_id_pr_review_events_id_fk": { + "name": "pr_review_event_deliveries_event_id_pr_review_events_id_fk", + "tableFrom": "pr_review_event_deliveries", + "tableTo": "pr_review_events", + "columnsFrom": ["event_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pr_review_event_deliveries_task_id_tasks_id_fk": { + "name": "pr_review_event_deliveries_task_id_tasks_id_fk", + "tableFrom": "pr_review_event_deliveries", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "pr_review_event_deliveries_status_check": { + "name": "pr_review_event_deliveries_status_check", + "value": "\"pr_review_event_deliveries\".\"status\" in ('pending', 'processing', 'delivered', 'suppressed')" + } + }, + "isRLSEnabled": false + }, + "public.pr_review_events": { + "name": "pr_review_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_control_provider": { + "name": "source_control_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event": { + "name": "event", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "batch_kind": { + "name": "batch_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "batch_id": { + "name": "batch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "review_head_sha": { + "name": "review_head_sha", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sealed_at": { + "name": "sealed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "superseded": { + "name": "superseded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "observed_at": { + "name": "observed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pr_review_events_source_unique": { + "name": "pr_review_events_source_unique", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repository", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pr_number", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pr_review_events_pr_idx": { + "name": "pr_review_events_pr_idx", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repository", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pr_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "pr_review_events_batch_kind_check": { + "name": "pr_review_events_batch_kind_check", + "value": "\"pr_review_events\".\"batch_kind\" in ('human', 'roomote')" + } + }, + "isRLSEnabled": false + }, + "public.pr_review_notification_deliveries": { + "name": "pr_review_notification_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "notification_unit_id": { + "name": "notification_unit_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "destination_kind": { + "name": "destination_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "destination_key": { + "name": "destination_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "due_at": { + "name": "due_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "deferrals": { + "name": "deferrals", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lease_token": { + "name": "lease_token", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "route_provider": { + "name": "route_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "route_workspace_id": { + "name": "route_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "route_channel_id": { + "name": "route_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "route_thread_id": { + "name": "route_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "follow_up_prompt": { + "name": "follow_up_prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_task_id": { + "name": "target_task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "acting_user_id": { + "name": "acting_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_message_id": { + "name": "provider_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action_claimed_at": { + "name": "action_claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "dispatch_key": { + "name": "dispatch_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dispatched_run_id": { + "name": "dispatched_run_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pr_review_notification_deliveries_destination_unique": { + "name": "pr_review_notification_deliveries_destination_unique", + "columns": [ + { + "expression": "notification_unit_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "destination_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "destination_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pr_review_notification_deliveries_dispatch_key_unique": { + "name": "pr_review_notification_deliveries_dispatch_key_unique", + "columns": [ + { + "expression": "dispatch_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pr_review_notification_deliveries_due_idx": { + "name": "pr_review_notification_deliveries_due_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "due_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lease_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pr_review_notification_deliveries_destination_idx": { + "name": "pr_review_notification_deliveries_destination_idx", + "columns": [ + { + "expression": "destination_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "destination_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pr_review_notification_deliveries_notification_unit_id_pr_review_notification_units_id_fk": { + "name": "pr_review_notification_deliveries_notification_unit_id_pr_review_notification_units_id_fk", + "tableFrom": "pr_review_notification_deliveries", + "tableTo": "pr_review_notification_units", + "columnsFrom": ["notification_unit_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pr_review_notification_deliveries_task_id_tasks_id_fk": { + "name": "pr_review_notification_deliveries_task_id_tasks_id_fk", + "tableFrom": "pr_review_notification_deliveries", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "pr_review_notification_deliveries_target_task_id_tasks_id_fk": { + "name": "pr_review_notification_deliveries_target_task_id_tasks_id_fk", + "tableFrom": "pr_review_notification_deliveries", + "tableTo": "tasks", + "columnsFrom": ["target_task_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "pr_review_notification_deliveries_acting_user_id_users_id_fk": { + "name": "pr_review_notification_deliveries_acting_user_id_users_id_fk", + "tableFrom": "pr_review_notification_deliveries", + "tableTo": "users", + "columnsFrom": ["acting_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "pr_review_notification_deliveries_destination_kind_check": { + "name": "pr_review_notification_deliveries_destination_kind_check", + "value": "\"pr_review_notification_deliveries\".\"destination_kind\" in ('fast_conversation', 'task')" + }, + "pr_review_notification_deliveries_status_check": { + "name": "pr_review_notification_deliveries_status_check", + "value": "\"pr_review_notification_deliveries\".\"status\" in ('pending', 'claimed', 'prepared', 'prompt_posting', 'awaiting_user_action', 'auto_dispatch_pending', 'completed', 'suppressed', 'dismissed')" + } + }, + "isRLSEnabled": false + }, + "public.pr_review_notification_unit_events": { + "name": "pr_review_notification_unit_events", + "schema": "", + "columns": { + "unit_id": { + "name": "unit_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "attached_at": { + "name": "attached_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pr_review_notification_unit_events_event_unique": { + "name": "pr_review_notification_unit_events_event_unique", + "columns": [ + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pr_review_notification_unit_events_unit_id_pr_review_notification_units_id_fk": { + "name": "pr_review_notification_unit_events_unit_id_pr_review_notification_units_id_fk", + "tableFrom": "pr_review_notification_unit_events", + "tableTo": "pr_review_notification_units", + "columnsFrom": ["unit_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pr_review_notification_unit_events_event_id_pr_review_events_id_fk": { + "name": "pr_review_notification_unit_events_event_id_pr_review_events_id_fk", + "tableFrom": "pr_review_notification_unit_events", + "tableTo": "pr_review_events", + "columnsFrom": ["event_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "pr_review_notification_unit_events_pk": { + "name": "pr_review_notification_unit_events_pk", + "columns": ["unit_id", "event_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pr_review_notification_units": { + "name": "pr_review_notification_units", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "source_control_provider": { + "name": "source_control_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "repository_identity_key": { + "name": "repository_identity_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "head_identity_key": { + "name": "head_identity_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "episode_kind": { + "name": "episode_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "episode_id": { + "name": "episode_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "due_at": { + "name": "due_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "first_observed_at": { + "name": "first_observed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_observed_at": { + "name": "last_observed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "sealed_at": { + "name": "sealed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pr_review_notification_units_identity_unique": { + "name": "pr_review_notification_units_identity_unique", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repository_identity_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pr_number", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "head_identity_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "episode_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pr_review_notification_units_open_head_idx": { + "name": "pr_review_notification_units_open_head_idx", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repository", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pr_number", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "head_sha", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sealed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pr_review_notification_units_repository_id_repositories_id_fk": { + "name": "pr_review_notification_units_repository_id_repositories_id_fk", + "tableFrom": "pr_review_notification_units", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "pr_review_notification_units_episode_kind_check": { + "name": "pr_review_notification_units_episode_kind_check", + "value": "\"pr_review_notification_units\".\"episode_kind\" in ('roomote_cycle', 'human', 'automated', 'ci')" + } + }, + "isRLSEnabled": false + }, + "public.pull_request_facts": { + "name": "pull_request_facts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "repository_full_name": { + "name": "repository_full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_control_provider": { + "name": "source_control_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "external_pull_request_id": { + "name": "external_pull_request_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "html_url": { + "name": "html_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_login": { + "name": "author_login", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "labels": { + "name": "labels", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "changed_files": { + "name": "changed_files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "changed_file_count": { + "name": "changed_file_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "files_capped": { + "name": "files_capped", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "reviews_capped": { + "name": "reviews_capped", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "additions": { + "name": "additions", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deletions": { + "name": "deletions", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "reviews": { + "name": "reviews", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "enriched_at": { + "name": "enriched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enriched_for_updated_at": { + "name": "enriched_for_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enrichment_failed_at": { + "name": "enrichment_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at_remote": { + "name": "created_at_remote", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at_remote": { + "name": "updated_at_remote", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "closed_at_remote": { + "name": "closed_at_remote", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "merged_at_remote": { + "name": "merged_at_remote", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "synced_at": { + "name": "synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pull_request_facts_deployment_repo_pr_unique": { + "name": "pull_request_facts_deployment_repo_pr_unique", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pr_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pull_request_facts_deployment_created_idx": { + "name": "pull_request_facts_deployment_created_idx", + "columns": [ + { + "expression": "created_at_remote", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pull_request_facts_deployment_repo_created_idx": { + "name": "pull_request_facts_deployment_repo_created_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_remote", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pull_request_facts_deployment_state_created_idx": { + "name": "pull_request_facts_deployment_state_created_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_remote", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pull_request_facts_deployment_author_created_idx": { + "name": "pull_request_facts_deployment_author_created_idx", + "columns": [ + { + "expression": "author_login", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_remote", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pull_request_facts_deployment_updated_idx": { + "name": "pull_request_facts_deployment_updated_idx", + "columns": [ + { + "expression": "updated_at_remote", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pull_request_facts_repository_id_repositories_id_fk": { + "name": "pull_request_facts_repository_id_repositories_id_fk", + "tableFrom": "pull_request_facts", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "pull_request_facts_source_control_provider_check": { + "name": "pull_request_facts_source_control_provider_check", + "value": "\"pull_request_facts\".\"source_control_provider\" in ('github', 'gitlab', 'gitea', 'ado', 'bitbucket')" + } + }, + "isRLSEnabled": false + }, + "public.pull_request_sync_states": { + "name": "pull_request_sync_states", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "last_incremental_updated_at": { + "name": "last_incremental_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "backfill_completed_at": { + "name": "backfill_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cooldown_until": { + "name": "cooldown_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_successful_sync_at": { + "name": "last_successful_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_attempted_sync_at": { + "name": "last_attempted_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error_at": { + "name": "last_error_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error_message": { + "name": "last_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pull_request_sync_states_repo_unique": { + "name": "pull_request_sync_states_repo_unique", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pull_request_sync_states_deployment_updated_idx": { + "name": "pull_request_sync_states_deployment_updated_idx", + "columns": [ + { + "expression": "last_successful_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pull_request_sync_states_cooldown_idx": { + "name": "pull_request_sync_states_cooldown_idx", + "columns": [ + { + "expression": "cooldown_until", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pull_request_sync_states_repository_id_repositories_id_fk": { + "name": "pull_request_sync_states_repository_id_repositories_id_fk", + "tableFrom": "pull_request_sync_states", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.repositories": { + "name": "repositories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "source_control_provider": { + "name": "source_control_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "installation_id": { + "name": "installation_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_repo_id": { + "name": "github_repo_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "external_repo_id": { + "name": "external_repo_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "private": { + "name": "private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "clone_url": { + "name": "clone_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "html_url": { + "name": "html_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "linked_by_user_id": { + "name": "linked_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "repositories_source_control_provider_idx": { + "name": "repositories_source_control_provider_idx", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repositories_installation_id_idx": { + "name": "repositories_installation_id_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repositories_full_name_idx": { + "name": "repositories_full_name_idx", + "columns": [ + { + "expression": "full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repositories_provider_host_full_name_idx": { + "name": "repositories_provider_host_full_name_idx", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "host", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repositories_deployment_active_installation_idx": { + "name": "repositories_deployment_active_installation_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repositories_deployment_github_repo_unique": { + "name": "repositories_deployment_github_repo_unique", + "columns": [ + { + "expression": "github_repo_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repositories_provider_host_external_repo_unique": { + "name": "repositories_provider_host_external_repo_unique", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"host\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "external_repo_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repositories_provider_host_full_name_unique": { + "name": "repositories_provider_host_full_name_unique", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"host\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "repositories_installation_id_github_installations_id_fk": { + "name": "repositories_installation_id_github_installations_id_fk", + "tableFrom": "repositories", + "tableTo": "github_installations", + "columnsFrom": ["installation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "repositories_user_id_users_id_fk": { + "name": "repositories_user_id_users_id_fk", + "tableFrom": "repositories", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "repositories_linked_by_user_id_users_id_fk": { + "name": "repositories_linked_by_user_id_users_id_fk", + "tableFrom": "repositories", + "tableTo": "users", + "columnsFrom": ["linked_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "repositories_source_control_provider_check": { + "name": "repositories_source_control_provider_check", + "value": "\"repositories\".\"source_control_provider\" in ('github', 'gitlab', 'gitea', 'ado', 'bitbucket')" + }, + "repositories_github_shape_check": { + "name": "repositories_github_shape_check", + "value": "\"repositories\".\"source_control_provider\" != 'github' OR (\"repositories\".\"installation_id\" IS NOT NULL AND \"repositories\".\"github_repo_id\" IS NOT NULL)" + }, + "repositories_gitlab_shape_check": { + "name": "repositories_gitlab_shape_check", + "value": "\"repositories\".\"source_control_provider\" != 'gitlab' OR \"repositories\".\"external_repo_id\" IS NOT NULL" + }, + "repositories_gitea_shape_check": { + "name": "repositories_gitea_shape_check", + "value": "\"repositories\".\"source_control_provider\" != 'gitea' OR \"repositories\".\"external_repo_id\" IS NOT NULL" + }, + "repositories_ado_shape_check": { + "name": "repositories_ado_shape_check", + "value": "\"repositories\".\"source_control_provider\" != 'ado' OR \"repositories\".\"external_repo_id\" IS NOT NULL" + }, + "repositories_bitbucket_shape_check": { + "name": "repositories_bitbucket_shape_check", + "value": "\"repositories\".\"source_control_provider\" != 'bitbucket' OR \"repositories\".\"external_repo_id\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.repository_automation_signals": { + "name": "repository_automation_signals", + "schema": "", + "columns": { + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "signals_version": { + "name": "signals_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "collected_at": { + "name": "collected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "partial": { + "name": "partial", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "repository_automation_signals_collected_idx": { + "name": "repository_automation_signals_collected_idx", + "columns": [ + { + "expression": "collected_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "repository_automation_signals_repository_id_repositories_id_fk": { + "name": "repository_automation_signals_repository_id_repositories_id_fk", + "tableFrom": "repository_automation_signals", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "repository_automation_signals_repository_id_signals_version_pk": { + "name": "repository_automation_signals_repository_id_signals_version_pk", + "columns": ["repository_id", "signals_version"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandbox_oidc_targets": { + "name": "sandbox_oidc_targets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "compute_provider": { + "name": "compute_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "compute_provider_id": { + "name": "compute_provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_kind": { + "name": "target_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_file": { + "name": "token_file", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "aws_role_arn": { + "name": "aws_role_arn", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aws_region": { + "name": "aws_region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_at": { + "name": "refresh_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sandbox_oidc_targets_environment_id_idx": { + "name": "sandbox_oidc_targets_environment_id_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_oidc_targets_run_id_idx": { + "name": "sandbox_oidc_targets_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_oidc_targets_refresh_at_idx": { + "name": "sandbox_oidc_targets_refresh_at_idx", + "columns": [ + { + "expression": "refresh_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_oidc_targets_provider_target_file_unique": { + "name": "sandbox_oidc_targets_provider_target_file_unique", + "columns": [ + { + "expression": "compute_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "compute_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "token_file", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sandbox_oidc_targets_environment_id_environments_id_fk": { + "name": "sandbox_oidc_targets_environment_id_environments_id_fk", + "tableFrom": "sandbox_oidc_targets", + "tableTo": "environments", + "columnsFrom": ["environment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sandbox_oidc_targets_run_id_task_runs_id_fk": { + "name": "sandbox_oidc_targets_run_id_task_runs_id_fk", + "tableFrom": "sandbox_oidc_targets", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "sandbox_oidc_targets_owner_required": { + "name": "sandbox_oidc_targets_owner_required", + "value": "run_id IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.session_backfill_state": { + "name": "session_backfill_state", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'fast_conversations'" + }, + "cursor_created_at": { + "name": "cursor_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cursor_id": { + "name": "cursor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "session_backfill_state_phase_check": { + "name": "session_backfill_state_phase_check", + "value": "\"session_backfill_state\".\"phase\" in ('fast_conversations', 'fast_tasks', 'tasks', 'participants')" + }, + "session_backfill_state_cursor_shape_check": { + "name": "session_backfill_state_cursor_shape_check", + "value": "(\"session_backfill_state\".\"cursor_created_at\" IS NULL) = (\"session_backfill_state\".\"cursor_id\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.session_participants": { + "name": "session_participants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "last_read_event_at": { + "name": "last_read_event_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "last_read_event_id": { + "name": "last_read_event_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_notified_event_at": { + "name": "last_notified_event_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "last_notified_event_id": { + "name": "last_notified_event_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "session_participants_session_user_unique": { + "name": "session_participants_session_user_unique", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_participants_user_id_idx": { + "name": "session_participants_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_participants_session_id_sessions_id_fk": { + "name": "session_participants_session_id_sessions_id_fk", + "tableFrom": "session_participants", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_participants_user_id_users_id_fk": { + "name": "session_participants_user_id_users_id_fk", + "tableFrom": "session_participants", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "session_participants_role_check": { + "name": "session_participants_role_check", + "value": "\"session_participants\".\"role\" in ('owner', 'member')" + } + }, + "isRLSEnabled": false + }, + "public.session_pins": { + "name": "session_pins", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "session_pins_user_session_unique": { + "name": "session_pins_user_session_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_pins_user_updated_at_idx": { + "name": "session_pins_user_updated_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_pins_session_id_idx": { + "name": "session_pins_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_pins_session_id_sessions_id_fk": { + "name": "session_pins_session_id_sessions_id_fk", + "tableFrom": "session_pins", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_pins_user_id_users_id_fk": { + "name": "session_pins_user_id_users_id_fk", + "tableFrom": "session_pins", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session_tasks": { + "name": "session_tasks", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attached_at": { + "name": "attached_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_tasks_task_id_unique": { + "name": "session_tasks_task_id_unique", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_tasks_session_attached_at_idx": { + "name": "session_tasks_session_attached_at_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "attached_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_tasks_session_id_sessions_id_fk": { + "name": "session_tasks_session_id_sessions_id_fk", + "tableFrom": "session_tasks", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_tasks_task_id_tasks_id_fk": { + "name": "session_tasks_task_id_tasks_id_fk", + "tableFrom": "session_tasks", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "session_tasks_session_id_task_id_pk": { + "name": "session_tasks_session_id_task_id_pk", + "columns": ["session_id", "task_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "session_tasks_origin_check": { + "name": "session_tasks_origin_check", + "value": "\"session_tasks\".\"origin\" in ('direct_launch', 'fast_delegation', 'backfill', 'follow_up')" + } + }, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title_edited_by_user_at": { + "name": "title_edited_by_user_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "llm_title_checkpoint": { + "name": "llm_title_checkpoint", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "owner_kind": { + "name": "owner_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_automation": { + "name": "owner_automation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_surface": { + "name": "source_surface", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_trigger": { + "name": "source_trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fast_conversation_id": { + "name": "fast_conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'visible'" + }, + "activity_at": { + "name": "activity_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "cached_status": { + "name": "cached_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "responding_until": { + "name": "responding_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sessions_visibility_activity_at_idx": { + "name": "sessions_visibility_activity_at_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_owner_user_id_idx": { + "name": "sessions_owner_user_id_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_fast_conversation_id_unique": { + "name": "sessions_fast_conversation_id_unique", + "columns": [ + { + "expression": "fast_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"sessions\".\"fast_conversation_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_owner_user_id_users_id_fk": { + "name": "sessions_owner_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": ["owner_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "sessions_owner_automation_automations_key_fk": { + "name": "sessions_owner_automation_automations_key_fk", + "tableFrom": "sessions", + "tableTo": "automations", + "columnsFrom": ["owner_automation"], + "columnsTo": ["key"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "sessions_fast_conversation_id_fast_agent_conversations_id_fk": { + "name": "sessions_fast_conversation_id_fast_agent_conversations_id_fk", + "tableFrom": "sessions", + "tableTo": "fast_agent_conversations", + "columnsFrom": ["fast_conversation_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "sessions_owner_shape_check": { + "name": "sessions_owner_shape_check", + "value": "(\"sessions\".\"owner_kind\" = 'user' AND \"sessions\".\"owner_automation\" IS NULL) OR (\"sessions\".\"owner_kind\" = 'automation' AND \"sessions\".\"owner_user_id\" IS NULL) OR (\"sessions\".\"owner_kind\" = 'system' AND \"sessions\".\"owner_user_id\" IS NULL AND \"sessions\".\"owner_automation\" IS NULL)" + }, + "sessions_owner_kind_check": { + "name": "sessions_owner_kind_check", + "value": "\"sessions\".\"owner_kind\" in ('user', 'automation', 'system')" + }, + "sessions_source_surface_check": { + "name": "sessions_source_surface_check", + "value": "\"sessions\".\"source_surface\" in ('web', 'api', 'slack', 'teams', 'telegram', 'discord', 'agentmail', 'linear', 'github', 'gitlab', 'gitea', 'ado', 'bitbucket', 'system', 'automation')" + }, + "sessions_source_trigger_check": { + "name": "sessions_source_trigger_check", + "value": "\"sessions\".\"source_trigger\" in ('message', 'webhook', 'schedule', 'manual')" + }, + "sessions_visibility_check": { + "name": "sessions_visibility_check", + "value": "\"sessions\".\"visibility\" in ('visible', 'hidden')" + }, + "sessions_cached_status_check": { + "name": "sessions_cached_status_check", + "value": "\"sessions\".\"cached_status\" IS NULL OR \"sessions\".\"cached_status\" in ('active', 'needs_input', 'blocked', 'ready')" + } + }, + "isRLSEnabled": false + }, + "public.setup_qualification_blocks": { + "name": "setup_qualification_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'blocked'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_domain": { + "name": "email_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_account_login": { + "name": "github_account_login", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_account_type": { + "name": "github_account_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_blocked_at": { + "name": "first_blocked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_blocked_at": { + "name": "last_blocked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lifted_by_admin_user_id": { + "name": "lifted_by_admin_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifted_by_admin_email": { + "name": "lifted_by_admin_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "setup_qualification_blocks_deployment_user_reason_unique": { + "name": "setup_qualification_blocks_deployment_user_reason_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reason", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "setup_qualification_blocks_deployment_status_idx": { + "name": "setup_qualification_blocks_deployment_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "setup_qualification_blocks_user_status_idx": { + "name": "setup_qualification_blocks_user_status_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "setup_qualification_blocks_user_id_users_id_fk": { + "name": "setup_qualification_blocks_user_id_users_id_fk", + "tableFrom": "setup_qualification_blocks", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_auth_tokens": { + "name": "slack_auth_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_user_id": { + "name": "slack_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_team_id": { + "name": "slack_team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_ts": { + "name": "thread_ts", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_text": { + "name": "original_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slack_auth_tokens_expires_at_idx": { + "name": "slack_auth_tokens_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "slack_auth_tokens_token_unique": { + "name": "slack_auth_tokens_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_conversation_messages": { + "name": "slack_conversation_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "subject_user_id": { + "name": "subject_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_team_id": { + "name": "slack_team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_slack_user_id": { + "name": "subject_slack_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sender_user_id": { + "name": "sender_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sender_slack_user_id": { + "name": "sender_slack_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_channel_id": { + "name": "slack_channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_kind": { + "name": "conversation_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_ts": { + "name": "thread_ts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message_ts": { + "name": "message_ts", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_at": { + "name": "message_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_kind": { + "name": "author_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slack_conversation_messages_deployment_user_message_at_idx": { + "name": "slack_conversation_messages_deployment_user_message_at_idx", + "columns": [ + { + "expression": "subject_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_conversation_messages_deployment_user_thread_idx": { + "name": "slack_conversation_messages_deployment_user_thread_idx", + "columns": [ + { + "expression": "subject_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slack_channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_ts", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_conversation_messages_task_id_idx": { + "name": "slack_conversation_messages_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_conversation_messages_run_id_idx": { + "name": "slack_conversation_messages_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_conversation_messages_team_channel_message_unique": { + "name": "slack_conversation_messages_team_channel_message_unique", + "columns": [ + { + "expression": "slack_team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slack_channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_ts", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slack_conversation_messages_subject_user_id_users_id_fk": { + "name": "slack_conversation_messages_subject_user_id_users_id_fk", + "tableFrom": "slack_conversation_messages", + "tableTo": "users", + "columnsFrom": ["subject_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "slack_conversation_messages_sender_user_id_users_id_fk": { + "name": "slack_conversation_messages_sender_user_id_users_id_fk", + "tableFrom": "slack_conversation_messages", + "tableTo": "users", + "columnsFrom": ["sender_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "slack_conversation_messages_task_id_tasks_id_fk": { + "name": "slack_conversation_messages_task_id_tasks_id_fk", + "tableFrom": "slack_conversation_messages", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "slack_conversation_messages_run_id_task_runs_id_fk": { + "name": "slack_conversation_messages_run_id_task_runs_id_fk", + "tableFrom": "slack_conversation_messages", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_directory_users": { + "name": "slack_directory_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slack_user_id": { + "name": "slack_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_team_id": { + "name": "slack_team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "real_name": { + "name": "real_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_deleted": { + "name": "is_deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_bot": { + "name": "is_bot", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_app_user": { + "name": "is_app_user", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "profile_updated_at": { + "name": "profile_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slack_directory_users_team_id_idx": { + "name": "slack_directory_users_team_id_idx", + "columns": [ + { + "expression": "slack_team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "slack_directory_users_unique": { + "name": "slack_directory_users_unique", + "nullsNotDistinct": false, + "columns": ["slack_user_id", "slack_team_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_fast_integration_calls": { + "name": "slack_fast_integration_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "fast_agent_conversation_id": { + "name": "fast_agent_conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_team_id": { + "name": "slack_team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_channel": { + "name": "slack_channel", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_thread_ts": { + "name": "slack_thread_ts", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_message_ts": { + "name": "slack_message_ts", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "integration_id": { + "name": "integration_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "arguments": { + "name": "arguments", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "result_preview": { + "name": "result_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slack_fast_integration_calls_conversation_idx": { + "name": "slack_fast_integration_calls_conversation_idx", + "columns": [ + { + "expression": "fast_agent_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_fast_integration_calls_user_idx": { + "name": "slack_fast_integration_calls_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_fast_integration_calls_status_idx": { + "name": "slack_fast_integration_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slack_fast_integration_calls_fast_agent_conversation_id_fast_agent_conversations_id_fk": { + "name": "slack_fast_integration_calls_fast_agent_conversation_id_fast_agent_conversations_id_fk", + "tableFrom": "slack_fast_integration_calls", + "tableTo": "fast_agent_conversations", + "columnsFrom": ["fast_agent_conversation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "slack_fast_integration_calls_user_id_users_id_fk": { + "name": "slack_fast_integration_calls_user_id_users_id_fk", + "tableFrom": "slack_fast_integration_calls", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_installation_channels": { + "name": "slack_installation_channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slack_installation_id": { + "name": "slack_installation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slack_installation_channels_installation_id_idx": { + "name": "slack_installation_channels_installation_id_idx", + "columns": [ + { + "expression": "slack_installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slack_installation_channels_slack_installation_id_slack_installations_id_fk": { + "name": "slack_installation_channels_slack_installation_id_slack_installations_id_fk", + "tableFrom": "slack_installation_channels", + "tableTo": "slack_installations", + "columnsFrom": ["slack_installation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "slack_installation_channels_unique": { + "name": "slack_installation_channels_unique", + "nullsNotDistinct": false, + "columns": ["slack_installation_id", "channel_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_installations": { + "name": "slack_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_name": { + "name": "team_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_domain": { + "name": "team_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enterprise_id": { + "name": "enterprise_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enterprise_name": { + "name": "enterprise_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_user_id": { + "name": "bot_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_name": { + "name": "bot_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "app_name": { + "name": "app_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bot_access_token": { + "name": "bot_access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_access_token": { + "name": "user_access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'bot'" + }, + "installed_by_user_id": { + "name": "installed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "member_count_snapshot": { + "name": "member_count_snapshot", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "member_count_snapshot_at": { + "name": "member_count_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slack_installations_bot_user_id_idx": { + "name": "slack_installations_bot_user_id_idx", + "columns": [ + { + "expression": "bot_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_installations_active_idx": { + "name": "slack_installations_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slack_installations_installed_by_user_id_users_id_fk": { + "name": "slack_installations_installed_by_user_id_users_id_fk", + "tableFrom": "slack_installations", + "tableTo": "users", + "columnsFrom": ["installed_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "slack_installations_team_id_unique": { + "name": "slack_installations_team_id_unique", + "nullsNotDistinct": false, + "columns": ["team_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_user_mappings": { + "name": "slack_user_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slack_user_id": { + "name": "slack_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_team_id": { + "name": "slack_team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slack_user_mappings_user_id_idx": { + "name": "slack_user_mappings_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slack_user_mappings_user_id_users_id_fk": { + "name": "slack_user_mappings_user_id_users_id_fk", + "tableFrom": "slack_user_mappings", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "slack_user_mappings_unique": { + "name": "slack_user_mappings_unique", + "nullsNotDistinct": false, + "columns": ["slack_user_id", "slack_team_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.source_control_user_mappings": { + "name": "source_control_user_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "auth_account_id": { + "name": "auth_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_control_provider": { + "name": "source_control_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_account_id": { + "name": "external_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "source_control_user_mappings_auth_account_unique": { + "name": "source_control_user_mappings_auth_account_unique", + "columns": [ + { + "expression": "auth_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "source_control_user_mappings_user_provider_host_idx": { + "name": "source_control_user_mappings_user_provider_host_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "host", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "source_control_user_mappings_provider_identity_unique": { + "name": "source_control_user_mappings_provider_identity_unique", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "host", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "source_control_user_mappings_auth_account_id_auth_accounts_id_fk": { + "name": "source_control_user_mappings_auth_account_id_auth_accounts_id_fk", + "tableFrom": "source_control_user_mappings", + "tableTo": "auth_accounts", + "columnsFrom": ["auth_account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "source_control_user_mappings_user_id_auth_users_id_fk": { + "name": "source_control_user_mappings_user_id_auth_users_id_fk", + "tableFrom": "source_control_user_mappings", + "tableTo": "auth_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_artifacts": { + "name": "task_artifacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "artifact_type": { + "name": "artifact_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "size": { + "name": "size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "uploaded": { + "name": "uploaded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_artifacts_task_id_idx": { + "name": "task_artifacts_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_artifacts_run_id_idx": { + "name": "task_artifacts_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_artifacts_uploaded_idx": { + "name": "task_artifacts_uploaded_idx", + "columns": [ + { + "expression": "uploaded", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_artifacts_created_at_idx": { + "name": "task_artifacts_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_artifacts_path_idx": { + "name": "task_artifacts_path_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_artifacts_task_id_tasks_id_fk": { + "name": "task_artifacts_task_id_tasks_id_fk", + "tableFrom": "task_artifacts", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_artifacts_run_id_task_runs_id_fk": { + "name": "task_artifacts_run_id_task_runs_id_fk", + "tableFrom": "task_artifacts", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "task_artifacts_task_id_path_version_unique": { + "name": "task_artifacts_task_id_path_version_unique", + "nullsNotDistinct": false, + "columns": ["task_id", "path", "version"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_messages": { + "name": "task_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ts": { + "name": "ts", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "protocol": { + "name": "protocol", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_blocks": { + "name": "content_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_messages_task_id_ts_idx": { + "name": "task_messages_task_id_ts_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ts", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_messages_run_id_idx": { + "name": "task_messages_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_messages_created_at_idx": { + "name": "task_messages_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_messages_run_id_task_runs_id_fk": { + "name": "task_messages_run_id_task_runs_id_fk", + "tableFrom": "task_messages", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_messages_task_id_tasks_id_fk": { + "name": "task_messages_task_id_tasks_id_fk", + "tableFrom": "task_messages", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_messages_user_id_users_id_fk": { + "name": "task_messages_user_id_users_id_fk", + "tableFrom": "task_messages", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "task_messages_task_protocol_ts_event_type_unique": { + "name": "task_messages_task_protocol_ts_event_type_unique", + "nullsNotDistinct": false, + "columns": ["task_id", "protocol", "ts", "event_type"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_pins": { + "name": "task_pins", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_pins_deployment_user_task_unique": { + "name": "task_pins_deployment_user_task_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_pins_deployment_user_updated_at_idx": { + "name": "task_pins_deployment_user_updated_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_pins_task_id_idx": { + "name": "task_pins_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_pins_task_id_tasks_id_fk": { + "name": "task_pins_task_id_tasks_id_fk", + "tableFrom": "task_pins", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_pins_user_id_users_id_fk": { + "name": "task_pins_user_id_users_id_fk", + "tableFrom": "task_pins", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_platform_issue_reports": { + "name": "task_platform_issue_reports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "task_message_id": { + "name": "task_message_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "report": { + "name": "report", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "slack_posted_at": { + "name": "slack_posted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_platform_issue_reports_created_at_idx": { + "name": "task_platform_issue_reports_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_platform_issue_reports_task_id_created_at_idx": { + "name": "task_platform_issue_reports_task_id_created_at_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_platform_issue_reports_run_id_created_at_idx": { + "name": "task_platform_issue_reports_run_id_created_at_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_platform_issue_reports_task_message_id_unique": { + "name": "task_platform_issue_reports_task_message_id_unique", + "columns": [ + { + "expression": "task_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_platform_issue_reports_task_id_tasks_id_fk": { + "name": "task_platform_issue_reports_task_id_tasks_id_fk", + "tableFrom": "task_platform_issue_reports", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_platform_issue_reports_run_id_task_runs_id_fk": { + "name": "task_platform_issue_reports_run_id_task_runs_id_fk", + "tableFrom": "task_platform_issue_reports", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_platform_issue_reports_task_message_id_task_messages_id_fk": { + "name": "task_platform_issue_reports_task_message_id_task_messages_id_fk", + "tableFrom": "task_platform_issue_reports", + "tableTo": "task_messages", + "columnsFrom": ["task_message_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_pull_requests": { + "name": "task_pull_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_control_provider": { + "name": "source_control_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pr_title": { + "name": "pr_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_sha": { + "name": "pr_sha", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_base_ref": { + "name": "pr_base_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_base_sha": { + "name": "pr_base_sha", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_reaction_id": { + "name": "github_reaction_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "github_check_run_id": { + "name": "github_check_run_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "github_review_comment_id": { + "name": "github_review_comment_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_by_roomote": { + "name": "created_by_roomote", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mergeability_status": { + "name": "mergeability_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "conflict_detected_at": { + "name": "conflict_detected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "conflict_notification_claimed_at": { + "name": "conflict_notification_claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "conflict_notified_at": { + "name": "conflict_notified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "auto_handle_feedback_by_user_id": { + "name": "auto_handle_feedback_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "detected_at": { + "name": "detected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_pull_requests_task_id_idx": { + "name": "task_pull_requests_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_pull_requests_repository_id_idx": { + "name": "task_pull_requests_repository_id_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_pull_requests_provider_repository_pr_number_idx": { + "name": "task_pull_requests_provider_repository_pr_number_idx", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repository", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pr_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_pull_requests_mergeability_lookup_idx": { + "name": "task_pull_requests_mergeability_lookup_idx", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repository", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_by_roomote", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pr_base_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_pull_requests_task_id_tasks_id_fk": { + "name": "task_pull_requests_task_id_tasks_id_fk", + "tableFrom": "task_pull_requests", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_pull_requests_repository_id_repositories_id_fk": { + "name": "task_pull_requests_repository_id_repositories_id_fk", + "tableFrom": "task_pull_requests", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "task_pull_requests_auto_handle_feedback_by_user_id_users_id_fk": { + "name": "task_pull_requests_auto_handle_feedback_by_user_id_users_id_fk", + "tableFrom": "task_pull_requests", + "tableTo": "users", + "columnsFrom": ["auto_handle_feedback_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "task_pull_requests_task_pr_unique": { + "name": "task_pull_requests_task_pr_unique", + "nullsNotDistinct": false, + "columns": ["task_id", "pr_url"] + } + }, + "policies": {}, + "checkConstraints": { + "task_pull_requests_source_control_provider_check": { + "name": "task_pull_requests_source_control_provider_check", + "value": "\"task_pull_requests\".\"source_control_provider\" in ('github', 'gitlab', 'gitea', 'ado', 'bitbucket')" + } + }, + "isRLSEnabled": false + }, + "public.task_run_events": { + "name": "task_run_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_run_events_run_id_created_at_idx": { + "name": "task_run_events_run_id_created_at_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_run_events_task_id_created_at_idx": { + "name": "task_run_events_task_id_created_at_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_run_events_created_at_idx": { + "name": "task_run_events_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_run_events_source_created_at_idx": { + "name": "task_run_events_source_created_at_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_run_events_run_id_task_runs_id_fk": { + "name": "task_run_events_run_id_task_runs_id_fk", + "tableFrom": "task_run_events", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_run_events_task_id_tasks_id_fk": { + "name": "task_run_events_task_id_tasks_id_fk", + "tableFrom": "task_run_events", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_runs": { + "name": "task_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "task_runs_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'fresh'" + }, + "source_run_id": { + "name": "source_run_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "acting_user_id": { + "name": "acting_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload_kind": { + "name": "payload_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "harness": { + "name": "harness", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'opencode-server'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queue_scope": { + "name": "queue_scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "task_phase": { + "name": "task_phase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "fast_agent_session_id": { + "name": "fast_agent_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "((payload ->> 'fastAgentSessionId')::uuid)", + "type": "stored" + } + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log": { + "name": "log", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "artifacts": { + "name": "artifacts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "machine_id": { + "name": "machine_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sandbox_cmd_id": { + "name": "sandbox_cmd_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "machine_domain": { + "name": "machine_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "machine_domains": { + "name": "machine_domains", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "initial_paths": { + "name": "initial_paths", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "primary_port_name": { + "name": "primary_port_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sandbox_server_url": { + "name": "sandbox_server_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "proxy_ports": { + "name": "proxy_ports", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "worker_release_tag": { + "name": "worker_release_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "worker_version": { + "name": "worker_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "worker_commit": { + "name": "worker_commit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "configured_vcpus": { + "name": "configured_vcpus", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "configured_cpu_cores": { + "name": "configured_cpu_cores", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "configured_memory_mib": { + "name": "configured_memory_mib", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "snapshot_requested_at": { + "name": "snapshot_requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "snapshot_created_at": { + "name": "snapshot_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "snapshot_failed_at": { + "name": "snapshot_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "keepalive_ms": { + "name": "keepalive_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "sleep_at": { + "name": "sleep_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "sleep_requested_at": { + "name": "sleep_requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "worker_heartbeat_at": { + "name": "worker_heartbeat_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_snapshot_id": { + "name": "source_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_bypass_value": { + "name": "auth_bypass_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_bypass_header_name": { + "name": "auth_bypass_header_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "dequeued_at": { + "name": "dequeued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "provision_started_at": { + "name": "provision_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "provision_ready_at": { + "name": "provision_ready_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "setup_completed_at": { + "name": "setup_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "environment_setup_state": { + "name": "environment_setup_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "environment_setup_completed_at": { + "name": "environment_setup_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "harness_started_at": { + "name": "harness_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "runtime_task_started_at": { + "name": "runtime_task_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "first_assistant_output_at": { + "name": "first_assistant_output_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_requested_at": { + "name": "cancel_requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "launch_mode": { + "name": "launch_mode", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "task_runs_task_id_idx": { + "name": "task_runs_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_fast_agent_session_id_idx": { + "name": "task_runs_fast_agent_session_id_idx", + "columns": [ + { + "expression": "fast_agent_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_queue_scope_idx": { + "name": "task_runs_queue_scope_idx", + "columns": [ + { + "expression": "queue_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_acting_user_id_idx": { + "name": "task_runs_acting_user_id_idx", + "columns": [ + { + "expression": "acting_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_snapshot_id_idx": { + "name": "task_runs_snapshot_id_idx", + "columns": [ + { + "expression": "snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_sleep_at_idx": { + "name": "task_runs_sleep_at_idx", + "columns": [ + { + "expression": "sleep_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_worker_heartbeat_at_idx": { + "name": "task_runs_worker_heartbeat_at_idx", + "columns": [ + { + "expression": "worker_heartbeat_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_sleep_check_due_v2_idx": { + "name": "task_runs_sleep_check_due_v2_idx", + "columns": [ + { + "expression": "sleep_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "vendor", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"task_runs\".\"status\" IN ('running', 'idle') AND \"task_runs\".\"machine_id\" IS NOT NULL AND \"task_runs\".\"sleep_at\" IS NOT NULL AND \"task_runs\".\"sleep_requested_at\" IS NULL AND \"task_runs\".\"snapshot_id\" IS NULL AND \"task_runs\".\"snapshot_requested_at\" IS NULL AND \"task_runs\".\"vendor\" IN ('modal', 'daytona', 'e2b', 'docker', 'blaxel', 'box', 'roomote', 'azure')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_sleep_check_stale_worker_v2_idx": { + "name": "task_runs_sleep_check_stale_worker_v2_idx", + "columns": [ + { + "expression": "worker_heartbeat_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "vendor", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"task_runs\".\"status\" IN ('running', 'idle') AND \"task_runs\".\"machine_id\" IS NOT NULL AND \"task_runs\".\"worker_heartbeat_at\" IS NOT NULL AND \"task_runs\".\"sleep_requested_at\" IS NULL AND \"task_runs\".\"snapshot_id\" IS NULL AND \"task_runs\".\"snapshot_requested_at\" IS NULL AND \"task_runs\".\"vendor\" IN ('modal', 'daytona', 'e2b', 'docker', 'blaxel', 'box', 'roomote', 'azure')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_sleep_check_active_v2_idx": { + "name": "task_runs_sleep_check_active_v2_idx", + "columns": [ + { + "expression": "vendor", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"task_runs\".\"status\" IN ('running', 'idle') AND \"task_runs\".\"machine_id\" IS NOT NULL AND \"task_runs\".\"sleep_requested_at\" IS NULL AND \"task_runs\".\"snapshot_id\" IS NULL AND \"task_runs\".\"snapshot_requested_at\" IS NULL AND \"task_runs\".\"vendor\" IN ('modal', 'daytona', 'e2b', 'docker', 'blaxel', 'box', 'roomote', 'azure')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_source_snapshot_id_idx": { + "name": "task_runs_source_snapshot_id_idx", + "columns": [ + { + "expression": "source_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_source_run_id_idx": { + "name": "task_runs_source_run_id_idx", + "columns": [ + { + "expression": "source_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_discord_source_event_unique": { + "name": "task_runs_discord_source_event_unique", + "columns": [ + { + "expression": "(\"payload\"->>'communicationSourceEventId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"task_runs\".\"payload\"->>'communicationProvider' = 'discord' AND \"task_runs\".\"payload\"->>'communicationSourceEventId' IS NOT NULL AND \"task_runs\".\"canceled_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_launch_idempotency_key_unique": { + "name": "task_runs_launch_idempotency_key_unique", + "columns": [ + { + "expression": "(\"payload\"->>'launchIdempotencyKey')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"task_runs\".\"payload\"->>'launchIdempotencyKey' IS NOT NULL AND \"task_runs\".\"canceled_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_first_assistant_output_at_idx": { + "name": "task_runs_first_assistant_output_at_idx", + "columns": [ + { + "expression": "first_assistant_output_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_runs_task_id_tasks_id_fk": { + "name": "task_runs_task_id_tasks_id_fk", + "tableFrom": "task_runs", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_runs_source_run_id_task_runs_id_fk": { + "name": "task_runs_source_run_id_task_runs_id_fk", + "tableFrom": "task_runs", + "tableTo": "task_runs", + "columnsFrom": ["source_run_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "task_runs_acting_user_id_users_id_fk": { + "name": "task_runs_acting_user_id_users_id_fk", + "tableFrom": "task_runs", + "tableTo": "users", + "columnsFrom": ["acting_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "task_runs_kind_check": { + "name": "task_runs_kind_check", + "value": "\"task_runs\".\"kind\" in ('fresh', 'resume')" + }, + "task_runs_harness_check": { + "name": "task_runs_harness_check", + "value": "\"task_runs\".\"harness\" in ('opencode-server')" + } + }, + "isRLSEnabled": false + }, + "public.task_slack_reply_details": { + "name": "task_slack_reply_details", + "schema": "", + "columns": { + "detail_id": { + "name": "detail_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "findings": { + "name": "findings", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_slack_reply_details_task_id_idx": { + "name": "task_slack_reply_details_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_slack_reply_details_deployment_task_detail_unique": { + "name": "task_slack_reply_details_deployment_task_detail_unique", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "detail_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_slack_reply_details_task_id_tasks_id_fk": { + "name": "task_slack_reply_details_task_id_tasks_id_fk", + "tableFrom": "task_slack_reply_details", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_start_parallel_counts": { + "name": "task_start_parallel_counts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "payload_kind": { + "name": "payload_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parallel_count": { + "name": "parallel_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "activity_window_seconds": { + "name": "activity_window_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_start_parallel_counts_run_id_unique": { + "name": "task_start_parallel_counts_run_id_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_start_parallel_counts_task_id_started_at_idx": { + "name": "task_start_parallel_counts_task_id_started_at_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_start_parallel_counts_started_at_idx": { + "name": "task_start_parallel_counts_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_start_parallel_counts_task_id_tasks_id_fk": { + "name": "task_start_parallel_counts_task_id_tasks_id_fk", + "tableFrom": "task_start_parallel_counts", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_start_parallel_counts_run_id_task_runs_id_fk": { + "name": "task_start_parallel_counts_run_id_task_runs_id_fk", + "tableFrom": "task_start_parallel_counts", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow": { + "name": "workflow", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'visible'" + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "initiator_kind": { + "name": "initiator_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "initiator_user_id": { + "name": "initiator_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "initiator_automation": { + "name": "initiator_automation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_external_id": { + "name": "actor_external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_display_name": { + "name": "actor_display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "commit_author_kind": { + "name": "commit_author_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "commit_author_user_id": { + "name": "commit_author_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "commit_author_login": { + "name": "commit_author_login", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "commit_author_external_id": { + "name": "commit_author_external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_assignee_login": { + "name": "pr_assignee_login", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_channel_id": { + "name": "slack_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_thread_ts": { + "name": "slack_thread_ts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linear_session_id": { + "name": "linear_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linear_issue_id": { + "name": "linear_issue_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linear_organization_id": { + "name": "linear_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "harness": { + "name": "harness", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'opencode-server'" + }, + "harness_session_id": { + "name": "harness_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_provider": { + "name": "model_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title_edited_by_user_at": { + "name": "title_edited_by_user_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "llm_title_checkpoint": { + "name": "llm_title_checkpoint", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "goal_objective": { + "name": "goal_objective", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "goal_status": { + "name": "goal_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "goal_max_continuations": { + "name": "goal_max_continuations", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "goal_continuations_used": { + "name": "goal_continuations_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "goal_blocked_reason": { + "name": "goal_blocked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "goal_completed_at": { + "name": "goal_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "goal_last_continuation_id": { + "name": "goal_last_continuation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "goal_continuation_ids": { + "name": "goal_continuation_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "goal_generation_ids": { + "name": "goal_generation_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "goal_blocker_candidate_reason": { + "name": "goal_blocker_candidate_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "goal_blocker_candidate_count": { + "name": "goal_blocker_candidate_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "goal_blocker_last_continuation_used": { + "name": "goal_blocker_last_continuation_used", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "draft_prompt": { + "name": "draft_prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_work_kind": { + "name": "requested_work_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "requested_work_kind_source": { + "name": "requested_work_kind_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system_default'" + }, + "requested_work_kind_confidence": { + "name": "requested_work_kind_confidence", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "harness_instructions": { + "name": "harness_instructions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "compute_duration_ms": { + "name": "compute_duration_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "activity_at": { + "name": "activity_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "repository_url": { + "name": "repository_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository_name": { + "name": "repository_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tasks_initiator_user_id_idx": { + "name": "tasks_initiator_user_id_idx", + "columns": [ + { + "expression": "initiator_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_initiator_automation_idx": { + "name": "tasks_initiator_automation_idx", + "columns": [ + { + "expression": "initiator_automation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_workflow_idx": { + "name": "tasks_workflow_idx", + "columns": [ + { + "expression": "workflow", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_visibility_activity_at_idx": { + "name": "tasks_visibility_activity_at_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_harness_session_id_idx": { + "name": "tasks_harness_session_id_idx", + "columns": [ + { + "expression": "harness_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_timestamp_idx": { + "name": "tasks_timestamp_idx", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_deployment_activity_at_idx": { + "name": "tasks_deployment_activity_at_idx", + "columns": [ + { + "expression": "activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_created_at_idx": { + "name": "tasks_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tasks_initiator_user_id_users_id_fk": { + "name": "tasks_initiator_user_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "columnsFrom": ["initiator_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tasks_initiator_automation_automations_key_fk": { + "name": "tasks_initiator_automation_automations_key_fk", + "tableFrom": "tasks", + "tableTo": "automations", + "columnsFrom": ["initiator_automation"], + "columnsTo": ["key"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tasks_commit_author_user_id_users_id_fk": { + "name": "tasks_commit_author_user_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "columnsFrom": ["commit_author_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "tasks_initiator_shape_check": { + "name": "tasks_initiator_shape_check", + "value": "(\"tasks\".\"initiator_kind\" = 'user' AND \"tasks\".\"initiator_automation\" IS NULL AND (\"tasks\".\"initiator_user_id\" IS NOT NULL OR \"tasks\".\"actor_external_id\" IS NOT NULL)) OR (\"tasks\".\"initiator_kind\" = 'automation' AND \"tasks\".\"initiator_automation\" IS NOT NULL AND \"tasks\".\"initiator_user_id\" IS NULL)" + }, + "tasks_workflow_check": { + "name": "tasks_workflow_check", + "value": "\"tasks\".\"workflow\" in ('standard', 'pr_review', 'pr_conflict_resolve', 'scan', 'mcp_recommendations', 'setup_onboarding', 'env_snapshot', 'eval')" + }, + "tasks_surface_check": { + "name": "tasks_surface_check", + "value": "\"tasks\".\"surface\" in ('web', 'api', 'slack', 'teams', 'telegram', 'discord', 'agentmail', 'linear', 'github', 'gitlab', 'gitea', 'ado', 'bitbucket', 'system')" + }, + "tasks_trigger_check": { + "name": "tasks_trigger_check", + "value": "\"tasks\".\"trigger\" in ('message', 'webhook', 'schedule', 'manual')" + }, + "tasks_visibility_check": { + "name": "tasks_visibility_check", + "value": "\"tasks\".\"visibility\" in ('visible', 'hidden')" + }, + "tasks_state_check": { + "name": "tasks_state_check", + "value": "\"tasks\".\"state\" in ('active', 'completed', 'failed', 'canceled')" + }, + "tasks_goal_status_check": { + "name": "tasks_goal_status_check", + "value": "\"tasks\".\"goal_status\" IS NULL OR \"tasks\".\"goal_status\" in ('active', 'complete', 'blocked', 'budget_limited')" + }, + "tasks_goal_continuations_check": { + "name": "tasks_goal_continuations_check", + "value": "\"tasks\".\"goal_continuations_used\" >= 0 AND (\"tasks\".\"goal_max_continuations\" IS NULL OR \"tasks\".\"goal_max_continuations\" > 0)" + }, + "tasks_goal_blocker_candidate_count_check": { + "name": "tasks_goal_blocker_candidate_count_check", + "value": "\"tasks\".\"goal_blocker_candidate_count\" >= 0" + }, + "tasks_harness_check": { + "name": "tasks_harness_check", + "value": "\"tasks\".\"harness\" in ('opencode-server')" + }, + "tasks_requested_work_kind_check": { + "name": "tasks_requested_work_kind_check", + "value": "\"tasks\".\"requested_work_kind\" in ('question', 'plan', 'implement', 'unknown')" + }, + "tasks_requested_work_kind_source_check": { + "name": "tasks_requested_work_kind_source_check", + "value": "\"tasks\".\"requested_work_kind_source\" in ('explicit_bootstrap', 'task_tool', 'llm_classifier', 'inherited', 'system_default')" + }, + "tasks_commit_author_kind_check": { + "name": "tasks_commit_author_kind_check", + "value": "\"tasks\".\"commit_author_kind\" IS NULL OR \"tasks\".\"commit_author_kind\" in ('roomote', 'user', 'external')" + } + }, + "isRLSEnabled": false + }, + "public.teams_installations": { + "name": "teams_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "installation_key": { + "name": "installation_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "team_name": { + "name": "team_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "channel_name": { + "name": "channel_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_type": { + "name": "conversation_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bot_app_id": { + "name": "bot_app_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_user_id": { + "name": "bot_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bot_name": { + "name": "bot_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "service_url": { + "name": "service_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "teams_installations_tenant_id_idx": { + "name": "teams_installations_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "teams_installations_team_id_idx": { + "name": "teams_installations_team_id_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "teams_installations_conversation_id_idx": { + "name": "teams_installations_conversation_id_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "teams_installations_active_idx": { + "name": "teams_installations_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "teams_installations_installation_key_unique": { + "name": "teams_installations_installation_key_unique", + "nullsNotDistinct": false, + "columns": ["installation_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.teams_user_mappings": { + "name": "teams_user_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "teams_user_id": { + "name": "teams_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "teams_tenant_id": { + "name": "teams_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "teams_aad_object_id": { + "name": "teams_aad_object_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "teams_user_mappings_aad_object_idx": { + "name": "teams_user_mappings_aad_object_idx", + "columns": [ + { + "expression": "teams_aad_object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "teams_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "teams_user_mappings_user_id_idx": { + "name": "teams_user_mappings_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "teams_user_mappings_user_id_users_id_fk": { + "name": "teams_user_mappings_user_id_users_id_fk", + "tableFrom": "teams_user_mappings", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "teams_user_mappings_unique": { + "name": "teams_user_mappings_unique", + "nullsNotDistinct": false, + "columns": ["teams_user_id", "teams_tenant_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.telegram_user_mappings": { + "name": "telegram_user_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "telegram_user_id": { + "name": "telegram_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_username": { + "name": "telegram_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "telegram_user_mappings_user_id_idx": { + "name": "telegram_user_mappings_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "telegram_user_mappings_user_id_users_id_fk": { + "name": "telegram_user_mappings_user_id_users_id_fk", + "tableFrom": "telegram_user_mappings", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "telegram_user_mappings_unique": { + "name": "telegram_user_mappings_unique", + "nullsNotDistinct": false, + "columns": ["telegram_user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tracked_messages": { + "name": "tracked_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_ts": { + "name": "message_ts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thread_ts": { + "name": "thread_ts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "work_item_id": { + "name": "work_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "automation_key": { + "name": "automation_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary_text": { + "name": "summary_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "posted_at": { + "name": "posted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tracked_messages_kind_dedupe_key_unique": { + "name": "tracked_messages_kind_dedupe_key_unique", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tracked_messages_work_item_id_idx": { + "name": "tracked_messages_work_item_id_idx", + "columns": [ + { + "expression": "work_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tracked_messages_channel_message_idx": { + "name": "tracked_messages_channel_message_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_ts", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tracked_messages_automation_channel_posted_idx": { + "name": "tracked_messages_automation_channel_posted_idx", + "columns": [ + { + "expression": "automation_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "posted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tracked_messages_work_item_id_work_items_id_fk": { + "name": "tracked_messages_work_item_id_work_items_id_fk", + "tableFrom": "tracked_messages", + "tableTo": "work_items", + "columnsFrom": ["work_item_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tracked_messages_automation_key_automations_key_fk": { + "name": "tracked_messages_automation_key_automations_key_fk", + "tableFrom": "tracked_messages", + "tableTo": "automations", + "columnsFrom": ["automation_key"], + "columnsTo": ["key"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tracked_messages_created_by_user_id_users_id_fk": { + "name": "tracked_messages_created_by_user_id_users_id_fk", + "tableFrom": "tracked_messages", + "tableTo": "users", + "columnsFrom": ["created_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_api_keys": { + "name": "user_api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_api_keys_user_id_idx": { + "name": "user_api_keys_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_api_keys_user_deployment_provider_unique": { + "name": "user_api_keys_user_deployment_provider_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_api_keys_user_id_users_id_fk": { + "name": "user_api_keys_user_id_users_id_fk", + "tableFrom": "user_api_keys", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity": { + "name": "entity", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "analytics_id": { + "name": "analytics_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cookie_consented_at": { + "name": "cookie_consented_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by_invite_id": { + "name": "invited_by_invite_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_created_at_idx": { + "name": "users_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_analytics_id_unique_idx": { + "name": "users_analytics_id_unique_idx", + "columns": [ + { + "expression": "analytics_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhooks": { + "name": "webhooks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "delivery_id": { + "name": "delivery_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "succeeded_at": { + "name": "succeeded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failed_at": { + "name": "failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhooks_provider_delivery_id_unique": { + "name": "webhooks_provider_delivery_id_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "delivery_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhooks_event_idx": { + "name": "webhooks_event_idx", + "columns": [ + { + "expression": "event", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhooks_created_at_idx": { + "name": "webhooks_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhooks_status_exclusive": { + "name": "webhooks_status_exclusive", + "value": "(\n (succeeded_at IS NOT NULL)::int +\n (failed_at IS NOT NULL)::int\n ) <= 1" + } + }, + "isRLSEnabled": false + }, + "public.work_items": { + "name": "work_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "automation_key": { + "name": "automation_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_id": { + "name": "source_task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "selected_by_user_id": { + "name": "selected_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_work_item_id": { + "name": "source_work_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "brief": { + "name": "brief", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_prompt": { + "name": "execution_prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "investigation_context": { + "name": "investigation_context", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action_kind": { + "name": "action_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disposition": { + "name": "disposition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "repository_ids": { + "name": "repository_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "target_repository_full_name": { + "name": "target_repository_full_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_environment_id": { + "name": "target_environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "workspace_readiness": { + "name": "workspace_readiness", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "readiness_message": { + "name": "readiness_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "launch_claimed_at": { + "name": "launch_claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "launched_task_id": { + "name": "launched_task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "launched_at": { + "name": "launched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failed_at": { + "name": "failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "launch_error": { + "name": "launch_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "work_items_source_task_idx": { + "name": "work_items_source_task_idx", + "columns": [ + { + "expression": "source_task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "work_items_kind_status_idx": { + "name": "work_items_kind_status_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "work_items_automation_key_fingerprint_idx": { + "name": "work_items_automation_key_fingerprint_idx", + "columns": [ + { + "expression": "automation_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "work_items_fingerprint_idx": { + "name": "work_items_fingerprint_idx", + "columns": [ + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "work_items_launched_task_id_idx": { + "name": "work_items_launched_task_id_idx", + "columns": [ + { + "expression": "launched_task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "work_items_source_task_kind_sort_order_unique": { + "name": "work_items_source_task_kind_sort_order_unique", + "columns": [ + { + "expression": "source_task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "work_items_automation_key_automations_key_fk": { + "name": "work_items_automation_key_automations_key_fk", + "tableFrom": "work_items", + "tableTo": "automations", + "columnsFrom": ["automation_key"], + "columnsTo": ["key"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "work_items_source_task_id_tasks_id_fk": { + "name": "work_items_source_task_id_tasks_id_fk", + "tableFrom": "work_items", + "tableTo": "tasks", + "columnsFrom": ["source_task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "work_items_selected_by_user_id_users_id_fk": { + "name": "work_items_selected_by_user_id_users_id_fk", + "tableFrom": "work_items", + "tableTo": "users", + "columnsFrom": ["selected_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "work_items_source_work_item_id_work_items_id_fk": { + "name": "work_items_source_work_item_id_work_items_id_fk", + "tableFrom": "work_items", + "tableTo": "work_items", + "columnsFrom": ["source_work_item_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "work_items_target_environment_id_environments_id_fk": { + "name": "work_items_target_environment_id_environments_id_fk", + "tableFrom": "work_items", + "tableTo": "environments", + "columnsFrom": ["target_environment_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "work_items_launched_task_id_tasks_id_fk": { + "name": "work_items_launched_task_id_tasks_id_fk", + "tableFrom": "work_items", + "tableTo": "tasks", + "columnsFrom": ["launched_task_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/drizzle/meta/0072_snapshot.json b/packages/db/drizzle/meta/0072_snapshot.json new file mode 100644 index 000000000..97fd4fd40 --- /dev/null +++ b/packages/db/drizzle/meta/0072_snapshot.json @@ -0,0 +1,14771 @@ +{ + "id": "b164513e-38a6-464c-95d8-cd9dd9baaae4", + "prevId": "4f685bf8-37eb-4bf0-8f1b-36dd98d90593", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.agentmail_conversation_participants": { + "name": "agentmail_conversation_participants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "inbox_id": { + "name": "inbox_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_thread_id": { + "name": "provider_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "added_at": { + "name": "added_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agentmail_conversation_participants_user_idx": { + "name": "agentmail_conversation_participants_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agentmail_conversation_participants_user_id_users_id_fk": { + "name": "agentmail_conversation_participants_user_id_users_id_fk", + "tableFrom": "agentmail_conversation_participants", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agentmail_conversation_participants_conversation_fk": { + "name": "agentmail_conversation_participants_conversation_fk", + "tableFrom": "agentmail_conversation_participants", + "tableTo": "agentmail_conversations", + "columnsFrom": ["conversation_id", "inbox_id", "provider_thread_id"], + "columnsTo": ["id", "inbox_id", "provider_thread_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "agentmail_conversation_participants_conversation_user_unique": { + "name": "agentmail_conversation_participants_conversation_user_unique", + "nullsNotDistinct": false, + "columns": ["conversation_id", "user_id"] + }, + "agentmail_conversation_participants_thread_user_unique": { + "name": "agentmail_conversation_participants_thread_user_unique", + "nullsNotDistinct": false, + "columns": ["inbox_id", "provider_thread_id", "user_id"] + } + }, + "policies": {}, + "checkConstraints": { + "agentmail_conversation_participants_role_check": { + "name": "agentmail_conversation_participants_role_check", + "value": "\"agentmail_conversation_participants\".\"role\" in ('owner', 'participant')" + }, + "agentmail_conversation_participants_source_check": { + "name": "agentmail_conversation_participants_source_check", + "value": "\"agentmail_conversation_participants\".\"source\" in ('initiator', 'cc', 'link_code', 'outbound')" + } + }, + "isRLSEnabled": false + }, + "public.agentmail_conversations": { + "name": "agentmail_conversations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "inbox_id": { + "name": "inbox_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_thread_id": { + "name": "provider_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latest_inbound_message_id": { + "name": "latest_inbound_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latest_inbound_at": { + "name": "latest_inbound_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "latest_inbound_sender_email": { + "name": "latest_inbound_sender_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latest_inbound_user_id": { + "name": "latest_inbound_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latest_outbound_message_id": { + "name": "latest_outbound_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agentmail_conversations_thread_idx": { + "name": "agentmail_conversations_thread_idx", + "columns": [ + { + "expression": "inbox_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentmail_conversations_owner_idx": { + "name": "agentmail_conversations_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agentmail_conversations_owner_user_id_users_id_fk": { + "name": "agentmail_conversations_owner_user_id_users_id_fk", + "tableFrom": "agentmail_conversations", + "tableTo": "users", + "columnsFrom": ["owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agentmail_conversations_latest_inbound_user_id_users_id_fk": { + "name": "agentmail_conversations_latest_inbound_user_id_users_id_fk", + "tableFrom": "agentmail_conversations", + "tableTo": "users", + "columnsFrom": ["latest_inbound_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "agentmail_conversations_id_thread_unique": { + "name": "agentmail_conversations_id_thread_unique", + "nullsNotDistinct": false, + "columns": ["id", "inbox_id", "provider_thread_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agentmail_inbound_turns": { + "name": "agentmail_inbound_turns", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "webhook_event_id": { + "name": "webhook_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider_message_id": { + "name": "provider_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_timestamp": { + "name": "provider_timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "sender_email": { + "name": "sender_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sender_user_id": { + "name": "sender_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "agentmail_inbound_turns_drain_idx": { + "name": "agentmail_inbound_turns_drain_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agentmail_inbound_turns_conversation_id_agentmail_conversations_id_fk": { + "name": "agentmail_inbound_turns_conversation_id_agentmail_conversations_id_fk", + "tableFrom": "agentmail_inbound_turns", + "tableTo": "agentmail_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agentmail_inbound_turns_webhook_event_id_agentmail_webhook_events_id_fk": { + "name": "agentmail_inbound_turns_webhook_event_id_agentmail_webhook_events_id_fk", + "tableFrom": "agentmail_inbound_turns", + "tableTo": "agentmail_webhook_events", + "columnsFrom": ["webhook_event_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agentmail_inbound_turns_sender_user_id_users_id_fk": { + "name": "agentmail_inbound_turns_sender_user_id_users_id_fk", + "tableFrom": "agentmail_inbound_turns", + "tableTo": "users", + "columnsFrom": ["sender_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "agentmail_inbound_turns_webhook_event_unique": { + "name": "agentmail_inbound_turns_webhook_event_unique", + "nullsNotDistinct": false, + "columns": ["webhook_event_id"] + } + }, + "policies": {}, + "checkConstraints": { + "agentmail_inbound_turns_state_check": { + "name": "agentmail_inbound_turns_state_check", + "value": "\"agentmail_inbound_turns\".\"state\" in ('pending', 'consumed')" + } + }, + "isRLSEnabled": false + }, + "public.agentmail_suppressions": { + "name": "agentmail_suppressions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email_address": { + "name": "email_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_message_id": { + "name": "provider_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "agentmail_suppressions_email_unique": { + "name": "agentmail_suppressions_email_unique", + "nullsNotDistinct": false, + "columns": ["email_address"] + } + }, + "policies": {}, + "checkConstraints": { + "agentmail_suppressions_reason_check": { + "name": "agentmail_suppressions_reason_check", + "value": "\"agentmail_suppressions\".\"reason\" in ('bounce', 'complaint', 'unsubscribe')" + } + }, + "isRLSEnabled": false + }, + "public.agentmail_user_mappings": { + "name": "agentmail_user_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email_address": { + "name": "email_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agentmail_user_mappings_user_id_idx": { + "name": "agentmail_user_mappings_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agentmail_user_mappings_user_id_users_id_fk": { + "name": "agentmail_user_mappings_user_id_users_id_fk", + "tableFrom": "agentmail_user_mappings", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "agentmail_user_mappings_unique": { + "name": "agentmail_user_mappings_unique", + "nullsNotDistinct": false, + "columns": ["email_address"] + } + }, + "policies": {}, + "checkConstraints": { + "agentmail_user_mappings_source_check": { + "name": "agentmail_user_mappings_source_check", + "value": "\"agentmail_user_mappings\".\"source\" in ('verified_match', 'link_code')" + } + }, + "isRLSEnabled": false + }, + "public.agentmail_webhook_events": { + "name": "agentmail_webhook_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "delivery_id": { + "name": "delivery_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "received_at": { + "name": "received_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agentmail_webhook_events_state_idx": { + "name": "agentmail_webhook_events_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "received_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "agentmail_webhook_events_delivery_unique": { + "name": "agentmail_webhook_events_delivery_unique", + "nullsNotDistinct": false, + "columns": ["delivery_id"] + } + }, + "policies": {}, + "checkConstraints": { + "agentmail_webhook_events_state_check": { + "name": "agentmail_webhook_events_state_check", + "value": "\"agentmail_webhook_events\".\"state\" in ('received', 'queued', 'processing', 'processed', 'failed')" + } + }, + "isRLSEnabled": false + }, + "public.auth_accounts": { + "name": "auth_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "auth_accounts_user_id_idx": { + "name": "auth_accounts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_accounts_provider_account_unique": { + "name": "auth_accounts_provider_account_unique", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auth_accounts_user_id_auth_users_id_fk": { + "name": "auth_accounts_user_id_auth_users_id_fk", + "tableFrom": "auth_accounts", + "tableTo": "auth_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_sessions": { + "name": "auth_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "auth_sessions_token_unique": { + "name": "auth_sessions_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_sessions_user_id_idx": { + "name": "auth_sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auth_sessions_user_id_auth_users_id_fk": { + "name": "auth_sessions_user_id_auth_users_id_fk", + "tableFrom": "auth_sessions", + "tableTo": "auth_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_users": { + "name": "auth_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "auth_users_email_unique": { + "name": "auth_users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_users_created_at_idx": { + "name": "auth_users_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_verifications": { + "name": "auth_verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "auth_verifications_identifier_idx": { + "name": "auth_verifications_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automations": { + "name": "automations", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "internal": { + "name": "internal", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "schedule": { + "name": "schedule", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "targets": { + "name": "targets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_succeeded_at": { + "name": "last_succeeded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scan_cursor": { + "name": "scan_cursor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brain_collector_items": { + "name": "brain_collector_items", + "schema": "", + "columns": { + "collector_id": { + "name": "collector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brain_collector_items_collector_seen_idx": { + "name": "brain_collector_items_collector_seen_idx", + "columns": [ + { + "expression": "collector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_seen_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "brain_collector_items_collector_item_pk": { + "name": "brain_collector_items_collector_item_pk", + "columns": ["collector_id", "item_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brain_memory_events": { + "name": "brain_memory_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "agent_summary": { + "name": "agent_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brain_memory_events_status_created_idx": { + "name": "brain_memory_events_status_created_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "brain_memory_events_run_id_task_runs_id_fk": { + "name": "brain_memory_events_run_id_task_runs_id_fk", + "tableFrom": "brain_memory_events", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "brain_memory_events_run_unique": { + "name": "brain_memory_events_run_unique", + "nullsNotDistinct": false, + "columns": ["run_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brain_sync_state": { + "name": "brain_sync_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "collector_id": { + "name": "collector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "watermark": { + "name": "watermark", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "backfill_cursor": { + "name": "backfill_cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "backfill_completed_at": { + "name": "backfill_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "brain_sync_state_collector_id_unique": { + "name": "brain_sync_state_collector_id_unique", + "nullsNotDistinct": false, + "columns": ["collector_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.compute_provider_usage": { + "name": "compute_provider_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_usage_id": { + "name": "provider_usage_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth_kind": { + "name": "auth_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "launch_mode": { + "name": "launch_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle_action": { + "name": "lifecycle_action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "measurement_source": { + "name": "measurement_source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "configured_vcpus": { + "name": "configured_vcpus", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "configured_cpu_cores": { + "name": "configured_cpu_cores", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "configured_memory_mib": { + "name": "configured_memory_mib", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "wall_clock_duration_ms": { + "name": "wall_clock_duration_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "active_cpu_duration_ms": { + "name": "active_cpu_duration_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "observed_memory_mib_milliseconds": { + "name": "observed_memory_mib_milliseconds", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "network_ingress_bytes": { + "name": "network_ingress_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "network_egress_bytes": { + "name": "network_egress_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "compute_provider_usage_provider_usage_id_unique": { + "name": "compute_provider_usage_provider_usage_id_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_usage_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "compute_provider_usage_run_id_idx": { + "name": "compute_provider_usage_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "compute_provider_usage_task_id_idx": { + "name": "compute_provider_usage_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "compute_provider_usage_created_at_idx": { + "name": "compute_provider_usage_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "compute_provider_usage_run_id_task_runs_id_fk": { + "name": "compute_provider_usage_run_id_task_runs_id_fk", + "tableFrom": "compute_provider_usage", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "compute_provider_usage_task_id_tasks_id_fk": { + "name": "compute_provider_usage_task_id_tasks_id_fk", + "tableFrom": "compute_provider_usage", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.compute_provider_usage_samples": { + "name": "compute_provider_usage_samples", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_usage_id": { + "name": "provider_usage_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sampled_at": { + "name": "sampled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "cpu_usage_ns_total": { + "name": "cpu_usage_ns_total", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "memory_usage_bytes": { + "name": "memory_usage_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "memory_peak_usage_bytes": { + "name": "memory_peak_usage_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "compute_provider_usage_samples_provider_usage_sampled_at_unique": { + "name": "compute_provider_usage_samples_provider_usage_sampled_at_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_usage_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sampled_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "compute_provider_usage_samples_run_id_idx": { + "name": "compute_provider_usage_samples_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "compute_provider_usage_samples_task_id_idx": { + "name": "compute_provider_usage_samples_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "compute_provider_usage_samples_created_at_idx": { + "name": "compute_provider_usage_samples_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "compute_provider_usage_samples_run_id_task_runs_id_fk": { + "name": "compute_provider_usage_samples_run_id_task_runs_id_fk", + "tableFrom": "compute_provider_usage_samples", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "compute_provider_usage_samples_task_id_tasks_id_fk": { + "name": "compute_provider_usage_samples_task_id_tasks_id_fk", + "tableFrom": "compute_provider_usage_samples", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_automations": { + "name": "custom_automations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "schedule_mode": { + "name": "schedule_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'off'" + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "all_repositories": { + "name": "all_repositories", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "execution_mode": { + "name": "execution_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'sandbox_task'" + }, + "target": { + "name": "target", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_succeeded_at": { + "name": "last_succeeded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_launched_task_id": { + "name": "last_launched_task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "launch_claimed_at": { + "name": "launch_claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_automations_name_unique_idx": { + "name": "custom_automations_name_unique_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_automations_enabled_idx": { + "name": "custom_automations_enabled_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_automations_environment_id_idx": { + "name": "custom_automations_environment_id_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_automations_environment_id_environments_id_fk": { + "name": "custom_automations_environment_id_environments_id_fk", + "tableFrom": "custom_automations", + "tableTo": "environments", + "columnsFrom": ["environment_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "custom_automations_created_by_user_id_users_id_fk": { + "name": "custom_automations_created_by_user_id_users_id_fk", + "tableFrom": "custom_automations", + "tableTo": "users", + "columnsFrom": ["created_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "custom_automations_last_launched_task_id_tasks_id_fk": { + "name": "custom_automations_last_launched_task_id_tasks_id_fk", + "tableFrom": "custom_automations", + "tableTo": "tasks", + "columnsFrom": ["last_launched_task_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_mcp_servers": { + "name": "custom_mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "headers": { + "name": "headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "stdio": { + "name": "stdio", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "disabled_tools": { + "name": "disabled_tools", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "manual_client_id": { + "name": "manual_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "manual_client_secret": { + "name": "manual_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_server_metadata": { + "name": "oauth_server_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "oauth_server_metadata_fetched_at": { + "name": "oauth_server_metadata_fetched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "oauth_resource_indicator_disabled": { + "name": "oauth_resource_indicator_disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "custom_mcp_servers_created_by_user_id_users_id_fk": { + "name": "custom_mcp_servers_created_by_user_id_users_id_fk", + "tableFrom": "custom_mcp_servers", + "tableTo": "users", + "columnsFrom": ["created_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "custom_mcp_servers_name_unique": { + "name": "custom_mcp_servers_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_mcp_enablements": { + "name": "deployment_mcp_enablements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "mcp_id": { + "name": "mcp_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enabled_by_user_id": { + "name": "enabled_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled_tools": { + "name": "disabled_tools", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tool_access_mode": { + "name": "tool_access_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "deployment_mcp_enablements_enabled_by_user_id_users_id_fk": { + "name": "deployment_mcp_enablements_enabled_by_user_id_users_id_fk", + "tableFrom": "deployment_mcp_enablements", + "tableTo": "users", + "columnsFrom": ["enabled_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "deployment_mcp_enablements_mcp_unique": { + "name": "deployment_mcp_enablements_mcp_unique", + "nullsNotDistinct": false, + "columns": ["mcp_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_secrets": { + "name": "deployment_secrets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deployment_secrets_name_unique": { + "name": "deployment_secrets_name_unique", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_settings": { + "name": "deployment_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "default": "'default'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "task_model_settings": { + "name": "task_model_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "workspace_routing_settings": { + "name": "workspace_routing_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "router_debug_provider": { + "name": "router_debug_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "router_debug_channel_id": { + "name": "router_debug_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "router_debug_disabled": { + "name": "router_debug_disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "router_debug_slack_channel_id": { + "name": "router_debug_slack_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "runtime_model_config": { + "name": "runtime_model_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "runtime_compute_config": { + "name": "runtime_compute_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "access_policy": { + "name": "access_policy", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "brain_enabled": { + "name": "brain_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "license_key": { + "name": "license_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "license_cloud_state": { + "name": "license_cloud_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "instance_analytics_id": { + "name": "instance_analytics_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latest_known_version": { + "name": "latest_known_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latest_version_checked_at": { + "name": "latest_version_checked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "setup_completed_at": { + "name": "setup_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "setup_new_state": { + "name": "setup_new_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "slack_onboarding_stage": { + "name": "slack_onboarding_stage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "manager_slack_channel_id": { + "name": "manager_slack_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "manager_discord_channel_id": { + "name": "manager_discord_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "global_agent_instructions": { + "name": "global_agent_instructions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time_zone": { + "name": "time_zone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time_zone_updated_at": { + "name": "time_zone_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "authorship_instructions": { + "name": "authorship_instructions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "compiled_authorship_rules": { + "name": "compiled_authorship_rules", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "compiled_authorship_issues": { + "name": "compiled_authorship_issues", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "compiled_authorship_at": { + "name": "compiled_authorship_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "style_guidance": { + "name": "style_guidance", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_summon_emoji": { + "name": "slack_summon_emoji", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_ack_emoji": { + "name": "slack_ack_emoji", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'eyes'" + }, + "slack_completion_emoji": { + "name": "slack_completion_emoji", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'white_check_mark'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.discord_gateway_sessions": { + "name": "discord_gateway_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resume_gateway_url": { + "name": "resume_gateway_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sequence": { + "name": "sequence", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "shard_count": { + "name": "shard_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_connected_at": { + "name": "last_connected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_heartbeat_ack_at": { + "name": "last_heartbeat_ack_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "disconnected_at": { + "name": "disconnected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.discord_installation_channels": { + "name": "discord_installation_channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "discord_installation_id": { + "name": "discord_installation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_name": { + "name": "channel_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "channel_type": { + "name": "channel_type", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_available": { + "name": "is_available", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "discord_installation_channels_installation_id_idx": { + "name": "discord_installation_channels_installation_id_idx", + "columns": [ + { + "expression": "discord_installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "discord_installation_channels_unique": { + "name": "discord_installation_channels_unique", + "columns": [ + { + "expression": "discord_installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "discord_installation_channels_discord_installation_id_discord_installations_id_fk": { + "name": "discord_installation_channels_discord_installation_id_discord_installations_id_fk", + "tableFrom": "discord_installation_channels", + "tableTo": "discord_installations", + "columnsFrom": ["discord_installation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.discord_installations": { + "name": "discord_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "guild_name": { + "name": "guild_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "application_id": { + "name": "application_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_user_id": { + "name": "bot_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "installed_by_user_id": { + "name": "installed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_channel_id": { + "name": "default_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_channel_name": { + "name": "default_channel_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_channel_type": { + "name": "default_channel_type", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "discord_installations_guild_id_unique": { + "name": "discord_installations_guild_id_unique", + "columns": [ + { + "expression": "guild_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "discord_installations_active_idx": { + "name": "discord_installations_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "discord_installations_default_channel_idx": { + "name": "discord_installations_default_channel_idx", + "columns": [ + { + "expression": "default_channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "discord_installations_installed_by_user_id_users_id_fk": { + "name": "discord_installations_installed_by_user_id_users_id_fk", + "tableFrom": "discord_installations", + "tableTo": "users", + "columnsFrom": ["installed_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.discord_user_mappings": { + "name": "discord_user_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "discord_user_id": { + "name": "discord_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "discord_username": { + "name": "discord_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "discord_global_name": { + "name": "discord_global_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "discord_dm_channel_id": { + "name": "discord_dm_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "discord_user_mappings_user_id_idx": { + "name": "discord_user_mappings_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "discord_user_mappings_discord_user_id_unique": { + "name": "discord_user_mappings_discord_user_id_unique", + "columns": [ + { + "expression": "discord_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "discord_user_mappings_user_id_users_id_fk": { + "name": "discord_user_mappings_user_id_users_id_fk", + "tableFrom": "discord_user_mappings", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environment_config_versions": { + "name": "environment_config_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "environment_config_versions_environment_id_idx": { + "name": "environment_config_versions_environment_id_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environment_config_versions_environment_version_unique": { + "name": "environment_config_versions_environment_version_unique", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "environment_config_versions_environment_id_environments_id_fk": { + "name": "environment_config_versions_environment_id_environments_id_fk", + "tableFrom": "environment_config_versions", + "tableTo": "environments", + "columnsFrom": ["environment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "environment_config_versions_created_by_user_id_users_id_fk": { + "name": "environment_config_versions_created_by_user_id_users_id_fk", + "tableFrom": "environment_config_versions", + "tableTo": "users", + "columnsFrom": ["created_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environment_repository_mappings": { + "name": "environment_repository_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "env_repo_mappings_env_id_idx": { + "name": "env_repo_mappings_env_id_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "env_repo_mappings_repo_id_idx": { + "name": "env_repo_mappings_repo_id_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "environment_repository_mappings_environment_id_environments_id_fk": { + "name": "environment_repository_mappings_environment_id_environments_id_fk", + "tableFrom": "environment_repository_mappings", + "tableTo": "environments", + "columnsFrom": ["environment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "environment_repository_mappings_repository_id_repositories_id_fk": { + "name": "environment_repository_mappings_repository_id_repositories_id_fk", + "tableFrom": "environment_repository_mappings", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "env_repo_mappings_unique": { + "name": "env_repo_mappings_unique", + "nullsNotDistinct": false, + "columns": ["environment_id", "repository_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environment_snapshots": { + "name": "environment_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "snapshot_created_at": { + "name": "snapshot_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "snapshot_expires_at": { + "name": "snapshot_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "snapshot_status": { + "name": "snapshot_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "environment_snapshots_environment_id_idx": { + "name": "environment_snapshots_environment_id_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environment_snapshots_env_provider_unique": { + "name": "environment_snapshots_env_provider_unique", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"environment_snapshots\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "environment_snapshots_environment_id_environments_id_fk": { + "name": "environment_snapshots_environment_id_environments_id_fk", + "tableFrom": "environment_snapshots", + "tableTo": "environments", + "columnsFrom": ["environment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environment_variables": { + "name": "environment_variables", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_updated_by_user_id": { + "name": "last_updated_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "environment_variables_user_id_idx": { + "name": "environment_variables_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environment_variables_name_unique": { + "name": "environment_variables_name_unique", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "environment_variables_user_id_users_id_fk": { + "name": "environment_variables_user_id_users_id_fk", + "tableFrom": "environment_variables", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "environment_variables_created_by_user_id_users_id_fk": { + "name": "environment_variables_created_by_user_id_users_id_fk", + "tableFrom": "environment_variables", + "tableTo": "users", + "columnsFrom": ["created_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "environment_variables_last_updated_by_user_id_users_id_fk": { + "name": "environment_variables_last_updated_by_user_id_users_id_fk", + "tableFrom": "environment_variables", + "tableTo": "users", + "columnsFrom": ["last_updated_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environments": { + "name": "environments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "is_eval": { + "name": "is_eval", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "declarative_source": { + "name": "declarative_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_verified": { + "name": "is_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verification_task_id": { + "name": "verification_task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "verification_error": { + "name": "verification_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "snapshot_created_at": { + "name": "snapshot_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "snapshot_expires_at": { + "name": "snapshot_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "snapshot_status": { + "name": "snapshot_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "environments_user_id_idx": { + "name": "environments_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environments_created_by_user_id_idx": { + "name": "environments_created_by_user_id_idx", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environments_snapshot_expires_at_idx": { + "name": "environments_snapshot_expires_at_idx", + "columns": [ + { + "expression": "snapshot_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environments_name_unique": { + "name": "environments_name_unique", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "environments_user_id_users_id_fk": { + "name": "environments_user_id_users_id_fk", + "tableFrom": "environments", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "environments_created_by_user_id_users_id_fk": { + "name": "environments_created_by_user_id_users_id_fk", + "tableFrom": "environments", + "tableTo": "users", + "columnsFrom": ["created_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.fast_agent_conversations": { + "name": "fast_agent_conversations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_reply_channel_id": { + "name": "current_reply_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_reply_thread_id": { + "name": "current_reply_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_reply_service_url": { + "name": "current_reply_service_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reply_target_verified": { + "name": "reply_target_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "compatibility_messages": { + "name": "compatibility_messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "opencode_session_id": { + "name": "opencode_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reasoning_effort": { + "name": "reasoning_effort", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title_edited_by_user_at": { + "name": "title_edited_by_user_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "llm_title_checkpoint": { + "name": "llm_title_checkpoint", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "legacy_conversation_ids": { + "name": "legacy_conversation_ids", + "type": "uuid[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::uuid[]" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "fast_agent_conversations_identity_unique": { + "name": "fast_agent_conversations_identity_unique", + "columns": [ + { + "expression": "surface", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "fast_agent_conversations_user_idx": { + "name": "fast_agent_conversations_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "fast_agent_conversations_legacy_ids_idx": { + "name": "fast_agent_conversations_legacy_ids_idx", + "columns": [ + { + "expression": "legacy_conversation_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "fast_agent_conversations_user_id_users_id_fk": { + "name": "fast_agent_conversations_user_id_users_id_fk", + "tableFrom": "fast_agent_conversations", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.fast_agent_memory_events": { + "name": "fast_agent_memory_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "memory": { + "name": "memory", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "fast_agent_memory_events_status_created_idx": { + "name": "fast_agent_memory_events_status_created_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "fast_agent_memory_events_conversation_id_fast_agent_conversations_id_fk": { + "name": "fast_agent_memory_events_conversation_id_fast_agent_conversations_id_fk", + "tableFrom": "fast_agent_memory_events", + "tableTo": "fast_agent_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "fast_agent_memory_events_conversation_unique": { + "name": "fast_agent_memory_events_conversation_unique", + "nullsNotDistinct": false, + "columns": ["conversation_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.fast_agent_messages": { + "name": "fast_agent_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "turn_seq": { + "name": "turn_seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "ts": { + "name": "ts", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_blocks": { + "name": "content_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "native_session_id": { + "name": "native_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "native_message_id": { + "name": "native_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "fast_agent_messages_conversation_event_unique": { + "name": "fast_agent_messages_conversation_event_unique", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "fast_agent_messages_conversation_order_idx": { + "name": "fast_agent_messages_conversation_order_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ts", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "turn_seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "fast_agent_messages_conversation_id_fast_agent_conversations_id_fk": { + "name": "fast_agent_messages_conversation_id_fast_agent_conversations_id_fk", + "tableFrom": "fast_agent_messages", + "tableTo": "fast_agent_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.fast_agent_parent_events": { + "name": "fast_agent_parent_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent": { + "name": "parent", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "event": { + "name": "event", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "retry_task_start_run_id": { + "name": "retry_task_start_run_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "discarded_at": { + "name": "discarded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "fast_agent_parent_events_pending_idx": { + "name": "fast_agent_parent_events_pending_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "delivered_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "discarded_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "fast_agent_parent_events_retry_run_idx": { + "name": "fast_agent_parent_events_retry_run_idx", + "columns": [ + { + "expression": "retry_task_start_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "fast_agent_parent_events_conversation_id_fast_agent_conversations_id_fk": { + "name": "fast_agent_parent_events_conversation_id_fast_agent_conversations_id_fk", + "tableFrom": "fast_agent_parent_events", + "tableTo": "fast_agent_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "fast_agent_parent_events_retry_task_start_run_id_task_runs_id_fk": { + "name": "fast_agent_parent_events_retry_task_start_run_id_task_runs_id_fk", + "tableFrom": "fast_agent_parent_events", + "tableTo": "task_runs", + "columnsFrom": ["retry_task_start_run_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "fast_agent_parent_events_event_key_unique": { + "name": "fast_agent_parent_events_event_key_unique", + "nullsNotDistinct": false, + "columns": ["event_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.fast_agent_pr_feedback_deliveries": { + "name": "fast_agent_pr_feedback_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "feedback_id": { + "name": "feedback_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_token": { + "name": "lease_token", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "fast_agent_pr_feedback_deliveries_identity_unique": { + "name": "fast_agent_pr_feedback_deliveries_identity_unique", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "feedback_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "fast_agent_pr_feedback_deliveries_task_idx": { + "name": "fast_agent_pr_feedback_deliveries_task_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "fast_agent_pr_feedback_deliveries_conversation_id_fast_agent_conversations_id_fk": { + "name": "fast_agent_pr_feedback_deliveries_conversation_id_fast_agent_conversations_id_fk", + "tableFrom": "fast_agent_pr_feedback_deliveries", + "tableTo": "fast_agent_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "fast_agent_pr_feedback_deliveries_task_id_tasks_id_fk": { + "name": "fast_agent_pr_feedback_deliveries_task_id_tasks_id_fk", + "tableFrom": "fast_agent_pr_feedback_deliveries", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.fast_agent_provider_messages": { + "name": "fast_agent_provider_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "fast_agent_provider_messages_route_unique": { + "name": "fast_agent_provider_messages_route_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "fast_agent_provider_messages_conversation_idx": { + "name": "fast_agent_provider_messages_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "fast_agent_provider_messages_thread_idx": { + "name": "fast_agent_provider_messages_thread_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "fast_agent_provider_messages_conversation_id_fast_agent_conversations_id_fk": { + "name": "fast_agent_provider_messages_conversation_id_fast_agent_conversations_id_fk", + "tableFrom": "fast_agent_provider_messages", + "tableTo": "fast_agent_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "fast_agent_provider_messages_provider_v3_check": { + "name": "fast_agent_provider_messages_provider_v3_check", + "value": "\"fast_agent_provider_messages\".\"provider\" in ('discord', 'slack', 'teams', 'telegram', 'agentmail')" + } + }, + "isRLSEnabled": false + }, + "public.github_installations": { + "name": "github_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installation_id": { + "name": "installation_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "app_id": { + "name": "app_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "account_login": { + "name": "account_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_type": { + "name": "account_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "installed_by_user_id": { + "name": "installed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "members_count": { + "name": "members_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_installations_account_login_idx": { + "name": "github_installations_account_login_idx", + "columns": [ + { + "expression": "account_login", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_installations_deployment_installation_unique": { + "name": "github_installations_deployment_installation_unique", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_installations_user_id_users_id_fk": { + "name": "github_installations_user_id_users_id_fk", + "tableFrom": "github_installations", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_installations_installed_by_user_id_users_id_fk": { + "name": "github_installations_installed_by_user_id_users_id_fk", + "tableFrom": "github_installations", + "tableTo": "users", + "columnsFrom": ["installed_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_pending_installations": { + "name": "github_pending_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_by_user_id": { + "name": "requested_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app_id": { + "name": "app_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_pending_installations_requested_by_user_id_idx": { + "name": "github_pending_installations_requested_by_user_id_idx", + "columns": [ + { + "expression": "requested_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_pending_installations_user_id_users_id_fk": { + "name": "github_pending_installations_user_id_users_id_fk", + "tableFrom": "github_pending_installations", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_pending_installations_requested_by_user_id_users_id_fk": { + "name": "github_pending_installations_requested_by_user_id_users_id_fk", + "tableFrom": "github_pending_installations", + "tableTo": "users", + "columnsFrom": ["requested_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_user_mappings": { + "name": "github_user_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "github_login": { + "name": "github_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_user_id": { + "name": "github_user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_expires_at": { + "name": "token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_user_mappings_github_login_idx": { + "name": "github_user_mappings_github_login_idx", + "columns": [ + { + "expression": "github_login", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_user_mappings_user_id_idx": { + "name": "github_user_mappings_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_user_mappings_user_id_users_id_fk": { + "name": "github_user_mappings_user_id_users_id_fk", + "tableFrom": "github_user_mappings", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_user_mappings_unique": { + "name": "github_user_mappings_unique", + "nullsNotDistinct": false, + "columns": ["github_user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invites": { + "name": "invites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "invited_by_user_id": { + "name": "invited_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "max_uses": { + "name": "max_uses", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "used_count": { + "name": "used_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invites_token_hash_unique": { + "name": "invites_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invites_created_at_idx": { + "name": "invites_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invites_invited_by_user_id_users_id_fk": { + "name": "invites_invited_by_user_id_users_id_fk", + "tableFrom": "invites", + "tableTo": "users", + "columnsFrom": ["invited_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.license_usage_observations": { + "name": "license_usage_observations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "observed_at": { + "name": "observed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "active_users": { + "name": "active_users", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempted_at": { + "name": "last_attempted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "license_usage_observations_pending_idx": { + "name": "license_usage_observations_pending_idx", + "columns": [ + { + "expression": "delivered_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "observed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.linear_pending_selections": { + "name": "linear_pending_selections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "linear_organization_id": { + "name": "linear_organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "step": { + "name": "step", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'awaiting_workspace'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "selected_repo": { + "name": "selected_repo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_options": { + "name": "workspace_options", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "linear_pending_selections_expires_at_idx": { + "name": "linear_pending_selections_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "linear_pending_selections_step_idx": { + "name": "linear_pending_selections_step_idx", + "columns": [ + { + "expression": "step", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "linear_pending_selections_user_id_users_id_fk": { + "name": "linear_pending_selections_user_id_users_id_fk", + "tableFrom": "linear_pending_selections", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "linear_pending_selections_session_id_unique": { + "name": "linear_pending_selections_session_id_unique", + "nullsNotDistinct": false, + "columns": ["session_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_inference_usage_events": { + "name": "task_inference_usage_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'opencode'" + }, + "usage_type": { + "name": "usage_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'inference'" + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "harness_session_id": { + "name": "harness_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reasoning_tokens": { + "name": "reasoning_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_read_tokens": { + "name": "cache_read_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_write_tokens": { + "name": "cache_write_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens": { + "name": "total_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "context_tokens": { + "name": "context_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_micro_usd": { + "name": "cost_micro_usd", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_source": { + "name": "cost_source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pricing_metadata": { + "name": "pricing_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "message_created_at": { + "name": "message_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "message_completed_at": { + "name": "message_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_inference_usage_events_session_message_unique": { + "name": "task_inference_usage_events_session_message_unique", + "columns": [ + { + "expression": "harness_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_inference_usage_events_event_key_unique": { + "name": "task_inference_usage_events_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_inference_usage_events_task_id_idx": { + "name": "task_inference_usage_events_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_inference_usage_events_run_id_idx": { + "name": "task_inference_usage_events_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_inference_usage_events_user_id_idx": { + "name": "task_inference_usage_events_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_inference_usage_events_environment_id_idx": { + "name": "task_inference_usage_events_environment_id_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_inference_usage_events_session_id_idx": { + "name": "task_inference_usage_events_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_inference_usage_events_provider_model_idx": { + "name": "task_inference_usage_events_provider_model_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "model_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_inference_usage_events_created_at_idx": { + "name": "task_inference_usage_events_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_inference_usage_events_task_id_tasks_id_fk": { + "name": "task_inference_usage_events_task_id_tasks_id_fk", + "tableFrom": "task_inference_usage_events", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_inference_usage_events_run_id_task_runs_id_fk": { + "name": "task_inference_usage_events_run_id_task_runs_id_fk", + "tableFrom": "task_inference_usage_events", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "task_inference_usage_events_user_id_users_id_fk": { + "name": "task_inference_usage_events_user_id_users_id_fk", + "tableFrom": "task_inference_usage_events", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "task_inference_usage_events_environment_id_environments_id_fk": { + "name": "task_inference_usage_events_environment_id_environments_id_fk", + "tableFrom": "task_inference_usage_events", + "tableTo": "environments", + "columnsFrom": ["environment_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "task_inference_usage_events_session_id_sessions_id_fk": { + "name": "task_inference_usage_events_session_id_sessions_id_fk", + "tableFrom": "task_inference_usage_events", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_connections": { + "name": "mcp_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_id": { + "name": "mcp_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_role": { + "name": "connection_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "auth_config": { + "name": "auth_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_status": { + "name": "auth_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_expires_at": { + "name": "token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_connections_user_id_idx": { + "name": "mcp_connections_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_connections_role_idx": { + "name": "mcp_connections_role_idx", + "columns": [ + { + "expression": "mcp_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection_role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_connections_user_id_users_id_fk": { + "name": "mcp_connections_user_id_users_id_fk", + "tableFrom": "mcp_connections", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mcp_connections_user_mcp_id_unique": { + "name": "mcp_connections_user_mcp_id_unique", + "nullsNotDistinct": true, + "columns": ["user_id", "mcp_id", "connection_role"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_oauth_replays": { + "name": "mcp_oauth_replays", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_id": { + "name": "mcp_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_role": { + "name": "connection_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "redirect_to": { + "name": "redirect_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_oauth_replays_connection_id_idx": { + "name": "mcp_oauth_replays_connection_id_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_oauth_replays_user_id_idx": { + "name": "mcp_oauth_replays_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_oauth_replays_expires_at_idx": { + "name": "mcp_oauth_replays_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_oauth_replays_connection_id_mcp_connections_id_fk": { + "name": "mcp_oauth_replays_connection_id_mcp_connections_id_fk", + "tableFrom": "mcp_oauth_replays", + "tableTo": "mcp_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_oauth_replays_user_id_users_id_fk": { + "name": "mcp_oauth_replays_user_id_users_id_fk", + "tableFrom": "mcp_oauth_replays", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mcp_oauth_replays_token_unique": { + "name": "mcp_oauth_replays_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.microsoft_auth_user_mappings": { + "name": "microsoft_auth_user_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "auth_account_id": { + "name": "auth_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "microsoft_tenant_id": { + "name": "microsoft_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "microsoft_aad_object_id": { + "name": "microsoft_aad_object_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "microsoft_auth_user_mappings_user_id_idx": { + "name": "microsoft_auth_user_mappings_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "microsoft_auth_user_mappings_account_id_idx": { + "name": "microsoft_auth_user_mappings_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "microsoft_auth_user_mappings_auth_account_idx": { + "name": "microsoft_auth_user_mappings_auth_account_idx", + "columns": [ + { + "expression": "auth_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "microsoft_auth_user_mappings_aad_object_unique": { + "name": "microsoft_auth_user_mappings_aad_object_unique", + "columns": [ + { + "expression": "microsoft_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "microsoft_aad_object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "microsoft_auth_user_mappings_auth_account_id_auth_accounts_id_fk": { + "name": "microsoft_auth_user_mappings_auth_account_id_auth_accounts_id_fk", + "tableFrom": "microsoft_auth_user_mappings", + "tableTo": "auth_accounts", + "columnsFrom": ["auth_account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "microsoft_auth_user_mappings_user_id_auth_users_id_fk": { + "name": "microsoft_auth_user_mappings_user_id_auth_users_id_fk", + "tableFrom": "microsoft_auth_user_mappings", + "tableTo": "auth_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notion_directory_users": { + "name": "notion_directory_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "notion_user_id": { + "name": "notion_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_deleted": { + "name": "is_deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "notion_directory_users_unique": { + "name": "notion_directory_users_unique", + "nullsNotDistinct": false, + "columns": ["notion_user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_state": { + "name": "oauth_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "replay_token": { + "name": "replay_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "oauth_state_connection_id_idx": { + "name": "oauth_state_connection_id_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_state_replay_token_idx": { + "name": "oauth_state_replay_token_idx", + "columns": [ + { + "expression": "replay_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_state_expires_at_idx": { + "name": "oauth_state_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_state_connection_id_mcp_connections_id_fk": { + "name": "oauth_state_connection_id_mcp_connections_id_fk", + "tableFrom": "oauth_state", + "tableTo": "mcp_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pr_review_auto_preferences": { + "name": "pr_review_auto_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "source_control_provider": { + "name": "source_control_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "repository_identity_key": { + "name": "repository_identity_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "enabled_by_user_id": { + "name": "enabled_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled_at": { + "name": "enabled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "source_task_id": { + "name": "source_task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_destination_key": { + "name": "source_destination_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pr_review_auto_preferences_identity_unique": { + "name": "pr_review_auto_preferences_identity_unique", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repository_identity_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pr_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pr_review_auto_preferences_repository_idx": { + "name": "pr_review_auto_preferences_repository_idx", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repository", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pr_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pr_review_auto_preferences_repository_id_repositories_id_fk": { + "name": "pr_review_auto_preferences_repository_id_repositories_id_fk", + "tableFrom": "pr_review_auto_preferences", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "pr_review_auto_preferences_enabled_by_user_id_users_id_fk": { + "name": "pr_review_auto_preferences_enabled_by_user_id_users_id_fk", + "tableFrom": "pr_review_auto_preferences", + "tableTo": "users", + "columnsFrom": ["enabled_by_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pr_review_auto_preferences_source_task_id_tasks_id_fk": { + "name": "pr_review_auto_preferences_source_task_id_tasks_id_fk", + "tableFrom": "pr_review_auto_preferences", + "tableTo": "tasks", + "columnsFrom": ["source_task_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pr_review_cycles": { + "name": "pr_review_cycles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "source_control_provider": { + "name": "source_control_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "review_head_sha": { + "name": "review_head_sha", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cycle_id": { + "name": "cycle_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "pr_review_cycles_source_unique": { + "name": "pr_review_cycles_source_unique", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repository", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pr_number", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "review_head_sha", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pr_review_event_deliveries": { + "name": "pr_review_event_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "due_at": { + "name": "due_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "deferrals": { + "name": "deferrals", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lease_token": { + "name": "lease_token", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pr_review_event_deliveries_event_task_unique": { + "name": "pr_review_event_deliveries_event_task_unique", + "columns": [ + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pr_review_event_deliveries_due_idx": { + "name": "pr_review_event_deliveries_due_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "due_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lease_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pr_review_event_deliveries_event_id_pr_review_events_id_fk": { + "name": "pr_review_event_deliveries_event_id_pr_review_events_id_fk", + "tableFrom": "pr_review_event_deliveries", + "tableTo": "pr_review_events", + "columnsFrom": ["event_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pr_review_event_deliveries_task_id_tasks_id_fk": { + "name": "pr_review_event_deliveries_task_id_tasks_id_fk", + "tableFrom": "pr_review_event_deliveries", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "pr_review_event_deliveries_status_check": { + "name": "pr_review_event_deliveries_status_check", + "value": "\"pr_review_event_deliveries\".\"status\" in ('pending', 'processing', 'delivered', 'suppressed')" + } + }, + "isRLSEnabled": false + }, + "public.pr_review_events": { + "name": "pr_review_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_control_provider": { + "name": "source_control_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event": { + "name": "event", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "batch_kind": { + "name": "batch_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "batch_id": { + "name": "batch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "review_head_sha": { + "name": "review_head_sha", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sealed_at": { + "name": "sealed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "superseded": { + "name": "superseded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "observed_at": { + "name": "observed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pr_review_events_source_unique": { + "name": "pr_review_events_source_unique", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repository", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pr_number", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pr_review_events_pr_idx": { + "name": "pr_review_events_pr_idx", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repository", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pr_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "pr_review_events_batch_kind_check": { + "name": "pr_review_events_batch_kind_check", + "value": "\"pr_review_events\".\"batch_kind\" in ('human', 'roomote')" + } + }, + "isRLSEnabled": false + }, + "public.pr_review_notification_deliveries": { + "name": "pr_review_notification_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "notification_unit_id": { + "name": "notification_unit_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "destination_kind": { + "name": "destination_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "destination_key": { + "name": "destination_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "due_at": { + "name": "due_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "deferrals": { + "name": "deferrals", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lease_token": { + "name": "lease_token", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "route_provider": { + "name": "route_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "route_workspace_id": { + "name": "route_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "route_channel_id": { + "name": "route_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "route_thread_id": { + "name": "route_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "follow_up_prompt": { + "name": "follow_up_prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_task_id": { + "name": "target_task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "acting_user_id": { + "name": "acting_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_message_id": { + "name": "provider_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action_claimed_at": { + "name": "action_claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "dispatch_key": { + "name": "dispatch_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dispatched_run_id": { + "name": "dispatched_run_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pr_review_notification_deliveries_destination_unique": { + "name": "pr_review_notification_deliveries_destination_unique", + "columns": [ + { + "expression": "notification_unit_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "destination_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "destination_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pr_review_notification_deliveries_dispatch_key_unique": { + "name": "pr_review_notification_deliveries_dispatch_key_unique", + "columns": [ + { + "expression": "dispatch_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pr_review_notification_deliveries_due_idx": { + "name": "pr_review_notification_deliveries_due_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "due_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lease_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pr_review_notification_deliveries_destination_idx": { + "name": "pr_review_notification_deliveries_destination_idx", + "columns": [ + { + "expression": "destination_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "destination_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pr_review_notification_deliveries_notification_unit_id_pr_review_notification_units_id_fk": { + "name": "pr_review_notification_deliveries_notification_unit_id_pr_review_notification_units_id_fk", + "tableFrom": "pr_review_notification_deliveries", + "tableTo": "pr_review_notification_units", + "columnsFrom": ["notification_unit_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pr_review_notification_deliveries_task_id_tasks_id_fk": { + "name": "pr_review_notification_deliveries_task_id_tasks_id_fk", + "tableFrom": "pr_review_notification_deliveries", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "pr_review_notification_deliveries_target_task_id_tasks_id_fk": { + "name": "pr_review_notification_deliveries_target_task_id_tasks_id_fk", + "tableFrom": "pr_review_notification_deliveries", + "tableTo": "tasks", + "columnsFrom": ["target_task_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "pr_review_notification_deliveries_acting_user_id_users_id_fk": { + "name": "pr_review_notification_deliveries_acting_user_id_users_id_fk", + "tableFrom": "pr_review_notification_deliveries", + "tableTo": "users", + "columnsFrom": ["acting_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "pr_review_notification_deliveries_destination_kind_check": { + "name": "pr_review_notification_deliveries_destination_kind_check", + "value": "\"pr_review_notification_deliveries\".\"destination_kind\" in ('fast_conversation', 'task')" + }, + "pr_review_notification_deliveries_status_check": { + "name": "pr_review_notification_deliveries_status_check", + "value": "\"pr_review_notification_deliveries\".\"status\" in ('pending', 'claimed', 'prepared', 'prompt_posting', 'awaiting_user_action', 'auto_dispatch_pending', 'completed', 'suppressed', 'dismissed')" + } + }, + "isRLSEnabled": false + }, + "public.pr_review_notification_unit_events": { + "name": "pr_review_notification_unit_events", + "schema": "", + "columns": { + "unit_id": { + "name": "unit_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "attached_at": { + "name": "attached_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pr_review_notification_unit_events_event_unique": { + "name": "pr_review_notification_unit_events_event_unique", + "columns": [ + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pr_review_notification_unit_events_unit_id_pr_review_notification_units_id_fk": { + "name": "pr_review_notification_unit_events_unit_id_pr_review_notification_units_id_fk", + "tableFrom": "pr_review_notification_unit_events", + "tableTo": "pr_review_notification_units", + "columnsFrom": ["unit_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pr_review_notification_unit_events_event_id_pr_review_events_id_fk": { + "name": "pr_review_notification_unit_events_event_id_pr_review_events_id_fk", + "tableFrom": "pr_review_notification_unit_events", + "tableTo": "pr_review_events", + "columnsFrom": ["event_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "pr_review_notification_unit_events_pk": { + "name": "pr_review_notification_unit_events_pk", + "columns": ["unit_id", "event_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pr_review_notification_units": { + "name": "pr_review_notification_units", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "source_control_provider": { + "name": "source_control_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "repository_identity_key": { + "name": "repository_identity_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "head_identity_key": { + "name": "head_identity_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "episode_kind": { + "name": "episode_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "episode_id": { + "name": "episode_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "due_at": { + "name": "due_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "first_observed_at": { + "name": "first_observed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_observed_at": { + "name": "last_observed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "sealed_at": { + "name": "sealed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pr_review_notification_units_identity_unique": { + "name": "pr_review_notification_units_identity_unique", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repository_identity_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pr_number", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "head_identity_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "episode_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pr_review_notification_units_open_head_idx": { + "name": "pr_review_notification_units_open_head_idx", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repository", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pr_number", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "head_sha", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sealed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pr_review_notification_units_repository_id_repositories_id_fk": { + "name": "pr_review_notification_units_repository_id_repositories_id_fk", + "tableFrom": "pr_review_notification_units", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "pr_review_notification_units_episode_kind_check": { + "name": "pr_review_notification_units_episode_kind_check", + "value": "\"pr_review_notification_units\".\"episode_kind\" in ('roomote_cycle', 'human', 'automated', 'ci')" + } + }, + "isRLSEnabled": false + }, + "public.pull_request_facts": { + "name": "pull_request_facts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "repository_full_name": { + "name": "repository_full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_control_provider": { + "name": "source_control_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "external_pull_request_id": { + "name": "external_pull_request_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "html_url": { + "name": "html_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_login": { + "name": "author_login", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "labels": { + "name": "labels", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "changed_files": { + "name": "changed_files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "changed_file_count": { + "name": "changed_file_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "files_capped": { + "name": "files_capped", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "reviews_capped": { + "name": "reviews_capped", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "additions": { + "name": "additions", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deletions": { + "name": "deletions", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "reviews": { + "name": "reviews", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "enriched_at": { + "name": "enriched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enriched_for_updated_at": { + "name": "enriched_for_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enrichment_failed_at": { + "name": "enrichment_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at_remote": { + "name": "created_at_remote", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at_remote": { + "name": "updated_at_remote", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "closed_at_remote": { + "name": "closed_at_remote", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "merged_at_remote": { + "name": "merged_at_remote", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "synced_at": { + "name": "synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pull_request_facts_deployment_repo_pr_unique": { + "name": "pull_request_facts_deployment_repo_pr_unique", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pr_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pull_request_facts_deployment_created_idx": { + "name": "pull_request_facts_deployment_created_idx", + "columns": [ + { + "expression": "created_at_remote", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pull_request_facts_deployment_repo_created_idx": { + "name": "pull_request_facts_deployment_repo_created_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_remote", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pull_request_facts_deployment_state_created_idx": { + "name": "pull_request_facts_deployment_state_created_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_remote", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pull_request_facts_deployment_author_created_idx": { + "name": "pull_request_facts_deployment_author_created_idx", + "columns": [ + { + "expression": "author_login", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_remote", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pull_request_facts_deployment_updated_idx": { + "name": "pull_request_facts_deployment_updated_idx", + "columns": [ + { + "expression": "updated_at_remote", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pull_request_facts_repository_id_repositories_id_fk": { + "name": "pull_request_facts_repository_id_repositories_id_fk", + "tableFrom": "pull_request_facts", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "pull_request_facts_source_control_provider_check": { + "name": "pull_request_facts_source_control_provider_check", + "value": "\"pull_request_facts\".\"source_control_provider\" in ('github', 'gitlab', 'gitea', 'ado', 'bitbucket')" + } + }, + "isRLSEnabled": false + }, + "public.pull_request_sync_states": { + "name": "pull_request_sync_states", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "last_incremental_updated_at": { + "name": "last_incremental_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "backfill_completed_at": { + "name": "backfill_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cooldown_until": { + "name": "cooldown_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_successful_sync_at": { + "name": "last_successful_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_attempted_sync_at": { + "name": "last_attempted_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error_at": { + "name": "last_error_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error_message": { + "name": "last_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pull_request_sync_states_repo_unique": { + "name": "pull_request_sync_states_repo_unique", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pull_request_sync_states_deployment_updated_idx": { + "name": "pull_request_sync_states_deployment_updated_idx", + "columns": [ + { + "expression": "last_successful_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pull_request_sync_states_cooldown_idx": { + "name": "pull_request_sync_states_cooldown_idx", + "columns": [ + { + "expression": "cooldown_until", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pull_request_sync_states_repository_id_repositories_id_fk": { + "name": "pull_request_sync_states_repository_id_repositories_id_fk", + "tableFrom": "pull_request_sync_states", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.repositories": { + "name": "repositories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "source_control_provider": { + "name": "source_control_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "installation_id": { + "name": "installation_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_repo_id": { + "name": "github_repo_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "external_repo_id": { + "name": "external_repo_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "private": { + "name": "private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "clone_url": { + "name": "clone_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "html_url": { + "name": "html_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "linked_by_user_id": { + "name": "linked_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "repositories_source_control_provider_idx": { + "name": "repositories_source_control_provider_idx", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repositories_installation_id_idx": { + "name": "repositories_installation_id_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repositories_full_name_idx": { + "name": "repositories_full_name_idx", + "columns": [ + { + "expression": "full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repositories_provider_host_full_name_idx": { + "name": "repositories_provider_host_full_name_idx", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "host", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repositories_deployment_active_installation_idx": { + "name": "repositories_deployment_active_installation_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repositories_deployment_github_repo_unique": { + "name": "repositories_deployment_github_repo_unique", + "columns": [ + { + "expression": "github_repo_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repositories_provider_host_external_repo_unique": { + "name": "repositories_provider_host_external_repo_unique", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"host\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "external_repo_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repositories_provider_host_full_name_unique": { + "name": "repositories_provider_host_full_name_unique", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"host\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "repositories_installation_id_github_installations_id_fk": { + "name": "repositories_installation_id_github_installations_id_fk", + "tableFrom": "repositories", + "tableTo": "github_installations", + "columnsFrom": ["installation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "repositories_user_id_users_id_fk": { + "name": "repositories_user_id_users_id_fk", + "tableFrom": "repositories", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "repositories_linked_by_user_id_users_id_fk": { + "name": "repositories_linked_by_user_id_users_id_fk", + "tableFrom": "repositories", + "tableTo": "users", + "columnsFrom": ["linked_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "repositories_source_control_provider_check": { + "name": "repositories_source_control_provider_check", + "value": "\"repositories\".\"source_control_provider\" in ('github', 'gitlab', 'gitea', 'ado', 'bitbucket')" + }, + "repositories_github_shape_check": { + "name": "repositories_github_shape_check", + "value": "\"repositories\".\"source_control_provider\" != 'github' OR (\"repositories\".\"installation_id\" IS NOT NULL AND \"repositories\".\"github_repo_id\" IS NOT NULL)" + }, + "repositories_gitlab_shape_check": { + "name": "repositories_gitlab_shape_check", + "value": "\"repositories\".\"source_control_provider\" != 'gitlab' OR \"repositories\".\"external_repo_id\" IS NOT NULL" + }, + "repositories_gitea_shape_check": { + "name": "repositories_gitea_shape_check", + "value": "\"repositories\".\"source_control_provider\" != 'gitea' OR \"repositories\".\"external_repo_id\" IS NOT NULL" + }, + "repositories_ado_shape_check": { + "name": "repositories_ado_shape_check", + "value": "\"repositories\".\"source_control_provider\" != 'ado' OR \"repositories\".\"external_repo_id\" IS NOT NULL" + }, + "repositories_bitbucket_shape_check": { + "name": "repositories_bitbucket_shape_check", + "value": "\"repositories\".\"source_control_provider\" != 'bitbucket' OR \"repositories\".\"external_repo_id\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.repository_automation_signals": { + "name": "repository_automation_signals", + "schema": "", + "columns": { + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "signals_version": { + "name": "signals_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "collected_at": { + "name": "collected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "partial": { + "name": "partial", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "repository_automation_signals_collected_idx": { + "name": "repository_automation_signals_collected_idx", + "columns": [ + { + "expression": "collected_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "repository_automation_signals_repository_id_repositories_id_fk": { + "name": "repository_automation_signals_repository_id_repositories_id_fk", + "tableFrom": "repository_automation_signals", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "repository_automation_signals_repository_id_signals_version_pk": { + "name": "repository_automation_signals_repository_id_signals_version_pk", + "columns": ["repository_id", "signals_version"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandbox_oidc_targets": { + "name": "sandbox_oidc_targets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "compute_provider": { + "name": "compute_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "compute_provider_id": { + "name": "compute_provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_kind": { + "name": "target_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_file": { + "name": "token_file", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "aws_role_arn": { + "name": "aws_role_arn", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aws_region": { + "name": "aws_region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_at": { + "name": "refresh_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sandbox_oidc_targets_environment_id_idx": { + "name": "sandbox_oidc_targets_environment_id_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_oidc_targets_run_id_idx": { + "name": "sandbox_oidc_targets_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_oidc_targets_refresh_at_idx": { + "name": "sandbox_oidc_targets_refresh_at_idx", + "columns": [ + { + "expression": "refresh_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_oidc_targets_provider_target_file_unique": { + "name": "sandbox_oidc_targets_provider_target_file_unique", + "columns": [ + { + "expression": "compute_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "compute_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "token_file", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sandbox_oidc_targets_environment_id_environments_id_fk": { + "name": "sandbox_oidc_targets_environment_id_environments_id_fk", + "tableFrom": "sandbox_oidc_targets", + "tableTo": "environments", + "columnsFrom": ["environment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sandbox_oidc_targets_run_id_task_runs_id_fk": { + "name": "sandbox_oidc_targets_run_id_task_runs_id_fk", + "tableFrom": "sandbox_oidc_targets", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "sandbox_oidc_targets_owner_required": { + "name": "sandbox_oidc_targets_owner_required", + "value": "run_id IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.session_backfill_state": { + "name": "session_backfill_state", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'fast_conversations'" + }, + "cursor_created_at": { + "name": "cursor_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cursor_id": { + "name": "cursor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "session_backfill_state_phase_check": { + "name": "session_backfill_state_phase_check", + "value": "\"session_backfill_state\".\"phase\" in ('fast_conversations', 'fast_tasks', 'tasks', 'participants')" + }, + "session_backfill_state_cursor_shape_check": { + "name": "session_backfill_state_cursor_shape_check", + "value": "(\"session_backfill_state\".\"cursor_created_at\" IS NULL) = (\"session_backfill_state\".\"cursor_id\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.session_participants": { + "name": "session_participants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "last_read_event_at": { + "name": "last_read_event_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "last_read_event_id": { + "name": "last_read_event_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_notified_event_at": { + "name": "last_notified_event_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "last_notified_event_id": { + "name": "last_notified_event_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "session_participants_session_user_unique": { + "name": "session_participants_session_user_unique", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_participants_user_id_idx": { + "name": "session_participants_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_participants_session_id_sessions_id_fk": { + "name": "session_participants_session_id_sessions_id_fk", + "tableFrom": "session_participants", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_participants_user_id_users_id_fk": { + "name": "session_participants_user_id_users_id_fk", + "tableFrom": "session_participants", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "session_participants_role_check": { + "name": "session_participants_role_check", + "value": "\"session_participants\".\"role\" in ('owner', 'member')" + } + }, + "isRLSEnabled": false + }, + "public.session_pins": { + "name": "session_pins", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "session_pins_user_session_unique": { + "name": "session_pins_user_session_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_pins_user_updated_at_idx": { + "name": "session_pins_user_updated_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_pins_session_id_idx": { + "name": "session_pins_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_pins_session_id_sessions_id_fk": { + "name": "session_pins_session_id_sessions_id_fk", + "tableFrom": "session_pins", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_pins_user_id_users_id_fk": { + "name": "session_pins_user_id_users_id_fk", + "tableFrom": "session_pins", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session_tasks": { + "name": "session_tasks", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attached_at": { + "name": "attached_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_tasks_task_id_unique": { + "name": "session_tasks_task_id_unique", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_tasks_session_attached_at_idx": { + "name": "session_tasks_session_attached_at_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "attached_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_tasks_session_id_sessions_id_fk": { + "name": "session_tasks_session_id_sessions_id_fk", + "tableFrom": "session_tasks", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_tasks_task_id_tasks_id_fk": { + "name": "session_tasks_task_id_tasks_id_fk", + "tableFrom": "session_tasks", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "session_tasks_session_id_task_id_pk": { + "name": "session_tasks_session_id_task_id_pk", + "columns": ["session_id", "task_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "session_tasks_origin_check": { + "name": "session_tasks_origin_check", + "value": "\"session_tasks\".\"origin\" in ('direct_launch', 'fast_delegation', 'backfill', 'follow_up')" + } + }, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title_edited_by_user_at": { + "name": "title_edited_by_user_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "llm_title_checkpoint": { + "name": "llm_title_checkpoint", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "owner_kind": { + "name": "owner_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_automation": { + "name": "owner_automation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_surface": { + "name": "source_surface", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_trigger": { + "name": "source_trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fast_conversation_id": { + "name": "fast_conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'visible'" + }, + "activity_at": { + "name": "activity_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "cached_status": { + "name": "cached_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "responding_until": { + "name": "responding_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sessions_visibility_activity_at_idx": { + "name": "sessions_visibility_activity_at_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_owner_user_id_idx": { + "name": "sessions_owner_user_id_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_fast_conversation_id_unique": { + "name": "sessions_fast_conversation_id_unique", + "columns": [ + { + "expression": "fast_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"sessions\".\"fast_conversation_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_owner_user_id_users_id_fk": { + "name": "sessions_owner_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": ["owner_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "sessions_owner_automation_automations_key_fk": { + "name": "sessions_owner_automation_automations_key_fk", + "tableFrom": "sessions", + "tableTo": "automations", + "columnsFrom": ["owner_automation"], + "columnsTo": ["key"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "sessions_fast_conversation_id_fast_agent_conversations_id_fk": { + "name": "sessions_fast_conversation_id_fast_agent_conversations_id_fk", + "tableFrom": "sessions", + "tableTo": "fast_agent_conversations", + "columnsFrom": ["fast_conversation_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "sessions_owner_shape_check": { + "name": "sessions_owner_shape_check", + "value": "(\"sessions\".\"owner_kind\" = 'user' AND \"sessions\".\"owner_automation\" IS NULL) OR (\"sessions\".\"owner_kind\" = 'automation' AND \"sessions\".\"owner_user_id\" IS NULL) OR (\"sessions\".\"owner_kind\" = 'system' AND \"sessions\".\"owner_user_id\" IS NULL AND \"sessions\".\"owner_automation\" IS NULL)" + }, + "sessions_owner_kind_check": { + "name": "sessions_owner_kind_check", + "value": "\"sessions\".\"owner_kind\" in ('user', 'automation', 'system')" + }, + "sessions_source_surface_check": { + "name": "sessions_source_surface_check", + "value": "\"sessions\".\"source_surface\" in ('web', 'api', 'slack', 'teams', 'telegram', 'discord', 'agentmail', 'linear', 'github', 'gitlab', 'gitea', 'ado', 'bitbucket', 'system', 'automation')" + }, + "sessions_source_trigger_check": { + "name": "sessions_source_trigger_check", + "value": "\"sessions\".\"source_trigger\" in ('message', 'webhook', 'schedule', 'manual')" + }, + "sessions_visibility_check": { + "name": "sessions_visibility_check", + "value": "\"sessions\".\"visibility\" in ('visible', 'hidden')" + }, + "sessions_cached_status_check": { + "name": "sessions_cached_status_check", + "value": "\"sessions\".\"cached_status\" IS NULL OR \"sessions\".\"cached_status\" in ('active', 'needs_input', 'blocked', 'ready')" + } + }, + "isRLSEnabled": false + }, + "public.setup_qualification_blocks": { + "name": "setup_qualification_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'blocked'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_domain": { + "name": "email_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_account_login": { + "name": "github_account_login", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_account_type": { + "name": "github_account_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_blocked_at": { + "name": "first_blocked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_blocked_at": { + "name": "last_blocked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lifted_by_admin_user_id": { + "name": "lifted_by_admin_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifted_by_admin_email": { + "name": "lifted_by_admin_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "setup_qualification_blocks_deployment_user_reason_unique": { + "name": "setup_qualification_blocks_deployment_user_reason_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reason", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "setup_qualification_blocks_deployment_status_idx": { + "name": "setup_qualification_blocks_deployment_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "setup_qualification_blocks_user_status_idx": { + "name": "setup_qualification_blocks_user_status_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "setup_qualification_blocks_user_id_users_id_fk": { + "name": "setup_qualification_blocks_user_id_users_id_fk", + "tableFrom": "setup_qualification_blocks", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_auth_tokens": { + "name": "slack_auth_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_user_id": { + "name": "slack_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_team_id": { + "name": "slack_team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_ts": { + "name": "thread_ts", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_text": { + "name": "original_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slack_auth_tokens_expires_at_idx": { + "name": "slack_auth_tokens_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "slack_auth_tokens_token_unique": { + "name": "slack_auth_tokens_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_conversation_messages": { + "name": "slack_conversation_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "subject_user_id": { + "name": "subject_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_team_id": { + "name": "slack_team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_slack_user_id": { + "name": "subject_slack_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sender_user_id": { + "name": "sender_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sender_slack_user_id": { + "name": "sender_slack_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_channel_id": { + "name": "slack_channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_kind": { + "name": "conversation_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_ts": { + "name": "thread_ts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message_ts": { + "name": "message_ts", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_at": { + "name": "message_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_kind": { + "name": "author_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slack_conversation_messages_deployment_user_message_at_idx": { + "name": "slack_conversation_messages_deployment_user_message_at_idx", + "columns": [ + { + "expression": "subject_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_conversation_messages_deployment_user_thread_idx": { + "name": "slack_conversation_messages_deployment_user_thread_idx", + "columns": [ + { + "expression": "subject_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slack_channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_ts", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_conversation_messages_task_id_idx": { + "name": "slack_conversation_messages_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_conversation_messages_run_id_idx": { + "name": "slack_conversation_messages_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_conversation_messages_team_channel_message_unique": { + "name": "slack_conversation_messages_team_channel_message_unique", + "columns": [ + { + "expression": "slack_team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slack_channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_ts", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slack_conversation_messages_subject_user_id_users_id_fk": { + "name": "slack_conversation_messages_subject_user_id_users_id_fk", + "tableFrom": "slack_conversation_messages", + "tableTo": "users", + "columnsFrom": ["subject_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "slack_conversation_messages_sender_user_id_users_id_fk": { + "name": "slack_conversation_messages_sender_user_id_users_id_fk", + "tableFrom": "slack_conversation_messages", + "tableTo": "users", + "columnsFrom": ["sender_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "slack_conversation_messages_task_id_tasks_id_fk": { + "name": "slack_conversation_messages_task_id_tasks_id_fk", + "tableFrom": "slack_conversation_messages", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "slack_conversation_messages_run_id_task_runs_id_fk": { + "name": "slack_conversation_messages_run_id_task_runs_id_fk", + "tableFrom": "slack_conversation_messages", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_directory_users": { + "name": "slack_directory_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slack_user_id": { + "name": "slack_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_team_id": { + "name": "slack_team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "real_name": { + "name": "real_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_deleted": { + "name": "is_deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_bot": { + "name": "is_bot", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_app_user": { + "name": "is_app_user", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "profile_updated_at": { + "name": "profile_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slack_directory_users_team_id_idx": { + "name": "slack_directory_users_team_id_idx", + "columns": [ + { + "expression": "slack_team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "slack_directory_users_unique": { + "name": "slack_directory_users_unique", + "nullsNotDistinct": false, + "columns": ["slack_user_id", "slack_team_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_fast_integration_calls": { + "name": "slack_fast_integration_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "fast_agent_conversation_id": { + "name": "fast_agent_conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_team_id": { + "name": "slack_team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_channel": { + "name": "slack_channel", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_thread_ts": { + "name": "slack_thread_ts", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_message_ts": { + "name": "slack_message_ts", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "integration_id": { + "name": "integration_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "arguments": { + "name": "arguments", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "result_preview": { + "name": "result_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slack_fast_integration_calls_conversation_idx": { + "name": "slack_fast_integration_calls_conversation_idx", + "columns": [ + { + "expression": "fast_agent_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_fast_integration_calls_user_idx": { + "name": "slack_fast_integration_calls_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_fast_integration_calls_status_idx": { + "name": "slack_fast_integration_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slack_fast_integration_calls_fast_agent_conversation_id_fast_agent_conversations_id_fk": { + "name": "slack_fast_integration_calls_fast_agent_conversation_id_fast_agent_conversations_id_fk", + "tableFrom": "slack_fast_integration_calls", + "tableTo": "fast_agent_conversations", + "columnsFrom": ["fast_agent_conversation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "slack_fast_integration_calls_user_id_users_id_fk": { + "name": "slack_fast_integration_calls_user_id_users_id_fk", + "tableFrom": "slack_fast_integration_calls", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_installation_channels": { + "name": "slack_installation_channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slack_installation_id": { + "name": "slack_installation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slack_installation_channels_installation_id_idx": { + "name": "slack_installation_channels_installation_id_idx", + "columns": [ + { + "expression": "slack_installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slack_installation_channels_slack_installation_id_slack_installations_id_fk": { + "name": "slack_installation_channels_slack_installation_id_slack_installations_id_fk", + "tableFrom": "slack_installation_channels", + "tableTo": "slack_installations", + "columnsFrom": ["slack_installation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "slack_installation_channels_unique": { + "name": "slack_installation_channels_unique", + "nullsNotDistinct": false, + "columns": ["slack_installation_id", "channel_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_installations": { + "name": "slack_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_name": { + "name": "team_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_domain": { + "name": "team_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enterprise_id": { + "name": "enterprise_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enterprise_name": { + "name": "enterprise_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_user_id": { + "name": "bot_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_name": { + "name": "bot_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "app_name": { + "name": "app_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bot_access_token": { + "name": "bot_access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_access_token": { + "name": "user_access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'bot'" + }, + "installed_by_user_id": { + "name": "installed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "member_count_snapshot": { + "name": "member_count_snapshot", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "member_count_snapshot_at": { + "name": "member_count_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slack_installations_bot_user_id_idx": { + "name": "slack_installations_bot_user_id_idx", + "columns": [ + { + "expression": "bot_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_installations_active_idx": { + "name": "slack_installations_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slack_installations_installed_by_user_id_users_id_fk": { + "name": "slack_installations_installed_by_user_id_users_id_fk", + "tableFrom": "slack_installations", + "tableTo": "users", + "columnsFrom": ["installed_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "slack_installations_team_id_unique": { + "name": "slack_installations_team_id_unique", + "nullsNotDistinct": false, + "columns": ["team_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_user_mappings": { + "name": "slack_user_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slack_user_id": { + "name": "slack_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_team_id": { + "name": "slack_team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slack_user_mappings_user_id_idx": { + "name": "slack_user_mappings_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slack_user_mappings_user_id_users_id_fk": { + "name": "slack_user_mappings_user_id_users_id_fk", + "tableFrom": "slack_user_mappings", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "slack_user_mappings_unique": { + "name": "slack_user_mappings_unique", + "nullsNotDistinct": false, + "columns": ["slack_user_id", "slack_team_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.source_control_user_mappings": { + "name": "source_control_user_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "auth_account_id": { + "name": "auth_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_control_provider": { + "name": "source_control_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_account_id": { + "name": "external_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "source_control_user_mappings_auth_account_unique": { + "name": "source_control_user_mappings_auth_account_unique", + "columns": [ + { + "expression": "auth_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "source_control_user_mappings_user_provider_host_idx": { + "name": "source_control_user_mappings_user_provider_host_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "host", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "source_control_user_mappings_provider_identity_unique": { + "name": "source_control_user_mappings_provider_identity_unique", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "host", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "source_control_user_mappings_auth_account_id_auth_accounts_id_fk": { + "name": "source_control_user_mappings_auth_account_id_auth_accounts_id_fk", + "tableFrom": "source_control_user_mappings", + "tableTo": "auth_accounts", + "columnsFrom": ["auth_account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "source_control_user_mappings_user_id_auth_users_id_fk": { + "name": "source_control_user_mappings_user_id_auth_users_id_fk", + "tableFrom": "source_control_user_mappings", + "tableTo": "auth_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_artifacts": { + "name": "task_artifacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "artifact_type": { + "name": "artifact_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "size": { + "name": "size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "uploaded": { + "name": "uploaded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_artifacts_task_id_idx": { + "name": "task_artifacts_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_artifacts_run_id_idx": { + "name": "task_artifacts_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_artifacts_uploaded_idx": { + "name": "task_artifacts_uploaded_idx", + "columns": [ + { + "expression": "uploaded", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_artifacts_created_at_idx": { + "name": "task_artifacts_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_artifacts_path_idx": { + "name": "task_artifacts_path_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_artifacts_task_id_tasks_id_fk": { + "name": "task_artifacts_task_id_tasks_id_fk", + "tableFrom": "task_artifacts", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_artifacts_run_id_task_runs_id_fk": { + "name": "task_artifacts_run_id_task_runs_id_fk", + "tableFrom": "task_artifacts", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "task_artifacts_task_id_path_version_unique": { + "name": "task_artifacts_task_id_path_version_unique", + "nullsNotDistinct": false, + "columns": ["task_id", "path", "version"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_messages": { + "name": "task_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ts": { + "name": "ts", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "protocol": { + "name": "protocol", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_blocks": { + "name": "content_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_messages_task_id_ts_idx": { + "name": "task_messages_task_id_ts_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ts", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_messages_run_id_idx": { + "name": "task_messages_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_messages_created_at_idx": { + "name": "task_messages_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_messages_run_id_task_runs_id_fk": { + "name": "task_messages_run_id_task_runs_id_fk", + "tableFrom": "task_messages", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_messages_task_id_tasks_id_fk": { + "name": "task_messages_task_id_tasks_id_fk", + "tableFrom": "task_messages", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_messages_user_id_users_id_fk": { + "name": "task_messages_user_id_users_id_fk", + "tableFrom": "task_messages", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "task_messages_task_protocol_ts_event_type_unique": { + "name": "task_messages_task_protocol_ts_event_type_unique", + "nullsNotDistinct": false, + "columns": ["task_id", "protocol", "ts", "event_type"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_pins": { + "name": "task_pins", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_pins_deployment_user_task_unique": { + "name": "task_pins_deployment_user_task_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_pins_deployment_user_updated_at_idx": { + "name": "task_pins_deployment_user_updated_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_pins_task_id_idx": { + "name": "task_pins_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_pins_task_id_tasks_id_fk": { + "name": "task_pins_task_id_tasks_id_fk", + "tableFrom": "task_pins", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_pins_user_id_users_id_fk": { + "name": "task_pins_user_id_users_id_fk", + "tableFrom": "task_pins", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_platform_issue_reports": { + "name": "task_platform_issue_reports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "task_message_id": { + "name": "task_message_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "report": { + "name": "report", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "slack_posted_at": { + "name": "slack_posted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_platform_issue_reports_created_at_idx": { + "name": "task_platform_issue_reports_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_platform_issue_reports_task_id_created_at_idx": { + "name": "task_platform_issue_reports_task_id_created_at_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_platform_issue_reports_run_id_created_at_idx": { + "name": "task_platform_issue_reports_run_id_created_at_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_platform_issue_reports_task_message_id_unique": { + "name": "task_platform_issue_reports_task_message_id_unique", + "columns": [ + { + "expression": "task_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_platform_issue_reports_task_id_tasks_id_fk": { + "name": "task_platform_issue_reports_task_id_tasks_id_fk", + "tableFrom": "task_platform_issue_reports", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_platform_issue_reports_run_id_task_runs_id_fk": { + "name": "task_platform_issue_reports_run_id_task_runs_id_fk", + "tableFrom": "task_platform_issue_reports", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_platform_issue_reports_task_message_id_task_messages_id_fk": { + "name": "task_platform_issue_reports_task_message_id_task_messages_id_fk", + "tableFrom": "task_platform_issue_reports", + "tableTo": "task_messages", + "columnsFrom": ["task_message_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_pull_requests": { + "name": "task_pull_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_control_provider": { + "name": "source_control_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pr_title": { + "name": "pr_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_sha": { + "name": "pr_sha", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_base_ref": { + "name": "pr_base_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_base_sha": { + "name": "pr_base_sha", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_reaction_id": { + "name": "github_reaction_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "github_check_run_id": { + "name": "github_check_run_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "github_review_comment_id": { + "name": "github_review_comment_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_by_roomote": { + "name": "created_by_roomote", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mergeability_status": { + "name": "mergeability_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "conflict_detected_at": { + "name": "conflict_detected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "conflict_notification_claimed_at": { + "name": "conflict_notification_claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "conflict_notified_at": { + "name": "conflict_notified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "auto_handle_feedback_by_user_id": { + "name": "auto_handle_feedback_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "detected_at": { + "name": "detected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_pull_requests_task_id_idx": { + "name": "task_pull_requests_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_pull_requests_repository_id_idx": { + "name": "task_pull_requests_repository_id_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_pull_requests_provider_repository_pr_number_idx": { + "name": "task_pull_requests_provider_repository_pr_number_idx", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repository", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pr_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_pull_requests_mergeability_lookup_idx": { + "name": "task_pull_requests_mergeability_lookup_idx", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repository", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_by_roomote", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pr_base_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_pull_requests_task_id_tasks_id_fk": { + "name": "task_pull_requests_task_id_tasks_id_fk", + "tableFrom": "task_pull_requests", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_pull_requests_repository_id_repositories_id_fk": { + "name": "task_pull_requests_repository_id_repositories_id_fk", + "tableFrom": "task_pull_requests", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "task_pull_requests_auto_handle_feedback_by_user_id_users_id_fk": { + "name": "task_pull_requests_auto_handle_feedback_by_user_id_users_id_fk", + "tableFrom": "task_pull_requests", + "tableTo": "users", + "columnsFrom": ["auto_handle_feedback_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "task_pull_requests_task_pr_unique": { + "name": "task_pull_requests_task_pr_unique", + "nullsNotDistinct": false, + "columns": ["task_id", "pr_url"] + } + }, + "policies": {}, + "checkConstraints": { + "task_pull_requests_source_control_provider_check": { + "name": "task_pull_requests_source_control_provider_check", + "value": "\"task_pull_requests\".\"source_control_provider\" in ('github', 'gitlab', 'gitea', 'ado', 'bitbucket')" + } + }, + "isRLSEnabled": false + }, + "public.task_run_events": { + "name": "task_run_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_run_events_run_id_created_at_idx": { + "name": "task_run_events_run_id_created_at_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_run_events_task_id_created_at_idx": { + "name": "task_run_events_task_id_created_at_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_run_events_created_at_idx": { + "name": "task_run_events_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_run_events_source_created_at_idx": { + "name": "task_run_events_source_created_at_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_run_events_run_id_task_runs_id_fk": { + "name": "task_run_events_run_id_task_runs_id_fk", + "tableFrom": "task_run_events", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_run_events_task_id_tasks_id_fk": { + "name": "task_run_events_task_id_tasks_id_fk", + "tableFrom": "task_run_events", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_runs": { + "name": "task_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "task_runs_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'fresh'" + }, + "source_run_id": { + "name": "source_run_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "acting_user_id": { + "name": "acting_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload_kind": { + "name": "payload_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "harness": { + "name": "harness", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'opencode-server'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queue_scope": { + "name": "queue_scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "task_phase": { + "name": "task_phase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "fast_agent_session_id": { + "name": "fast_agent_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "((payload ->> 'fastAgentSessionId')::uuid)", + "type": "stored" + } + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log": { + "name": "log", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "artifacts": { + "name": "artifacts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "machine_id": { + "name": "machine_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sandbox_cmd_id": { + "name": "sandbox_cmd_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "machine_domain": { + "name": "machine_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "machine_domains": { + "name": "machine_domains", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "initial_paths": { + "name": "initial_paths", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "primary_port_name": { + "name": "primary_port_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sandbox_server_url": { + "name": "sandbox_server_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "proxy_ports": { + "name": "proxy_ports", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "worker_release_tag": { + "name": "worker_release_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "worker_version": { + "name": "worker_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "worker_commit": { + "name": "worker_commit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "configured_vcpus": { + "name": "configured_vcpus", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "configured_cpu_cores": { + "name": "configured_cpu_cores", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "configured_memory_mib": { + "name": "configured_memory_mib", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "snapshot_requested_at": { + "name": "snapshot_requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "snapshot_created_at": { + "name": "snapshot_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "snapshot_failed_at": { + "name": "snapshot_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "keepalive_ms": { + "name": "keepalive_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "sleep_at": { + "name": "sleep_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "sleep_requested_at": { + "name": "sleep_requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "worker_heartbeat_at": { + "name": "worker_heartbeat_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_snapshot_id": { + "name": "source_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_bypass_value": { + "name": "auth_bypass_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_bypass_header_name": { + "name": "auth_bypass_header_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "dequeued_at": { + "name": "dequeued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "provision_started_at": { + "name": "provision_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "provision_ready_at": { + "name": "provision_ready_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "setup_completed_at": { + "name": "setup_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "environment_setup_state": { + "name": "environment_setup_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "environment_setup_completed_at": { + "name": "environment_setup_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "harness_started_at": { + "name": "harness_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "runtime_task_started_at": { + "name": "runtime_task_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "first_assistant_output_at": { + "name": "first_assistant_output_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_requested_at": { + "name": "cancel_requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "launch_mode": { + "name": "launch_mode", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "task_runs_task_id_idx": { + "name": "task_runs_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_fast_agent_session_id_idx": { + "name": "task_runs_fast_agent_session_id_idx", + "columns": [ + { + "expression": "fast_agent_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_queue_scope_idx": { + "name": "task_runs_queue_scope_idx", + "columns": [ + { + "expression": "queue_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_acting_user_id_idx": { + "name": "task_runs_acting_user_id_idx", + "columns": [ + { + "expression": "acting_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_snapshot_id_idx": { + "name": "task_runs_snapshot_id_idx", + "columns": [ + { + "expression": "snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_sleep_at_idx": { + "name": "task_runs_sleep_at_idx", + "columns": [ + { + "expression": "sleep_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_worker_heartbeat_at_idx": { + "name": "task_runs_worker_heartbeat_at_idx", + "columns": [ + { + "expression": "worker_heartbeat_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_sleep_check_due_v2_idx": { + "name": "task_runs_sleep_check_due_v2_idx", + "columns": [ + { + "expression": "sleep_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "vendor", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"task_runs\".\"status\" IN ('running', 'idle') AND \"task_runs\".\"machine_id\" IS NOT NULL AND \"task_runs\".\"sleep_at\" IS NOT NULL AND \"task_runs\".\"sleep_requested_at\" IS NULL AND \"task_runs\".\"snapshot_id\" IS NULL AND \"task_runs\".\"snapshot_requested_at\" IS NULL AND \"task_runs\".\"vendor\" IN ('modal', 'daytona', 'e2b', 'docker', 'blaxel', 'box', 'roomote', 'azure')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_sleep_check_stale_worker_v2_idx": { + "name": "task_runs_sleep_check_stale_worker_v2_idx", + "columns": [ + { + "expression": "worker_heartbeat_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "vendor", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"task_runs\".\"status\" IN ('running', 'idle') AND \"task_runs\".\"machine_id\" IS NOT NULL AND \"task_runs\".\"worker_heartbeat_at\" IS NOT NULL AND \"task_runs\".\"sleep_requested_at\" IS NULL AND \"task_runs\".\"snapshot_id\" IS NULL AND \"task_runs\".\"snapshot_requested_at\" IS NULL AND \"task_runs\".\"vendor\" IN ('modal', 'daytona', 'e2b', 'docker', 'blaxel', 'box', 'roomote', 'azure')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_sleep_check_active_v2_idx": { + "name": "task_runs_sleep_check_active_v2_idx", + "columns": [ + { + "expression": "vendor", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"task_runs\".\"status\" IN ('running', 'idle') AND \"task_runs\".\"machine_id\" IS NOT NULL AND \"task_runs\".\"sleep_requested_at\" IS NULL AND \"task_runs\".\"snapshot_id\" IS NULL AND \"task_runs\".\"snapshot_requested_at\" IS NULL AND \"task_runs\".\"vendor\" IN ('modal', 'daytona', 'e2b', 'docker', 'blaxel', 'box', 'roomote', 'azure')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_source_snapshot_id_idx": { + "name": "task_runs_source_snapshot_id_idx", + "columns": [ + { + "expression": "source_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_source_run_id_idx": { + "name": "task_runs_source_run_id_idx", + "columns": [ + { + "expression": "source_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_discord_source_event_unique": { + "name": "task_runs_discord_source_event_unique", + "columns": [ + { + "expression": "(\"payload\"->>'communicationSourceEventId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"task_runs\".\"payload\"->>'communicationProvider' = 'discord' AND \"task_runs\".\"payload\"->>'communicationSourceEventId' IS NOT NULL AND \"task_runs\".\"canceled_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_launch_idempotency_key_unique": { + "name": "task_runs_launch_idempotency_key_unique", + "columns": [ + { + "expression": "(\"payload\"->>'launchIdempotencyKey')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"task_runs\".\"payload\"->>'launchIdempotencyKey' IS NOT NULL AND \"task_runs\".\"canceled_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_first_assistant_output_at_idx": { + "name": "task_runs_first_assistant_output_at_idx", + "columns": [ + { + "expression": "first_assistant_output_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_runs_task_id_tasks_id_fk": { + "name": "task_runs_task_id_tasks_id_fk", + "tableFrom": "task_runs", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_runs_source_run_id_task_runs_id_fk": { + "name": "task_runs_source_run_id_task_runs_id_fk", + "tableFrom": "task_runs", + "tableTo": "task_runs", + "columnsFrom": ["source_run_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "task_runs_acting_user_id_users_id_fk": { + "name": "task_runs_acting_user_id_users_id_fk", + "tableFrom": "task_runs", + "tableTo": "users", + "columnsFrom": ["acting_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "task_runs_kind_check": { + "name": "task_runs_kind_check", + "value": "\"task_runs\".\"kind\" in ('fresh', 'resume')" + }, + "task_runs_harness_check": { + "name": "task_runs_harness_check", + "value": "\"task_runs\".\"harness\" in ('opencode-server')" + } + }, + "isRLSEnabled": false + }, + "public.task_slack_reply_details": { + "name": "task_slack_reply_details", + "schema": "", + "columns": { + "detail_id": { + "name": "detail_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "findings": { + "name": "findings", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_slack_reply_details_task_id_idx": { + "name": "task_slack_reply_details_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_slack_reply_details_deployment_task_detail_unique": { + "name": "task_slack_reply_details_deployment_task_detail_unique", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "detail_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_slack_reply_details_task_id_tasks_id_fk": { + "name": "task_slack_reply_details_task_id_tasks_id_fk", + "tableFrom": "task_slack_reply_details", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_start_parallel_counts": { + "name": "task_start_parallel_counts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "payload_kind": { + "name": "payload_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parallel_count": { + "name": "parallel_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "activity_window_seconds": { + "name": "activity_window_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_start_parallel_counts_run_id_unique": { + "name": "task_start_parallel_counts_run_id_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_start_parallel_counts_task_id_started_at_idx": { + "name": "task_start_parallel_counts_task_id_started_at_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_start_parallel_counts_started_at_idx": { + "name": "task_start_parallel_counts_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_start_parallel_counts_task_id_tasks_id_fk": { + "name": "task_start_parallel_counts_task_id_tasks_id_fk", + "tableFrom": "task_start_parallel_counts", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_start_parallel_counts_run_id_task_runs_id_fk": { + "name": "task_start_parallel_counts_run_id_task_runs_id_fk", + "tableFrom": "task_start_parallel_counts", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow": { + "name": "workflow", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'visible'" + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "initiator_kind": { + "name": "initiator_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "initiator_user_id": { + "name": "initiator_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "initiator_automation": { + "name": "initiator_automation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_external_id": { + "name": "actor_external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_display_name": { + "name": "actor_display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "commit_author_kind": { + "name": "commit_author_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "commit_author_user_id": { + "name": "commit_author_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "commit_author_login": { + "name": "commit_author_login", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "commit_author_external_id": { + "name": "commit_author_external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_assignee_login": { + "name": "pr_assignee_login", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_channel_id": { + "name": "slack_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_thread_ts": { + "name": "slack_thread_ts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linear_session_id": { + "name": "linear_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linear_issue_id": { + "name": "linear_issue_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linear_organization_id": { + "name": "linear_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "harness": { + "name": "harness", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'opencode-server'" + }, + "harness_session_id": { + "name": "harness_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_provider": { + "name": "model_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title_edited_by_user_at": { + "name": "title_edited_by_user_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "llm_title_checkpoint": { + "name": "llm_title_checkpoint", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "goal_objective": { + "name": "goal_objective", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "goal_status": { + "name": "goal_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "goal_max_continuations": { + "name": "goal_max_continuations", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "goal_continuations_used": { + "name": "goal_continuations_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "goal_blocked_reason": { + "name": "goal_blocked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "goal_completed_at": { + "name": "goal_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "goal_last_continuation_id": { + "name": "goal_last_continuation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "goal_continuation_ids": { + "name": "goal_continuation_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "goal_generation_ids": { + "name": "goal_generation_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "goal_blocker_candidate_reason": { + "name": "goal_blocker_candidate_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "goal_blocker_candidate_count": { + "name": "goal_blocker_candidate_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "goal_blocker_last_continuation_used": { + "name": "goal_blocker_last_continuation_used", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "draft_prompt": { + "name": "draft_prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_work_kind": { + "name": "requested_work_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "requested_work_kind_source": { + "name": "requested_work_kind_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system_default'" + }, + "requested_work_kind_confidence": { + "name": "requested_work_kind_confidence", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "harness_instructions": { + "name": "harness_instructions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "compute_duration_ms": { + "name": "compute_duration_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "activity_at": { + "name": "activity_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "repository_url": { + "name": "repository_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository_name": { + "name": "repository_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tasks_initiator_user_id_idx": { + "name": "tasks_initiator_user_id_idx", + "columns": [ + { + "expression": "initiator_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_initiator_automation_idx": { + "name": "tasks_initiator_automation_idx", + "columns": [ + { + "expression": "initiator_automation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_workflow_idx": { + "name": "tasks_workflow_idx", + "columns": [ + { + "expression": "workflow", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_visibility_activity_at_idx": { + "name": "tasks_visibility_activity_at_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_harness_session_id_idx": { + "name": "tasks_harness_session_id_idx", + "columns": [ + { + "expression": "harness_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_timestamp_idx": { + "name": "tasks_timestamp_idx", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_deployment_activity_at_idx": { + "name": "tasks_deployment_activity_at_idx", + "columns": [ + { + "expression": "activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_created_at_idx": { + "name": "tasks_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tasks_initiator_user_id_users_id_fk": { + "name": "tasks_initiator_user_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "columnsFrom": ["initiator_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tasks_initiator_automation_automations_key_fk": { + "name": "tasks_initiator_automation_automations_key_fk", + "tableFrom": "tasks", + "tableTo": "automations", + "columnsFrom": ["initiator_automation"], + "columnsTo": ["key"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tasks_commit_author_user_id_users_id_fk": { + "name": "tasks_commit_author_user_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "columnsFrom": ["commit_author_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "tasks_initiator_shape_check": { + "name": "tasks_initiator_shape_check", + "value": "(\"tasks\".\"initiator_kind\" = 'user' AND \"tasks\".\"initiator_automation\" IS NULL AND (\"tasks\".\"initiator_user_id\" IS NOT NULL OR \"tasks\".\"actor_external_id\" IS NOT NULL)) OR (\"tasks\".\"initiator_kind\" = 'automation' AND \"tasks\".\"initiator_automation\" IS NOT NULL AND \"tasks\".\"initiator_user_id\" IS NULL)" + }, + "tasks_workflow_check": { + "name": "tasks_workflow_check", + "value": "\"tasks\".\"workflow\" in ('standard', 'pr_review', 'pr_conflict_resolve', 'scan', 'mcp_recommendations', 'setup_onboarding', 'env_snapshot', 'eval')" + }, + "tasks_surface_check": { + "name": "tasks_surface_check", + "value": "\"tasks\".\"surface\" in ('web', 'api', 'slack', 'teams', 'telegram', 'discord', 'agentmail', 'linear', 'github', 'gitlab', 'gitea', 'ado', 'bitbucket', 'system')" + }, + "tasks_trigger_check": { + "name": "tasks_trigger_check", + "value": "\"tasks\".\"trigger\" in ('message', 'webhook', 'schedule', 'manual')" + }, + "tasks_visibility_check": { + "name": "tasks_visibility_check", + "value": "\"tasks\".\"visibility\" in ('visible', 'hidden')" + }, + "tasks_state_check": { + "name": "tasks_state_check", + "value": "\"tasks\".\"state\" in ('active', 'completed', 'failed', 'canceled')" + }, + "tasks_goal_status_check": { + "name": "tasks_goal_status_check", + "value": "\"tasks\".\"goal_status\" IS NULL OR \"tasks\".\"goal_status\" in ('active', 'complete', 'blocked', 'budget_limited')" + }, + "tasks_goal_continuations_check": { + "name": "tasks_goal_continuations_check", + "value": "\"tasks\".\"goal_continuations_used\" >= 0 AND (\"tasks\".\"goal_max_continuations\" IS NULL OR \"tasks\".\"goal_max_continuations\" > 0)" + }, + "tasks_goal_blocker_candidate_count_check": { + "name": "tasks_goal_blocker_candidate_count_check", + "value": "\"tasks\".\"goal_blocker_candidate_count\" >= 0" + }, + "tasks_harness_check": { + "name": "tasks_harness_check", + "value": "\"tasks\".\"harness\" in ('opencode-server')" + }, + "tasks_requested_work_kind_check": { + "name": "tasks_requested_work_kind_check", + "value": "\"tasks\".\"requested_work_kind\" in ('question', 'plan', 'implement', 'unknown')" + }, + "tasks_requested_work_kind_source_check": { + "name": "tasks_requested_work_kind_source_check", + "value": "\"tasks\".\"requested_work_kind_source\" in ('explicit_bootstrap', 'task_tool', 'llm_classifier', 'inherited', 'system_default')" + }, + "tasks_commit_author_kind_check": { + "name": "tasks_commit_author_kind_check", + "value": "\"tasks\".\"commit_author_kind\" IS NULL OR \"tasks\".\"commit_author_kind\" in ('roomote', 'user', 'external')" + } + }, + "isRLSEnabled": false + }, + "public.teams_installations": { + "name": "teams_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "installation_key": { + "name": "installation_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "team_name": { + "name": "team_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "channel_name": { + "name": "channel_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_type": { + "name": "conversation_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bot_app_id": { + "name": "bot_app_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_user_id": { + "name": "bot_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bot_name": { + "name": "bot_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "service_url": { + "name": "service_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "teams_installations_tenant_id_idx": { + "name": "teams_installations_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "teams_installations_team_id_idx": { + "name": "teams_installations_team_id_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "teams_installations_conversation_id_idx": { + "name": "teams_installations_conversation_id_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "teams_installations_active_idx": { + "name": "teams_installations_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "teams_installations_installation_key_unique": { + "name": "teams_installations_installation_key_unique", + "nullsNotDistinct": false, + "columns": ["installation_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.teams_user_mappings": { + "name": "teams_user_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "teams_user_id": { + "name": "teams_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "teams_tenant_id": { + "name": "teams_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "teams_aad_object_id": { + "name": "teams_aad_object_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "teams_user_mappings_aad_object_idx": { + "name": "teams_user_mappings_aad_object_idx", + "columns": [ + { + "expression": "teams_aad_object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "teams_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "teams_user_mappings_user_id_idx": { + "name": "teams_user_mappings_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "teams_user_mappings_user_id_users_id_fk": { + "name": "teams_user_mappings_user_id_users_id_fk", + "tableFrom": "teams_user_mappings", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "teams_user_mappings_unique": { + "name": "teams_user_mappings_unique", + "nullsNotDistinct": false, + "columns": ["teams_user_id", "teams_tenant_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.telegram_user_mappings": { + "name": "telegram_user_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "telegram_user_id": { + "name": "telegram_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_username": { + "name": "telegram_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "telegram_user_mappings_user_id_idx": { + "name": "telegram_user_mappings_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "telegram_user_mappings_user_id_users_id_fk": { + "name": "telegram_user_mappings_user_id_users_id_fk", + "tableFrom": "telegram_user_mappings", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "telegram_user_mappings_unique": { + "name": "telegram_user_mappings_unique", + "nullsNotDistinct": false, + "columns": ["telegram_user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tracked_messages": { + "name": "tracked_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_ts": { + "name": "message_ts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thread_ts": { + "name": "thread_ts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "work_item_id": { + "name": "work_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "automation_key": { + "name": "automation_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary_text": { + "name": "summary_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "posted_at": { + "name": "posted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tracked_messages_kind_dedupe_key_unique": { + "name": "tracked_messages_kind_dedupe_key_unique", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tracked_messages_work_item_id_idx": { + "name": "tracked_messages_work_item_id_idx", + "columns": [ + { + "expression": "work_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tracked_messages_channel_message_idx": { + "name": "tracked_messages_channel_message_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_ts", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tracked_messages_automation_channel_posted_idx": { + "name": "tracked_messages_automation_channel_posted_idx", + "columns": [ + { + "expression": "automation_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "posted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tracked_messages_work_item_id_work_items_id_fk": { + "name": "tracked_messages_work_item_id_work_items_id_fk", + "tableFrom": "tracked_messages", + "tableTo": "work_items", + "columnsFrom": ["work_item_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tracked_messages_automation_key_automations_key_fk": { + "name": "tracked_messages_automation_key_automations_key_fk", + "tableFrom": "tracked_messages", + "tableTo": "automations", + "columnsFrom": ["automation_key"], + "columnsTo": ["key"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tracked_messages_created_by_user_id_users_id_fk": { + "name": "tracked_messages_created_by_user_id_users_id_fk", + "tableFrom": "tracked_messages", + "tableTo": "users", + "columnsFrom": ["created_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_api_keys": { + "name": "user_api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_api_keys_user_id_idx": { + "name": "user_api_keys_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_api_keys_user_deployment_provider_unique": { + "name": "user_api_keys_user_deployment_provider_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_api_keys_user_id_users_id_fk": { + "name": "user_api_keys_user_id_users_id_fk", + "tableFrom": "user_api_keys", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity": { + "name": "entity", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "analytics_id": { + "name": "analytics_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cookie_consented_at": { + "name": "cookie_consented_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by_invite_id": { + "name": "invited_by_invite_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_created_at_idx": { + "name": "users_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_analytics_id_unique_idx": { + "name": "users_analytics_id_unique_idx", + "columns": [ + { + "expression": "analytics_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhooks": { + "name": "webhooks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "delivery_id": { + "name": "delivery_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "succeeded_at": { + "name": "succeeded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failed_at": { + "name": "failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhooks_provider_delivery_id_unique": { + "name": "webhooks_provider_delivery_id_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "delivery_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhooks_event_idx": { + "name": "webhooks_event_idx", + "columns": [ + { + "expression": "event", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhooks_created_at_idx": { + "name": "webhooks_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhooks_status_exclusive": { + "name": "webhooks_status_exclusive", + "value": "(\n (succeeded_at IS NOT NULL)::int +\n (failed_at IS NOT NULL)::int\n ) <= 1" + } + }, + "isRLSEnabled": false + }, + "public.work_items": { + "name": "work_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "automation_key": { + "name": "automation_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_id": { + "name": "source_task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "selected_by_user_id": { + "name": "selected_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_work_item_id": { + "name": "source_work_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "brief": { + "name": "brief", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_prompt": { + "name": "execution_prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "investigation_context": { + "name": "investigation_context", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action_kind": { + "name": "action_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disposition": { + "name": "disposition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "repository_ids": { + "name": "repository_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "target_repository_full_name": { + "name": "target_repository_full_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_environment_id": { + "name": "target_environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "workspace_readiness": { + "name": "workspace_readiness", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "readiness_message": { + "name": "readiness_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "launch_claimed_at": { + "name": "launch_claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "launched_task_id": { + "name": "launched_task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "launched_at": { + "name": "launched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failed_at": { + "name": "failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "launch_error": { + "name": "launch_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "work_items_source_task_idx": { + "name": "work_items_source_task_idx", + "columns": [ + { + "expression": "source_task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "work_items_kind_status_idx": { + "name": "work_items_kind_status_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "work_items_automation_key_fingerprint_idx": { + "name": "work_items_automation_key_fingerprint_idx", + "columns": [ + { + "expression": "automation_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "work_items_fingerprint_idx": { + "name": "work_items_fingerprint_idx", + "columns": [ + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "work_items_launched_task_id_idx": { + "name": "work_items_launched_task_id_idx", + "columns": [ + { + "expression": "launched_task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "work_items_source_task_kind_sort_order_unique": { + "name": "work_items_source_task_kind_sort_order_unique", + "columns": [ + { + "expression": "source_task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "work_items_automation_key_automations_key_fk": { + "name": "work_items_automation_key_automations_key_fk", + "tableFrom": "work_items", + "tableTo": "automations", + "columnsFrom": ["automation_key"], + "columnsTo": ["key"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "work_items_source_task_id_tasks_id_fk": { + "name": "work_items_source_task_id_tasks_id_fk", + "tableFrom": "work_items", + "tableTo": "tasks", + "columnsFrom": ["source_task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "work_items_selected_by_user_id_users_id_fk": { + "name": "work_items_selected_by_user_id_users_id_fk", + "tableFrom": "work_items", + "tableTo": "users", + "columnsFrom": ["selected_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "work_items_source_work_item_id_work_items_id_fk": { + "name": "work_items_source_work_item_id_work_items_id_fk", + "tableFrom": "work_items", + "tableTo": "work_items", + "columnsFrom": ["source_work_item_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "work_items_target_environment_id_environments_id_fk": { + "name": "work_items_target_environment_id_environments_id_fk", + "tableFrom": "work_items", + "tableTo": "environments", + "columnsFrom": ["target_environment_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "work_items_launched_task_id_tasks_id_fk": { + "name": "work_items_launched_task_id_tasks_id_fk", + "tableFrom": "work_items", + "tableTo": "tasks", + "columnsFrom": ["launched_task_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 8d77709d9..a2d22093e 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -498,6 +498,20 @@ "when": 1788068378271, "tag": "0070_misty_gertrude_yorkes", "breakpoints": true + }, + { + "idx": 71, + "version": "7", + "when": 1788206480608, + "tag": "0071_milky_the_fallen", + "breakpoints": true + }, + { + "idx": 72, + "version": "7", + "when": 1788236835780, + "tag": "0072_lethal_iron_fist", + "breakpoints": true } ] } diff --git a/packages/db/src/lib/agentmail-runtime-credentials.ts b/packages/db/src/lib/agentmail-runtime-credentials.ts new file mode 100644 index 000000000..471043560 --- /dev/null +++ b/packages/db/src/lib/agentmail-runtime-credentials.ts @@ -0,0 +1,68 @@ +import { resolveEffectiveDeploymentEnvVars } from './model-runtime-config'; + +export type AgentMailRuntimeCredentials = { + apiKey: string | null; + webhookSecret: string | null; + inboxId: string | null; +}; + +const CACHE_TTL_MS = 30_000; + +let cachedCredentials: { + value: AgentMailRuntimeCredentials; + expiresAtMs: number; +} | null = null; + +function normalizeInboxId(value: string | null | undefined): string | null { + const normalized = value?.trim().toLowerCase(); + return normalized || null; +} + +function readProcessEnvCredentials(): AgentMailRuntimeCredentials { + return { + apiKey: process.env.R_AGENTMAIL_API_KEY?.trim() || null, + webhookSecret: process.env.R_AGENTMAIL_WEBHOOK_SECRET?.trim() || null, + inboxId: normalizeInboxId(process.env.R_AGENTMAIL_INBOX_ID), + }; +} + +/** + * Resolve the AgentMail credentials the way operators configure them: real + * environment variables always win, and values saved from the comms settings + * UI (encrypted deployment env vars) fill any gaps. Resolved values are cached + * briefly so webhook-path callers do not hit the database on every delivery. + */ +export async function resolveAgentMailRuntimeCredentials(): Promise { + const fromEnv = readProcessEnvCredentials(); + + const nowMs = Date.now(); + + if (cachedCredentials && cachedCredentials.expiresAtMs > nowMs) { + return cachedCredentials.value; + } + + const deploymentEnvVars = + fromEnv.apiKey && fromEnv.webhookSecret && fromEnv.inboxId + ? {} + : await resolveEffectiveDeploymentEnvVars(); + const value: AgentMailRuntimeCredentials = { + apiKey: + fromEnv.apiKey || deploymentEnvVars.R_AGENTMAIL_API_KEY?.trim() || null, + webhookSecret: + fromEnv.webhookSecret || + deploymentEnvVars.R_AGENTMAIL_WEBHOOK_SECRET?.trim() || + null, + inboxId: + fromEnv.inboxId || + normalizeInboxId(deploymentEnvVars.R_AGENTMAIL_INBOX_ID), + }; + + cachedCredentials = { value, expiresAtMs: nowMs + CACHE_TTL_MS }; + + return value; +} + +/** Drop the cached credentials, e.g. right after the settings UI saves. */ +export function invalidateAgentMailRuntimeCredentialsCache(): void { + cachedCredentials = null; +} diff --git a/packages/db/src/lib/invocation-identities.ts b/packages/db/src/lib/invocation-identities.ts index 078bf583b..98f2ad55c 100644 --- a/packages/db/src/lib/invocation-identities.ts +++ b/packages/db/src/lib/invocation-identities.ts @@ -4,6 +4,7 @@ import { buildGitHubInvocationIdentity, buildSlackInvocationIdentity, buildTeamsInvocationIdentity, + buildAgentMailInvocationIdentity, buildTelegramInvocationIdentity, type InvocationIdentity, } from '@roomote/types'; @@ -14,6 +15,7 @@ import { slackInstallations, teamsInstallations } from '../schema'; import { resolveDiscordRuntimeCredentials } from './discord-runtime-credentials'; import { getDeploymentGitHubRoomoteMentionEnabled } from './github-mention-settings'; import { resolveEffectiveDeploymentEnvVars } from './model-runtime-config'; +import { resolveAgentMailRuntimeCredentials } from './agentmail-runtime-credentials'; import { resolveTelegramRuntimeCredentials } from './telegram-runtime-credentials'; const TEAMS_PACKAGE_DEFAULT_BOT_NAME = 'Roomote'; @@ -33,6 +35,7 @@ export async function resolveInvocationIdentities(): Promise< slackInstallation, teamsInstallation, telegramCredentials, + agentMailCredentials, discordCredentials, githubRoomoteMentionEnabled, ] = await Promise.all([ @@ -53,6 +56,7 @@ export async function resolveInvocationIdentities(): Promise< }, }), resolveTelegramRuntimeCredentials(), + resolveAgentMailRuntimeCredentials(), resolveDiscordRuntimeCredentials(), getDeploymentGitHubRoomoteMentionEnabled(), ]); @@ -81,6 +85,7 @@ export async function resolveInvocationIdentities(): Promise< configured: Boolean(configuredTeamsBotName || teamsInstallation?.botName), }), buildTelegramInvocationIdentity(telegramCredentials.botUsername), + buildAgentMailInvocationIdentity(agentMailCredentials.inboxId), buildDiscordInvocationIdentity({ botUserId: discordCredentials.botUserId, username: discordCredentials.botUsername, diff --git a/packages/db/src/lib/pr-review-notification-units.ts b/packages/db/src/lib/pr-review-notification-units.ts index d6261d5e7..f5a47cb8b 100644 --- a/packages/db/src/lib/pr-review-notification-units.ts +++ b/packages/db/src/lib/pr-review-notification-units.ts @@ -245,9 +245,15 @@ function fastDestination( conversation.conversationId, ]); - if (conversation.surface === 'automation' || conversation.surface === 'web') { + if ( + conversation.surface === 'automation' || + conversation.surface === 'web' || + conversation.surface === 'agentmail' + ) { // Identity-only surfaces have no reply channel; delivery resolves the - // Fast conversation itself. + // Fast conversation itself. PR-review notifications never post to email + // directly either (only the consent-checked outbound entry point may + // initiate email). return { destinationKey, routeProvider: null, diff --git a/packages/db/src/lib/router-debug-settings.ts b/packages/db/src/lib/router-debug-settings.ts index 0c7ff3d0f..60b6d9b9d 100644 --- a/packages/db/src/lib/router-debug-settings.ts +++ b/packages/db/src/lib/router-debug-settings.ts @@ -1,9 +1,17 @@ import { eq } from 'drizzle-orm'; +import { z } from 'zod'; import { communicationProviderSchema, type CommunicationProvider, } from '@roomote/types'; +/** Router diagnostics post to a chat channel; email (agentmail) has none. */ +export type RouterDebugProvider = Exclude; + +const routerDebugProviderSchema = communicationProviderSchema.refine( + (provider): provider is RouterDebugProvider => provider !== 'agentmail', +) as unknown as z.ZodType; + import { type DatabaseOrTransaction, db } from '../db'; import { deploymentSettings } from '../schema'; @@ -17,7 +25,7 @@ export type RouterDebugChannelSource = | 'none'; export type RouterDebugDestination = { - provider: CommunicationProvider; + provider: RouterDebugProvider; channelId: string; }; @@ -36,7 +44,7 @@ export function normalizeRouterDebugSlackChannelId( export function normalizeRouterDebugDestination( value: Partial | null | undefined, ): RouterDebugDestination | null { - const provider = communicationProviderSchema.safeParse(value?.provider); + const provider = routerDebugProviderSchema.safeParse(value?.provider); const channelId = value?.channelId?.trim(); if (!provider.success || !channelId) { @@ -90,7 +98,7 @@ export async function getRouterDebugSettings( ); const deploymentDestination = normalizeRouterDebugDestination({ provider: deployment?.routerDebugProvider as - | CommunicationProvider + | RouterDebugProvider | undefined, channelId: deployment?.routerDebugChannelId ?? undefined, }); diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 190c7a9ca..c46285912 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -13,6 +13,7 @@ import { primaryKey, check, uniqueIndex, + foreignKey, type AnyPgColumn, } from 'drizzle-orm/pg-core'; import { relations, sql } from 'drizzle-orm'; @@ -796,7 +797,7 @@ export const tasks = pgTable( ), check( 'tasks_surface_check', - sql`${table.surface} in ('web', 'api', 'slack', 'teams', 'telegram', 'discord', 'linear', 'github', 'gitlab', 'gitea', 'ado', 'bitbucket', 'system')`, + sql`${table.surface} in ('web', 'api', 'slack', 'teams', 'telegram', 'discord', 'agentmail', 'linear', 'github', 'gitlab', 'gitea', 'ado', 'bitbucket', 'system')`, ), check( 'tasks_trigger_check', @@ -2809,6 +2810,287 @@ export const telegramUserMappingsRelations = relations( }), ); +/** + * agentmail_user_mappings + * + * Email address → Roomote user, for senders whose address is not a verified + * auth_users email. Provenance records whether the row came from an automatic + * verified-email match or an explicit link-code pairing. + */ +export const agentmailUserMappings = pgTable( + 'agentmail_user_mappings', + { + id: uuid('id').primaryKey().defaultRandom(), + emailAddress: text('email_address').notNull(), + userId: text('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + source: text('source').notNull().$type<'verified_match' | 'link_code'>(), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + }, + (table) => [ + index('agentmail_user_mappings_user_id_idx').on(table.userId), + unique('agentmail_user_mappings_unique').on(table.emailAddress), + check( + 'agentmail_user_mappings_source_check', + sql`${table.source} in ('verified_match', 'link_code')`, + ), + ], +); + +export const agentmailUserMappingsRelations = relations( + agentmailUserMappings, + ({ one }) => ({ + user: one(users, { + fields: [agentmailUserMappings.userId], + references: [users.id], + }), + }), +); + +/** + * agentmail_conversations + * + * The unit of email routing. Usually 1:1 with an AgentMail provider thread, + * but forwarded threads fork into a second conversation on the same provider + * thread, so (inbox_id, provider_thread_id) is deliberately NOT unique. The + * row is the durable reply route: inbound and outbound anchors are separate + * columns so an in-flight send can never overwrite the record of a newer + * inbound message, and inbound anchors only advance by the total order + * (latest_inbound_at, latest_inbound_message_id) under the version guard. + */ +export const agentmailConversations = pgTable( + 'agentmail_conversations', + { + id: uuid('id').primaryKey().defaultRandom(), + inboxId: text('inbox_id').notNull(), + providerThreadId: text('provider_thread_id').notNull(), + ownerUserId: text('owner_user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + subject: text('subject'), + latestInboundMessageId: text('latest_inbound_message_id'), + latestInboundAt: timestamp('latest_inbound_at'), + latestInboundSenderEmail: text('latest_inbound_sender_email'), + latestInboundUserId: text('latest_inbound_user_id').references( + () => users.id, + { onDelete: 'set null' }, + ), + latestOutboundMessageId: text('latest_outbound_message_id'), + version: integer('version').notNull().default(0), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + }, + (table) => [ + index('agentmail_conversations_thread_idx').on( + table.inboxId, + table.providerThreadId, + ), + index('agentmail_conversations_owner_idx').on(table.ownerUserId), + // Composite FK target so the participants table's denormalized + // inbox/thread columns cannot drift from the conversation they reference. + unique('agentmail_conversations_id_thread_unique').on( + table.id, + table.inboxId, + table.providerThreadId, + ), + ], +); + +/** + * agentmail_conversation_participants + * + * Membership and authorization for a conversation. The (inbox_id, + * provider_thread_id, user_id) unique index enforces the resolution + * invariant: a user belongs to at most one conversation per provider thread, + * which makes sender → conversation lookup unambiguous and turns the + * simultaneous first-contact race into an insert conflict the loser retries. + */ +export const agentmailConversationParticipants = pgTable( + 'agentmail_conversation_participants', + { + id: uuid('id').primaryKey().defaultRandom(), + conversationId: uuid('conversation_id').notNull(), + inboxId: text('inbox_id').notNull(), + providerThreadId: text('provider_thread_id').notNull(), + userId: text('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + role: text('role').notNull().$type<'owner' | 'participant'>(), + source: text('source') + .notNull() + .$type<'initiator' | 'cc' | 'link_code' | 'outbound'>(), + addedAt: timestamp('added_at').notNull().defaultNow(), + }, + (table) => [ + unique('agentmail_conversation_participants_conversation_user_unique').on( + table.conversationId, + table.userId, + ), + unique('agentmail_conversation_participants_thread_user_unique').on( + table.inboxId, + table.providerThreadId, + table.userId, + ), + index('agentmail_conversation_participants_user_idx').on(table.userId), + foreignKey({ + columns: [table.conversationId, table.inboxId, table.providerThreadId], + foreignColumns: [ + agentmailConversations.id, + agentmailConversations.inboxId, + agentmailConversations.providerThreadId, + ], + name: 'agentmail_conversation_participants_conversation_fk', + }).onDelete('cascade'), + check( + 'agentmail_conversation_participants_role_check', + sql`${table.role} in ('owner', 'participant')`, + ), + check( + 'agentmail_conversation_participants_source_check', + sql`${table.source} in ('initiator', 'cc', 'link_code', 'outbound')`, + ), + ], +); + +export const agentmailConversationParticipantsRelations = relations( + agentmailConversationParticipants, + ({ one }) => ({ + user: one(users, { + fields: [agentmailConversationParticipants.userId], + references: [users.id], + }), + }), +); + +/** + * agentmail_webhook_events + * + * Ingestion outbox, dedupe memory, audit trail, and dead-letter surface for + * inbound AgentMail webhooks. The row, not the BullMQ job, is the durable + * commitment: a delivery is recorded as `received` before it is dispatched, + * marked `queued` only after the queue accepts the job, and a duplicate + * delivery re-dispatches a still-`received` row instead of blindly acking. + * BullMQ job retention is irrelevant to dedupe; Svix retries can arrive after + * completed jobs are pruned. + */ +export const agentmailWebhookEvents = pgTable( + 'agentmail_webhook_events', + { + id: uuid('id').primaryKey().defaultRandom(), + deliveryId: text('delivery_id').notNull(), + eventId: text('event_id'), + eventType: text('event_type').notNull(), + payload: jsonb('payload').notNull(), + state: text('state') + .notNull() + .default('received') + .$type<'received' | 'queued' | 'processing' | 'processed' | 'failed'>(), + attempts: integer('attempts').notNull().default(0), + lastError: text('last_error'), + receivedAt: timestamp('received_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + }, + (table) => [ + unique('agentmail_webhook_events_delivery_unique').on(table.deliveryId), + index('agentmail_webhook_events_state_idx').on( + table.state, + table.receivedAt, + ), + check( + 'agentmail_webhook_events_state_check', + sql`${table.state} in ('received', 'queued', 'processing', 'processed', 'failed')`, + ), + ], +); + +/** + * agentmail_inbound_turns + * + * Durable Fast-admission record. Inserting this row IS admission: the webhook + * event becomes `processed` only after this insert commits, and a crash later + * leaves the row `pending` instead of losing the email. A per-conversation + * runner drains pending rows ordered by (provider_timestamp, message_id); the + * Fast turn lock only serializes turns, this table is what orders them. + */ +export const agentmailInboundTurns = pgTable( + 'agentmail_inbound_turns', + { + id: uuid('id').primaryKey().defaultRandom(), + conversationId: uuid('conversation_id') + .notNull() + .references(() => agentmailConversations.id, { onDelete: 'cascade' }), + webhookEventId: uuid('webhook_event_id') + .notNull() + .references(() => agentmailWebhookEvents.id, { onDelete: 'cascade' }), + providerMessageId: text('provider_message_id').notNull(), + providerTimestamp: timestamp('provider_timestamp').notNull(), + // Everything the drain needs is captured at admission (including the + // re-fetched body of oversize deliveries), so consuming a turn never + // depends on re-parsing the raw webhook payload or re-resolving the + // sender against state that may have changed since admission. + senderEmail: text('sender_email').notNull(), + senderUserId: text('sender_user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + bodyText: text('body_text').notNull().default(''), + state: text('state') + .notNull() + .default('pending') + .$type<'pending' | 'consumed'>(), + createdAt: timestamp('created_at').notNull().defaultNow(), + consumedAt: timestamp('consumed_at'), + }, + (table) => [ + unique('agentmail_inbound_turns_webhook_event_unique').on( + table.webhookEventId, + ), + index('agentmail_inbound_turns_drain_idx').on( + table.conversationId, + table.state, + table.providerTimestamp, + table.providerMessageId, + ), + check( + 'agentmail_inbound_turns_state_check', + sql`${table.state} in ('pending', 'consumed')`, + ), + ], +); + +/** + * agentmail_suppressions + * + * Addresses Roomote must never initiate email to: permanent bounces, spam + * complaints, and one-click unsubscribes. Consulted only on the + * outbound-initiation path — replying within a conversation the recipient + * started (or is actively participating in) is never suppressed. A row is + * intentionally sticky: it survives account changes and re-links, and only an + * explicit operator action should remove one. + */ +export const agentmailSuppressions = pgTable( + 'agentmail_suppressions', + { + id: uuid('id').primaryKey().defaultRandom(), + emailAddress: text('email_address').notNull(), + reason: text('reason') + .notNull() + .$type<'bounce' | 'complaint' | 'unsubscribe'>(), + /** Human-readable provenance, e.g. the bounce type/sub-type. */ + details: text('details'), + providerMessageId: text('provider_message_id'), + createdAt: timestamp('created_at').notNull().defaultNow(), + }, + (table) => [ + unique('agentmail_suppressions_email_unique').on(table.emailAddress), + check( + 'agentmail_suppressions_reason_check', + sql`${table.reason} in ('bounce', 'complaint', 'unsubscribe')`, + ), + ], +); + /** * discord_installations * @@ -3223,7 +3505,7 @@ export const fastAgentProviderMessages = pgTable( .references(() => fastAgentConversations.id, { onDelete: 'cascade' }), provider: text('provider') .notNull() - .$type<'discord' | 'slack' | 'teams' | 'telegram'>(), + .$type<'discord' | 'slack' | 'teams' | 'telegram' | 'agentmail'>(), workspaceId: text('workspace_id').notNull(), channelId: text('channel_id').notNull(), threadId: text('thread_id'), @@ -3249,7 +3531,7 @@ export const fastAgentProviderMessages = pgTable( ), check( 'fast_agent_provider_messages_provider_v3_check', - sql`${table.provider} in ('discord', 'slack', 'teams', 'telegram')`, + sql`${table.provider} in ('discord', 'slack', 'teams', 'telegram', 'agentmail')`, ), ], ); @@ -3673,7 +3955,7 @@ export const sessions = pgTable( ), check( 'sessions_source_surface_check', - sql`${table.sourceSurface} in ('web', 'api', 'slack', 'teams', 'telegram', 'discord', 'linear', 'github', 'gitlab', 'gitea', 'ado', 'bitbucket', 'system', 'automation')`, + sql`${table.sourceSurface} in ('web', 'api', 'slack', 'teams', 'telegram', 'discord', 'agentmail', 'linear', 'github', 'gitlab', 'gitea', 'ado', 'bitbucket', 'system', 'automation')`, ), check( 'sessions_source_trigger_check', diff --git a/packages/db/src/server.ts b/packages/db/src/server.ts index 93ef7cafa..fa6e6395c 100644 --- a/packages/db/src/server.ts +++ b/packages/db/src/server.ts @@ -79,6 +79,7 @@ export * from './lib/record-task-kickoff-message'; export * from './lib/slack-runtime-credentials'; export * from './lib/teams-runtime-credentials'; export * from './lib/telegram-runtime-credentials'; +export * from './lib/agentmail-runtime-credentials'; export * from './lib/discord-runtime-credentials'; export * from './lib/router-debug-settings'; export * from './lib/slack-fast-integration-calls'; @@ -181,6 +182,14 @@ export { notionDirectoryUsers, telegramUserMappings, telegramUserMappingsRelations, + agentmailUserMappings, + agentmailUserMappingsRelations, + agentmailConversations, + agentmailConversationParticipants, + agentmailConversationParticipantsRelations, + agentmailWebhookEvents, + agentmailInboundTurns, + agentmailSuppressions, discordInstallations, discordInstallationsRelations, discordInstallationChannels, diff --git a/packages/env/src/index.ts b/packages/env/src/index.ts index f1f8ab53c..80d6df1dd 100644 --- a/packages/env/src/index.ts +++ b/packages/env/src/index.ts @@ -217,6 +217,14 @@ const serverSchema = { R_TELEGRAM_BOT_TOKEN: z.string().min(1).optional(), R_TELEGRAM_WEBHOOK_SECRET: z.string().min(1).optional(), TELEGRAM_API_BASE_URL: z.string().url().default('https://api.telegram.org'), + // Rollout gate for the email (AgentMail) channel: inbound, outbound, and + // the settings surface are all inert unless this is set. Read dynamically + // via isEmailChannelEnabled() so tests and runtime reads agree. + R_EMAIL_CHANNEL_ENABLED: optInBoolean(), + R_AGENTMAIL_API_KEY: z.string().min(1).optional(), + R_AGENTMAIL_WEBHOOK_SECRET: z.string().min(1).optional(), + R_AGENTMAIL_INBOX_ID: z.string().min(1).optional(), + AGENTMAIL_API_BASE_URL: z.string().url().default('https://api.agentmail.to'), R_DISCORD_BOT_TOKEN: z.string().min(1).optional(), R_DISCORD_GATEWAY_SECRET: z.string().min(1).optional(), DISCORD_API_BASE_URL: z.string().url().default('https://discord.com/api/v10'), @@ -540,6 +548,12 @@ const OPTIONAL_NON_EMPTY_KEYS = new Set([ 'R_BRAIN_OPENROUTER_API_KEY', 'R_BRAIN_OPENAI_API_KEY', 'R_TRIAL_OPENROUTER_API_KEY', + // Cloud clears managed-email variables with empty strings on disable; + // an empty enum flag must fall back to its default, not fail boot. + 'R_EMAIL_CHANNEL_ENABLED', + 'R_AGENTMAIL_API_KEY', + 'R_AGENTMAIL_WEBHOOK_SECRET', + 'R_AGENTMAIL_INBOX_ID', 'R_BRAIN_EMBEDDINGS_UPSTREAM_URL', 'R_BRAIN_INFERENCE_UPSTREAM_API_KEY', 'R_BRAIN_GATEWAY_TOKEN', @@ -579,6 +593,9 @@ const OPTIONAL_NON_EMPTY_KEYS = new Set([ 'R_TEAMS_BOT_OAUTH_SCOPE', 'R_TELEGRAM_BOT_TOKEN', 'R_TELEGRAM_WEBHOOK_SECRET', + 'R_AGENTMAIL_API_KEY', + 'R_AGENTMAIL_WEBHOOK_SECRET', + 'R_AGENTMAIL_INBOX_ID', 'R_DISCORD_BOT_TOKEN', 'R_DISCORD_GATEWAY_SECRET', 'DISCORD_API_BASE_URL', @@ -696,6 +713,18 @@ export function isEnvFlagEnabled(value: string | undefined): boolean { return normalized === 'true' || normalized === '1'; } +/** + * Whether the email (AgentMail) channel is enabled for this deployment. + * Reads the process environment at call time — the same way the AgentMail + * credential resolver does — so the gate can never disagree with the + * credentials it guards. + */ +export function isEmailChannelEnabled( + env: Record = process.env, +): boolean { + return isEnvFlagEnabled(env.R_EMAIL_CHANNEL_ENABLED); +} + /** Whether Roomote Cloud-only behavior is enabled for this deployment. */ export function isRoomoteCloudEnabled( value: string | boolean | undefined, diff --git a/packages/sdk/package.json b/packages/sdk/package.json index b8d1a9e9d..58d9420e9 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -48,6 +48,10 @@ "import": "./src/server/lib/telegram-primary-chat.ts", "require": "./src/server/lib/telegram-primary-chat.ts" }, + "./server/agentmail-outbound": { + "import": "./src/server/lib/agentmail/outbound.ts", + "require": "./src/server/lib/agentmail/outbound.ts" + }, "./server/discord-persistence": { "import": "./src/server/lib/discord-persistence.ts", "require": "./src/server/lib/discord-persistence.ts" diff --git a/packages/sdk/src/server/automations/custom-automations.ts b/packages/sdk/src/server/automations/custom-automations.ts index 0fdb7d292..f6eb05e8c 100644 --- a/packages/sdk/src/server/automations/custom-automations.ts +++ b/packages/sdk/src/server/automations/custom-automations.ts @@ -71,7 +71,12 @@ class CustomAutomationClaimSettlementError extends Error { } } -const PROVIDER_LABELS: Record = { +// Email (agentmail) is inbound-initiated only and never an automation +// destination, so it is deliberately absent here. +const PROVIDER_LABELS: Record< + Exclude, + string +> = { discord: 'Discord', slack: 'Slack', teams: 'Teams', @@ -647,7 +652,7 @@ async function launchCustomAutomationRow( automation.target.provider === 'slack' && automation.target.targetKind === 'slack_channel'; if (fastExecution && !isFastDeliveryTarget(automation.target)) { - const message = `${PROVIDER_LABELS[automation.target.provider as CommunicationProvider]} report destinations of this type are not supported in Fast mode.`; + const message = `${PROVIDER_LABELS[automation.target.provider as Exclude]} report destinations of this type are not supported in Fast mode.`; result.skippedReason = message; result.errors.push(message); await recordCustomAutomationRunOutcome(db, { @@ -672,7 +677,7 @@ async function launchCustomAutomationRow( const message = isBackgroundAutomationUserTargetKind( automation.target.targetKind, ) - ? `The automation owner does not have a linked ${PROVIDER_LABELS[automation.target.provider as CommunicationProvider]} account that can receive direct messages.` + ? `The automation owner does not have a linked ${PROVIDER_LABELS[automation.target.provider as Exclude]} account that can receive direct messages.` : automation.target.provider === 'teams' ? 'Teams report destination is missing a resolvable service URL.' : 'Report destination could not be resolved.'; diff --git a/packages/sdk/src/server/automations/destination.ts b/packages/sdk/src/server/automations/destination.ts index d5079c3bb..7806aba31 100644 --- a/packages/sdk/src/server/automations/destination.ts +++ b/packages/sdk/src/server/automations/destination.ts @@ -12,7 +12,7 @@ import { } from '@roomote/db/server'; import { isBackgroundAutomationUserTargetKind, - type CommunicationProvider, + type AutomationCapableCommunicationProvider, } from '@roomote/types'; import { findDiscordDefaultDestination } from '../lib/discord-persistence'; @@ -22,7 +22,7 @@ import { findUserDirectMessageDestination } from '../lib/user-direct-message'; /** Fully resolved destination an automation run reports to. */ export type ResolvedAutomationDestination = { - provider: CommunicationProvider; + provider: AutomationCapableCommunicationProvider; channelId: string; /** Provider workspace/tenant that owns the destination when routing is installation-specific. */ teamId?: string; @@ -37,8 +37,12 @@ export type ResolvedAutomationDestination = { * an installation is active; Teams when bot credentials resolve; Telegram * and Discord when a bot token resolves. */ +/** + * Chat providers that can receive automation output. Email (agentmail) is + * inbound-initiated and deliberately never listed here. + */ export async function listConnectedCommunicationProviders(): Promise< - CommunicationProvider[] + AutomationCapableCommunicationProvider[] > { const [ slackInstallation, @@ -177,12 +181,13 @@ export async function resolveAutomationRuntimeDestination(params: { ); if (userTarget) { const directMessage = await findUserDirectMessageDestination( - userTarget.provider as CommunicationProvider, + userTarget.provider as AutomationCapableCommunicationProvider, userTarget.externalRef, ); return directMessage ? { - provider: userTarget.provider as CommunicationProvider, + provider: + userTarget.provider as AutomationCapableCommunicationProvider, ...directMessage, source: 'automation_target', } diff --git a/packages/sdk/src/server/index.ts b/packages/sdk/src/server/index.ts index d3354fd5e..2f1d0b720 100644 --- a/packages/sdk/src/server/index.ts +++ b/packages/sdk/src/server/index.ts @@ -223,6 +223,57 @@ export { type RuntimeCommunicationProviderAdapter, } from './lib/communication-providers'; +export { createAgentMailCommunicationProviderFromRuntimeCredentials } from './lib/agentmail-communication'; + +export { + advanceAgentMailInboundAnchor, + normalizeEmailAddress, + recordAgentMailOutboundMessage, + resolveAgentMailReplyRoute, + resolveAgentMailSenderUserId, + resolveOrCreateAgentMailConversation, + type AgentMailConversationRow, + type AgentMailReplyRouteData, +} from './lib/agentmail/conversation-store'; + +export { + buildAgentMailRuiAnswerToken, + buildAgentMailRuiAnswerUrl, + verifyAgentMailRuiAnswerToken, +} from './lib/agentmail/rui-answer-links'; + +export { + buildAgentMailEmailLinkToken, + buildAgentMailEmailLinkUrl, + buildAgentMailUnsubscribeToken, + buildAgentMailUnsubscribeUrl, + verifyAgentMailEmailLinkToken, + verifyAgentMailUnsubscribeToken, +} from './lib/agentmail/email-link-tokens'; + +export { + canStartAgentMailConversationWithUser, + isAgentMailAddressSuppressed, + resolveAgentMailOutboundAddress, + sendAgentMailSystemEmail, + startAgentMailConversation, + suppressAgentMailAddress, + type AgentMailOutboundAddressResolution, + type AgentMailSystemEmailResult, + type AgentMailSuppressionReason, +} from './lib/agentmail/outbound'; + +export { + AGENTMAIL_WEBHOOK_EVENT_QUEUE_NAME, + AgentMailConversationBusyError, + drainAgentMailInboundTurns, + processAgentMailWebhookEvent, + recordAgentMailWebhookEvent, + recoverPendingAgentMailWork, + redispatchAgentMailEventsForSender, + type AgentMailWebhookEventJob, +} from './lib/agentmail/inbound'; + export { findTelegramPrimaryChatId, TELEGRAM_PRIMARY_CHAT_ENV_VAR_NAME, diff --git a/packages/sdk/src/server/lib/agentmail-communication.ts b/packages/sdk/src/server/lib/agentmail-communication.ts new file mode 100644 index 000000000..f460d7d8f --- /dev/null +++ b/packages/sdk/src/server/lib/agentmail-communication.ts @@ -0,0 +1,48 @@ +import { AgentMailCommunicationProvider } from '@roomote/communication/agentmail-provider'; +import { resolveAgentMailRuntimeCredentials } from '@roomote/db/server'; +import { isEmailChannelEnabled } from '@roomote/env'; + +import { + recordAgentMailOutboundMessage, + resolveAgentMailReplyRoute, +} from './agentmail/conversation-store'; + +type AgentMailCommunicationProviderRuntimeOptions = { + /** Custom fetch, e.g. a base-URL-rewriting fetch for the mock harness. */ + fetch?: typeof fetch; +}; + +/** + * Builds an `AgentMailCommunicationProvider` from the resolved runtime + * credentials (env vars, or values saved from the comms settings UI), or + * `null` when no API key is configured or the email channel is disabled. + * Every reply path (Fast surface replies, parent events, finish-run result + * emails, MCP thread replies) builds its adapter here, so this null is what + * makes R_EMAIL_CHANNEL_ENABLED a complete kill switch for already-admitted + * conversations, not just for new ones. The adapter resolves reply anchors + * and recipients from the durable conversation row at send time and writes + * completed sends back to the outbound anchor only. + */ +export async function createAgentMailCommunicationProviderFromRuntimeCredentials( + options?: AgentMailCommunicationProviderRuntimeOptions, +): Promise { + if (!isEmailChannelEnabled()) { + return null; + } + + const { apiKey } = await resolveAgentMailRuntimeCredentials(); + + if (!apiKey) { + return null; + } + + return new AgentMailCommunicationProvider({ + apiKey, + resolveRoute: async (conversationId) => + resolveAgentMailReplyRoute(conversationId), + onMessageSent: async ({ conversationId, messageId }) => { + await recordAgentMailOutboundMessage({ conversationId, messageId }); + }, + ...(options?.fetch ? { fetch: options.fetch } : {}), + }); +} diff --git a/packages/sdk/src/server/lib/agentmail/__tests__/conversation-store.db.test.ts b/packages/sdk/src/server/lib/agentmail/__tests__/conversation-store.db.test.ts new file mode 100644 index 000000000..1d8bf5ff1 --- /dev/null +++ b/packages/sdk/src/server/lib/agentmail/__tests__/conversation-store.db.test.ts @@ -0,0 +1,195 @@ +import { randomUUID } from 'node:crypto'; + +import { + agentmailConversationParticipants, + agentmailConversations, + agentmailUserMappings, + db, + eq, + userFactory, +} from '@roomote/db/server'; + +import { + advanceAgentMailInboundAnchor, + recordAgentMailOutboundMessage, + resolveOrCreateAgentMailConversation, +} from '../conversation-store'; + +const INBOX = 'roomote-test@agentmail.to'; + +async function mappedUser() { + const user = await userFactory.create(); + const email = `${randomUUID()}@example.com`; + await db.insert(agentmailUserMappings).values({ + emailAddress: email, + userId: user.id, + source: 'link_code', + }); + return { user, email }; +} + +describe('agentmail conversation store (real database)', () => { + it('creates a conversation with the sender as owner on first contact and reuses it after', async () => { + const { user } = await mappedUser(); + const threadId = `thread-${randomUUID()}`; + + const first = await resolveOrCreateAgentMailConversation({ + inboxId: INBOX, + providerThreadId: threadId, + senderUserId: user.id, + subject: 'Hello', + recipientAddresses: [], + }); + expect(first.created).toBe(true); + expect(first.conversation.ownerUserId).toBe(user.id); + + const second = await resolveOrCreateAgentMailConversation({ + inboxId: INBOX, + providerThreadId: threadId, + senderUserId: user.id, + subject: 'Hello', + recipientAddresses: [], + }); + expect(second.created).toBe(false); + expect(second.conversation.id).toBe(first.conversation.id); + }); + + it('forks a forwarded thread into an isolated conversation for a non-participant', async () => { + const { user: owner } = await mappedUser(); + const { user: forwarder } = await mappedUser(); + const threadId = `thread-${randomUUID()}`; + + const original = await resolveOrCreateAgentMailConversation({ + inboxId: INBOX, + providerThreadId: threadId, + senderUserId: owner.id, + subject: 'Original', + recipientAddresses: [], + }); + + const fork = await resolveOrCreateAgentMailConversation({ + inboxId: INBOX, + providerThreadId: threadId, + senderUserId: forwarder.id, + subject: 'Fwd: Original', + recipientAddresses: [], + }); + + expect(fork.created).toBe(true); + expect(fork.conversation.id).not.toBe(original.conversation.id); + expect(fork.conversation.providerThreadId).toBe(threadId); + expect(fork.conversation.ownerUserId).toBe(forwarder.id); + }); + + it('joins the single candidate conversation when the inbound to/cc identities intersect it', async () => { + const { user: owner, email: ownerEmail } = await mappedUser(); + const { user: ccUser } = await mappedUser(); + const threadId = `thread-${randomUUID()}`; + + const original = await resolveOrCreateAgentMailConversation({ + inboxId: INBOX, + providerThreadId: threadId, + senderUserId: owner.id, + subject: 'Original', + recipientAddresses: [], + }); + + const joined = await resolveOrCreateAgentMailConversation({ + inboxId: INBOX, + providerThreadId: threadId, + senderUserId: ccUser.id, + subject: 'Re: Original', + recipientAddresses: [ownerEmail, INBOX], + }); + + expect(joined.created).toBe(false); + expect(joined.joinedAsCc).toBe(true); + expect(joined.conversation.id).toBe(original.conversation.id); + + const membership = + await db.query.agentmailConversationParticipants.findFirst({ + where: eq(agentmailConversationParticipants.userId, ccUser.id), + }); + expect(membership?.role).toBe('participant'); + expect(membership?.source).toBe('cc'); + }); + + it('never creates two conversations for one sender racing on first contact', async () => { + const { user } = await mappedUser(); + const threadId = `thread-${randomUUID()}`; + + const results = await Promise.all( + Array.from({ length: 4 }, () => + resolveOrCreateAgentMailConversation({ + inboxId: INBOX, + providerThreadId: threadId, + senderUserId: user.id, + subject: 'Race', + recipientAddresses: [], + }), + ), + ); + + const ids = new Set(results.map((result) => result.conversation.id)); + expect(ids.size).toBe(1); + }); + + it('advances the inbound anchor by (timestamp, message id) and never regresses it', async () => { + const { user, email } = await mappedUser(); + const threadId = `thread-${randomUUID()}`; + const { conversation } = await resolveOrCreateAgentMailConversation({ + inboxId: INBOX, + providerThreadId: threadId, + senderUserId: user.id, + subject: 'Anchors', + recipientAddresses: [], + }); + + const t1 = new Date('2026-08-31T10:00:00Z'); + const t2 = new Date('2026-08-31T10:00:05Z'); + + expect( + await advanceAgentMailInboundAnchor({ + conversationId: conversation.id, + messageId: 'msg-b', + providerTimestamp: t2, + senderEmail: email, + senderUserId: user.id, + }), + ).toBe(true); + + // An out-of-order older delivery must not regress the anchor. + expect( + await advanceAgentMailInboundAnchor({ + conversationId: conversation.id, + messageId: 'msg-a', + providerTimestamp: t1, + senderEmail: email, + senderUserId: user.id, + }), + ).toBe(false); + + // Same timestamp: the message id breaks the tie deterministically. + expect( + await advanceAgentMailInboundAnchor({ + conversationId: conversation.id, + messageId: 'msg-c', + providerTimestamp: t2, + senderEmail: email, + senderUserId: user.id, + }), + ).toBe(true); + + // Outbound completion touches only the outbound anchor. + await recordAgentMailOutboundMessage({ + conversationId: conversation.id, + messageId: 'out-1', + }); + + const row = await db.query.agentmailConversations.findFirst({ + where: eq(agentmailConversations.id, conversation.id), + }); + expect(row?.latestInboundMessageId).toBe('msg-c'); + expect(row?.latestOutboundMessageId).toBe('out-1'); + }); +}); diff --git a/packages/sdk/src/server/lib/agentmail/__tests__/inbound.db.test.ts b/packages/sdk/src/server/lib/agentmail/__tests__/inbound.db.test.ts new file mode 100644 index 000000000..894e22bc5 --- /dev/null +++ b/packages/sdk/src/server/lib/agentmail/__tests__/inbound.db.test.ts @@ -0,0 +1,360 @@ +import { randomUUID } from 'node:crypto'; + +import { + agentmailConversations, + agentmailInboundTurns, + agentmailSuppressions, + agentmailUserMappings, + agentmailWebhookEvents, + db, + eq, + userFactory, +} from '@roomote/db/server'; + +import { + processAgentMailWebhookEvent, + recordAgentMailWebhookEvent, +} from '../inbound'; +import { isAgentMailAddressSuppressed } from '../outbound'; + +const INBOX = 'roomote-test@agentmail.to'; + +function messageReceivedPayload(input: { + eventId: string; + threadId: string; + messageId: string; + from: string; + text: string; + timestamp?: string; +}) { + return { + type: 'event', + event_type: 'message.received', + event_id: input.eventId, + message: { + message_id: input.messageId, + thread_id: input.threadId, + inbox_id: INBOX, + from: input.from, + to: [INBOX], + subject: 'Test request', + text: input.text, + extracted_text: input.text, + timestamp: input.timestamp ?? new Date().toISOString(), + }, + thread: { + thread_id: input.threadId, + last_message_id: input.messageId, + message_count: 1, + }, + }; +} + +describe('agentmail webhook event outbox (real database)', () => { + beforeAll(() => { + process.env.R_EMAIL_CHANNEL_ENABLED = 'true'; + process.env.R_AGENTMAIL_API_KEY = 'am_test_key'; + process.env.R_AGENTMAIL_WEBHOOK_SECRET = 'whsec_dGVzdA=='; + process.env.R_AGENTMAIL_INBOX_ID = INBOX; + }); + + it('acknowledges and drops deliveries while the email channel is disabled', async () => { + process.env.R_EMAIL_CHANNEL_ENABLED = 'false'; + try { + const deliveryId = `msg_${randomUUID()}`; + const result = await recordAgentMailWebhookEvent({ + deliveryId, + eventId: null, + eventType: 'message.received', + payload: messageReceivedPayload({ + eventId: `evt_${randomUUID()}`, + threadId: `thread-${randomUUID()}`, + messageId: `m-${randomUUID()}`, + from: `${randomUUID()}@example.com`, + text: 'Hello', + }), + }); + expect(result).toEqual({ + accepted: false, + reason: 'email_channel_disabled', + }); + const row = await db.query.agentmailWebhookEvents.findFirst({ + where: eq(agentmailWebhookEvents.deliveryId, deliveryId), + }); + expect(row).toBeUndefined(); + } finally { + process.env.R_EMAIL_CHANNEL_ENABLED = 'true'; + } + }); + + it('records a delivery as received, dispatches it, and acks duplicates by row state', async () => { + const deliveryId = `msg_${randomUUID()}`; + const payload = messageReceivedPayload({ + eventId: `evt_${randomUUID()}`, + threadId: `thread-${randomUUID()}`, + messageId: `m-${randomUUID()}`, + from: `${randomUUID()}@example.com`, + text: 'Hello', + }); + + const first = await recordAgentMailWebhookEvent({ + deliveryId, + eventId: null, + eventType: 'message.received', + payload, + }); + expect(first).toEqual({ accepted: true, duplicate: false }); + + const row = await db.query.agentmailWebhookEvents.findFirst({ + where: eq(agentmailWebhookEvents.deliveryId, deliveryId), + }); + expect(row?.state).toBe('queued'); + + const retry = await recordAgentMailWebhookEvent({ + deliveryId, + eventId: null, + eventType: 'message.received', + payload, + }); + expect(retry).toEqual({ accepted: true, duplicate: true }); + }); + + it('ignores event types the channel does not consume', async () => { + const result = await recordAgentMailWebhookEvent({ + deliveryId: `msg_${randomUUID()}`, + eventId: null, + eventType: 'message.opened', + payload: {}, + }); + expect(result).toEqual({ accepted: false, reason: 'ignored_event_type' }); + }); + + it('admits a known sender as a durable inbound turn before marking the event processed', async () => { + const user = await userFactory.create(); + const senderEmail = `${randomUUID()}@example.com`; + await db.insert(agentmailUserMappings).values({ + emailAddress: senderEmail, + userId: user.id, + source: 'link_code', + }); + + const deliveryId = `msg_${randomUUID()}`; + const threadId = `thread-${randomUUID()}`; + const messageId = `m-${randomUUID()}`; + await recordAgentMailWebhookEvent({ + deliveryId, + eventId: null, + eventType: 'message.received', + payload: messageReceivedPayload({ + eventId: `evt_${randomUUID()}`, + threadId, + messageId, + from: `Sender <${senderEmail}>`, + text: 'Please look into the flaky test', + }), + }); + + await processAgentMailWebhookEvent(deliveryId); + + const eventRow = await db.query.agentmailWebhookEvents.findFirst({ + where: eq(agentmailWebhookEvents.deliveryId, deliveryId), + }); + expect(eventRow?.state).toBe('processed'); + + const conversation = await db.query.agentmailConversations.findFirst({ + where: eq(agentmailConversations.providerThreadId, threadId), + }); + expect(conversation?.ownerUserId).toBe(user.id); + expect(conversation?.latestInboundMessageId).toBe(messageId); + expect(conversation?.latestInboundSenderEmail).toBe(senderEmail); + + const turn = await db.query.agentmailInboundTurns.findFirst({ + where: eq(agentmailInboundTurns.conversationId, conversation!.id), + }); + expect(turn?.state).toBe('pending'); + expect(turn?.providerMessageId).toBe(messageId); + + // Reprocessing the same delivery is a no-op: idempotent on the event row. + await processAgentMailWebhookEvent(deliveryId); + const turns = await db.query.agentmailInboundTurns.findMany({ + where: eq(agentmailInboundTurns.conversationId, conversation!.id), + }); + expect(turns).toHaveLength(1); + }); + + it('drops auto-generated mail without admitting a turn', async () => { + const user = await userFactory.create(); + const senderEmail = `${randomUUID()}@example.com`; + await db.insert(agentmailUserMappings).values({ + emailAddress: senderEmail, + userId: user.id, + source: 'link_code', + }); + + const deliveryId = `msg_${randomUUID()}`; + const threadId = `thread-${randomUUID()}`; + const payload = messageReceivedPayload({ + eventId: `evt_${randomUUID()}`, + threadId, + messageId: `m-${randomUUID()}`, + from: senderEmail, + text: 'I am out of the office', + }); + (payload.message as Record).headers = { + 'Auto-Submitted': 'auto-replied', + }; + + await recordAgentMailWebhookEvent({ + deliveryId, + eventId: null, + eventType: 'message.received', + payload, + }); + await processAgentMailWebhookEvent(deliveryId); + + const eventRow = await db.query.agentmailWebhookEvents.findFirst({ + where: eq(agentmailWebhookEvents.deliveryId, deliveryId), + }); + expect(eventRow?.state).toBe('processed'); + + const conversation = await db.query.agentmailConversations.findFirst({ + where: eq(agentmailConversations.providerThreadId, threadId), + }); + expect(conversation).toBeUndefined(); + }); + + it('keeps the stranger-refusal claim on ambiguous failures, releases it on definite rejections', async () => { + const originalFetch = globalThis.fetch; + const deliverStranger = async (threadId: string, sender: string) => { + const deliveryId = `msg_${randomUUID()}`; + await recordAgentMailWebhookEvent({ + deliveryId, + eventId: null, + eventType: 'message.received', + payload: messageReceivedPayload({ + eventId: `evt_${randomUUID()}`, + threadId, + messageId: `m-${randomUUID()}`, + from: sender, + text: 'Hello from a stranger', + }), + }); + await processAgentMailWebhookEvent(deliveryId); + }; + + try { + // Ambiguous failure (503 after retries): the provider may have sent + // the refusal, so the once-per-thread claim must be kept. + const ambiguousThread = `thread-${randomUUID()}`; + const ambiguousSender = `${randomUUID()}@example.com`; + globalThis.fetch = (async () => + new Response('oops', { status: 503 })) as typeof fetch; + await deliverStranger(ambiguousThread, ambiguousSender); + + let refusalAttempts = 0; + globalThis.fetch = (async () => { + refusalAttempts += 1; + return new Response(JSON.stringify({ message_id: 'm-refusal' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }) as typeof fetch; + await deliverStranger(ambiguousThread, ambiguousSender); + expect(refusalAttempts).toBe(0); + + // Definite rejection (400): the refusal was never processed, so the + // claim is released and the next email gets its refusal. + const rejectedThread = `thread-${randomUUID()}`; + const rejectedSender = `${randomUUID()}@example.com`; + globalThis.fetch = (async () => + new Response('bad request', { status: 400 })) as typeof fetch; + await deliverStranger(rejectedThread, rejectedSender); + + globalThis.fetch = (async () => { + refusalAttempts += 1; + return new Response(JSON.stringify({ message_id: 'm-refusal' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }) as typeof fetch; + await deliverStranger(rejectedThread, rejectedSender); + expect(refusalAttempts).toBe(1); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('suppresses recipients of permanent bounces and complaints, but not transient bounces', async () => { + const bounced = `${randomUUID()}@example.com`; + const complained = `${randomUUID()}@example.com`; + const transient = `${randomUUID()}@example.com`; + + const record = async (eventType: string, payload: unknown) => { + const deliveryId = `msg_${randomUUID()}`; + await recordAgentMailWebhookEvent({ + deliveryId, + eventId: null, + eventType, + payload, + }); + await processAgentMailWebhookEvent(deliveryId); + const row = await db.query.agentmailWebhookEvents.findFirst({ + where: eq(agentmailWebhookEvents.deliveryId, deliveryId), + }); + expect(row?.state).toBe('processed'); + }; + + await record('message.bounced', { + type: 'event', + event_type: 'message.bounced', + event_id: `evt_${randomUUID()}`, + bounce: { + inbox_id: INBOX, + message_id: `<${randomUUID()}@agentmail.to>`, + type: 'Permanent', + sub_type: 'General', + recipients: [{ address: bounced, status: 'bounced' }], + }, + }); + await record('message.complained', { + type: 'event', + event_type: 'message.complained', + event_id: `evt_${randomUUID()}`, + complaint: { + inbox_id: INBOX, + message_id: `<${randomUUID()}@agentmail.to>`, + type: 'abuse', + sub_type: 'spam', + recipients: [complained], + }, + }); + await record('message.bounced', { + type: 'event', + event_type: 'message.bounced', + event_id: `evt_${randomUUID()}`, + bounce: { + inbox_id: INBOX, + message_id: `<${randomUUID()}@agentmail.to>`, + type: 'Transient', + sub_type: 'MailboxFull', + recipients: [{ address: transient, status: 'bounced' }], + }, + }); + + expect(await isAgentMailAddressSuppressed(bounced)).toBe(true); + expect(await isAgentMailAddressSuppressed(complained)).toBe(true); + expect(await isAgentMailAddressSuppressed(transient)).toBe(false); + + const bounceRow = await db.query.agentmailSuppressions.findFirst({ + where: eq(agentmailSuppressions.emailAddress, bounced), + }); + expect(bounceRow).toMatchObject({ + reason: 'bounce', + details: 'Permanent/General', + }); + const complaintRow = await db.query.agentmailSuppressions.findFirst({ + where: eq(agentmailSuppressions.emailAddress, complained), + }); + expect(complaintRow?.reason).toBe('complaint'); + }); +}); diff --git a/packages/sdk/src/server/lib/agentmail/__tests__/outbound.db.test.ts b/packages/sdk/src/server/lib/agentmail/__tests__/outbound.db.test.ts new file mode 100644 index 000000000..ee0eba2c4 --- /dev/null +++ b/packages/sdk/src/server/lib/agentmail/__tests__/outbound.db.test.ts @@ -0,0 +1,360 @@ +import { randomUUID } from 'node:crypto'; + +import { + agentmailConversationParticipants, + agentmailConversations, + agentmailSuppressions, + agentmailUserMappings, + authUsers, + db, + eq, + userFactory, +} from '@roomote/db/server'; + +import { createAgentMailCommunicationProviderFromRuntimeCredentials } from '../../agentmail-communication'; +import { + canStartAgentMailConversationWithUser, + isAgentMailAddressSuppressed, + resolveAgentMailOutboundAddress, + sendAgentMailSystemEmail, + startAgentMailConversation, + suppressAgentMailAddress, +} from '../outbound'; + +const INBOX = 'roomote-outbound-test@agentmail.to'; + +function uniqueEmail(prefix: string): string { + return `${prefix}-${randomUUID()}@example.test`; +} + +async function createVerifiedUser(email: string) { + const user = await userFactory.create({ email }); + await db.insert(authUsers).values({ + id: user.id, + name: user.name ?? 'Test User', + email, + emailVerified: true, + }); + return user; +} + +describe('agentmail suppression store (real database)', () => { + it('is sticky and first-reason-wins', async () => { + const address = uniqueEmail('bounced'); + + expect(await isAgentMailAddressSuppressed(address)).toBe(false); + expect( + await suppressAgentMailAddress({ + emailAddress: address.toUpperCase(), + reason: 'bounce', + details: 'Permanent/General', + }), + ).toBe(true); + expect( + await suppressAgentMailAddress({ + emailAddress: address, + reason: 'complaint', + }), + ).toBe(false); + + expect(await isAgentMailAddressSuppressed(address)).toBe(true); + const row = await db.query.agentmailSuppressions.findFirst({ + where: eq(agentmailSuppressions.emailAddress, address), + }); + expect(row?.reason).toBe('bounce'); + }); +}); + +describe('resolveAgentMailOutboundAddress (real database)', () => { + it('prefers the verified account email over a linked mapping', async () => { + const accountEmail = uniqueEmail('account'); + const linkedEmail = uniqueEmail('linked'); + const user = await createVerifiedUser(accountEmail); + await db.insert(agentmailUserMappings).values({ + emailAddress: linkedEmail, + userId: user.id, + source: 'link_code', + }); + + expect(await resolveAgentMailOutboundAddress(user.id)).toEqual({ + ok: true, + emailAddress: accountEmail.toLowerCase(), + }); + }); + + it('falls back to a linked mapping when the account email is suppressed', async () => { + const accountEmail = uniqueEmail('account'); + const linkedEmail = uniqueEmail('linked'); + const user = await createVerifiedUser(accountEmail); + await db.insert(agentmailUserMappings).values({ + emailAddress: linkedEmail, + userId: user.id, + source: 'link_code', + }); + await suppressAgentMailAddress({ + emailAddress: accountEmail, + reason: 'unsubscribe', + }); + + expect(await resolveAgentMailOutboundAddress(user.id)).toEqual({ + ok: true, + emailAddress: linkedEmail.toLowerCase(), + }); + }); + + it('refuses when every permitted address is suppressed', async () => { + const accountEmail = uniqueEmail('account'); + const user = await createVerifiedUser(accountEmail); + await suppressAgentMailAddress({ + emailAddress: accountEmail, + reason: 'complaint', + }); + + expect(await resolveAgentMailOutboundAddress(user.id)).toEqual({ + ok: false, + reason: 'suppressed', + }); + }); + + it('refuses users with no verified email and no mapping', async () => { + const user = await userFactory.create(); + + expect(await resolveAgentMailOutboundAddress(user.id)).toEqual({ + ok: false, + reason: 'no_permitted_address', + }); + }); +}); + +describe('startAgentMailConversation (real database, stubbed AgentMail API)', () => { + const originalFetch = globalThis.fetch; + + beforeAll(() => { + process.env.R_EMAIL_CHANNEL_ENABLED = 'true'; + process.env.R_AGENTMAIL_API_KEY = 'am_test_key'; + process.env.R_AGENTMAIL_WEBHOOK_SECRET = 'whsec_dGVzdA=='; + process.env.R_AGENTMAIL_INBOX_ID = INBOX; + }); + + it('refuses to send while the email channel is disabled', async () => { + const accountEmail = uniqueEmail('gated'); + const user = await createVerifiedUser(accountEmail); + let called = false; + globalThis.fetch = (async () => { + called = true; + return new Response('{}', { status: 200 }); + }) as typeof fetch; + + process.env.R_EMAIL_CHANNEL_ENABLED = 'false'; + try { + // The shared reply-path factory is the kill switch for conversations + // that were admitted before the flag was turned off. + expect( + await createAgentMailCommunicationProviderFromRuntimeCredentials(), + ).toBeNull(); + expect(await canStartAgentMailConversationWithUser(user.id)).toBe(false); + expect( + await startAgentMailConversation({ + userId: user.id, + subject: 'Gated', + text: 'nope', + logContext: 'outbound-test', + }), + ).toBe(false); + expect( + await sendAgentMailSystemEmail({ + to: accountEmail, + subject: 'Gated', + text: 'nope', + logContext: 'outbound-test', + }), + ).toEqual({ sent: false, reason: 'channel_disabled' }); + expect(called).toBe(false); + } finally { + process.env.R_EMAIL_CHANNEL_ENABLED = 'true'; + } + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it('sends with List-Unsubscribe headers and records a replyable conversation', async () => { + const accountEmail = uniqueEmail('recipient'); + const user = await createVerifiedUser(accountEmail); + const threadId = `thread_${randomUUID()}`; + const messageId = `<${randomUUID()}@agentmail.to>`; + + const requests: { + url: string; + body: Record; + headers: Record; + }[] = []; + globalThis.fetch = (async ( + input: RequestInfo | URL, + init?: RequestInit, + ) => { + requests.push({ + url: String(input), + body: JSON.parse(String(init?.body ?? '{}')) as Record, + headers: (init?.headers ?? {}) as Record, + }); + return new Response( + JSON.stringify({ message_id: messageId, thread_id: threadId }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + }) as typeof fetch; + + const sent = await startAgentMailConversation({ + userId: user.id, + subject: 'Your GitHub installation was approved', + text: 'The installation for acme was approved.', + logContext: 'outbound-test', + }); + + expect(sent).toBe(true); + expect(requests).toHaveLength(1); + expect(requests[0]!.url).toContain( + `/v0/inboxes/${encodeURIComponent(INBOX)}/messages/send`, + ); + expect(requests[0]!.body.to).toEqual([accountEmail.toLowerCase()]); + const headers = requests[0]!.body.headers as Record; + expect(headers['List-Unsubscribe']).toMatch( + /^<.*\/api\/webhooks\/agentmail\/unsubscribe\?token=.*>$/, + ); + expect(headers['List-Unsubscribe-Post']).toBe('List-Unsubscribe=One-Click'); + // Idempotency key present: the client's internal 5xx/lost-response + // retries replay the accepted send instead of emailing twice. + expect(requests[0]!.headers['idempotency-key']).toMatch(/^[0-9a-f]{64}$/); + + const conversation = await db.query.agentmailConversations.findFirst({ + where: eq(agentmailConversations.providerThreadId, threadId), + }); + expect(conversation).toMatchObject({ + inboxId: INBOX, + ownerUserId: user.id, + latestOutboundMessageId: messageId, + latestInboundMessageId: null, + }); + + const participant = + await db.query.agentmailConversationParticipants.findFirst({ + where: eq( + agentmailConversationParticipants.conversationId, + conversation!.id, + ), + }); + expect(participant).toMatchObject({ + userId: user.id, + role: 'owner', + source: 'outbound', + }); + }); + + it('does not call the API for a suppressed recipient', async () => { + const accountEmail = uniqueEmail('suppressed'); + const user = await createVerifiedUser(accountEmail); + await suppressAgentMailAddress({ + emailAddress: accountEmail, + reason: 'unsubscribe', + }); + + let called = false; + globalThis.fetch = (async () => { + called = true; + return new Response('{}', { status: 200 }); + }) as typeof fetch; + + const sent = await startAgentMailConversation({ + userId: user.id, + subject: 'Should never send', + text: 'nope', + logContext: 'outbound-test', + }); + + expect(sent).toBe(false); + expect(called).toBe(false); + }); +}); + +describe('sendAgentMailSystemEmail (real database, stubbed AgentMail API)', () => { + const originalFetch = globalThis.fetch; + + beforeAll(() => { + process.env.R_EMAIL_CHANNEL_ENABLED = 'true'; + process.env.R_AGENTMAIL_API_KEY = 'am_test_key'; + process.env.R_AGENTMAIL_WEBHOOK_SECRET = 'whsec_dGVzdA=='; + process.env.R_AGENTMAIL_INBOX_ID = INBOX; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + function stubSend() { + const requests: { body: Record }[] = []; + globalThis.fetch = (async ( + _input: RequestInfo | URL, + init?: RequestInit, + ) => { + requests.push({ + body: JSON.parse(String(init?.body ?? '{}')) as Record, + }); + return new Response( + JSON.stringify({ message_id: `<${randomUUID()}@agentmail.to>` }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + }) as typeof fetch; + return requests; + } + + it('sends to an unverified address with no unsubscribe header', async () => { + const requests = stubSend(); + const to = uniqueEmail('unverified'); + + const result = await sendAgentMailSystemEmail({ + to, + subject: 'Verify your email for Roomote', + text: '[Verify](https://example.test/verify)', + logContext: 'outbound-test', + }); + + expect(result).toEqual({ sent: true }); + expect(requests).toHaveLength(1); + expect(requests[0]!.body.to).toEqual([to.toLowerCase()]); + expect(requests[0]!.body.headers).toBeUndefined(); + expect(String(requests[0]!.body.text)).not.toContain('unsubscribe'); + }); + + it('still sends to an address that unsubscribed from notifications', async () => { + const requests = stubSend(); + const to = uniqueEmail('unsubscribed'); + await suppressAgentMailAddress({ emailAddress: to, reason: 'unsubscribe' }); + + const result = await sendAgentMailSystemEmail({ + to, + subject: 'Reset your Roomote password', + text: 'reset', + logContext: 'outbound-test', + }); + + expect(result).toEqual({ sent: true }); + expect(requests).toHaveLength(1); + }); + + it('never sends to a bounced or complained address', async () => { + const requests = stubSend(); + const to = uniqueEmail('bounced'); + await suppressAgentMailAddress({ emailAddress: to, reason: 'bounce' }); + + const result = await sendAgentMailSystemEmail({ + to, + subject: 'Verify your email for Roomote', + text: 'verify', + logContext: 'outbound-test', + }); + + expect(result).toEqual({ sent: false, reason: 'suppressed' }); + expect(requests).toHaveLength(0); + }); +}); diff --git a/packages/sdk/src/server/lib/agentmail/conversation-store.ts b/packages/sdk/src/server/lib/agentmail/conversation-store.ts new file mode 100644 index 000000000..b38c6cb38 --- /dev/null +++ b/packages/sdk/src/server/lib/agentmail/conversation-store.ts @@ -0,0 +1,366 @@ +import { + agentmailConversationParticipants, + agentmailConversations, + agentmailUserMappings, + and, + authUsers, + db, + eq, + inArray, + sql, + users, +} from '@roomote/db/server'; + +export type AgentMailConversationRow = + typeof agentmailConversations.$inferSelect; + +export type AgentMailReplyRouteData = { + inboxId: string; + replyToMessageId: string | null; + recipientEmail: string | null; + subject: string | null; +}; + +export function normalizeEmailAddress(value: string): string { + return value.trim().toLowerCase(); +} + +/** + * Resolve a sender address to a user id: verified auth_users emails first, + * then explicit link-code mappings. Unverified account emails never match. + */ +export async function resolveAgentMailSenderUserId( + senderEmail: string, +): Promise { + const normalized = normalizeEmailAddress(senderEmail); + + // Verified emails live on Better Auth's auth_users; the app users table + // shares its id. Only a verified address matches automatically. + const verifiedAuthUser = await db.query.authUsers.findFirst({ + where: and( + eq(authUsers.email, normalized), + eq(authUsers.emailVerified, true), + ), + columns: { id: true }, + }); + if (verifiedAuthUser) { + const appUser = await db.query.users.findFirst({ + where: eq(users.id, verifiedAuthUser.id), + columns: { id: true }, + }); + if (appUser) { + return appUser.id; + } + } + + const mapping = await db.query.agentmailUserMappings.findFirst({ + where: eq(agentmailUserMappings.emailAddress, normalized), + columns: { userId: true }, + }); + + return mapping?.userId ?? null; +} + +/** Authorization check: is this user a member of the conversation? */ +export async function isAgentMailConversationParticipant(input: { + conversationId: string; + userId: string; +}): Promise { + const membership = await db.query.agentmailConversationParticipants.findFirst( + { + where: and( + eq( + agentmailConversationParticipants.conversationId, + input.conversationId, + ), + eq(agentmailConversationParticipants.userId, input.userId), + ), + columns: { id: true }, + }, + ); + return Boolean(membership); +} + +/** + * The durable reply route for a conversation. Replies target the latest + * inbound message and address the latest authorized sender only; the adapter + * reads this at send time and never trusts caller-supplied values. + */ +export async function resolveAgentMailReplyRoute( + conversationId: string, +): Promise { + const conversation = await db.query.agentmailConversations.findFirst({ + where: eq(agentmailConversations.id, conversationId), + columns: { + inboxId: true, + latestInboundMessageId: true, + latestInboundSenderEmail: true, + subject: true, + }, + }); + + if (!conversation) { + return null; + } + + return { + inboxId: conversation.inboxId, + replyToMessageId: conversation.latestInboundMessageId, + recipientEmail: conversation.latestInboundSenderEmail, + subject: conversation.subject, + }; +} + +/** + * Advance the inbound anchor. Inbound and outbound anchors are separate + * columns so an in-flight send can never overwrite this, and the guard is the + * total order (latest_inbound_at, latest_inbound_message_id) so out-of-order + * webhook deliveries and same-timestamp messages resolve deterministically. + * Returns true when the anchor advanced (false = an equal-or-newer message + * already holds it, which is fine). + */ +export async function advanceAgentMailInboundAnchor(input: { + conversationId: string; + messageId: string; + providerTimestamp: Date; + senderEmail: string; + senderUserId: string | null; +}): Promise { + const timestampIso = input.providerTimestamp.toISOString(); + const updated = await db + .update(agentmailConversations) + .set({ + latestInboundMessageId: input.messageId, + latestInboundAt: input.providerTimestamp, + latestInboundSenderEmail: normalizeEmailAddress(input.senderEmail), + latestInboundUserId: input.senderUserId, + version: sql`${agentmailConversations.version} + 1`, + updatedAt: new Date(), + }) + .where( + and( + eq(agentmailConversations.id, input.conversationId), + sql`( + ${agentmailConversations.latestInboundAt} IS NULL + OR (${agentmailConversations.latestInboundAt}, ${agentmailConversations.latestInboundMessageId}) + < (${timestampIso}::timestamp, ${input.messageId}) + )`, + ), + ) + .returning({ id: agentmailConversations.id }); + + return updated.length > 0; +} + +/** Record a completed outbound send. Never touches the inbound anchor. */ +export async function recordAgentMailOutboundMessage(input: { + conversationId: string; + messageId: string; +}): Promise { + await db + .update(agentmailConversations) + .set({ + latestOutboundMessageId: input.messageId, + version: sql`${agentmailConversations.version} + 1`, + updatedAt: new Date(), + }) + .where(eq(agentmailConversations.id, input.conversationId)); +} + +type AgentMailConversationResolution = { + conversation: AgentMailConversationRow; + created: boolean; + joinedAsCc: boolean; +}; + +/** + * Deterministic sender → conversation resolution, never "first database row": + * + * 1. The sender is already a participant of a conversation on this provider + * thread → that conversation (unique by the participant-table invariant). + * 2. No match, but exactly one existing conversation's participants intersect + * the inbound to/cc identities → join it as a cc participant. + * 3. Zero or multiple candidates → create an isolated fork owned by the + * sender. Forwarding never grants access to an existing conversation. + * + * Creation runs conversation + owner participant in one transaction; the + * unique (inbox_id, provider_thread_id, user_id) participant index turns a + * simultaneous double-create into an insert conflict, and the loser retries + * resolution from the top, finding the winner's conversation. + */ +export async function resolveOrCreateAgentMailConversation(input: { + inboxId: string; + providerThreadId: string; + senderUserId: string; + subject: string | null; + /** Normalized to/cc addresses of the inbound message, sender excluded. */ + recipientAddresses: string[]; +}): Promise { + const inboxId = normalizeEmailAddress(input.inboxId); + + for (let attempt = 0; attempt < 2; attempt += 1) { + const senderMembership = + await db.query.agentmailConversationParticipants.findFirst({ + where: and( + eq(agentmailConversationParticipants.inboxId, inboxId), + eq( + agentmailConversationParticipants.providerThreadId, + input.providerThreadId, + ), + eq(agentmailConversationParticipants.userId, input.senderUserId), + ), + columns: { conversationId: true }, + }); + + if (senderMembership) { + const conversation = await db.query.agentmailConversations.findFirst({ + where: eq(agentmailConversations.id, senderMembership.conversationId), + }); + if (conversation) { + return { conversation, created: false, joinedAsCc: false }; + } + } + + const ccCandidate = await findSingleCcJoinCandidate({ + inboxId, + providerThreadId: input.providerThreadId, + recipientAddresses: input.recipientAddresses, + }); + + if (ccCandidate) { + try { + await db.insert(agentmailConversationParticipants).values({ + conversationId: ccCandidate.id, + inboxId, + providerThreadId: input.providerThreadId, + userId: input.senderUserId, + role: 'participant', + source: 'cc', + }); + return { + conversation: ccCandidate, + created: false, + joinedAsCc: true, + }; + } catch (error) { + if (isUniqueViolation(error)) { + continue; + } + throw error; + } + } + + try { + const conversation = await db.transaction(async (tx) => { + const [created] = await tx + .insert(agentmailConversations) + .values({ + inboxId, + providerThreadId: input.providerThreadId, + ownerUserId: input.senderUserId, + subject: input.subject, + }) + .returning(); + if (!created) { + throw new Error('agentmail conversation insert returned no row'); + } + await tx.insert(agentmailConversationParticipants).values({ + conversationId: created.id, + inboxId, + providerThreadId: input.providerThreadId, + userId: input.senderUserId, + role: 'owner', + source: 'initiator', + }); + return created; + }); + return { conversation, created: true, joinedAsCc: false }; + } catch (error) { + if (isUniqueViolation(error)) { + // A concurrent first message from the same sender won the race; + // retry resolution and find their conversation. + continue; + } + throw error; + } + } + + throw new Error( + 'agentmail conversation resolution did not converge after retry', + ); +} + +/** + * Step 2 of resolution: the single conversation on this thread whose + * participant set intersects the inbound to/cc identities, or null when zero + * or multiple qualify (ambiguity always forks). + */ +async function findSingleCcJoinCandidate(input: { + inboxId: string; + providerThreadId: string; + recipientAddresses: string[]; +}): Promise { + const addresses = input.recipientAddresses + .map(normalizeEmailAddress) + .filter((address) => address && address !== input.inboxId); + + if (addresses.length === 0) { + return null; + } + + const recipientUsers = await db.query.authUsers.findMany({ + where: and( + inArray(authUsers.email, addresses), + eq(authUsers.emailVerified, true), + ), + columns: { id: true }, + }); + const mappedUsers = await db.query.agentmailUserMappings.findMany({ + where: inArray(agentmailUserMappings.emailAddress, addresses), + columns: { userId: true }, + }); + const recipientUserIds = new Set([ + ...recipientUsers.map((user) => user.id), + ...mappedUsers.map((mapping) => mapping.userId), + ]); + + if (recipientUserIds.size === 0) { + return null; + } + + const memberships = await db.query.agentmailConversationParticipants.findMany( + { + where: and( + eq(agentmailConversationParticipants.inboxId, input.inboxId), + eq( + agentmailConversationParticipants.providerThreadId, + input.providerThreadId, + ), + ), + columns: { conversationId: true, userId: true }, + }, + ); + + const candidateConversationIds = new Set( + memberships + .filter((membership) => recipientUserIds.has(membership.userId)) + .map((membership) => membership.conversationId), + ); + + if (candidateConversationIds.size !== 1) { + return null; + } + + const [conversationId] = candidateConversationIds; + const conversation = await db.query.agentmailConversations.findFirst({ + where: eq(agentmailConversations.id, conversationId!), + }); + + return conversation ?? null; +} + +function isUniqueViolation(error: unknown): boolean { + const code = (error as { code?: string; cause?: { code?: string } }).code; + const causeCode = (error as { cause?: { code?: string } }).cause?.code; + return code === '23505' || causeCode === '23505'; +} diff --git a/packages/sdk/src/server/lib/agentmail/email-link-tokens.ts b/packages/sdk/src/server/lib/agentmail/email-link-tokens.ts new file mode 100644 index 000000000..2a51a0ba6 --- /dev/null +++ b/packages/sdk/src/server/lib/agentmail/email-link-tokens.ts @@ -0,0 +1,154 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; + +import { Env } from '@roomote/env'; + +/** + * Signed link-this-address tokens for stranger-refusal emails. The token + * proves possession of the mailbox (it was delivered there); the web page it + * opens requires a signed-in Roomote session to choose which account claims + * the address — two factors, no secrets in the email. + */ + +const TOKEN_VERSION = 'v1'; +const TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000; + +type AgentMailEmailLinkTokenPayload = { + emailAddress: string; + expiresAtMs: number; +}; + +function signingKey(): Buffer { + return createHmac('sha256', Env.ARTIFACT_SIGNING_KEY) + .update('agentmail-email-link') + .digest(); +} + +function signPayload(encodedPayload: string): string { + return createHmac('sha256', signingKey()) + .update(`${TOKEN_VERSION}.${encodedPayload}`) + .digest('base64url'); +} + +export function buildAgentMailEmailLinkToken( + emailAddress: string, + expiresAtMs = Date.now() + TOKEN_TTL_MS, +): string { + const payload: AgentMailEmailLinkTokenPayload = { + emailAddress: emailAddress.trim().toLowerCase(), + expiresAtMs, + }; + const encoded = Buffer.from(JSON.stringify(payload)).toString('base64url'); + return `${TOKEN_VERSION}.${encoded}.${signPayload(encoded)}`; +} + +export function verifyAgentMailEmailLinkToken( + token: string, +): { emailAddress: string } | null { + const [version, encoded, signature] = token.split('.'); + if (version !== TOKEN_VERSION || !encoded || !signature) { + return null; + } + + const expected = Buffer.from(signPayload(encoded)); + const actual = Buffer.from(signature); + if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) { + return null; + } + + let payload: AgentMailEmailLinkTokenPayload; + try { + payload = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')); + } catch { + return null; + } + + if ( + typeof payload.emailAddress !== 'string' || + !payload.emailAddress.includes('@') || + typeof payload.expiresAtMs !== 'number' || + payload.expiresAtMs < Date.now() + ) { + return null; + } + + return { emailAddress: payload.emailAddress }; +} + +export function buildAgentMailEmailLinkUrl(emailAddress: string): string { + const url = new URL('/link-email', Env.R_APP_URL); + url.searchParams.set('token', buildAgentMailEmailLinkToken(emailAddress)); + return url.toString(); +} + +/** + * Unsubscribe tokens for outbound-initiated (transactional) email, carried in + * RFC 8058 List-Unsubscribe headers. Domain-separated from link tokens so one + * can never be replayed as the other. Long-lived by design: mail providers + * fire one-click posts from messages sitting in inboxes for months, and the + * only action the token authorizes is suppressing its own address. + */ +const UNSUBSCRIBE_TOKEN_VERSION = 'v1'; +const UNSUBSCRIBE_TOKEN_TTL_MS = 365 * 24 * 60 * 60 * 1000; + +function unsubscribeSigningKey(): Buffer { + return createHmac('sha256', Env.ARTIFACT_SIGNING_KEY) + .update('agentmail-unsubscribe') + .digest(); +} + +function signUnsubscribePayload(encodedPayload: string): string { + return createHmac('sha256', unsubscribeSigningKey()) + .update(`${UNSUBSCRIBE_TOKEN_VERSION}.${encodedPayload}`) + .digest('base64url'); +} + +export function buildAgentMailUnsubscribeToken( + emailAddress: string, + expiresAtMs = Date.now() + UNSUBSCRIBE_TOKEN_TTL_MS, +): string { + const payload: AgentMailEmailLinkTokenPayload = { + emailAddress: emailAddress.trim().toLowerCase(), + expiresAtMs, + }; + const encoded = Buffer.from(JSON.stringify(payload)).toString('base64url'); + return `${UNSUBSCRIBE_TOKEN_VERSION}.${encoded}.${signUnsubscribePayload(encoded)}`; +} + +export function verifyAgentMailUnsubscribeToken( + token: string, +): { emailAddress: string } | null { + const [version, encoded, signature] = token.split('.'); + if (version !== UNSUBSCRIBE_TOKEN_VERSION || !encoded || !signature) { + return null; + } + + const expected = Buffer.from(signUnsubscribePayload(encoded)); + const actual = Buffer.from(signature); + if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) { + return null; + } + + let payload: AgentMailEmailLinkTokenPayload; + try { + payload = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')); + } catch { + return null; + } + + if ( + typeof payload.emailAddress !== 'string' || + !payload.emailAddress.includes('@') || + typeof payload.expiresAtMs !== 'number' || + payload.expiresAtMs < Date.now() + ) { + return null; + } + + return { emailAddress: payload.emailAddress }; +} + +export function buildAgentMailUnsubscribeUrl(emailAddress: string): string { + const url = new URL('/api/webhooks/agentmail/unsubscribe', Env.R_APP_URL); + url.searchParams.set('token', buildAgentMailUnsubscribeToken(emailAddress)); + return url.toString(); +} diff --git a/packages/sdk/src/server/lib/agentmail/inbound.ts b/packages/sdk/src/server/lib/agentmail/inbound.ts new file mode 100644 index 000000000..a1f3fc061 --- /dev/null +++ b/packages/sdk/src/server/lib/agentmail/inbound.ts @@ -0,0 +1,822 @@ +import { Queue } from 'bullmq'; + +import { + AgentMailApiClient, + AgentMailApiError, + buildAgentMailButtonSections, + escapeAgentMailHtml, + getAgentMailDeliveryFailureRecipients, + getAgentMailMessageBodyText, + getAgentMailSenderAddress, + getPendingCommunicationRequestUserInput, + isAgentMailAutoGeneratedMessage, + isAgentMailMessageBouncedEvent, + isAgentMailMessageComplainedEvent, + isAgentMailMessageReceivedEvent, + isAgentMailPermanentBounce, + normalizeAgentMailAddress, + parseAgentMailWebhookEvent, + queueCommunicationMessageOnce, + setLatestInboundMessageId, + submitPendingCommunicationRequestUserInputAnswer, + type AgentMailMessage, +} from '@roomote/communication'; +import { + acquireFastAgentTurnLock, + getOrCreateFastAgentSession, +} from '@roomote/cloud-agents/server'; +import { + agentmailConversations, + agentmailInboundTurns, + agentmailWebhookEvents, + and, + asc, + db, + eq, + resolveAgentMailRuntimeCredentials, + setTrustedRunActingUserOnSuccess, + sql, +} from '@roomote/db/server'; +import { isEmailChannelEnabled } from '@roomote/env'; +import { getRedis } from '@roomote/redis'; +import { + parseAcpRequestUserInputAnswerReply, + type FastAgentConversation, +} from '@roomote/types'; + +import { buildAgentMailEmailLinkUrl } from './email-link-tokens'; +import { suppressAgentMailAddress } from './outbound'; +import { + advanceAgentMailInboundAnchor, + normalizeEmailAddress, + resolveAgentMailSenderUserId, + resolveOrCreateAgentMailConversation, + type AgentMailConversationRow, +} from './conversation-store'; +import { findActiveCommunicationTaskRun } from '../communication/communication-task-run-lookup'; +import { continueFastAgentSurfaceReplyWithLock } from '../fast-agent-surface-reply'; + +export const AGENTMAIL_WEBHOOK_EVENT_QUEUE_NAME = 'agentmail-webhook-events'; + +const LOG_PREFIX = '[agentmail]'; +const STRANGER_REFUSAL_TTL_SECONDS = 30 * 24 * 60 * 60; +const FAILED_EVENT_ATTEMPT_CAP = 10; +const RECOVERY_SWEEP_BATCH_SIZE = 500; + +export type AgentMailWebhookEventJob = + | { kind: 'process'; deliveryId: string } + | { kind: 'drain'; conversationId: string }; + +export class AgentMailConversationBusyError extends Error { + constructor() { + super('AgentMail conversation is busy; retry the durable queue later.'); + this.name = 'AgentMailConversationBusyError'; + } +} + +let webhookEventQueue: Queue | null = null; + +function getWebhookEventQueue() { + webhookEventQueue ??= new Queue( + AGENTMAIL_WEBHOOK_EVENT_QUEUE_NAME, + { + connection: getRedis(), + defaultJobOptions: { + attempts: 5, + backoff: { type: 'exponential', delay: 2_000 }, + removeOnComplete: true, + // PostgreSQL is the source of truth (agentmail_webhook_events / + // agentmail_inbound_turns); the recovery sweep recreates wakeups. + removeOnFail: true, + }, + }, + ); + return webhookEventQueue; +} + +function sanitizeJobIdPart(value: string): string { + // BullMQ job ids reject ':'; keep them to a safe charset. + return value.replace(/[^a-zA-Z0-9_-]/g, '-'); +} + +async function addProcessJob(deliveryId: string) { + await getWebhookEventQueue().add( + 'process', + { kind: 'process', deliveryId }, + { jobId: `process-${sanitizeJobIdPart(deliveryId)}` }, + ); +} + +async function addDrainJob(conversationId: string, dedupeSuffix: string) { + // Each admitted turn gets its own wakeup: a drain job id shared per + // conversation could be silently dropped while a previous drain is still + // active, stranding the new turn until the sweeper. + await getWebhookEventQueue().add( + 'drain', + { kind: 'drain', conversationId }, + { + jobId: `drain-${sanitizeJobIdPart(conversationId)}-${sanitizeJobIdPart(dedupeSuffix)}`, + }, + ); +} + +type RecordAgentMailWebhookEventResult = + | { accepted: true; duplicate: boolean } + | { + accepted: false; + reason: 'ignored_event_type' | 'email_channel_disabled'; + }; + +/** + * The ingestion outbox: record the verified delivery durably, then dispatch. + * No transaction spans Postgres and Redis, so the row (state `received`) is + * the commitment; it moves to `queued` only after BullMQ accepts the job. A + * duplicate delivery checks row state: a still-`received` row means the + * earlier attempt crashed between insert and enqueue and is re-dispatched; + * anything further along is acked untouched. Never a blind 200 on conflict. + */ +const ACCEPTED_EVENT_TYPES = new Set([ + 'message.received', + // Delivery-failure events feed the outbound suppression list; recipients + // who bounce permanently or complain must never be emailed again. + 'message.bounced', + 'message.complained', +]); + +export async function recordAgentMailWebhookEvent(input: { + deliveryId: string; + eventId: string | null; + eventType: string; + payload: unknown; +}): Promise { + // The rollout gate is a real kill switch: a webhook that is still + // registered at AgentMail is acknowledged and dropped, never queued. + if (!isEmailChannelEnabled()) { + return { accepted: false, reason: 'email_channel_disabled' }; + } + if (!ACCEPTED_EVENT_TYPES.has(input.eventType)) { + return { accepted: false, reason: 'ignored_event_type' }; + } + + const inserted = await db + .insert(agentmailWebhookEvents) + .values({ + deliveryId: input.deliveryId, + eventId: input.eventId, + eventType: input.eventType, + payload: input.payload as Record, + }) + .onConflictDoNothing({ target: agentmailWebhookEvents.deliveryId }) + .returning({ id: agentmailWebhookEvents.id }); + + const duplicate = inserted.length === 0; + + if (duplicate) { + const existing = await db.query.agentmailWebhookEvents.findFirst({ + where: eq(agentmailWebhookEvents.deliveryId, input.deliveryId), + columns: { state: true }, + }); + if (existing && existing.state !== 'received') { + return { accepted: true, duplicate: true }; + } + } + + try { + await addProcessJob(input.deliveryId); + await db + .update(agentmailWebhookEvents) + .set({ state: 'queued', updatedAt: new Date() }) + .where( + and( + eq(agentmailWebhookEvents.deliveryId, input.deliveryId), + eq(agentmailWebhookEvents.state, 'received'), + ), + ); + } catch (error) { + // Admission is already durable; the recovery sweep re-dispatches + // `received` rows without making AgentMail retry carry the burden. + console.error( + `${LOG_PREFIX} Recorded delivery ${input.deliveryId}, but its immediate dispatch failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + return { accepted: true, duplicate }; +} + +function buildAgentMailFastConversation( + conversation: Pick, +): Extract { + return { + surface: 'agentmail', + workspaceId: conversation.inboxId, + // The INTERNAL conversation id, never the provider thread id: forwarded + // threads fork into a second conversation on the same provider thread. + conversationId: conversation.id, + replyTarget: { + channelId: conversation.inboxId, + threadId: conversation.id, + }, + }; +} + +async function markEventProcessed(id: string) { + await db + .update(agentmailWebhookEvents) + .set({ state: 'processed', lastError: null, updatedAt: new Date() }) + .where(eq(agentmailWebhookEvents.id, id)); +} + +async function markEventFailed(id: string, error: unknown) { + await db + .update(agentmailWebhookEvents) + .set({ + state: 'failed', + lastError: error instanceof Error ? error.message : String(error), + updatedAt: new Date(), + }) + .where(eq(agentmailWebhookEvents.id, id)); +} + +function parseProviderTimestamp(message: AgentMailMessage): Date { + const parsed = message.timestamp ? new Date(message.timestamp) : null; + return parsed && !Number.isNaN(parsed.getTime()) ? parsed : new Date(); +} + +function collectRecipientAddresses( + message: AgentMailMessage, + senderAddress: string, +): string[] { + const raw = [...(message.to ?? []), ...(message.cc ?? [])]; + const normalized = raw + .map((value) => normalizeAgentMailAddress(value)) + .filter((value): value is string => Boolean(value)) + .map(normalizeEmailAddress); + return [...new Set(normalized)].filter( + (address) => address !== senderAddress, + ); +} + +/** + * One refusal per unknown address per provider thread, and never for + * auto-generated mail, so a stranger's email cannot start a reply loop. + */ +async function maybeSendStrangerRefusal(input: { + client: AgentMailApiClient; + inboxId: string; + message: AgentMailMessage; + senderAddress: string; +}): Promise { + const redis = getRedis(); + const key = `agentmail:stranger_refusal:${input.inboxId}:${input.message.thread_id}:${input.senderAddress}`; + const claimed = await redis.set( + key, + '1', + 'EX', + STRANGER_REFUSAL_TTL_SECONDS, + 'NX', + ); + if (claimed !== 'OK') { + return; + } + + try { + const linkUrl = buildAgentMailEmailLinkUrl(input.senderAddress); + const refusalText = `This address isn't linked to a Roomote account, so I can't act on this email yet. If you have a Roomote account, link this address and this email will be processed automatically — no need to resend.`; + const buttonSections = buildAgentMailButtonSections([ + [{ text: 'Link this address to my Roomote account', url: linkUrl }], + ]); + await input.client.replyToMessage( + input.inboxId, + input.message.message_id, + { + text: `${refusalText}\n\n${buttonSections.text}`, + html: `

${escapeAgentMailHtml(refusalText)}

${buttonSections.html}
`, + }, + { + idempotencyKey: `agentmail:refusal:${input.message.message_id}`, + }, + ); + } catch (error) { + // Release the once-per-thread claim only on a definite rejection (4xx: + // the provider did not process the request), so a later email from this + // sender still gets its refusal. A 5xx or network failure is ambiguous — + // AgentMail may have sent the refusal before failing — and a kept claim + // (worst case: one refusal silently lost) beats a released one (worst + // case: duplicate refusals on the next email, whose different message-id + // idempotency key would not dedupe them). + const definitelyNotSent = + error instanceof AgentMailApiError && + error.status >= 400 && + error.status < 500; + if (definitelyNotSent) { + await redis.del(key).catch(() => undefined); + } + console.warn( + `${LOG_PREFIX} Failed to send stranger refusal for thread ${input.message.thread_id} (claim ${definitelyNotSent ? 'released' : 'kept'}): ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +/** + * Suppress the recipients of a permanent bounce or a spam complaint so no + * future outbound-initiated email targets them. Transient bounces (full + * mailbox, greylisting) are logged, never suppressed. Idempotent: suppression + * writes are first-reason-wins upserts. + */ +async function processDeliveryFailureEvent( + event: NonNullable>, +): Promise { + const complained = isAgentMailMessageComplainedEvent(event); + const failure = complained ? event.complaint : event.bounce; + if (!failure) { + return; + } + + if (!complained && !isAgentMailPermanentBounce(failure)) { + console.info( + `${LOG_PREFIX} Transient bounce for message ${failure.message_id ?? 'unknown'} (${failure.sub_type ?? 'no sub_type'}); not suppressing.`, + ); + return; + } + + const recipients = getAgentMailDeliveryFailureRecipients(failure); + for (const recipient of recipients) { + const suppressed = await suppressAgentMailAddress({ + emailAddress: recipient, + reason: complained ? 'complaint' : 'bounce', + details: [failure.type, failure.sub_type].filter(Boolean).join('/'), + providerMessageId: failure.message_id ?? null, + }); + if (suppressed) { + console.warn( + `${LOG_PREFIX} Suppressed ${recipient} after ${complained ? 'spam complaint' : 'permanent bounce'} on message ${failure.message_id ?? 'unknown'}.`, + ); + } + } +} + +/** + * Process one recorded delivery: resolve sender and conversation, advance the + * inbound anchor, and admit the message as a durable inbound turn. Marking + * the event `processed` happens only after the turn insert commits (or after + * a terminal no-turn outcome such as a stranger refusal); a crash before that + * leaves the row for retry, never a lost email. Idempotent on the event row. + */ +export async function processAgentMailWebhookEvent( + deliveryId: string, +): Promise { + // Disabled mid-flight: leave already-recorded events untouched (still + // `queued`), so nothing is refused or replied to while off, and the + // recovery sweep re-dispatches them if the channel is re-enabled. + if (!isEmailChannelEnabled()) { + return; + } + const row = await db.query.agentmailWebhookEvents.findFirst({ + where: eq(agentmailWebhookEvents.deliveryId, deliveryId), + }); + if (!row || row.state === 'processed') { + return; + } + if (row.state === 'failed' && row.attempts >= FAILED_EVENT_ATTEMPT_CAP) { + // True dead letter: the sweep stops re-dispatching past the cap, and a + // late duplicate delivery must not resurrect it either. + return; + } + + await db + .update(agentmailWebhookEvents) + .set({ + state: 'processing', + attempts: sql`${agentmailWebhookEvents.attempts} + 1`, + updatedAt: new Date(), + }) + .where(eq(agentmailWebhookEvents.id, row.id)); + + try { + const event = parseAgentMailWebhookEvent(row.payload); + if (!event) { + await markEventProcessed(row.id); + return; + } + + if ( + isAgentMailMessageBouncedEvent(event) || + isAgentMailMessageComplainedEvent(event) + ) { + await processDeliveryFailureEvent(event); + await markEventProcessed(row.id); + return; + } + + if (!isAgentMailMessageReceivedEvent(event) || !event.message) { + await markEventProcessed(row.id); + return; + } + + const credentials = await resolveAgentMailRuntimeCredentials(); + if (!credentials.apiKey) { + throw new Error('AgentMail credentials are no longer configured.'); + } + const client = new AgentMailApiClient({ apiKey: credentials.apiKey }); + + let message = event.message; + const inboxId = normalizeEmailAddress(message.inbox_id); + + if (isAgentMailAutoGeneratedMessage(message)) { + // Auto-responders and bounce handlers never get replies and never + // reach the agent; dropping them here breaks mail loops. + await markEventProcessed(row.id); + return; + } + + // Payloads over the provider's size cap arrive with body fields dropped; + // re-fetch the full message before routing. + if (!message.text && !message.extracted_text && !message.html) { + const fetched = await client.getMessage(inboxId, message.message_id); + if (fetched) { + message = { ...message, ...fetched }; + } + } + + const senderAddress = getAgentMailSenderAddress(message); + if (!senderAddress || senderAddress === inboxId) { + await markEventProcessed(row.id); + return; + } + + const senderUserId = await resolveAgentMailSenderUserId(senderAddress); + if (!senderUserId) { + await maybeSendStrangerRefusal({ + client, + inboxId, + message, + senderAddress, + }); + await markEventProcessed(row.id); + return; + } + + const { conversation } = await resolveOrCreateAgentMailConversation({ + inboxId, + providerThreadId: message.thread_id, + senderUserId, + subject: message.subject?.trim() || null, + recipientAddresses: collectRecipientAddresses(message, senderAddress), + }); + + const providerTimestamp = parseProviderTimestamp(message); + await advanceAgentMailInboundAnchor({ + conversationId: conversation.id, + messageId: message.message_id, + providerTimestamp, + senderEmail: senderAddress, + senderUserId, + }); + + // Admission: inserting this row IS the durable handoff. The webhook + // event may be marked processed only after this commits. The turn row + // captures everything the drain needs (including the re-fetched body of + // oversize deliveries), so consuming it never re-parses the raw payload + // or re-resolves the sender against state that may have changed. + await db + .insert(agentmailInboundTurns) + .values({ + conversationId: conversation.id, + webhookEventId: row.id, + providerMessageId: message.message_id, + providerTimestamp, + senderEmail: senderAddress, + senderUserId, + bodyText: getAgentMailMessageBodyText(message).trim(), + }) + .onConflictDoNothing({ target: agentmailInboundTurns.webhookEventId }); + + await markEventProcessed(row.id); + await addDrainJob(conversation.id, row.id); + } catch (error) { + await markEventFailed(row.id, error); + throw error; + } +} + +type DrainableTurn = { + turnId: string; + providerMessageId: string; + senderUserId: string; + senderEmail: string; + bodyText: string; +}; + +async function getNextPendingTurn( + conversationId: string, +): Promise { + const turn = await db.query.agentmailInboundTurns.findFirst({ + where: and( + eq(agentmailInboundTurns.conversationId, conversationId), + eq(agentmailInboundTurns.state, 'pending'), + ), + orderBy: [ + asc(agentmailInboundTurns.providerTimestamp), + asc(agentmailInboundTurns.providerMessageId), + ], + }); + if (!turn) return null; + + return { + turnId: turn.id, + providerMessageId: turn.providerMessageId, + senderUserId: turn.senderUserId, + senderEmail: turn.senderEmail, + bodyText: turn.bodyText, + }; +} + +async function markTurnConsumed(turnId: string) { + await db + .update(agentmailInboundTurns) + .set({ state: 'consumed', consumedAt: new Date() }) + .where(eq(agentmailInboundTurns.id, turnId)); +} + +async function tryClaimPendingInputAnswer(input: { + conversation: AgentMailConversationRow; + senderUserId: string; + bodyText: string; + activeRunId: number; +}): Promise { + const pendingRequest = await getPendingCommunicationRequestUserInput( + 'agentmail', + input.conversation.id, + ); + if (!pendingRequest || pendingRequest.runId !== input.activeRunId) { + return false; + } + if (pendingRequest.status === 'submitted') { + return false; + } + + const parsedReply = parseAcpRequestUserInputAnswerReply( + pendingRequest.questions, + input.bodyText, + ); + if (!parsedReply) { + return false; + } + + const cancelled = parsedReply.resolution === 'cancelled'; + return setTrustedRunActingUserOnSuccess({ + runId: input.activeRunId, + userId: input.senderUserId, + operation: async () => + submitPendingCommunicationRequestUserInputAnswer( + 'agentmail', + input.conversation.id, + pendingRequest, + { + answers: cancelled ? {} : parsedReply.answers, + userId: input.senderUserId, + timestamp: Date.now(), + }, + ), + }); +} + +/** + * Email requests often live in the subject line ("what time is it" over an + * empty or one-word body), so the agent-visible turn text carries both. The + * raw body stays separate for answer parsing, where a prepended subject + * would corrupt option matching. + */ +function buildTurnQuestionText( + turn: DrainableTurn, + conversation: AgentMailConversationRow, +): string { + const subject = conversation.subject?.trim(); + return subject ? `Subject: ${subject}\n\n${turn.bodyText}` : turn.bodyText; +} + +async function deliverTurn( + turn: DrainableTurn, + conversation: AgentMailConversationRow, + turnSignal: AbortSignal, +) { + if (!turn.bodyText && !conversation.subject?.trim()) { + return; + } + + const activeRun = await findActiveCommunicationTaskRun({ + provider: 'agentmail', + channelId: conversation.inboxId, + threadId: conversation.id, + }); + + if (activeRun) { + const claimed = await tryClaimPendingInputAnswer({ + conversation, + senderUserId: turn.senderUserId, + bodyText: turn.bodyText, + activeRunId: activeRun.id, + }); + if (claimed) { + return; + } + + // Follow-up to the active delegated run through the provider-generic + // queue. queueCommunicationMessageOnce is idempotent on the message ts, + // so a crash-and-retry of this turn cannot double-queue. + await queueCommunicationMessageOnce('agentmail', activeRun.id, { + provider: 'agentmail', + text: buildTurnQuestionText(turn, conversation), + user: turn.senderEmail, + userId: turn.senderUserId, + ts: turn.providerMessageId, + channel: conversation.inboxId, + threadTs: conversation.id, + turnPolicy: { reactionsAllowed: false }, + }); + await setLatestInboundMessageId( + 'agentmail', + activeRun.id, + turn.providerMessageId, + ).catch(() => undefined); + return; + } + + const fastConversation = buildAgentMailFastConversation(conversation); + const session = await getOrCreateFastAgentSession({ + userId: conversation.ownerUserId, + conversation: fastConversation, + }); + + const continued = await continueFastAgentSurfaceReplyWithLock( + { + sessionId: session.id, + userId: turn.senderUserId, + senderDisplayName: turn.senderEmail, + question: buildTurnQuestionText(turn, conversation), + currentMessageId: turn.providerMessageId, + }, + turnSignal, + ); + if (!continued) { + console.warn( + `${LOG_PREFIX} Fast session ${session.id} could not resolve a delivery route for conversation ${conversation.id}`, + ); + } +} + +/** + * Drain one conversation's admitted turns in provider order under one Fast + * turn lock, mirroring `drainFastAgentParentEvents`. The Fast turn lock only + * serializes concurrent turns; this ordered drain is what guarantees two + * rapid emails run in arrival order. A busy conversation throws + * `AgentMailConversationBusyError` so BullMQ delays and retries without + * parking a worker slot. + */ +export async function drainAgentMailInboundTurns( + conversationId: string, +): Promise { + if (!isEmailChannelEnabled()) { + return; + } + const first = await getNextPendingTurn(conversationId); + if (!first) return; + + const conversation = await db.query.agentmailConversations.findFirst({ + where: eq(agentmailConversations.id, conversationId), + }); + if (!conversation) { + return; + } + + const turnLock = await acquireFastAgentTurnLock({ + conversation: buildAgentMailFastConversation(conversation), + maxWaitMs: 0, + }); + if (!turnLock) { + throw new AgentMailConversationBusyError(); + } + + try { + let turn: DrainableTurn | null = first; + while (turn) { + await deliverTurn(turn, conversation, turnLock.signal); + // Consumed only after delivery: a crash mid-turn leaves the row + // pending and the sweeper re-triggers the drain. Delivery is + // idempotent (queueCommunicationMessageOnce; Fast turns re-run). + await markTurnConsumed(turn.turnId); + turn = await getNextPendingTurn(conversationId); + } + } finally { + await turnLock().catch(() => {}); + } +} + +/** + * Recovery sweep: re-dispatch webhook events stranded between insert and + * enqueue (`received`), events whose queued job was pruned before starting + * (`queued`/`processing`), events whose processing failed transiently + * (`failed`, until the attempt cap — only then is a row a true dead letter), + * and conversations with pending turns whose drain wakeup was lost. Runs on + * BullMQ startup and on a schedule. Staleness compares database time against + * database time (`now() - interval`), never a Node-side ISO string, so a + * non-UTC database timezone cannot skew the window. + */ +export async function recoverPendingAgentMailWork(): Promise { + const staleEvents = await db + .select({ deliveryId: agentmailWebhookEvents.deliveryId }) + .from(agentmailWebhookEvents) + .where( + and( + sql`( + ${agentmailWebhookEvents.state} in ('received', 'queued', 'processing') + or (${agentmailWebhookEvents.state} = 'failed' + and ${agentmailWebhookEvents.attempts} < ${FAILED_EVENT_ATTEMPT_CAP}) + )`, + sql`${agentmailWebhookEvents.updatedAt} < now() - interval '60 seconds'`, + ), + ) + .orderBy(asc(agentmailWebhookEvents.receivedAt)) + .limit(RECOVERY_SWEEP_BATCH_SIZE); + + for (const event of staleEvents) { + await addProcessJob(event.deliveryId); + } + + const pendingTurnConversations = await db + .selectDistinct({ conversationId: agentmailInboundTurns.conversationId }) + .from(agentmailInboundTurns) + .where( + and( + eq(agentmailInboundTurns.state, 'pending'), + sql`${agentmailInboundTurns.createdAt} < now() - interval '60 seconds'`, + ), + ) + .limit(RECOVERY_SWEEP_BATCH_SIZE); + + for (const row of pendingTurnConversations) { + // Stable per-conversation sweep id: while a conversation stays pending + // (e.g. busy in a long Fast turn) repeated sweeps must not mint a new + // job each minute; the next sweep re-arms after the previous job ends. + await addDrainJob(row.conversationId, 'sweep'); + } + + return staleEvents.length + pendingTurnConversations.length; +} + +const REDISPATCH_LOOKBACK_MS = 7 * 24 * 60 * 60 * 1000; +const REDISPATCH_MAX_EVENTS = 10; + +/** + * After an address is linked to an account, reprocess the sender's recent + * refused emails so the original request is handled without a resend. A + * refused event is one that reached `processed` without admitting a turn; + * resetting it to `received` and re-dispatching runs the normal pipeline, + * which now resolves the sender. Idempotent: the unique turn-per-event + * constraint makes double dispatch harmless. + */ +export async function redispatchAgentMailEventsForSender( + emailAddress: string, +): Promise { + const normalized = normalizeEmailAddress(emailAddress); + const since = new Date(Date.now() - REDISPATCH_LOOKBACK_MS); + + const candidates = await db + .select({ + id: agentmailWebhookEvents.id, + deliveryId: agentmailWebhookEvents.deliveryId, + payload: agentmailWebhookEvents.payload, + }) + .from(agentmailWebhookEvents) + .where( + and( + eq(agentmailWebhookEvents.state, 'processed'), + sql`${agentmailWebhookEvents.receivedAt} > ${since.toISOString()}::timestamp`, + sql`not exists ( + select 1 from ${agentmailInboundTurns} + where ${agentmailInboundTurns.webhookEventId} = ${agentmailWebhookEvents.id} + )`, + ), + ) + .orderBy(asc(agentmailWebhookEvents.receivedAt)); + + let redispatched = 0; + for (const candidate of candidates) { + if (redispatched >= REDISPATCH_MAX_EVENTS) break; + const event = parseAgentMailWebhookEvent(candidate.payload); + const message = event?.message; + if (!message) continue; + const sender = getAgentMailSenderAddress(message); + if (!sender || normalizeEmailAddress(sender) !== normalized) continue; + if (isAgentMailAutoGeneratedMessage(message)) continue; + + await db + .update(agentmailWebhookEvents) + .set({ state: 'received', lastError: null, updatedAt: new Date() }) + .where(eq(agentmailWebhookEvents.id, candidate.id)); + await addProcessJob(candidate.deliveryId); + redispatched += 1; + } + + return redispatched; +} diff --git a/packages/sdk/src/server/lib/agentmail/outbound.ts b/packages/sdk/src/server/lib/agentmail/outbound.ts new file mode 100644 index 000000000..c6a0aee96 --- /dev/null +++ b/packages/sdk/src/server/lib/agentmail/outbound.ts @@ -0,0 +1,353 @@ +import { randomUUID } from 'node:crypto'; + +import { + AgentMailApiClient, + buildAgentMailEmailBody, +} from '@roomote/communication'; +import { isEmailChannelEnabled } from '@roomote/env'; +import { + agentmailConversationParticipants, + agentmailConversations, + agentmailSuppressions, + agentmailUserMappings, + and, + authUsers, + db, + desc, + eq, + resolveAgentMailRuntimeCredentials, + users, +} from '@roomote/db/server'; + +import { buildAgentMailUnsubscribeUrl } from './email-link-tokens'; +import { + normalizeEmailAddress, + recordAgentMailOutboundMessage, +} from './conversation-store'; + +const LOG_PREFIX = '[agentmail-outbound]'; + +/** + * Outbound-initiated (transactional) email. The consent invariant lives + * here, enforced in code rather than call-site discipline: Roomote initiates + * email only to (a) the recipient's own verified account address or (b) an + * address they explicitly linked by proving mailbox possession — and never to + * a suppressed address. Replies within an existing conversation do not pass + * through this module and are never suppressed. + */ + +export type AgentMailSuppressionReason = 'bounce' | 'complaint' | 'unsubscribe'; + +export async function isAgentMailAddressSuppressed( + emailAddress: string, +): Promise { + const suppression = await db.query.agentmailSuppressions.findFirst({ + where: eq( + agentmailSuppressions.emailAddress, + normalizeEmailAddress(emailAddress), + ), + columns: { id: true }, + }); + return Boolean(suppression); +} + +/** + * Sticky, first-reason-wins: a complaint arriving after a bounce (or a repeat + * delivery of the same webhook) is a no-op, which also makes the webhook + * processing path idempotent. + */ +export async function suppressAgentMailAddress(input: { + emailAddress: string; + reason: AgentMailSuppressionReason; + details?: string | null; + providerMessageId?: string | null; +}): Promise { + const inserted = await db + .insert(agentmailSuppressions) + .values({ + emailAddress: normalizeEmailAddress(input.emailAddress), + reason: input.reason, + details: input.details ?? null, + providerMessageId: input.providerMessageId ?? null, + }) + .onConflictDoNothing({ target: agentmailSuppressions.emailAddress }) + .returning({ id: agentmailSuppressions.id }); + return inserted.length > 0; +} + +export type AgentMailOutboundAddressResolution = + | { ok: true; emailAddress: string } + | { ok: false; reason: 'no_permitted_address' | 'suppressed' }; + +/** + * The address Roomote may initiate email to for this user: their verified + * account email first, then their most recently linked mailbox-possession + * address. Unverified account emails never qualify. + */ +export async function resolveAgentMailOutboundAddress( + userId: string, +): Promise { + const candidates: string[] = []; + + const authUser = await db.query.authUsers.findFirst({ + where: and(eq(authUsers.id, userId), eq(authUsers.emailVerified, true)), + columns: { email: true }, + }); + if (authUser?.email) { + candidates.push(normalizeEmailAddress(authUser.email)); + } + + const mapping = await db.query.agentmailUserMappings.findFirst({ + where: eq(agentmailUserMappings.userId, userId), + orderBy: [desc(agentmailUserMappings.createdAt)], + columns: { emailAddress: true }, + }); + if (mapping) { + candidates.push(normalizeEmailAddress(mapping.emailAddress)); + } + + if (candidates.length === 0) { + return { ok: false, reason: 'no_permitted_address' }; + } + + for (const emailAddress of [...new Set(candidates)]) { + if (!(await isAgentMailAddressSuppressed(emailAddress))) { + return { ok: true, emailAddress }; + } + } + + return { ok: false, reason: 'suppressed' }; +} + +/** Whether an outbound-initiated email to this user could be sent right now. */ +export async function canStartAgentMailConversationWithUser( + userId: string, +): Promise { + if (!isEmailChannelEnabled()) { + return false; + } + const credentials = await resolveAgentMailRuntimeCredentials(); + if (!credentials.apiKey || !credentials.inboxId) { + return false; + } + const resolution = await resolveAgentMailOutboundAddress(userId); + return resolution.ok; +} + +/** + * The single entry point for Roomote-initiated email. Sends a fresh message + * (new provider thread) to the user's permitted address with one-click + * List-Unsubscribe headers, then records the conversation so a reply threads + * straight back into the normal inbound pipeline — every transactional email + * is answerable. + */ +export async function startAgentMailConversation(input: { + userId: string; + subject: string; + text: string; + logContext: string; + /** + * Stable logical-send id: retries carrying the same id replay the accepted + * send at the provider instead of emailing the user twice. Callers with a + * durable trigger (a webhook delivery, a queued job) should derive it from + * that trigger; without one, a per-invocation id still makes the client's + * internal retries (5xx / lost response) exactly-once. + */ + clientSendId?: string; +}): Promise { + if (!isEmailChannelEnabled()) { + return false; + } + const credentials = await resolveAgentMailRuntimeCredentials(); + if (!credentials.apiKey || !credentials.inboxId) { + return false; + } + + const resolution = await resolveAgentMailOutboundAddress(input.userId); + if (!resolution.ok) { + if (resolution.reason === 'suppressed') { + console.warn( + `${LOG_PREFIX} [${input.logContext}] Not emailing user ${input.userId}: address is suppressed.`, + ); + } + return false; + } + + const inboxId = normalizeEmailAddress(credentials.inboxId); + const body = buildAgentMailEmailBody(input.text); + const unsubscribeUrl = buildAgentMailUnsubscribeUrl(resolution.emailAddress); + + let response: { message_id?: string; thread_id?: string }; + try { + const client = new AgentMailApiClient({ apiKey: credentials.apiKey }); + response = await client.sendMessage( + inboxId, + { + to: [resolution.emailAddress], + subject: input.subject, + text: `${body.text}\n\nTo stop receiving these emails: ${unsubscribeUrl}`, + html: `${body.html}

Stop receiving these emails

`, + headers: { + // RFC 8058 one-click unsubscribe; Gmail and Yahoo require it for + // sender reputation, and honoring it protects every tenant sharing + // the sending infrastructure. + 'List-Unsubscribe': `<${unsubscribeUrl}>`, + 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click', + }, + }, + { + idempotencyKey: `agentmail:outbound:${input.clientSendId ?? randomUUID()}`, + }, + ); + } catch (error) { + console.warn( + `${LOG_PREFIX} [${input.logContext}] Failed to send email to user ${input.userId}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + return false; + } + + // The email is out; conversation bookkeeping failures must not report the + // send as failed (a retry would email the user twice). + try { + await recordOutboundConversation({ + inboxId, + userId: input.userId, + subject: input.subject, + messageId: response.message_id ?? null, + providerThreadId: response.thread_id ?? null, + }); + } catch (error) { + console.warn( + `${LOG_PREFIX} [${input.logContext}] Sent email but failed to record its conversation: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + + return true; +} + +async function recordOutboundConversation(input: { + inboxId: string; + userId: string; + subject: string; + messageId: string | null; + providerThreadId: string | null; +}): Promise { + if (!input.providerThreadId) { + return; + } + + // The recipient must exist as an app user for the participant FK; the + // resolver only produces addresses for real users, so this is a guard + // against races, not a normal path. + const appUser = await db.query.users.findFirst({ + where: eq(users.id, input.userId), + columns: { id: true }, + }); + if (!appUser) { + return; + } + + const conversation = await db.transaction(async (tx) => { + const [created] = await tx + .insert(agentmailConversations) + .values({ + inboxId: input.inboxId, + providerThreadId: input.providerThreadId!, + ownerUserId: input.userId, + subject: input.subject, + }) + .returning(); + if (!created) { + throw new Error('agentmail conversation insert returned no row'); + } + await tx.insert(agentmailConversationParticipants).values({ + conversationId: created.id, + inboxId: input.inboxId, + providerThreadId: input.providerThreadId!, + userId: input.userId, + role: 'owner', + source: 'outbound', + }); + return created; + }); + + if (input.messageId) { + await recordAgentMailOutboundMessage({ + conversationId: conversation.id, + messageId: input.messageId, + }); + } +} + +export type AgentMailSystemEmailResult = + | { sent: true } + | { + sent: false; + reason: + | 'channel_disabled' + | 'not_configured' + | 'suppressed' + | 'send_failed'; + }; + +/** + * Account-lifecycle email (verification, password reset): the one kind of + * outbound email that must be able to reach an address Roomote has NOT yet + * verified, because it is how the address gets verified. Deliberately + * narrower than startAgentMailConversation — no unsubscribe link or header + * (the recipient initiated the action and the mail is not a subscription), + * no conversation record (a reply has nothing to route to), and only + * bounce/complaint suppressions apply: an address that unsubscribed from + * notifications must still be able to verify itself or reset a password. + */ +export async function sendAgentMailSystemEmail(input: { + to: string; + subject: string; + text: string; + logContext: string; + clientSendId?: string; +}): Promise { + if (!isEmailChannelEnabled()) { + return { sent: false, reason: 'channel_disabled' }; + } + const credentials = await resolveAgentMailRuntimeCredentials(); + if (!credentials.apiKey || !credentials.inboxId) { + return { sent: false, reason: 'not_configured' }; + } + + const to = normalizeEmailAddress(input.to); + const suppression = await db.query.agentmailSuppressions.findFirst({ + where: eq(agentmailSuppressions.emailAddress, to), + columns: { reason: true }, + }); + if (suppression && suppression.reason !== 'unsubscribe') { + console.warn( + `${LOG_PREFIX} [${input.logContext}] Not sending system email to ${to}: address is suppressed (${suppression.reason}).`, + ); + return { sent: false, reason: 'suppressed' }; + } + + const body = buildAgentMailEmailBody(input.text); + try { + const client = new AgentMailApiClient({ apiKey: credentials.apiKey }); + await client.sendMessage( + normalizeEmailAddress(credentials.inboxId), + { to: [to], subject: input.subject, text: body.text, html: body.html }, + { + idempotencyKey: `agentmail:system:${input.clientSendId ?? randomUUID()}`, + }, + ); + return { sent: true }; + } catch (error) { + console.warn( + `${LOG_PREFIX} [${input.logContext}] Failed to send system email to ${to}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + return { sent: false, reason: 'send_failed' }; + } +} diff --git a/packages/sdk/src/server/lib/agentmail/request-user-input.ts b/packages/sdk/src/server/lib/agentmail/request-user-input.ts new file mode 100644 index 000000000..898bad089 --- /dev/null +++ b/packages/sdk/src/server/lib/agentmail/request-user-input.ts @@ -0,0 +1,73 @@ +import { + buildDiscordRequestUserInputPromptText, + getDiscordRequestUserInputCurrentQuestion, + type CommunicationMessageButton, +} from '@roomote/communication'; +import { agentmailConversations, db, eq } from '@roomote/db/server'; +import type { AcpRequestUserInputQuestion } from '@roomote/types'; + +import { + buildAgentMailRuiAnswerToken, + buildAgentMailRuiAnswerUrl, +} from './rui-answer-links'; + +/** + * request_user_input over email: the prompt is one email whose options are + * button-styled one-click answer links (signed tokens; the claim stays + * atomic), with a free-text reply as the always-available fallback. Buttons + * render only for single-question prompts with options — the same restriction + * the chat callback intake enforces — and multi-question prompts fall back to + * reply-per-line text. + */ +export async function buildAgentMailRequestUserInputMessage(params: { + conversationId: string; + requestId: string; + questions: AcpRequestUserInputQuestion[]; + currentQuestionIndex: number; +}): Promise<{ text: string; buttons?: CommunicationMessageButton[][] }> { + const promptText = buildDiscordRequestUserInputPromptText({ + requestId: params.requestId, + questions: params.questions, + currentQuestionIndex: params.currentQuestionIndex, + }); + const text = `${promptText}\n\nReply to this email with your answer, or use the buttons below when shown.`; + + if (params.questions.length !== 1) { + return { text }; + } + + const current = getDiscordRequestUserInputCurrentQuestion(params); + const options = current?.question.options ?? []; + if (!current || options.length === 0) { + return { text }; + } + + const conversation = await db.query.agentmailConversations.findFirst({ + where: eq(agentmailConversations.id, params.conversationId), + columns: { latestInboundUserId: true, ownerUserId: true }, + }); + const responderUserId = + conversation?.latestInboundUserId ?? conversation?.ownerUserId; + if (!responderUserId) { + return { text }; + } + + const buttons: CommunicationMessageButton[][] = options.map( + (option, index) => [ + { + text: option.label, + url: buildAgentMailRuiAnswerUrl( + buildAgentMailRuiAnswerToken({ + conversationId: params.conversationId, + requestId: params.requestId, + questionId: current.question.id, + optionIndex: index, + userId: responderUserId, + }), + ), + }, + ], + ); + + return { text, buttons }; +} diff --git a/packages/sdk/src/server/lib/agentmail/rui-answer-links.ts b/packages/sdk/src/server/lib/agentmail/rui-answer-links.ts new file mode 100644 index 000000000..c1de094a0 --- /dev/null +++ b/packages/sdk/src/server/lib/agentmail/rui-answer-links.ts @@ -0,0 +1,100 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; + +import { Env } from '@roomote/env'; + +/** + * One-click answer links for request_user_input over email. Each option in a + * question email is a button whose href carries a signed token; opening it + * records the answer. The trust model is the magic link's: the token was + * delivered to the responder's mailbox, expiry bounds replay, and the claim + * itself still goes through the atomic pending → submitted transition, so a + * stale or double-clicked link can never double-answer. + */ + +const TOKEN_VERSION = 'v1'; +const TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000; + +type AgentMailRuiAnswerTokenPayload = { + conversationId: string; + requestId: string; + questionId: string; + optionIndex: number; + /** The user the answer is attributed to (the email's recipient). */ + userId: string; + expiresAtMs: number; +}; + +function signingKey(): Buffer { + // Domain-separated derivation from the deployment's URL-signing key so a + // leaked answer token can never be replayed against another surface. + return createHmac('sha256', Env.ARTIFACT_SIGNING_KEY) + .update('agentmail-rui-answer') + .digest(); +} + +function signPayload(encodedPayload: string): string { + return createHmac('sha256', signingKey()) + .update(`${TOKEN_VERSION}.${encodedPayload}`) + .digest('base64url'); +} + +export function buildAgentMailRuiAnswerToken( + payload: Omit & { + expiresAtMs?: number; + }, +): string { + const fullPayload: AgentMailRuiAnswerTokenPayload = { + ...payload, + expiresAtMs: payload.expiresAtMs ?? Date.now() + TOKEN_TTL_MS, + }; + const encoded = Buffer.from(JSON.stringify(fullPayload)).toString( + 'base64url', + ); + return `${TOKEN_VERSION}.${encoded}.${signPayload(encoded)}`; +} + +export function verifyAgentMailRuiAnswerToken( + token: string, +): AgentMailRuiAnswerTokenPayload | null { + const [version, encoded, signature] = token.split('.'); + if (version !== TOKEN_VERSION || !encoded || !signature) { + return null; + } + + const expected = signPayload(encoded); + const expectedBuffer = Buffer.from(expected); + const actualBuffer = Buffer.from(signature); + if ( + expectedBuffer.length !== actualBuffer.length || + !timingSafeEqual(expectedBuffer, actualBuffer) + ) { + return null; + } + + let payload: AgentMailRuiAnswerTokenPayload; + try { + payload = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')); + } catch { + return null; + } + + if ( + typeof payload.conversationId !== 'string' || + typeof payload.requestId !== 'string' || + typeof payload.questionId !== 'string' || + typeof payload.optionIndex !== 'number' || + typeof payload.userId !== 'string' || + typeof payload.expiresAtMs !== 'number' || + payload.expiresAtMs < Date.now() + ) { + return null; + } + + return payload; +} + +export function buildAgentMailRuiAnswerUrl(token: string): string { + const url = new URL('/api/webhooks/agentmail/answer', Env.R_APP_URL); + url.searchParams.set('token', token); + return url.toString(); +} diff --git a/packages/sdk/src/server/lib/communication-link-codes.ts b/packages/sdk/src/server/lib/communication-link-codes.ts index 610f828e0..336bf26cf 100644 --- a/packages/sdk/src/server/lib/communication-link-codes.ts +++ b/packages/sdk/src/server/lib/communication-link-codes.ts @@ -2,7 +2,7 @@ import { randomBytes } from 'node:crypto'; import { getRedis } from '@roomote/redis'; -type CommunicationLinkProvider = 'discord' | 'telegram'; +type CommunicationLinkProvider = 'discord' | 'telegram' | 'agentmail'; const LINK_CODE_PREFIX = 'link-'; const LINK_CODE_PATTERN = /^link-[A-Za-z0-9_-]{16,}$/; diff --git a/packages/sdk/src/server/lib/communication-providers.ts b/packages/sdk/src/server/lib/communication-providers.ts index 706d7f74c..e47f586cb 100644 --- a/packages/sdk/src/server/lib/communication-providers.ts +++ b/packages/sdk/src/server/lib/communication-providers.ts @@ -1,4 +1,5 @@ import type { + AgentMailCommunicationProvider, CommunicationProviderAdapter, DiscordCommunicationProvider, TeamsCommunicationProvider, @@ -8,6 +9,7 @@ import { and, db, eq, slackInstallations } from '@roomote/db/server'; import { SlackCommunicationProvider, SlackNotifier } from '@roomote/slack'; import type { CommunicationProvider } from '@roomote/types'; +import { createAgentMailCommunicationProviderFromRuntimeCredentials } from './agentmail-communication'; import { createDiscordCommunicationProviderFromRuntimeCredentials } from './discord-communication'; import { createTeamsCommunicationProviderFromRuntimeCredentials } from './teams-communication'; import { createTelegramCommunicationProviderFromRuntimeCredentials } from './telegram-communication'; @@ -18,6 +20,7 @@ export type RuntimeCommunicationProviderAdapter = CommunicationProviderAdapter & | TeamsCommunicationProvider | TelegramCommunicationProvider | DiscordCommunicationProvider + | AgentMailCommunicationProvider ); /** @@ -67,5 +70,7 @@ export async function getCommunicationProviderAdapter( return createTelegramCommunicationProviderFromRuntimeCredentials(); case 'discord': return createDiscordCommunicationProviderFromRuntimeCredentials(); + case 'agentmail': + return createAgentMailCommunicationProviderFromRuntimeCredentials(); } } diff --git a/packages/sdk/src/server/lib/communication-request-user-input.ts b/packages/sdk/src/server/lib/communication-request-user-input.ts index 8d7b8f9b4..e305561f4 100644 --- a/packages/sdk/src/server/lib/communication-request-user-input.ts +++ b/packages/sdk/src/server/lib/communication-request-user-input.ts @@ -18,15 +18,23 @@ import { type CommunicationProvider, } from '@roomote/types'; +import { buildAgentMailRequestUserInputMessage } from './agentmail/request-user-input'; import { getCommunicationProviderAdapter } from './communication-providers'; -type SupportedCommunicationRuiProvider = 'discord' | 'telegram' | 'teams'; +type SupportedCommunicationRuiProvider = + | 'discord' + | 'telegram' + | 'teams' + | 'agentmail'; function isSupportedCommunicationRuiProvider( provider: CommunicationProvider | null | undefined, ): provider is SupportedCommunicationRuiProvider { return ( - provider === 'discord' || provider === 'telegram' || provider === 'teams' + provider === 'discord' || + provider === 'telegram' || + provider === 'teams' || + provider === 'agentmail' ); } @@ -91,10 +99,24 @@ export async function publishCommunicationRequestUserInput(params: { answers: existingForEdit?.answers, }); - const promptText = buildDiscordRequestUserInputPromptText(promptState); - // Teams has no callback-button intake yet; keep options in the text body. - const buttons = - provider === 'teams' + // Email renders options as signed one-click answer links; chat providers + // use callback buttons. Teams has no callback-button intake yet and keeps + // options in the text body. + const agentMailMessage = + provider === 'agentmail' + ? await buildAgentMailRequestUserInputMessage({ + conversationId, + requestId: params.request.requestId, + questions: params.request.questions, + currentQuestionIndex, + }) + : null; + const promptText = + agentMailMessage?.text ?? + buildDiscordRequestUserInputPromptText(promptState); + const buttons = agentMailMessage + ? agentMailMessage.buttons + : provider === 'teams' ? undefined : buildDiscordRequestUserInputButtons({ runId: params.runId, @@ -131,7 +153,12 @@ export async function publishCommunicationRequestUserInput(params: { // only reply/topic scope (Telegram topics, Discord threads, Teams reply). const posted = await adapter.postMessage({ channelId, - ...(threadId && (provider === 'discord' || provider === 'telegram') + // For agentmail, threadId carries the internal conversation id the + // adapter resolves its durable reply route from. + ...(threadId && + (provider === 'discord' || + provider === 'telegram' || + provider === 'agentmail') ? { threadId } : {}), ...(provider === 'teams' && threadId @@ -139,7 +166,16 @@ export async function publishCommunicationRequestUserInput(params: { : {}), ...(serviceUrl ? { serviceUrl } : {}), text: promptText, - ...(provider === 'teams' ? { textFormat: 'markdown' as const } : {}), + ...(provider === 'teams' || provider === 'agentmail' + ? { textFormat: 'markdown' as const } + : {}), + // A re-publish of the same question (worker restart, snapshot resume) + // must not send the recipient a second question email. + ...(provider === 'agentmail' + ? { + idempotencyKey: `agentmail:${conversationId}:rui:${params.request.requestId}:${currentQuestionIndex}`, + } + : {}), ...(buttons ? { buttons } : {}), }); promptMessageId = posted.messageId; diff --git a/packages/sdk/src/server/lib/fast-agent-parent-event.ts b/packages/sdk/src/server/lib/fast-agent-parent-event.ts index 88280d49f..c2016359f 100644 --- a/packages/sdk/src/server/lib/fast-agent-parent-event.ts +++ b/packages/sdk/src/server/lib/fast-agent-parent-event.ts @@ -76,6 +76,7 @@ import { } from './artifacts/raw-url'; import { createDiscordCommunicationProviderFromRuntimeCredentials } from './discord-communication'; import { createTeamsCommunicationProviderFromRuntimeCredentials } from './teams-communication'; +import { createAgentMailCommunicationProviderFromRuntimeCredentials } from './agentmail-communication'; import { createTelegramCommunicationProviderFromRuntimeCredentials } from './telegram-communication'; import { findTeamsConversationRoute } from '../automations/destination'; import { recordFastAgentConversationMessageBestEffort } from './fast-agent-provider-message'; @@ -929,7 +930,7 @@ export function createFastAgentCommunicationTaskLauncher(params: { userId: string; conversation: Extract< FastAgentConversation, - { surface: 'teams' | 'telegram' } + { surface: 'teams' | 'telegram' | 'agentmail' } >; serviceUrl?: string; }): LaunchFastAgentTask { @@ -1351,6 +1352,62 @@ async function createTeamsFastAgentParentTurn( }; } +async function createAgentMailFastAgentParentTurn( + params: FastAgentParentTurnParams, +): Promise { + const fallbackConversation = params.parent.conversation; + if (fallbackConversation.surface !== 'agentmail') { + throw new Error('Expected an AgentMail Fast parent conversation.'); + } + const [session, provider] = await Promise.all([ + fastAgentConversationRepository.findById({ + id: params.parent.sessionId, + fallbackConversation, + }), + createAgentMailCommunicationProviderFromRuntimeCredentials(), + ]); + if (!session || session.conversation.surface !== 'agentmail' || !provider) { + throw new FastAgentParentEventDeliveryError( + 'Fast parent session or AgentMail credentials were not found.', + { replyPosted: false, permanent: true }, + ); + } + const conversation = session.conversation; + return { + userId: session.userId, + conversation, + adapter: { + launchTask: createFastAgentCommunicationTaskLauncher({ + userId: session.userId, + conversation, + }), + // Email is a low-frequency surface: one coalesced reply per event, no + // suggestion buttons or reactions. The adapter resolves the reply + // anchor and recipient from the durable conversation row; threadId + // carries the internal conversation id. + postReply: async ({ message }) => { + const posted = await provider.postMessage({ + channelId: conversation.replyTarget.channelId, + threadId: conversation.conversationId, + text: `${message}\n\n${buildFastSessionReplyFooterText({ provider: 'agentmail', sessionId: params.parent.sessionId, ...params.footerContext })}`, + textFormat: 'markdown', + // Durable parent events retry after crashes that may land AFTER the + // provider accepted the email; the event's stable identity makes + // the replay a no-op instead of a duplicate result email. + idempotencyKey: `agentmail:${conversation.conversationId}:parent-event:${createHash('sha256').update(buildEventClientMessageSeed(params.event)).update('\0').update(message).digest('hex').slice(0, 24)}`, + }); + await recordFastAgentConversationMessageBestEffort({ + sessionId: session.id, + conversation, + messageId: posted.lastTextMessageId ?? posted.messageId, + }); + params.onReplyPosted(); + return { messageId: posted.messageId }; + }, + }, + }; +} + async function createTelegramFastAgentParentTurn( params: FastAgentParentTurnParams, ): Promise { @@ -1473,6 +1530,8 @@ async function createFastAgentParentTurn(params: { return createTeamsFastAgentParentTurn(turnParams); case 'telegram': return createTelegramFastAgentParentTurn(turnParams); + case 'agentmail': + return createAgentMailFastAgentParentTurn(turnParams); } } diff --git a/packages/sdk/src/server/lib/fast-agent-provider-message.ts b/packages/sdk/src/server/lib/fast-agent-provider-message.ts index 28c0fe9b5..c9ac50a8e 100644 --- a/packages/sdk/src/server/lib/fast-agent-provider-message.ts +++ b/packages/sdk/src/server/lib/fast-agent-provider-message.ts @@ -11,7 +11,12 @@ import { type FastAgentConversationRecord, } from '@roomote/cloud-agents/server'; -export type FastAgentReplyProvider = 'discord' | 'slack' | 'teams' | 'telegram'; +export type FastAgentReplyProvider = + | 'discord' + | 'slack' + | 'teams' + | 'telegram' + | 'agentmail'; type ProviderRoute = { provider: FastAgentReplyProvider; @@ -74,7 +79,8 @@ export async function recordFastAgentConversationMessage(input: { conversation.surface !== 'discord' && conversation.surface !== 'slack' && conversation.surface !== 'teams' && - conversation.surface !== 'telegram' + conversation.surface !== 'telegram' && + conversation.surface !== 'agentmail' ) { return false; } diff --git a/packages/sdk/src/server/lib/fast-agent-surface-reply.ts b/packages/sdk/src/server/lib/fast-agent-surface-reply.ts index 4da651a47..cd5f93c7f 100644 --- a/packages/sdk/src/server/lib/fast-agent-surface-reply.ts +++ b/packages/sdk/src/server/lib/fast-agent-surface-reply.ts @@ -1,3 +1,5 @@ +import { createHash } from 'node:crypto'; + import { acquireFastAgentTurnLock, answerFastAgentQuestion, @@ -33,6 +35,8 @@ import { createFastAgentDiscordTaskLauncher, } from './fast-agent-parent-event'; import { createTeamsCommunicationProviderFromRuntimeCredentials } from './teams-communication'; +import { createAgentMailCommunicationProviderFromRuntimeCredentials } from './agentmail-communication'; +import { isAgentMailConversationParticipant } from './agentmail/conversation-store'; import { createTelegramCommunicationProviderFromRuntimeCredentials } from './telegram-communication'; import { findTeamsConversationRoute } from '../automations/destination'; import { recordFastAgentConversationMessageBestEffort } from './fast-agent-provider-message'; @@ -157,12 +161,20 @@ export async function buildFastAgentSurfaceReplyDelivery(params: { if (!session) { return null; } - if ( - !(await canUserAccessFastAgentSession({ + const canAccess = + (await canUserAccessFastAgentSession({ sessionId: session.id, userId: params.userId, - })) - ) { + })) || + // Email conversations are owned by their initiator but deliberately + // admit cc'd verified users as participants; the participant table is + // the authorization source for their turns. + (session.conversation.surface === 'agentmail' && + (await isAgentMailConversationParticipant({ + conversationId: session.conversation.conversationId, + userId: params.userId, + }))); + if (!canAccess) { return null; } const conversation = session.conversation; @@ -506,6 +518,51 @@ export async function buildFastAgentSurfaceReplyDelivery(params: { }; } + if (conversation.surface === 'agentmail') { + const provider = + await createAgentMailCommunicationProviderFromRuntimeCredentials(); + if (!provider) { + return null; + } + // Deterministic per-post identity: a re-run of the same inbound turn + // (crash between the provider accepting the email and the turn being + // marked consumed) replays the same key sequence, so retries cannot + // duplicate outbound emails. The text digest keeps distinct replies + // from ever colliding — web-initiated turns have no unique inbound + // message id, and a reused key with a different body is a provider 409. + let agentMailPostIndex = 0; + return { + conversation, + adapter: { + launchTask: createFastAgentCommunicationTaskLauncher({ + userId: params.userId, + conversation, + }), + // The adapter resolves the durable reply anchor and recipient from + // the conversation row; threadId carries the internal conversation + // id. A sent email is immutable, so replaceReply keeps the original + // message instead of editing (email is one final reply per turn, + // never a streamed draft). + postReply: async ({ message }) => { + const posted = await provider.postMessage({ + channelId: conversation.replyTarget.channelId, + threadId: conversation.conversationId, + text: `${message}\n\n${buildFastSessionReplyFooterText({ provider: 'agentmail', sessionId: session.id, ...footerContext })}`, + textFormat: 'markdown', + idempotencyKey: `agentmail:${conversation.conversationId}:fast-reply:${params.currentMessageId ?? 'web'}:${agentMailPostIndex++}:${createHash('sha256').update(message).digest('hex').slice(0, 12)}`, + }); + await recordFastAgentConversationMessageBestEffort({ + sessionId: session.id, + conversation, + messageId: posted.lastTextMessageId ?? posted.messageId, + }); + return { messageId: posted.messageId }; + }, + replaceReply: async (handle) => handle, + }, + }; + } + return null; } @@ -569,8 +626,44 @@ async function runFastAgentSurfaceReply( })); if (!release) return false; - const apiBaseUrl = resolveApiBaseUrl() ?? undefined; try { + await runFastAgentSurfaceReplyWithSignal(params, release.signal); + return true; + } finally { + await release().catch(() => {}); + } +} + +/** + * Run one surface turn while the CALLER owns the Fast turn lock. Durable + * queue drainers (AgentMail inbound turns) hold the lock across a whole + * ordered drain, so the per-turn acquire in `runFastAgentSurfaceReply` would + * deadlock; this awaited variant mirrors `deliverFastAgentParentEventWithLock`. + */ +export async function continueFastAgentSurfaceReplyWithLock( + params: FastAgentSurfaceReplyParams, + turnSignal: AbortSignal, +): Promise { + const delivery = await buildFastAgentSurfaceReplyDelivery(params); + if (!delivery) { + return false; + } + + await runFastAgentSurfaceReplyWithSignal({ ...params, delivery }, turnSignal); + return true; +} + +async function runFastAgentSurfaceReplyWithSignal( + params: FastAgentSurfaceReplyParams & { + delivery: FastAgentSurfaceReplyDelivery; + }, + turnSignal: AbortSignal, +): Promise { + const { delivery } = params; + const release = { signal: turnSignal }; + + const apiBaseUrl = resolveApiBaseUrl() ?? undefined; + { const activeTasks = params.externalInput ? await getActiveFastAgentTasks(params.sessionId) : undefined; @@ -603,9 +696,6 @@ async function runFastAgentSurfaceReply( ...delivery.adapter, }, }); - return true; - } finally { - await release().catch(() => {}); } } diff --git a/packages/sdk/src/server/lib/task-runs/finish-run.ts b/packages/sdk/src/server/lib/task-runs/finish-run.ts index 209357131..474141d06 100644 --- a/packages/sdk/src/server/lib/task-runs/finish-run.ts +++ b/packages/sdk/src/server/lib/task-runs/finish-run.ts @@ -20,6 +20,7 @@ import { TASK_STARTUP_FAILURE_TEXT, } from '@roomote/communication/chat-messages'; import { DiscordCommunicationProvider } from '@roomote/communication/discord-provider'; +import { createAgentMailCommunicationProviderFromRuntimeCredentials } from '../agentmail-communication'; import { createTeamsCommunicationProviderFromRuntimeCredentials } from '../teams-communication'; import { createTelegramCommunicationProviderFromRuntimeCredentials } from '../telegram-communication'; import { Env, isBrainConfigured } from '@roomote/env'; @@ -558,6 +559,22 @@ export const finishRun = async ({ } } + if ( + status === RunStatus.Failed && + !task.slackThreadTs && + getCommunicationProviderFromTaskPayload(run.payload) === 'agentmail' + ) { + try { + await sendAgentMailFailureNotification(run, channelProviderError); + } catch (err) { + console.error( + `[finishRun] Failed to send email failure notification for run ${id}: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + } + const linkedEnvironmentDefinitionId = status === RunStatus.Idle && run.taskPhase === 'waiting_for_prompt' ? await resolveSetupCompletionEnvironmentDefinitionId(run) @@ -1080,6 +1097,64 @@ async function sendTelegramFailureNotification( ); } +/** + * Post the terminal failure result back into the originating email + * conversation when an email-launched run fails. Mirrors the Telegram path: + * same result-text composition (failure text, error details, task link), no + * reactions, no typing indicators, no topic-name updates. The adapter + * resolves the actual reply anchor and recipient from the durable + * agentmail_conversations row; the payload thread id is the INTERNAL + * conversation id. The Idempotency-Key is stable per run so a finish-run + * retry can never double-send the email. + */ +async function sendAgentMailFailureNotification( + run: FinishedRun, + error?: string, +): Promise { + const provider = + await createAgentMailCommunicationProviderFromRuntimeCredentials(); + if (!provider) { + console.warn( + `[finishRun] AgentMail credentials are not configured, skipping email failure notification for run ${run.id}`, + ); + return; + } + + const channelId = getCommunicationChannelFromTaskPayload(run.payload); + const conversationId = getCommunicationThreadIdFromTaskPayload(run.payload); + if (!channelId || !conversationId) { + console.warn( + `[finishRun] Missing email conversation metadata for run ${run.id}, skipping email failure notification`, + ); + return; + } + + const failureText = hasReachedTaskRuntime(run) + ? TASK_RUNTIME_FAILURE_TEXT + : TASK_STARTUP_FAILURE_TEXT; + const taskUrl = getTaskUrl({ + taskId: run.taskId, + utm: { campaign: run.payloadKind, source: 'agentmail' }, + }); + const text = [ + failureText, + error ? `**Error details:** ${error}` : null, + taskUrl ? formatMarkdownLink('Open the task', taskUrl) : null, + ] + .filter((part): part is string => part !== null) + .join('\n\n'); + + await provider.postMessage({ + channelId, + threadId: conversationId, + text, + textFormat: 'markdown', + idempotencyKey: `agentmail:${conversationId}:finish-run:${run.id}`, + }); + + console.log(`[finishRun] Sent email failure notification for run ${run.id}`); +} + async function sendDiscordFailureNotification( run: FinishedRun, error?: string, diff --git a/packages/sdk/src/server/lib/task-runs/notify-source-thread-provider-error.ts b/packages/sdk/src/server/lib/task-runs/notify-source-thread-provider-error.ts index 42c3af649..dca905926 100644 --- a/packages/sdk/src/server/lib/task-runs/notify-source-thread-provider-error.ts +++ b/packages/sdk/src/server/lib/task-runs/notify-source-thread-provider-error.ts @@ -25,6 +25,7 @@ import { getTaskUrl } from '@roomote/cloud-agents/server'; import { getRedis } from '@roomote/redis'; import { SlackNotifier } from '@roomote/slack'; +import { createAgentMailCommunicationProviderFromRuntimeCredentials } from '../agentmail-communication'; import { createDiscordCommunicationProviderFromRuntimeCredentials } from '../discord-communication'; import { createTeamsCommunicationProviderFromRuntimeCredentials } from '../teams-communication'; import { createTelegramCommunicationProviderFromRuntimeCredentials } from '../telegram-communication'; @@ -165,12 +166,14 @@ async function notifyTeams(run: NotifiedRun, error: string): Promise { async function notifyThreadedMarkdownProvider( run: NotifiedRun, error: string, - provider: 'discord' | 'telegram', + provider: 'discord' | 'telegram' | 'agentmail', ): Promise { const adapter = provider === 'discord' ? await createDiscordCommunicationProviderFromRuntimeCredentials() - : await createTelegramCommunicationProviderFromRuntimeCredentials(); + : provider === 'agentmail' + ? await createAgentMailCommunicationProviderFromRuntimeCredentials() + : await createTelegramCommunicationProviderFromRuntimeCredentials(); if (!adapter) { console.warn( @@ -328,6 +331,9 @@ async function notifySourceThreadOfTerminalProviderError(input: { break; case 'discord': case 'telegram': + case 'agentmail': + // Email threads get the same threaded markdown notice; the adapter + // resolves the reply route from the conversation id in threadId. delivered = await notifyThreadedMarkdownProvider(run, error, provider); break; } diff --git a/packages/sdk/src/server/lib/task-runs/pr-review-notification.ts b/packages/sdk/src/server/lib/task-runs/pr-review-notification.ts index f826ab7ca..55c35ef38 100644 --- a/packages/sdk/src/server/lib/task-runs/pr-review-notification.ts +++ b/packages/sdk/src/server/lib/task-runs/pr-review-notification.ts @@ -1027,6 +1027,8 @@ function getPrReviewLinkFormatter( return (label, url) => `${label} (${url})`; case 'discord': return (label, url) => `[${label}](${url})`; + case 'agentmail': + return (label, url) => `[${label}](${url})`; } } diff --git a/packages/sdk/src/server/lib/user-direct-message.ts b/packages/sdk/src/server/lib/user-direct-message.ts index 611d19e6a..10af4590e 100644 --- a/packages/sdk/src/server/lib/user-direct-message.ts +++ b/packages/sdk/src/server/lib/user-direct-message.ts @@ -11,6 +11,10 @@ import { import type { CommunicationProvider } from '@roomote/types'; import { SlackNotifier } from '@roomote/slack'; +import { + canStartAgentMailConversationWithUser, + startAgentMailConversation, +} from './agentmail/outbound'; import { createDiscordCommunicationProviderFromRuntimeCredentials } from './discord-communication'; import { createTeamsCommunicationProviderFromRuntimeCredentials } from './teams-communication'; import { createTelegramCommunicationProviderFromRuntimeCredentials } from './telegram-communication'; @@ -20,7 +24,8 @@ export type UserDirectMessageProvider = | 'slack' | 'teams' | 'telegram' - | 'discord'; + | 'discord' + | 'agentmail'; export type UserDirectMessageDestination = { channelId: string; @@ -145,6 +150,11 @@ export async function findUserDirectMessageDestination( return findTelegramUserDirectMessageDestination(userId); case 'discord': return findDiscordUserDirectMessageDestination(userId); + case 'agentmail': + // Email conversations are created at send time (there is no standing + // DM channel), so email cannot be a pre-resolved task destination; + // automation destinations over email are a follow-up. + return null; } return null; @@ -193,6 +203,10 @@ export async function hasUserDirectMessageIdentity( columns: { discordUserId: true }, }), ); + case 'agentmail': + // True when a consent-checked address exists (verified account email + // or explicitly linked mailbox, not suppressed) and email is set up. + return canStartAgentMailConversationWithUser(userId); } } @@ -310,6 +324,39 @@ async function sendTelegramUserDirectMessage( } } +/** + * Email needs a subject line the chat providers never supply; derive one + * from the first content line so the inbox row is meaningful. + */ +function deriveEmailSubject(text: string): string { + const firstLine = text + .split('\n') + .map((line) => line.replace(/^[#>\s*-]+/, '').trim()) + .find(Boolean); + const subject = firstLine ?? 'Notification'; + return subject.length > 80 ? `${subject.slice(0, 77)}...` : subject; +} + +async function sendAgentMailUserDirectMessage( + userId: string, + text: string, + logContext: string, +): Promise { + try { + return await startAgentMailConversation({ + userId, + subject: deriveEmailSubject(text), + text, + logContext, + }); + } catch (error) { + console.warn( + `[${logContext}] Failed to send email DM: ${formatError(error)}`, + ); + return false; + } +} + async function sendDiscordUserDirectMessage( userId: string, text: string, @@ -363,6 +410,8 @@ export async function sendUserDirectMessage({ return sendTelegramUserDirectMessage(userId, text, logContext); case 'discord': return sendDiscordUserDirectMessage(userId, text, logContext); + case 'agentmail': + return sendAgentMailUserDirectMessage(userId, text, logContext); } } @@ -387,10 +436,20 @@ export async function sendUserDirectMessageBestEffort({ sendDiscordUserDirectMessage(userId, text, logContext), ]); + const chatDelivered = slack || teams || telegram || discord; + + // Email is the fallback reach, not another parallel copy: emailing a user + // who already got the message in chat violates the email-cadence contract + // (email is low-frequency by design). + const agentmail = chatDelivered + ? false + : await sendAgentMailUserDirectMessage(userId, text, logContext); + return [ ...(slack ? (['slack'] as const) : []), ...(teams ? (['teams'] as const) : []), ...(telegram ? (['telegram'] as const) : []), ...(discord ? (['discord'] as const) : []), + ...(agentmail ? (['agentmail'] as const) : []), ]; } diff --git a/packages/sdk/src/server/routers/task-runs.ts b/packages/sdk/src/server/routers/task-runs.ts index 6ec0a5fab..c4f582a10 100644 --- a/packages/sdk/src/server/routers/task-runs.ts +++ b/packages/sdk/src/server/routers/task-runs.ts @@ -954,7 +954,8 @@ export const taskRunsRouter = router({ if ( provider !== 'discord' && provider !== 'telegram' && - provider !== 'teams' + provider !== 'teams' && + provider !== 'agentmail' ) { throw new TRPCError({ code: 'BAD_REQUEST', @@ -1014,7 +1015,8 @@ export const taskRunsRouter = router({ if ( provider !== 'discord' && provider !== 'telegram' && - provider !== 'teams' + provider !== 'teams' && + provider !== 'agentmail' ) { throw new TRPCError({ code: 'BAD_REQUEST', diff --git a/packages/slack/src/router-debug.ts b/packages/slack/src/router-debug.ts index fd2bcd915..489306ac3 100644 --- a/packages/slack/src/router-debug.ts +++ b/packages/slack/src/router-debug.ts @@ -195,10 +195,15 @@ async function getActiveSlackBotToken(): Promise { } async function postNonSlackRouterDebugMessage(params: { - provider: 'discord' | 'teams' | 'telegram'; + provider: 'discord' | 'teams' | 'telegram' | 'agentmail'; channelId: string; text: string; }): Promise { + if (params.provider === 'agentmail') { + // Email has no debug channel; router debug output stays chat-only. + return; + } + if (params.provider === 'discord') { const credentials = await resolveDiscordRuntimeCredentials(); if (!credentials.botToken) return; diff --git a/packages/types/src/background-agents.ts b/packages/types/src/background-agents.ts index 2591ef7f9..b6f4e80e8 100644 --- a/packages/types/src/background-agents.ts +++ b/packages/types/src/background-agents.ts @@ -192,18 +192,27 @@ export function isBackgroundAutomationUserTargetKind( ); } +/** + * Communication providers that can act as automation destinations. Email + * (agentmail) is inbound-initiated only and never receives automation posts. + */ +export type AutomationCapableCommunicationProvider = Exclude< + CommunicationProvider, + 'agentmail' +>; + export const communicationAutomationTargetKinds = { slack: { channel: 'slack_channel', direct_message: 'slack_user' }, discord: { channel: 'discord_channel', direct_message: 'discord_user' }, teams: { channel: 'teams_channel', direct_message: 'teams_user' }, telegram: { channel: 'telegram_chat', direct_message: 'telegram_user' }, } as const satisfies Record< - CommunicationProvider, + AutomationCapableCommunicationProvider, Record<'channel' | 'direct_message', BackgroundAutomationTargetKind> >; export function getCommunicationAutomationTargetKind( - provider: CommunicationProvider, + provider: AutomationCapableCommunicationProvider, mode: 'channel' | 'direct_message', ): BackgroundAutomationTargetKind { return communicationAutomationTargetKinds[provider][mode]; @@ -212,14 +221,14 @@ export function getCommunicationAutomationTargetKind( export function isCommunicationAutomationTarget( target: Pick, ): target is Pick & { - provider: CommunicationProvider; + provider: AutomationCapableCommunicationProvider; } { if (!(target.provider in communicationAutomationTargetKinds)) { return false; } const kinds = communicationAutomationTargetKinds[ - target.provider as CommunicationProvider + target.provider as AutomationCapableCommunicationProvider ]; return ( target.targetKind === kinds.channel || diff --git a/packages/types/src/communication.ts b/packages/types/src/communication.ts index f10200271..0ace9bedd 100644 --- a/packages/types/src/communication.ts +++ b/packages/types/src/communication.ts @@ -5,6 +5,7 @@ export const communicationProviders = [ 'teams', 'telegram', 'discord', + 'agentmail', ] as const; export const communicationProviderSchema = z.enum(communicationProviders); @@ -56,6 +57,7 @@ export const communicationProviderQueuePrefixes = { teams: 'teams:messages:', telegram: 'telegram:messages:', discord: 'discord:messages:', + agentmail: 'agentmail:messages:', } as const satisfies Record; export function getCommunicationProviderQueuePrefix( @@ -69,6 +71,7 @@ export const communicationProviderDisplayNames = { teams: 'Microsoft Teams', telegram: 'Telegram', discord: 'Discord', + agentmail: 'Email', } as const satisfies Record; export function getCommunicationProviderDisplayName( diff --git a/packages/types/src/control-plane-env-vars.ts b/packages/types/src/control-plane-env-vars.ts index 68265e601..a6461b959 100644 --- a/packages/types/src/control-plane-env-vars.ts +++ b/packages/types/src/control-plane-env-vars.ts @@ -43,6 +43,8 @@ export const INTEGRATION_BOT_SECRET_ENV_VAR_NAMES: ReadonlySet = new Set([ 'R_TELEGRAM_BOT_TOKEN', 'R_TELEGRAM_WEBHOOK_SECRET', + 'R_AGENTMAIL_API_KEY', + 'R_AGENTMAIL_WEBHOOK_SECRET', 'R_DISCORD_BOT_TOKEN', 'R_DISCORD_GATEWAY_SECRET', 'R_TEAMS_BOT_APP_ID', @@ -68,6 +70,7 @@ export const PROVIDER_IDENTIFIER_ENV_VAR_NAMES: ReadonlySet = new Set([ 'GITLAB_CLIENT_ID', 'GITEA_CLIENT_ID', 'SLACK_APP_ID', + 'R_AGENTMAIL_INBOX_ID', 'ADO_CLIENT_ID', 'ADO_TENANT_ID', 'ADO_AUTH_MODE', diff --git a/packages/types/src/fast-agent.ts b/packages/types/src/fast-agent.ts index be3bc8df7..69cef2e9a 100644 --- a/packages/types/src/fast-agent.ts +++ b/packages/types/src/fast-agent.ts @@ -5,6 +5,7 @@ export const fastAgentSurfaces = [ 'discord', 'teams', 'telegram', + 'agentmail', 'automation', 'web', ] as const; @@ -49,6 +50,18 @@ export const fastAgentConversationSchema = z.discriminatedUnion('surface', [ ...fastAgentConversationIdentitySchema, replyTarget: fastAgentReplyTargetSchema, }), + z.object({ + surface: z.literal('agentmail'), + /** + * conversationId is the internal agentmail_conversations id, not the + * provider thread id: forwarded threads fork into a second conversation + * on the same provider thread, and the fork must be a distinct identity. + * The durable reply route (anchor, recipient) lives on the conversation + * row; replyTarget carries the inbox as channelId for display/context. + */ + ...fastAgentConversationIdentitySchema, + replyTarget: fastAgentReplyTargetSchema, + }), z.object({ surface: z.literal('automation'), ...fastAgentConversationIdentitySchema, @@ -63,7 +76,7 @@ export type FastAgentConversation = z.infer; export type FastAgentCommunicationConversation = Extract< FastAgentConversation, - { surface: 'slack' | 'discord' | 'teams' | 'telegram' } + { surface: 'slack' | 'discord' | 'teams' | 'telegram' | 'agentmail' } >; export function isFastAgentCommunicationConversation( @@ -73,7 +86,8 @@ export function isFastAgentCommunicationConversation( conversation.surface === 'slack' || conversation.surface === 'discord' || conversation.surface === 'teams' || - conversation.surface === 'telegram' + conversation.surface === 'telegram' || + conversation.surface === 'agentmail' ); } diff --git a/packages/types/src/invocation-identity.ts b/packages/types/src/invocation-identity.ts index ab6388198..0c2b2f141 100644 --- a/packages/types/src/invocation-identity.ts +++ b/packages/types/src/invocation-identity.ts @@ -5,6 +5,7 @@ export const invocationProviders = [ 'microsoft', 'telegram', 'discord', + 'agentmail', 'github', 'linear', 'gitlab', @@ -102,6 +103,26 @@ export function buildTelegramInvocationIdentity( }; } +export function buildAgentMailInvocationIdentity( + inboxAddress: string | null | undefined, +): InvocationIdentity { + const normalizedAddress = inboxAddress?.trim() || null; + + return { + provider: 'agentmail', + label: 'Email', + configured: Boolean(normalizedAddress), + displayName: normalizedAddress, + mentionText: null, + nativeMention: null, + deepLinkUrl: normalizedAddress ? `mailto:${normalizedAddress}` : null, + guidanceName: normalizedAddress ?? 'Email', + examplePrompt: normalizedAddress + ? `Email ${normalizedAddress} with a request like "Add support for a reset password flow."` + : null, + }; +} + export function buildDiscordInvocationIdentity(input: { botUserId: string | null | undefined; username: string | null | undefined; diff --git a/packages/types/src/task-runs.ts b/packages/types/src/task-runs.ts index 29e951154..26b7b0e4c 100644 --- a/packages/types/src/task-runs.ts +++ b/packages/types/src/task-runs.ts @@ -65,6 +65,7 @@ export const TASK_SURFACES = [ 'teams', 'telegram', 'discord', + 'agentmail', 'linear', 'github', 'gitlab', @@ -291,6 +292,7 @@ export const TRACKED_MESSAGE_SURFACES = [ 'teams', 'telegram', 'discord', + 'agentmail', ] as const; export type TrackedMessageSurface = (typeof TRACKED_MESSAGE_SURFACES)[number]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bd9ca0a03..0afd2d577 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -231,6 +231,9 @@ importers: snowflake-sdk: specifier: ^2.4.3 version: 2.4.3(asn1.js@5.4.1) + svix: + specifier: ^1.99.1 + version: 1.99.1 undici: specifier: ^7.29.0 version: 7.29.0 @@ -5492,6 +5495,9 @@ packages: '@so-ric/colorspace@1.1.6': resolution: {integrity: sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==} + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -7989,6 +7995,9 @@ packages: fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fast-uri@3.1.5: resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==} @@ -10781,6 +10790,9 @@ packages: standard-as-callback@2.1.0: resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + standardwebhooks@1.0.0: + resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -10984,6 +10996,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + svix@1.99.1: + resolution: {integrity: sha512-JfpvbAg5iT5NagDAfOq3e6Ci6NbOMbMm6C3DnfDBB60m2JDK+Z2hXPE9XNAmutb53DCdPoih1V70IbklZnX09A==} + symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} @@ -16065,6 +16080,8 @@ snapshots: color: 5.0.3 text-hex: 1.0.0 + '@stablelib/base64@1.0.1': {} + '@standard-schema/spec@1.1.0': {} '@standard-schema/utils@0.3.0': {} @@ -18787,6 +18804,8 @@ snapshots: fast-safe-stringify@2.1.1: {} + fast-sha256@1.3.0: {} + fast-uri@3.1.5: {} fast-xml-builder@1.2.0: @@ -22247,6 +22266,11 @@ snapshots: standard-as-callback@2.1.0: {} + standardwebhooks@1.0.0: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + statuses@2.0.2: {} std-env@4.0.0: {} @@ -22514,6 +22538,10 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + svix@1.99.1: + dependencies: + standardwebhooks: 1.0.0 + symbol-tree@3.2.4: {} tailwind-merge@3.4.0: {} diff --git a/turbo.json b/turbo.json index 3dff58f92..ff8c0cd22 100644 --- a/turbo.json +++ b/turbo.json @@ -13,6 +13,7 @@ "globalPassThroughEnv": [ "APP_ENV", "DATABASE_URL", + "REDIS_URL", "MISE_DATA_DIR", "MISE_CACHE_DIR", "SKIP_ENV_VALIDATION",