From a917cca9c30de4aeaa960a800d436e69aaec0d72 Mon Sep 17 00:00:00 2001 From: Daniel <57051444+daniel-lxs@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:33:33 -0500 Subject: [PATCH 01/11] feat: session wakeups let a Fast Session schedule a message to itself Add the manage_wakeups Fast native tool (create, list, get, cancel) backed by a session_wakeups table. A wakeup is a durable row plus one delayed BullMQ job; when it fires, a scheduled_wakeup platform event is admitted into the conversation's existing parent-event inbox and runs as a normal turn with the full history in context. Occurrences are claimed with a compare-and-set on next_run_at, so duplicate jobs cannot double-fire, and a 60s recovery sweep re-adds hints for due rows. One-shot wakeups always reply; recurring ones stay quiet unless notable and retire after five consecutive failed turns. Archiving a Session cancels its wakeups. --- .changeset/session-wakeups.md | 5 + apps/bullmq/src/index.ts | 10 + apps/bullmq/src/session-wakeup-queue.ts | 87 + apps/docs/fast-sessions.mdx | 25 + .../messages/acp/tool-presentation.ts | 1 + apps/web/src/trpc/commands/sessions/index.ts | 16 +- packages/cloud-agents/package.json | 2 + .../fast-agent/fast-agent-conversation.ts | 3 +- .../fast-agent-native-tool-bridge.ts | 42 + .../server/fast-agent/fast-agent-prompt.ts | 16 +- .../server/fast-agent/fast-agent-service.ts | 18 + packages/cloud-agents/src/server/index.ts | 1 + .../src/server/session-wakeups/index.ts | 31 + .../src/server/session-wakeups/queue.ts | 66 + .../server/session-wakeups/schedule.test.ts | 231 + .../src/server/session-wakeups/schedule.ts | 249 + .../src/server/session-wakeups/service.ts | 326 + packages/db/drizzle/0077_normal_lionheart.sql | 28 + packages/db/drizzle/meta/0077_snapshot.json | 14408 ++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + .../src/lib/__tests__/session-wakeups.test.ts | 197 + packages/db/src/lib/session-wakeups.ts | 296 + packages/db/src/schema.ts | 73 + packages/db/src/server.ts | 3 + packages/db/src/types.ts | 12 + packages/sdk/src/server/index.ts | 9 + .../lib/fast-agent-parent-event-queue.test.ts | 71 + .../lib/fast-agent-parent-event-queue.ts | 20 + .../src/server/lib/fast-agent-parent-event.ts | 18 +- .../src/server/lib/session-wakeups.test.ts | 244 + .../sdk/src/server/lib/session-wakeups.ts | 190 + packages/types/src/fast-agent-tool-catalog.ts | 5 + packages/types/src/fast-agent.ts | 1 + packages/types/src/index.ts | 1 + packages/types/src/session-wakeups.test.ts | 92 + packages/types/src/session-wakeups.ts | 290 + pnpm-lock.yaml | 6 + 37 files changed, 17093 insertions(+), 7 deletions(-) create mode 100644 .changeset/session-wakeups.md create mode 100644 apps/bullmq/src/session-wakeup-queue.ts create mode 100644 packages/cloud-agents/src/server/session-wakeups/index.ts create mode 100644 packages/cloud-agents/src/server/session-wakeups/queue.ts create mode 100644 packages/cloud-agents/src/server/session-wakeups/schedule.test.ts create mode 100644 packages/cloud-agents/src/server/session-wakeups/schedule.ts create mode 100644 packages/cloud-agents/src/server/session-wakeups/service.ts create mode 100644 packages/db/drizzle/0077_normal_lionheart.sql create mode 100644 packages/db/drizzle/meta/0077_snapshot.json create mode 100644 packages/db/src/lib/__tests__/session-wakeups.test.ts create mode 100644 packages/db/src/lib/session-wakeups.ts create mode 100644 packages/sdk/src/server/lib/session-wakeups.test.ts create mode 100644 packages/sdk/src/server/lib/session-wakeups.ts create mode 100644 packages/types/src/session-wakeups.test.ts create mode 100644 packages/types/src/session-wakeups.ts diff --git a/.changeset/session-wakeups.md b/.changeset/session-wakeups.md new file mode 100644 index 000000000..b1f47e47f --- /dev/null +++ b/.changeset/session-wakeups.md @@ -0,0 +1,5 @@ +--- +'@roomote/web': minor +--- + +Fast Sessions can schedule a message to themselves with the new `manage_wakeups` tool. Ask Fast to "remind me in twenty minutes" or "check every ten minutes whether CI is green" and it creates a wakeup for that Session; when it fires, Fast picks the conversation back up with its history in context, does what was asked, and replies on the surface the Session lives on. One-shot wakeups always reply, recurring ones stay quiet unless there is news and cancel themselves when the monitored condition resolves. Wakeups are scoped to the conversation, need no administrator, are capped at ten per Session, and are cancelled when the Session is archived. Firing is durable: each occurrence is claimed on the row, a delayed queue job is only a hint, and a recovery sweep re-adds lost hints. diff --git a/apps/bullmq/src/index.ts b/apps/bullmq/src/index.ts index be81d6950..d17e204ce 100644 --- a/apps/bullmq/src/index.ts +++ b/apps/bullmq/src/index.ts @@ -54,6 +54,7 @@ import { startTaskSleepQueue } from './task-sleep-queue'; import { startAutomationRecommendationsQueue } from './automation-recommendations-queue'; import { startFastAgentParentEventQueue } from './fast-agent-parent-event-queue'; import { readBullMqQueueHealth } from './health'; +import { startSessionWakeupQueue } from './session-wakeup-queue'; import { installBullMqGracefulShutdown } from './graceful-shutdown'; // Deployments roll every service at once while migrations run only ahead @@ -216,6 +217,11 @@ const { worker: fastAgentParentEventWorker, queueEvents: fastAgentParentEventQueueEvents, } = await startFastAgentParentEventQueue(); +const { + queue: sessionWakeupQueue, + worker: sessionWakeupWorker, + queueEvents: sessionWakeupQueueEvents, +} = await startSessionWakeupQueue(); const serverAdapter = new HonoAdapter(serveStatic); @@ -256,6 +262,7 @@ createBullBoard({ readOnlyMode: false, }), new BullMQAdapter(fastAgentParentEventQueue, { readOnlyMode: false }), + new BullMQAdapter(sessionWakeupQueue, { readOnlyMode: false }), ], serverAdapter, }); @@ -448,6 +455,9 @@ installBullMqGracefulShutdown({ await pullRequestMergeabilityCheckQueue.close(); await fastAgentParentEventQueueEvents.close(); await fastAgentParentEventQueue.close(); + await sessionWakeupWorker.close(); + await sessionWakeupQueueEvents.close(); + await sessionWakeupQueue.close(); await discordGatewaySupervisor.stop(); await closeRedis(); }, diff --git a/apps/bullmq/src/session-wakeup-queue.ts b/apps/bullmq/src/session-wakeup-queue.ts new file mode 100644 index 000000000..d177764ec --- /dev/null +++ b/apps/bullmq/src/session-wakeup-queue.ts @@ -0,0 +1,87 @@ +import { Queue, QueueEvents, Worker, type Job } from 'bullmq'; + +import { + SESSION_WAKEUP_FIRE_JOB_NAME, + SESSION_WAKEUP_QUEUE_NAME, + fireSessionWakeup, + recoverPendingSessionWakeups, + type SessionWakeupFireJob, +} from '@roomote/sdk/server'; + +import { getRedis } from './redis'; + +const RECOVERY_JOB_NAME = 'recover-due'; +const RECOVERY_SCHEDULER_ID = 'session-wakeup-recovery'; +const RECOVERY_INTERVAL_MS = 60_000; + +type SessionWakeupQueueJob = SessionWakeupFireJob | { recovery: true }; + +async function processJob(job: Job) { + if (job.name === RECOVERY_JOB_NAME || 'recovery' in job.data) { + const recovered = await recoverPendingSessionWakeups(); + if (recovered > 0) { + console.log( + `[SessionWakeupQueue] Re-added ${recovered} due wakeup hint(s).`, + ); + } + return; + } + if (job.name !== SESSION_WAKEUP_FIRE_JOB_NAME) return; + const result = await fireSessionWakeup(job.data); + if (result.outcome === 'skipped') { + console.log( + `[SessionWakeupQueue] Skipped wakeup ${job.data.wakeupId}: ${result.reason}`, + ); + } +} + +/** + * Fires session wakeups. Every job is a hint for a `session_wakeups` row: + * the row's `next_run_at` decides whether anything happens, and a recovery + * sweep re-adds hints for due rows so lost jobs cannot strand a wakeup. + */ +export async function startSessionWakeupQueue() { + const connection = getRedis(); + const queue = new Queue(SESSION_WAKEUP_QUEUE_NAME, { + connection, + defaultJobOptions: { + attempts: 3, + 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 recoverPendingSessionWakeups(); + + const worker = new Worker( + SESSION_WAKEUP_QUEUE_NAME, + processJob, + // Firing only admits an event into the conversation's durable inbox; the + // turn itself runs on the Fast parent event worker. + { connection, concurrency: 10, autorun: true }, + ); + + worker.on('failed', (job, error) => + console.error( + `[SessionWakeupQueue] job ${job?.id} failed: ${error.message}`, + ), + ); + worker.on('error', (error) => + console.error('[SessionWakeupQueue] worker error:', error), + ); + + const queueEvents = new QueueEvents(SESSION_WAKEUP_QUEUE_NAME, { + connection, + }); + queueEvents.on('failed', ({ jobId, failedReason }) => + console.error(`[SessionWakeupQueue] job ${jobId} failed: ${failedReason}`), + ); + + return { queue, worker, queueEvents }; +} diff --git a/apps/docs/fast-sessions.mdx b/apps/docs/fast-sessions.mdx index 5ca77f0ad..26b561052 100644 --- a/apps/docs/fast-sessions.mdx +++ b/apps/docs/fast-sessions.mdx @@ -145,6 +145,31 @@ In shared Slack and Discord conversations, Fast can stay silent when linked participants are talking to each other. A direct reply to Roomote, a mention, or a direct message still requires a response. +## Reminders and monitors + +A Session can schedule a message to itself. Ask Fast to "remind me in twenty +minutes", "check every ten minutes whether CI is green", or "every weekday at +nine, summarize the open pull requests", and it creates a wakeup for that +Session. When the wakeup fires, Fast picks the conversation back up with its +full history in context, does what was asked, and replies on the same surface +the Session lives on: the transcript in the dashboard, or the originating Slack, +Discord, Teams, or Telegram thread. + +A one-shot wakeup is a reminder and always replies. A recurring wakeup is a +monitor and stays quiet unless it has something to report, such as a change, a +result, a blocker, or a decision you need to make. When the monitored condition +resolves, Fast tells you and cancels the wakeup itself. You can also ask Fast to +list or cancel wakeups at any time; archiving a Session cancels all of its +wakeups. + +Wakeups belong to the conversation and do not require an administrator. A +Session may hold up to ten active wakeups, intervals range from one minute to +seven days, and recurring wakeups can be bounded by a run count or an end time. +Intervals under five minutes must carry one of those bounds. A recurring wakeup +whose turns fail five times in a row is retired. Deployment-wide recurring work +that should run outside a conversation or report to a channel is an +[automation](/automations) instead. + ## Session and execution access Any signed-in deployment user with a Session link can view its timeline and diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-presentation.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-presentation.ts index afcab26b7..6b7443c51 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-presentation.ts +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-presentation.ts @@ -104,6 +104,7 @@ const COMMUNICATION_TOOL_NAMES = new Set([ ]); const TOOL_ICON_OVERRIDES: Readonly>> = { manage_custom_automations: 'task', + manage_wakeups: 'task', get_about_me: 'roomote', describe_video: 'video', manage_goal: 'target', diff --git a/apps/web/src/trpc/commands/sessions/index.ts b/apps/web/src/trpc/commands/sessions/index.ts index 96eb423d7..3aef47429 100644 --- a/apps/web/src/trpc/commands/sessions/index.ts +++ b/apps/web/src/trpc/commands/sessions/index.ts @@ -1,6 +1,10 @@ import { z } from 'zod'; import { SESSION_STATUSES } from '@roomote/types'; -import { advanceSessionReadCursor, db } from '@roomote/db/server'; +import { + advanceSessionReadCursor, + cancelSessionWakeupsForConversation, + db, +} from '@roomote/db/server'; import { captureEvent } from '@roomote/telemetry/server'; import type { UserAuthSuccess } from '@/types'; @@ -131,6 +135,16 @@ export async function archiveSessionCommand( archivedAt: new Date(), }); if (archived) { + if (archived.fastConversationId) { + // An archived session must not wake itself up later. + await cancelSessionWakeupsForConversation( + archived.fastConversationId, + ).catch((error) => { + console.error( + `[sessions] Failed to cancel wakeups for archived session ${sessionId}: ${error instanceof Error ? error.message : String(error)}`, + ); + }); + } void captureEvent('session_archived', { userId: auth.userId, properties: { surface: 'web', outcome: 'archived' }, diff --git a/packages/cloud-agents/package.json b/packages/cloud-agents/package.json index bb0636afa..9413636e3 100644 --- a/packages/cloud-agents/package.json +++ b/packages/cloud-agents/package.json @@ -79,6 +79,8 @@ "@roomote/telemetry": "workspace:^", "@roomote/types": "workspace:^", "ai": "^6.0.116", + "bullmq": "^5.78.0", + "cron-parser": "5.6.1", "dompurify": "3.4.13", "jsdom": "26.1.0", "jszip": "^3.10.1", diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-conversation.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-conversation.ts index 8af832c86..f944966dd 100644 --- a/packages/cloud-agents/src/server/fast-agent/fast-agent-conversation.ts +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-conversation.ts @@ -30,7 +30,8 @@ export type FastAgentPlatformEventKind = | 'delegated_task' | 'automation' | 'setup' - | 'input_response'; + | 'input_response' + | 'scheduled_wakeup'; /** Shared with the durable follow-up event so an admitted reaction resumes as the same input. */ export type FastAgentReactionExternalInput = diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-native-tool-bridge.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-native-tool-bridge.ts index 62e07a6eb..fb83f89fc 100644 --- a/packages/cloud-agents/src/server/fast-agent/fast-agent-native-tool-bridge.ts +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-native-tool-bridge.ts @@ -27,6 +27,13 @@ import { FIND_INTEGRATION_TOOLS_TOOL, INTEGRATION_TOOL_LOOKUP_MAX_LIMIT, REASONING_EFFORT_VALUES, + MANAGE_WAKEUPS_TOOL_DESCRIPTION, + MAX_ACTIVE_SESSION_WAKEUPS, + SESSION_WAKEUP_MAX_INTERVAL_MINUTES, + SESSION_WAKEUP_MAX_ONCE_HORIZON_MINUTES, + SESSION_WAKEUP_MAX_RUNS_LIMIT, + SESSION_WAKEUP_NAME_MAX_LENGTH, + SESSION_WAKEUP_PROMPT_MAX_LENGTH, type FastAgentSurface, FAST_EXECUTION, } from '@roomote/types'; @@ -386,6 +393,41 @@ export default { args: { taskId: z.string().nullable().optional() }, execute: (args, context) => invoke("cancel_task", args, context), } +`, + + [FAST_AGENT_NATIVE_TOOL_NAMES.manageWakeups]: String.raw` +import { z } from "zod" +import { invoke } from "../roomote-fast-tool-bridge.js" + +export default { + description: ${JSON.stringify(MANAGE_WAKEUPS_TOOL_DESCRIPTION)}, + args: { + action: z.enum(["create", "list", "get", "cancel"]).describe("create schedules a wakeup; list shows active wakeups in this conversation; get shows one; cancel stops one. Cancel is the only stop action."), + wakeupId: z.string().optional().describe("Required for get and cancel"), + name: z.string().min(3).max(${SESSION_WAKEUP_NAME_MAX_LENGTH}).optional().describe("[create] Short label, e.g. 'Check PR #85 for merge'"), + prompt: z.string().min(10).max(${SESSION_WAKEUP_PROMPT_MAX_LENGTH}).optional().describe("[create] What to do when it fires. This conversation stays in context, so keep it short: what to check, what counts as done, what to tell the user."), + schedule: z.discriminatedUnion("mode", [ + z.object({ + mode: z.literal("once").describe("Fire one time. Use for every reminder or delayed follow-up."), + inMinutes: z.number().int().min(1).max(${SESSION_WAKEUP_MAX_ONCE_HORIZON_MINUTES}).optional().describe("Minutes from now. Preferred for relative requests. Provide exactly one of inMinutes or at."), + at: z.string().optional().describe("Absolute ISO 8601 date-time with UTC offset, e.g. 2026-09-04T15:00:00-04:00. Provide exactly one of inMinutes or at."), + }), + z.object({ + mode: z.literal("interval").describe("Fire repeatedly on a fixed interval measured from each run."), + everyMinutes: z.number().int().min(1).max(${SESSION_WAKEUP_MAX_INTERVAL_MINUTES}).describe("Minutes between runs. Intervals under 5 minutes require maxRuns or until."), + }), + z.object({ + mode: z.literal("cron").describe("Fire repeatedly on a calendar schedule."), + expression: z.string().describe("Five-field cron expression, e.g. '0 9 * * 1-5'"), + timezone: z.string().optional().describe("IANA timezone, e.g. 'America/New_York'. Defaults to the deployment timezone."), + }), + ]).optional().describe("[create] Exactly one schedule mode. Reminders must use mode 'once'; monitors use 'interval' or 'cron'."), + maxRuns: z.number().int().min(1).max(${SESSION_WAKEUP_MAX_RUNS_LIMIT}).optional().describe("[create] Stop after this many runs. Interval and cron only."), + until: z.string().optional().describe("[create] Stop after this ISO 8601 date-time. Interval and cron only."), + reportPolicy: z.enum(["always", "only_when_notable"]).optional().describe("[create] 'always' replies on every run (default for once); 'only_when_notable' stays silent unless there is news (default for interval and cron). At most ${MAX_ACTIVE_SESSION_WAKEUPS} wakeups may be active per conversation."), + }, + execute: (args, context) => invoke("manage_wakeups", args, context), +} `, [FAST_AGENT_NATIVE_TOOL_NAMES.retryTaskStart]: String.raw` 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 544ca675e..283f6674b 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 @@ -209,6 +209,7 @@ export function buildFastAgentSystemPrompt({ : ''; const recurringAutomationGuidance = `## Recurring Work and Automations - When an admin explicitly asks for recurring work, recognize a real cadence expression such as "every Monday", "daily", "weekly", "whenever X happens", "from now on", or "on a schedule". Do not treat preference words such as "always use tabs" as a cadence. +- Reminders and recurring checks that belong to this conversation ("remind me in an hour", "check every 10 minutes until CI is green", "ping me here every weekday at 9") are wakeups, not automations: use "manage_wakeups", which needs no admin. Reach for a custom automation only for deployment-wide recurring work that should run outside this conversation or report to a channel. - Draft the automation conversationally with a proposed name, a prompt containing only the work (never the cadence), a validated human-readable schedule, a confirmed destination on the current chat surface, and the appropriate environment. Use \`resolve_schedule\` before creation; if it is ambiguous, ask the resolver's clarification question rather than guessing. - Before \`create\`, use \`list\` to check for an equivalent automation. Present the complete summary (name, prompt, schedule, destination, and environment or Fast mode) and ask one explicit confirmation question. Never create, update, enable, or delete silently. After creation, ask whether the user wants to \`run_now\` to test it. - If the user is not an admin, do not attempt creation. Explain that an administrator is required and provide a copy-pasteable draft name, prompt, and schedule instead. @@ -349,6 +350,7 @@ ${reactionGuidance} - Use "cancel_task" only when the user explicitly asks to stop an active task. - Call a deployment MCP tool when it can answer the request. Fast receives the same actor-authorized remote and deployment-proxied MCP tool catalog as delegated tasks; local stdio servers remain sandbox-only. Servers listed with a tool prefix expose each tool individually with its native JSON schema. On-demand servers are reached through \`find_integration_tools\` (fetch the schema by server id and tool name, or search by keywords) followed by \`call_integration_tool\`; the same acknowledgement, duplicate, and audit rules apply to both paths. - Use \`roomote_manage_custom_automations\` for custom automation lifecycle requests. It uses the current user's deployment authorization, is admin-only, and is unavailable to advisor and judge subagents. List before modifying an existing automation, use "list_models" before setting a model override, use update with "enabled" to enable or disable, and use "run_now" rather than "launch_task" to test an automation. Communicate first on a human-authored turn; platform events remain exempt. Delete only when the user explicitly requests it, and after creating an automation ask whether they want to run it now. +- Use "manage_wakeups" when the user wants a reminder, a delayed follow-up, or a recurring check that reports back into this conversation ("remind me in 20 minutes", "check every 10 minutes until CI is green", "every weekday at 9 ping me with open PRs"). Reminders use schedule mode "once" (prefer inMinutes); recurring checks use "interval" or "cron". It is scoped to this conversation and available to every participant. Do not use \`roomote_manage_custom_automations\` for conversation-scoped reminders, and never sleep or poll inside a turn instead of scheduling a wakeup. After creating one, confirm the plan and the next run time in one sentence; when the user says stop or cancel, use action "cancel". ${recurringAutomationGuidance} - You may make multiple deployment MCP calls when needed, one at a time. Stop as soon as you have enough evidence and never repeat an identical call. @@ -359,8 +361,8 @@ ${recurringAutomationGuidance} - Select an environment ID only when the target is clear. Otherwise use null to use the deployment default. ${ platformEvent - ? `## ${platformEventKind === 'automation' ? 'Automation Platform Event' : platformEventKind === 'setup' ? 'Setup Platform Event' : platformEventKind === 'input_response' ? 'Structured Input Response Event' : 'Delegated Task Platform Event'} -- The current input is a trusted platform-generated ${platformEventKind === 'automation' ? 'custom automation request' : platformEventKind === 'setup' ? 'setup lifecycle event' : platformEventKind === 'input_response' ? 'structured user-input response' : 'event about a delegated task'}, not a human-authored request. + ? `## ${platformEventKind === 'automation' ? 'Automation Platform Event' : platformEventKind === 'setup' ? 'Setup Platform Event' : platformEventKind === 'input_response' ? 'Structured Input Response Event' : platformEventKind === 'scheduled_wakeup' ? 'Scheduled Wakeup Event' : 'Delegated Task Platform Event'} +- The current input is a trusted platform-generated ${platformEventKind === 'automation' ? 'custom automation request' : platformEventKind === 'setup' ? 'setup lifecycle event' : platformEventKind === 'input_response' ? 'structured user-input response' : platformEventKind === 'scheduled_wakeup' ? 'wakeup this conversation scheduled for itself' : 'event about a delegated task'}, not a human-authored request. ${ platformEventVisibility === 'required' ? '- This event requires one user-visible terminal response because it carries user-useful substance. Present its result, changed expectation, required decision, or recovery action; never narrate lifecycle state alone. Use a closeout unless the setup instructions require `request_user_input`. Do not call "ignore_event".' @@ -377,6 +379,16 @@ ${ ? "- The payload contains the user's submitted structured answers. Persist any needed state, continue the interrupted work with those answers, and acknowledge the choice in one closeout. Do not re-ask the same questions." : '' } +${ + platformEventKind === 'scheduled_wakeup' + ? `- The payload is a wakeup you scheduled earlier in this conversation with "manage_wakeups"; its \`prompt\` says what to do now. The conversation history is still in context, so act on the prompt directly rather than treating it as a new request. +- Do the work the prompt asks for. Use integrations directly when sufficient, and launch a task only when repository or workspace execution is actually required. +- \`reportPolicy\` governs whether to speak. With "always", finish with one closeout addressed to the user. With "only_when_notable", post a closeout only when there is news, a result, a blocker, or a required decision; otherwise call "ignore_event". +- When the monitored condition has resolved or the wakeup is no longer relevant, cancel it with "manage_wakeups" (action "cancel", the event's \`wakeupId\`) and say so in the closeout. \`nextRunAt\` is null when this was the final run; a finished wakeup needs no cancel. +- Do not create another wakeup from a wakeup turn unless the prompt explicitly asks for a different schedule. +` + : '' +} ${ platformEventKind === 'setup' ? '- Setup lifecycle events carry trusted readiness, connection, selection, and recommendation facts. Reconcile them against the setup snapshot, continue the next setup step, and finish with the terminal response required by the setup instructions.' diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts index 014fd444e..f56c6d03d 100644 --- a/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts @@ -20,6 +20,7 @@ import { buildInferenceProviderRecoveryPrompt, fastAgentHumanFollowUpEventSchema, formatErrorForLog, + manageWakeupsInputSchema, resolveInferenceProviderRetryDelayMs, isMemoryMcpServer, truncateAcpOutputText, @@ -57,6 +58,7 @@ import { z } from 'zod'; import packageJson from '../../../../../package.json'; import { appendAttachmentTextsToPromptText } from '../../file-attachments'; +import { handleManageWakeupsToolCall } from '../session-wakeups'; import { buildSlackThreadPromptBlocks, wrapSlackMessage, @@ -3394,6 +3396,10 @@ export async function answerFastAgentQuestion({ FAST_AGENT_NATIVE_TOOL_NAMES.sendChatReply, FAST_AGENT_NATIVE_TOOL_NAMES.sendChatReaction, FAST_AGENT_NATIVE_TOOL_NAMES.ignoreEvent, + // Scheduling or cancelling a wakeup is instant and its own confirmation + // follows in the closeout; an acknowledgement first would only add a + // second message. + FAST_AGENT_NATIVE_TOOL_NAMES.manageWakeups, // A catalog lookup reads nothing external; the call it prepares for is // still gated on the acknowledgement. FAST_AGENT_NATIVE_TOOL_NAMES.findIntegrationTools, @@ -4201,6 +4207,18 @@ export async function answerFastAgentQuestion({ return result; } + case FAST_AGENT_NATIVE_TOOL_NAMES.manageWakeups: { + const args = manageWakeupsInputSchema.parse(call.args); + + throwIfTurnCancelled(); + + return await handleManageWakeupsToolCall( + { conversationId: session.id, userId }, + + args, + ); + } + case FAST_AGENT_NATIVE_TOOL_NAMES.retryTaskStart: { if (!platformEvent || !adapter.retryTaskStart) { return { diff --git a/packages/cloud-agents/src/server/index.ts b/packages/cloud-agents/src/server/index.ts index f85cc08f6..c017a95c6 100644 --- a/packages/cloud-agents/src/server/index.ts +++ b/packages/cloud-agents/src/server/index.ts @@ -24,6 +24,7 @@ export * from './automation-root-summary'; export * from './audio-transcription'; export * from './file-attachments'; export * from './fast-agent'; +export * from './session-wakeups'; // Canonical API base URL fallback chain (explicit -> TRPC_URL -> R_APP_URL). // Fast surfaces must derive apiBaseUrl through this so the broker's // deployment-proxy origin check matches the resolver-built proxy URLs. diff --git a/packages/cloud-agents/src/server/session-wakeups/index.ts b/packages/cloud-agents/src/server/session-wakeups/index.ts new file mode 100644 index 000000000..c5d37a706 --- /dev/null +++ b/packages/cloud-agents/src/server/session-wakeups/index.ts @@ -0,0 +1,31 @@ +export { + SESSION_WAKEUP_FIRE_JOB_NAME, + SESSION_WAKEUP_QUEUE_NAME, + buildSessionWakeupFireJobId, + enqueueSessionWakeupFire, + enqueueSessionWakeupFireBestEffort, + type SessionWakeupFireJob, +} from './queue'; +export { + SessionWakeupValidationError, + computeNextSessionWakeupRunAt, + describeSessionWakeupSchedule, + normalizeSessionWakeupSchedule, + normalizeSessionWakeupTimeZone, + resolveSessionWakeupNextRun, + validateSessionWakeupCaps, + type NormalizedSessionWakeupSchedule, +} from './schedule'; +export { + cancelSessionWakeupForConversation, + createSessionWakeup, + getSessionWakeupForConversation, + handleManageWakeupsToolCall, + listSessionWakeupsForConversation, + resolveSessionWakeupTimeZone, + toSessionWakeupSummary, + type CancelSessionWakeupResult, + type CreateSessionWakeupInput, + type CreateSessionWakeupResult, + type SessionWakeupActor, +} from './service'; diff --git a/packages/cloud-agents/src/server/session-wakeups/queue.ts b/packages/cloud-agents/src/server/session-wakeups/queue.ts new file mode 100644 index 000000000..7802c5e59 --- /dev/null +++ b/packages/cloud-agents/src/server/session-wakeups/queue.ts @@ -0,0 +1,66 @@ +import { Queue } from 'bullmq'; + +import { getRedis } from '@roomote/redis'; + +/** + * Delayed BullMQ jobs are only wakeup hints for `session_wakeups` rows; the + * row's `next_run_at` is the source of truth and the firing path claims it + * with a compare-and-set. The job id carries the occurrence time so the + * creator's hint and every recovery sweep collapse into one job, while a + * later occurrence of the same wakeup gets its own. + */ +export const SESSION_WAKEUP_QUEUE_NAME = 'session-wakeups'; +export const SESSION_WAKEUP_FIRE_JOB_NAME = 'fire'; + +export type SessionWakeupFireJob = { + wakeupId: string; + /** Unix milliseconds of the occurrence this job fires. */ + runAt: number; +}; + +let sessionWakeupQueue: Queue | null = null; + +function getSessionWakeupQueue(): Queue { + sessionWakeupQueue ??= new Queue( + SESSION_WAKEUP_QUEUE_NAME, + { + connection: getRedis(), + defaultJobOptions: { + attempts: 3, + backoff: { type: 'exponential', delay: 2_000 }, + removeOnComplete: true, + // PostgreSQL remains the source of truth; the recovery sweep re-adds + // a hint for any due row whose job was lost. + removeOnFail: true, + }, + }, + ); + return sessionWakeupQueue; +} + +export function buildSessionWakeupFireJobId(job: SessionWakeupFireJob): string { + return `${job.wakeupId}:${job.runAt}`; +} + +/** + * Add the delayed hint for one occurrence. Failure is not fatal for callers + * that already persisted the row: the recovery sweep re-adds it. + */ +export async function enqueueSessionWakeupFire( + job: SessionWakeupFireJob, +): Promise { + await getSessionWakeupQueue().add(SESSION_WAKEUP_FIRE_JOB_NAME, job, { + jobId: buildSessionWakeupFireJobId(job), + delay: Math.max(0, job.runAt - Date.now()), + }); +} + +export function enqueueSessionWakeupFireBestEffort( + job: SessionWakeupFireJob, +): void { + void enqueueSessionWakeupFire(job).catch((error) => { + console.error( + `[SessionWakeups] Persisted wakeup ${job.wakeupId}, but its delayed job failed to enqueue: ${error instanceof Error ? error.message : String(error)}`, + ); + }); +} diff --git a/packages/cloud-agents/src/server/session-wakeups/schedule.test.ts b/packages/cloud-agents/src/server/session-wakeups/schedule.test.ts new file mode 100644 index 000000000..1a235f627 --- /dev/null +++ b/packages/cloud-agents/src/server/session-wakeups/schedule.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, it } from 'vitest'; + +import { + SessionWakeupValidationError, + computeNextSessionWakeupRunAt, + describeSessionWakeupSchedule, + normalizeSessionWakeupSchedule, + resolveSessionWakeupNextRun, + validateSessionWakeupCaps, +} from './schedule'; + +const now = new Date('2026-09-04T17:00:00.000Z'); +const options = { now, defaultTimeZone: 'America/New_York' }; + +describe('normalizeSessionWakeupSchedule', () => { + it('resolves a relative once schedule against now', () => { + const result = normalizeSessionWakeupSchedule( + { mode: 'once', inMinutes: 20 }, + options, + ); + expect(result.firstRunAt.toISOString()).toBe('2026-09-04T17:20:00.000Z'); + expect(result.schedule).toEqual({ + mode: 'once', + at: '2026-09-04T17:20:00.000Z', + }); + }); + + it('accepts an absolute once schedule with an offset', () => { + const result = normalizeSessionWakeupSchedule( + { mode: 'once', at: '2026-09-04T15:00:00-04:00' }, + options, + ); + expect(result.firstRunAt.toISOString()).toBe('2026-09-04T19:00:00.000Z'); + }); + + it('rejects a once schedule with both or neither time fields', () => { + expect(() => + normalizeSessionWakeupSchedule( + { mode: 'once', inMinutes: 5, at: '2026-09-04T18:00:00Z' }, + options, + ), + ).toThrow(SessionWakeupValidationError); + expect(() => + normalizeSessionWakeupSchedule({ mode: 'once' }, options), + ).toThrow(SessionWakeupValidationError); + }); + + it('rejects a once schedule in the past and one beyond the horizon', () => { + expect(() => + normalizeSessionWakeupSchedule( + { mode: 'once', at: '2026-09-04T16:59:00Z' }, + options, + ), + ).toThrow(/must be in the future/); + expect(() => + normalizeSessionWakeupSchedule( + { mode: 'once', at: '2026-11-04T17:00:00Z' }, + options, + ), + ).toThrow(/30 days/); + }); + + it('schedules the first interval run one interval from now', () => { + const result = normalizeSessionWakeupSchedule( + { mode: 'interval', everyMinutes: 15 }, + options, + ); + expect(result.firstRunAt.toISOString()).toBe('2026-09-04T17:15:00.000Z'); + }); + + it('resolves cron in the deployment timezone by default', () => { + const result = normalizeSessionWakeupSchedule( + { mode: 'cron', expression: '0 9 * * 1-5' }, + options, + ); + expect(result.schedule).toEqual({ + mode: 'cron', + expression: '0 9 * * 1-5', + timezone: 'America/New_York', + }); + // 2026-09-04 is a Friday; 17:00Z is 13:00 in New York, so the next + // weekday 9am is Monday 2026-09-07 09:00 EDT. + expect(result.firstRunAt.toISOString()).toBe('2026-09-07T13:00:00.000Z'); + }); + + it('rejects malformed cron and unknown timezones', () => { + expect(() => + normalizeSessionWakeupSchedule( + { mode: 'cron', expression: '0 9 * *' }, + options, + ), + ).toThrow(/five-field/); + expect(() => + normalizeSessionWakeupSchedule( + { mode: 'cron', expression: '0 9 * * *', timezone: 'Mars/Olympus' }, + options, + ), + ).toThrow(/timezone/); + }); +}); + +describe('resolveSessionWakeupNextRun', () => { + it('ends a once schedule after it fires', () => { + expect( + resolveSessionWakeupNextRun({ + schedule: { mode: 'once', at: now.toISOString() }, + firedAt: now, + runCountAfterFire: 1, + maxRuns: null, + until: null, + }), + ).toBeNull(); + }); + + it('computes the next interval from the fire time, not the missed slot', () => { + const firedAt = new Date('2026-09-04T20:03:00.000Z'); + expect( + resolveSessionWakeupNextRun({ + schedule: { mode: 'interval', everyMinutes: 10 }, + firedAt, + runCountAfterFire: 4, + maxRuns: null, + until: null, + })?.toISOString(), + ).toBe('2026-09-04T20:13:00.000Z'); + }); + + it('honours maxRuns and until', () => { + const schedule = { mode: 'interval' as const, everyMinutes: 10 }; + expect( + resolveSessionWakeupNextRun({ + schedule, + firedAt: now, + runCountAfterFire: 3, + maxRuns: 3, + until: null, + }), + ).toBeNull(); + expect( + resolveSessionWakeupNextRun({ + schedule, + firedAt: now, + runCountAfterFire: 1, + maxRuns: null, + until: new Date('2026-09-04T17:05:00.000Z'), + }), + ).toBeNull(); + expect( + resolveSessionWakeupNextRun({ + schedule, + firedAt: now, + runCountAfterFire: 1, + maxRuns: null, + until: new Date('2026-09-04T18:00:00.000Z'), + })?.toISOString(), + ).toBe('2026-09-04T17:10:00.000Z'); + }); +}); + +describe('validateSessionWakeupCaps', () => { + it('rejects caps on a once schedule', () => { + expect(() => + validateSessionWakeupCaps({ + schedule: { mode: 'once', at: now.toISOString() }, + firstRunAt: now, + maxRuns: 2, + until: null, + }), + ).toThrow(/only apply/); + }); + + it('requires a cap on tight intervals', () => { + expect(() => + validateSessionWakeupCaps({ + schedule: { mode: 'interval', everyMinutes: 2 }, + firstRunAt: now, + maxRuns: null, + until: null, + }), + ).toThrow(/maxRuns or until/); + expect(() => + validateSessionWakeupCaps({ + schedule: { mode: 'interval', everyMinutes: 2 }, + firstRunAt: now, + maxRuns: 10, + until: null, + }), + ).not.toThrow(); + }); + + it('requires until to follow the first occurrence', () => { + expect(() => + validateSessionWakeupCaps({ + schedule: { mode: 'interval', everyMinutes: 30 }, + firstRunAt: new Date('2026-09-04T17:30:00.000Z'), + maxRuns: null, + until: new Date('2026-09-04T17:10:00.000Z'), + }), + ).toThrow(/later than the first occurrence/); + }); +}); + +describe('describeSessionWakeupSchedule', () => { + it('renders each mode for humans', () => { + expect( + describeSessionWakeupSchedule({ mode: 'interval', everyMinutes: 90 }), + ).toBe('every 90 minutes'); + expect( + describeSessionWakeupSchedule({ mode: 'interval', everyMinutes: 120 }), + ).toBe('every 2 hours'); + expect( + describeSessionWakeupSchedule({ mode: 'interval', everyMinutes: 1440 }), + ).toBe('every 1 day'); + expect( + describeSessionWakeupSchedule({ + mode: 'cron', + expression: '0 9 * * 1-5', + timezone: 'UTC', + }), + ).toBe('cron 0 9 * * 1-5 (UTC)'); + }); + + it('computes the next cron occurrence after a given time', () => { + expect( + computeNextSessionWakeupRunAt( + { mode: 'cron', expression: '*/15 * * * *', timezone: 'UTC' }, + new Date('2026-09-04T17:01:00.000Z'), + )?.toISOString(), + ).toBe('2026-09-04T17:15:00.000Z'); + }); +}); diff --git a/packages/cloud-agents/src/server/session-wakeups/schedule.ts b/packages/cloud-agents/src/server/session-wakeups/schedule.ts new file mode 100644 index 000000000..923e6988a --- /dev/null +++ b/packages/cloud-agents/src/server/session-wakeups/schedule.ts @@ -0,0 +1,249 @@ +import { CronExpressionParser } from 'cron-parser'; + +import { + SESSION_WAKEUP_MAX_INTERVAL_MINUTES, + SESSION_WAKEUP_MAX_ONCE_HORIZON_MINUTES, + SESSION_WAKEUP_MIN_INTERVAL_MINUTES, + SESSION_WAKEUP_UNCAPPED_MIN_INTERVAL_MINUTES, + type SessionWakeupSchedule, + type SessionWakeupScheduleInput, +} from '@roomote/types'; + +const MINUTE_MS = 60_000; + +/** A schedule or option the agent supplied that cannot be honoured. */ +export class SessionWakeupValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'SessionWakeupValidationError'; + } +} + +export function normalizeSessionWakeupTimeZone(value: string): string { + const trimmed = value.trim(); + if (!trimmed) { + throw new SessionWakeupValidationError('Timezone is required.'); + } + try { + return new Intl.DateTimeFormat('en-US', { + timeZone: trimmed, + }).resolvedOptions().timeZone; + } catch { + throw new SessionWakeupValidationError( + `"${trimmed}" is not a valid IANA timezone.`, + ); + } +} + +function parseIsoDate(value: string, field: string): Date { + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + throw new SessionWakeupValidationError( + `${field} must be an ISO 8601 date-time.`, + ); + } + return parsed; +} + +function nextCronOccurrence( + expression: string, + timeZone: string, + from: Date, +): Date { + const interval = CronExpressionParser.parse(expression, { + currentDate: from, + tz: timeZone, + }); + return interval.next().toDate(); +} + +export type NormalizedSessionWakeupSchedule = { + schedule: SessionWakeupSchedule; + firstRunAt: Date; +}; + +/** + * Validate the agent-supplied schedule and resolve its first occurrence. + * Relative delays are resolved against `now`; cron expressions default to + * the deployment timezone. + */ +export function normalizeSessionWakeupSchedule( + input: SessionWakeupScheduleInput, + options: { now: Date; defaultTimeZone: string }, +): NormalizedSessionWakeupSchedule { + const { now } = options; + switch (input.mode) { + case 'once': { + const hasDelay = input.inMinutes !== undefined; + const hasAt = input.at !== undefined; + if (hasDelay === hasAt) { + throw new SessionWakeupValidationError( + 'A once schedule needs exactly one of inMinutes or at.', + ); + } + const at = hasDelay + ? new Date(now.getTime() + input.inMinutes! * MINUTE_MS) + : parseIsoDate(input.at!, 'at'); + if (at.getTime() <= now.getTime()) { + throw new SessionWakeupValidationError( + `at must be in the future. The current time is ${now.toISOString()}.`, + ); + } + if ( + at.getTime() - now.getTime() > + SESSION_WAKEUP_MAX_ONCE_HORIZON_MINUTES * MINUTE_MS + ) { + throw new SessionWakeupValidationError( + 'A once schedule may be at most 30 days out.', + ); + } + return { + schedule: { mode: 'once', at: at.toISOString() }, + firstRunAt: at, + }; + } + case 'interval': { + if ( + !Number.isInteger(input.everyMinutes) || + input.everyMinutes < SESSION_WAKEUP_MIN_INTERVAL_MINUTES || + input.everyMinutes > SESSION_WAKEUP_MAX_INTERVAL_MINUTES + ) { + throw new SessionWakeupValidationError( + `everyMinutes must be a whole number between ${SESSION_WAKEUP_MIN_INTERVAL_MINUTES} and ${SESSION_WAKEUP_MAX_INTERVAL_MINUTES}.`, + ); + } + return { + schedule: { mode: 'interval', everyMinutes: input.everyMinutes }, + firstRunAt: new Date(now.getTime() + input.everyMinutes * MINUTE_MS), + }; + } + case 'cron': { + const timezone = normalizeSessionWakeupTimeZone( + input.timezone ?? options.defaultTimeZone, + ); + const expression = input.expression.trim().replace(/\s+/g, ' '); + if (expression.split(' ').length !== 5) { + throw new SessionWakeupValidationError( + 'Use a standard five-field cron expression.', + ); + } + let firstRunAt: Date; + try { + firstRunAt = nextCronOccurrence(expression, timezone, now); + } catch (error) { + throw new SessionWakeupValidationError( + `Invalid cron expression "${expression}": ${error instanceof Error ? error.message : String(error)}`, + ); + } + return { + schedule: { mode: 'cron', expression, timezone }, + firstRunAt, + }; + } + } +} + +/** + * The occurrence after `from` for a stored schedule, ignoring run caps. A + * once schedule has no occurrence after it fires. + */ +export function computeNextSessionWakeupRunAt( + schedule: SessionWakeupSchedule, + from: Date, +): Date | null { + switch (schedule.mode) { + case 'once': { + const at = new Date(schedule.at); + return at.getTime() > from.getTime() ? at : null; + } + case 'interval': + return new Date(from.getTime() + schedule.everyMinutes * MINUTE_MS); + case 'cron': + return nextCronOccurrence(schedule.expression, schedule.timezone, from); + } +} + +/** + * The occurrence to schedule after a fire, or null when the wakeup is done. + * Missed occurrences are computed from `firedAt`, not from the missed slot, + * so a deployment that was down for hours fires a monitor once on recovery + * rather than once per missed slot. + */ +export function resolveSessionWakeupNextRun(params: { + schedule: SessionWakeupSchedule; + firedAt: Date; + runCountAfterFire: number; + maxRuns: number | null; + until: Date | null; +}): Date | null { + if (params.schedule.mode === 'once') return null; + if (params.maxRuns !== null && params.runCountAfterFire >= params.maxRuns) { + return null; + } + const next = computeNextSessionWakeupRunAt(params.schedule, params.firedAt); + if (!next) return null; + if (params.until && next.getTime() > params.until.getTime()) return null; + return next; +} + +/** + * Enforce the run-cap rules for a normalized schedule: caps only apply to + * recurring schedules, tight intervals must be capped, and `until` must lie + * ahead of the first occurrence. + */ +export function validateSessionWakeupCaps(params: { + schedule: SessionWakeupSchedule; + firstRunAt: Date; + maxRuns: number | null; + until: Date | null; +}): void { + const { schedule } = params; + if (schedule.mode === 'once') { + if (params.maxRuns !== null || params.until !== null) { + throw new SessionWakeupValidationError( + 'maxRuns and until only apply to interval and cron schedules.', + ); + } + return; + } + if (params.until && params.until.getTime() <= params.firstRunAt.getTime()) { + throw new SessionWakeupValidationError( + `until must be later than the first occurrence at ${params.firstRunAt.toISOString()}.`, + ); + } + if ( + schedule.mode === 'interval' && + schedule.everyMinutes < SESSION_WAKEUP_UNCAPPED_MIN_INTERVAL_MINUTES && + params.maxRuns === null && + params.until === null + ) { + throw new SessionWakeupValidationError( + `Intervals under ${SESSION_WAKEUP_UNCAPPED_MIN_INTERVAL_MINUTES} minutes need maxRuns or until so they cannot run forever.`, + ); + } +} + +function formatMinutes(minutes: number): string { + if (minutes % (24 * 60) === 0) { + const days = minutes / (24 * 60); + return `${days} day${days === 1 ? '' : 's'}`; + } + if (minutes % 60 === 0) { + const hours = minutes / 60; + return `${hours} hour${hours === 1 ? '' : 's'}`; + } + return `${minutes} minute${minutes === 1 ? '' : 's'}`; +} + +export function describeSessionWakeupSchedule( + schedule: SessionWakeupSchedule, +): string { + switch (schedule.mode) { + case 'once': + return `once at ${schedule.at}`; + case 'interval': + return `every ${formatMinutes(schedule.everyMinutes)}`; + case 'cron': + return `cron ${schedule.expression} (${schedule.timezone})`; + } +} diff --git a/packages/cloud-agents/src/server/session-wakeups/service.ts b/packages/cloud-agents/src/server/session-wakeups/service.ts new file mode 100644 index 000000000..03e01a39b --- /dev/null +++ b/packages/cloud-agents/src/server/session-wakeups/service.ts @@ -0,0 +1,326 @@ +import { + buildSessionWakeupPromptSignature, + cancelSessionWakeup, + countActiveSessionWakeups, + db, + deploymentSettings, + eq, + getSessionWakeupById, + insertSessionWakeup, + listActiveSessionWakeups, + listSessionWakeups, + type SessionWakeup, +} from '@roomote/db/server'; +import { + MAX_ACTIVE_SESSION_WAKEUPS, + isSessionWakeupRecurring, + type ManageWakeupsInput, + type SessionWakeupReportPolicy, + type SessionWakeupScheduleInput, + type SessionWakeupSummary, +} from '@roomote/types'; + +import { enqueueSessionWakeupFireBestEffort } from './queue'; +import { + SessionWakeupValidationError, + describeSessionWakeupSchedule, + normalizeSessionWakeupSchedule, + normalizeSessionWakeupTimeZone, + validateSessionWakeupCaps, +} from './schedule'; + +const DEFAULT_DEPLOYMENT_SETTINGS_ID = 'default'; + +/** The conversation a wakeup tool call acts on, and who is acting. */ +export type SessionWakeupActor = { + conversationId: string; + userId: string; +}; + +export type CreateSessionWakeupInput = { + name: string; + prompt: string; + schedule: SessionWakeupScheduleInput; + maxRuns?: number | null; + until?: string | null; + reportPolicy?: SessionWakeupReportPolicy | null; +}; + +export type CreateSessionWakeupResult = { + wakeup: SessionWakeupSummary; + /** True when an equivalent active wakeup already existed and was reused. */ + duplicate: boolean; + timeZone: string; +}; + +/** + * Cron defaults and next-run confirmations use the deployment timezone when + * one is configured, otherwise UTC. The Slack-workspace fallback that + * custom automations use lives in the SDK and is not needed here: the agent + * can always name a timezone explicitly. + */ +export async function resolveSessionWakeupTimeZone(): Promise { + const settings = await db.query.deploymentSettings.findFirst({ + where: eq(deploymentSettings.id, DEFAULT_DEPLOYMENT_SETTINGS_ID), + columns: { timeZone: true }, + }); + if (!settings?.timeZone) return 'UTC'; + try { + return normalizeSessionWakeupTimeZone(settings.timeZone); + } catch { + return 'UTC'; + } +} + +export function toSessionWakeupSummary( + row: SessionWakeup, +): SessionWakeupSummary { + return { + id: row.id, + name: row.name, + prompt: row.prompt, + schedule: row.schedule, + scheduleDescription: describeSessionWakeupSchedule(row.schedule), + reportPolicy: row.reportPolicy, + status: row.status, + runCount: row.runCount, + maxRuns: row.maxRuns, + until: row.until?.toISOString() ?? null, + nextRunAt: row.nextRunAt?.toISOString() ?? null, + lastFiredAt: row.lastFiredAt?.toISOString() ?? null, + lastError: row.lastError, + createdAt: row.createdAt.toISOString(), + }; +} + +function schedulesMatch( + left: SessionWakeup['schedule'], + right: SessionWakeup['schedule'], +): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +export async function createSessionWakeup( + actor: SessionWakeupActor, + input: CreateSessionWakeupInput, + options: { now?: Date } = {}, +): Promise { + const now = options.now ?? new Date(); + const name = input.name.trim().replace(/\s+/g, ' '); + const prompt = input.prompt.trim(); + if (!name) throw new SessionWakeupValidationError('name is required.'); + if (!prompt) throw new SessionWakeupValidationError('prompt is required.'); + + const timeZone = await resolveSessionWakeupTimeZone(); + const { schedule, firstRunAt } = normalizeSessionWakeupSchedule( + input.schedule, + { now, defaultTimeZone: timeZone }, + ); + const maxRuns = input.maxRuns ?? null; + const until = input.until ? new Date(input.until) : null; + if (until && Number.isNaN(until.getTime())) { + throw new SessionWakeupValidationError( + 'until must be an ISO 8601 date-time.', + ); + } + validateSessionWakeupCaps({ schedule, firstRunAt, maxRuns, until }); + const reportPolicy: SessionWakeupReportPolicy = + input.reportPolicy ?? + (isSessionWakeupRecurring(schedule) ? 'only_when_notable' : 'always'); + + // Reuse an equivalent active wakeup instead of stacking duplicates; a model + // that retries a create call must not double-schedule. + const promptSignature = buildSessionWakeupPromptSignature(prompt); + const active = await listActiveSessionWakeups(actor.conversationId); + const existing = active.find( + (row) => + row.promptSignature === promptSignature && + schedulesMatch(row.schedule, schedule), + ); + if (existing) { + return { + wakeup: toSessionWakeupSummary(existing), + duplicate: true, + timeZone, + }; + } + + const activeCount = await countActiveSessionWakeups(actor.conversationId); + if (activeCount >= MAX_ACTIVE_SESSION_WAKEUPS) { + throw new SessionWakeupValidationError( + `This conversation already has ${MAX_ACTIVE_SESSION_WAKEUPS} active wakeups. Cancel one before creating another.`, + ); + } + + const row = await insertSessionWakeup({ + conversationId: actor.conversationId, + createdByUserId: actor.userId, + name, + prompt, + schedule, + reportPolicy, + maxRuns, + until, + nextRunAt: firstRunAt, + }); + enqueueSessionWakeupFireBestEffort({ + wakeupId: row.id, + runAt: firstRunAt.getTime(), + }); + + return { wakeup: toSessionWakeupSummary(row), duplicate: false, timeZone }; +} + +export async function listSessionWakeupsForConversation( + conversationId: string, + options: { includeTerminal?: boolean } = {}, +): Promise { + const rows = await listSessionWakeups(conversationId, options); + return rows.map(toSessionWakeupSummary); +} + +export async function getSessionWakeupForConversation( + conversationId: string, + wakeupId: string, +): Promise { + const row = await getSessionWakeupById(wakeupId); + if (!row || row.conversationId !== conversationId) return null; + return toSessionWakeupSummary(row); +} + +export type CancelSessionWakeupResult = + | { outcome: 'cancelled'; wakeup: SessionWakeupSummary } + | { outcome: 'already_terminal'; wakeup: SessionWakeupSummary } + | { outcome: 'not_found' }; + +export async function cancelSessionWakeupForConversation( + conversationId: string, + wakeupId: string, +): Promise { + const cancelled = await cancelSessionWakeup({ id: wakeupId, conversationId }); + if (cancelled) { + return { outcome: 'cancelled', wakeup: toSessionWakeupSummary(cancelled) }; + } + const row = await getSessionWakeupById(wakeupId); + if (!row || row.conversationId !== conversationId) { + return { outcome: 'not_found' }; + } + return { outcome: 'already_terminal', wakeup: toSessionWakeupSummary(row) }; +} + +function formatNextRun(nextRunAt: string | null, timeZone: string): string { + if (!nextRunAt) return 'no further runs'; + const date = new Date(nextRunAt); + const local = new Intl.DateTimeFormat('en-US', { + timeZone, + dateStyle: 'medium', + timeStyle: 'short', + }).format(date); + return `${local} ${timeZone} (${date.toISOString()})`; +} + +/** + * Execute one `manage_wakeups` call on behalf of a Fast turn. Every branch + * returns a JSON-serializable result the model can read; validation + * problems come back as `{ success: false, error }` rather than throwing so + * the turn can correct and retry. + */ +export async function handleManageWakeupsToolCall( + actor: SessionWakeupActor, + input: ManageWakeupsInput, +): Promise> { + try { + switch (input.action) { + case 'create': { + if (!input.name || !input.prompt || !input.schedule) { + return { + success: false, + error: 'create requires name, prompt, and schedule.', + }; + } + const result = await createSessionWakeup(actor, { + name: input.name, + prompt: input.prompt, + schedule: input.schedule, + maxRuns: input.maxRuns ?? null, + until: input.until ?? null, + reportPolicy: input.reportPolicy ?? null, + }); + return { + success: true, + duplicate: result.duplicate, + wakeup: result.wakeup, + timeZone: result.timeZone, + nextRunLocal: formatNextRun(result.wakeup.nextRunAt, result.timeZone), + note: result.duplicate + ? 'An equivalent wakeup was already active in this conversation; it was reused instead of creating a duplicate.' + : 'Scheduled. When it fires you will receive a scheduled_wakeup platform event in this conversation. Confirm the plan to the user in one sentence.', + }; + } + case 'list': { + const timeZone = await resolveSessionWakeupTimeZone(); + const wakeups = await listSessionWakeupsForConversation( + actor.conversationId, + ); + return { + success: true, + now: new Date().toISOString(), + timeZone, + count: wakeups.length, + wakeups, + }; + } + case 'get': { + if (!input.wakeupId) { + return { success: false, error: 'wakeupId is required for get.' }; + } + const wakeup = await getSessionWakeupForConversation( + actor.conversationId, + input.wakeupId, + ); + if (!wakeup) { + return { + success: false, + error: 'No wakeup with that id exists in this conversation.', + }; + } + return { success: true, wakeup }; + } + case 'cancel': { + if (!input.wakeupId) { + return { success: false, error: 'wakeupId is required for cancel.' }; + } + const result = await cancelSessionWakeupForConversation( + actor.conversationId, + input.wakeupId, + ); + switch (result.outcome) { + case 'cancelled': + return { + success: true, + cancelled: true, + wakeup: result.wakeup, + note: `Cancelled "${result.wakeup.name}". It will not fire again.`, + }; + case 'already_terminal': + return { + success: true, + cancelled: false, + wakeup: result.wakeup, + note: `"${result.wakeup.name}" was already ${result.wakeup.status}.`, + }; + case 'not_found': + return { + success: false, + error: 'No wakeup with that id exists in this conversation.', + }; + } + } + } + } catch (error) { + if (error instanceof SessionWakeupValidationError) { + return { success: false, error: error.message }; + } + throw error; + } +} diff --git a/packages/db/drizzle/0077_normal_lionheart.sql b/packages/db/drizzle/0077_normal_lionheart.sql new file mode 100644 index 000000000..871d5fe93 --- /dev/null +++ b/packages/db/drizzle/0077_normal_lionheart.sql @@ -0,0 +1,28 @@ +CREATE TABLE "session_wakeups" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "conversation_id" uuid NOT NULL, + "created_by_user_id" text, + "name" text NOT NULL, + "prompt" text NOT NULL, + "prompt_signature" text NOT NULL, + "schedule" jsonb NOT NULL, + "report_policy" text NOT NULL, + "status" text DEFAULT 'active' NOT NULL, + "run_count" integer DEFAULT 0 NOT NULL, + "max_runs" integer, + "until" timestamp, + "consecutive_failures" integer DEFAULT 0 NOT NULL, + "next_run_at" timestamp, + "last_fired_at" timestamp, + "last_error" text, + "completed_at" timestamp, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "session_wakeups_status_check" CHECK ("session_wakeups"."status" in ('active', 'completed', 'cancelled', 'failed')), + CONSTRAINT "session_wakeups_report_policy_check" CHECK ("session_wakeups"."report_policy" in ('always', 'only_when_notable')) +); +--> statement-breakpoint +ALTER TABLE "session_wakeups" ADD CONSTRAINT "session_wakeups_conversation_id_fast_agent_conversations_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."fast_agent_conversations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session_wakeups" ADD CONSTRAINT "session_wakeups_created_by_user_id_users_id_fk" FOREIGN KEY ("created_by_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "session_wakeups_due_idx" ON "session_wakeups" USING btree ("status","next_run_at");--> statement-breakpoint +CREATE INDEX "session_wakeups_conversation_idx" ON "session_wakeups" USING btree ("conversation_id","status"); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0077_snapshot.json b/packages/db/drizzle/meta/0077_snapshot.json new file mode 100644 index 000000000..7a5fc11df --- /dev/null +++ b/packages/db/drizzle/meta/0077_snapshot.json @@ -0,0 +1,14408 @@ +{ + "id": "016783b4-5c4d-409b-b5b5-11e62a98b417", + "prevId": "8d6cf3e9-e29b-4d3f-b53c-e0c7a5f0c8b0", + "version": "7", + "dialect": "postgresql", + "tables": { + "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 + }, + "reasoning_effort": { + "name": "reasoning_effort", + "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": false + }, + "owner_automation": { + "name": "owner_automation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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_owner_automation_idx": { + "name": "fast_agent_conversations_owner_automation_idx", + "columns": [ + { + "expression": "owner_automation", + "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": { + "fast_agent_conversations_owner_shape_check": { + "name": "fast_agent_conversations_owner_shape_check", + "value": "(\n (\"fast_agent_conversations\".\"user_id\" is not null and \"fast_agent_conversations\".\"owner_automation\" is null)\n or\n (\"fast_agent_conversations\".\"user_id\" is null and \"fast_agent_conversations\".\"owner_automation\" is not null)\n )" + } + }, + "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 + }, + "admission": { + "name": "admission", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claimed_until": { + "name": "claimed_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "retry_at": { + "name": "retry_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "inference_retries": { + "name": "inference_retries", + "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": { + "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')" + } + }, + "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.session_wakeups": { + "name": "session_wakeups", + "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 + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prompt_signature": { + "name": "prompt_signature", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule": { + "name": "schedule", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "report_policy": { + "name": "report_policy", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "until": { + "name": "until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "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": { + "session_wakeups_due_idx": { + "name": "session_wakeups_due_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_wakeups_conversation_idx": { + "name": "session_wakeups_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_wakeups_conversation_id_fast_agent_conversations_id_fk": { + "name": "session_wakeups_conversation_id_fast_agent_conversations_id_fk", + "tableFrom": "session_wakeups", + "tableTo": "fast_agent_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_wakeups_created_by_user_id_users_id_fk": { + "name": "session_wakeups_created_by_user_id_users_id_fk", + "tableFrom": "session_wakeups", + "tableTo": "users", + "columnsFrom": ["created_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "session_wakeups_status_check": { + "name": "session_wakeups_status_check", + "value": "\"session_wakeups\".\"status\" in ('active', 'completed', 'cancelled', 'failed')" + }, + "session_wakeups_report_policy_check": { + "name": "session_wakeups_report_policy_check", + "value": "\"session_wakeups\".\"report_policy\" in ('always', 'only_when_notable')" + } + }, + "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', '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 + }, + "message_ts": { + "name": "message_ts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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": false + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "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_session_id_idx": { + "name": "task_artifacts_session_id_idx", + "columns": [ + { + "expression": "session_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": {} + }, + "task_artifacts_session_id_path_version_unique": { + "name": "task_artifacts_session_id_path_version_unique", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"task_artifacts\".\"session_id\" IS NOT NULL", + "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_session_id_sessions_id_fk": { + "name": "task_artifacts_session_id_sessions_id_fk", + "tableFrom": "task_artifacts", + "tableTo": "sessions", + "columnsFrom": ["session_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": { + "task_artifacts_owner_shape_check": { + "name": "task_artifacts_owner_shape_check", + "value": "(\"task_artifacts\".\"task_id\" IS NOT NULL) <> (\"task_artifacts\".\"session_id\" IS NOT NULL)" + } + }, + "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', '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 3e8c640c5..df7564c5f 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -540,6 +540,13 @@ "when": 1788530600978, "tag": "0076_fearless_skullbuster", "breakpoints": true + }, + { + "idx": 77, + "version": "7", + "when": 1788560388583, + "tag": "0077_normal_lionheart", + "breakpoints": true } ] } diff --git a/packages/db/src/lib/__tests__/session-wakeups.test.ts b/packages/db/src/lib/__tests__/session-wakeups.test.ts new file mode 100644 index 000000000..667ba7696 --- /dev/null +++ b/packages/db/src/lib/__tests__/session-wakeups.test.ts @@ -0,0 +1,197 @@ +// Real-DB coverage for session wakeups. The compare-and-set claim on +// next_run_at is load-bearing: it is what keeps a duplicate delayed job, or +// two workers holding the same job, from firing one occurrence twice. + +import { SESSION_WAKEUP_MAX_CONSECUTIVE_FAILURES } from '@roomote/types'; + +import { + cancelSessionWakeup, + cancelSessionWakeupsForConversation, + claimSessionWakeupFire, + countActiveSessionWakeups, + db, + fastAgentConversations, + getSessionWakeupById, + insertSessionWakeup, + listDueSessionWakeups, + listSessionWakeups, + recordSessionWakeupOutcome, + sessionWakeups, + userFactory, +} from '../../server'; + +async function makeConversation() { + const user = await userFactory.create(); + const [conversation] = await db + .insert(fastAgentConversations) + .values({ + userId: user.id, + surface: 'web', + workspaceId: user.id, + conversationId: `conversation-${crypto.randomUUID()}`, + }) + .returning(); + return { user, conversation: conversation! }; +} + +const firstRunAt = new Date('2026-09-04T17:10:00.000Z'); + +async function makeWakeup( + conversationId: string, + userId: string, + overrides: Partial[0]> = {}, +) { + return insertSessionWakeup({ + conversationId, + createdByUserId: userId, + name: 'Check PR #85', + prompt: 'Check whether PR #85 merged.', + schedule: { mode: 'interval', everyMinutes: 10 }, + reportPolicy: 'only_when_notable', + maxRuns: null, + until: null, + nextRunAt: firstRunAt, + ...overrides, + }); +} + +afterEach(async () => { + await db.delete(sessionWakeups); +}); + +describe('session wakeup helpers', () => { + it('claims an occurrence exactly once and advances the row', async () => { + const { user, conversation } = await makeConversation(); + const row = await makeWakeup(conversation.id, user.id); + const nextRunAt = new Date('2026-09-04T17:20:00.000Z'); + const firedAt = new Date('2026-09-04T17:10:02.000Z'); + + const claimed = await claimSessionWakeupFire({ + id: row.id, + expectedNextRunAt: firstRunAt, + nextRunAt, + firedAt, + }); + expect(claimed?.runCount).toBe(1); + expect(claimed?.status).toBe('active'); + expect(claimed?.nextRunAt?.toISOString()).toBe(nextRunAt.toISOString()); + + // A duplicate job for the same occurrence finds the row already moved. + const duplicate = await claimSessionWakeupFire({ + id: row.id, + expectedNextRunAt: firstRunAt, + nextRunAt: new Date('2026-09-04T17:30:00.000Z'), + firedAt, + }); + expect(duplicate).toBeNull(); + expect((await getSessionWakeupById(row.id))?.runCount).toBe(1); + }); + + it('completes the row when the claim carries no next occurrence', async () => { + const { user, conversation } = await makeConversation(); + const row = await makeWakeup(conversation.id, user.id, { + schedule: { mode: 'once', at: firstRunAt.toISOString() }, + reportPolicy: 'always', + }); + + const claimed = await claimSessionWakeupFire({ + id: row.id, + expectedNextRunAt: firstRunAt, + nextRunAt: null, + firedAt: firstRunAt, + }); + expect(claimed?.status).toBe('completed'); + expect(claimed?.nextRunAt).toBeNull(); + expect(claimed?.completedAt).not.toBeNull(); + expect(await countActiveSessionWakeups(conversation.id)).toBe(0); + }); + + it('retires an active wakeup after enough consecutive failures', async () => { + const { user, conversation } = await makeConversation(); + const row = await makeWakeup(conversation.id, user.id); + + for (let i = 1; i < SESSION_WAKEUP_MAX_CONSECUTIVE_FAILURES; i += 1) { + const updated = await recordSessionWakeupOutcome({ + id: row.id, + status: 'failed', + error: `boom ${i}`, + }); + expect(updated?.status).toBe('active'); + expect(updated?.consecutiveFailures).toBe(i); + } + const succeeded = await recordSessionWakeupOutcome({ + id: row.id, + status: 'succeeded', + }); + expect(succeeded?.consecutiveFailures).toBe(0); + expect(succeeded?.lastError).toBeNull(); + + let last = null; + for (let i = 0; i < SESSION_WAKEUP_MAX_CONSECUTIVE_FAILURES; i += 1) { + last = await recordSessionWakeupOutcome({ + id: row.id, + status: 'failed', + error: 'still broken', + }); + } + expect(last?.status).toBe('failed'); + expect(last?.nextRunAt).toBeNull(); + expect(last?.lastError).toBe('still broken'); + }); + + it('cancels only active rows scoped to their conversation', async () => { + const { user, conversation } = await makeConversation(); + const other = await makeConversation(); + const row = await makeWakeup(conversation.id, user.id); + + expect( + await cancelSessionWakeup({ + id: row.id, + conversationId: other.conversation.id, + }), + ).toBeNull(); + const cancelled = await cancelSessionWakeup({ + id: row.id, + conversationId: conversation.id, + }); + expect(cancelled?.status).toBe('cancelled'); + expect(cancelled?.nextRunAt).toBeNull(); + expect( + await cancelSessionWakeup({ + id: row.id, + conversationId: conversation.id, + }), + ).toBeNull(); + }); + + it('cancels every active wakeup in a conversation and keeps history', async () => { + const { user, conversation } = await makeConversation(); + await makeWakeup(conversation.id, user.id, { name: 'A' }); + await makeWakeup(conversation.id, user.id, { + name: 'B', + prompt: 'Something else entirely.', + }); + + expect(await cancelSessionWakeupsForConversation(conversation.id)).toBe(2); + expect(await countActiveSessionWakeups(conversation.id)).toBe(0); + expect(await listSessionWakeups(conversation.id)).toHaveLength(0); + expect( + await listSessionWakeups(conversation.id, { includeTerminal: true }), + ).toHaveLength(2); + }); + + it('lists due rows for recovery', async () => { + const { user, conversation } = await makeConversation(); + const due = await makeWakeup(conversation.id, user.id, { name: 'Due' }); + await makeWakeup(conversation.id, user.id, { + name: 'Later', + prompt: 'Later prompt for a different check.', + nextRunAt: new Date('2026-09-04T18:00:00.000Z'), + }); + + const rows = await listDueSessionWakeups({ + dueBy: new Date('2026-09-04T17:12:00.000Z'), + }); + expect(rows.map((row) => row.id)).toEqual([due.id]); + }); +}); diff --git a/packages/db/src/lib/session-wakeups.ts b/packages/db/src/lib/session-wakeups.ts new file mode 100644 index 000000000..04db0099d --- /dev/null +++ b/packages/db/src/lib/session-wakeups.ts @@ -0,0 +1,296 @@ +import { and, asc, count, desc, eq, lte, sql } from 'drizzle-orm'; + +import { + SESSION_WAKEUP_MAX_CONSECUTIVE_FAILURES, + type SessionWakeupReportPolicy, + type SessionWakeupSchedule, +} from '@roomote/types'; + +import { type DatabaseOrTransaction, db } from '../db'; +import { sessionWakeups } from '../schema'; +import type { SessionWakeup } from '../types'; + +/** + * Collapse whitespace and case so two prompts that read the same dedupe to + * the same active wakeup. + */ +export function buildSessionWakeupPromptSignature(prompt: string): string { + return prompt.toLowerCase().replace(/\s+/g, ' ').trim(); +} + +export function countActiveSessionWakeups( + conversationId: string, + tx: DatabaseOrTransaction = db, +): Promise { + return tx + .select({ value: count() }) + .from(sessionWakeups) + .where( + and( + eq(sessionWakeups.conversationId, conversationId), + eq(sessionWakeups.status, 'active'), + ), + ) + .then((rows) => rows[0]?.value ?? 0); +} + +export function listActiveSessionWakeups( + conversationId: string, + tx: DatabaseOrTransaction = db, +): Promise { + return tx + .select() + .from(sessionWakeups) + .where( + and( + eq(sessionWakeups.conversationId, conversationId), + eq(sessionWakeups.status, 'active'), + ), + ) + .orderBy(asc(sessionWakeups.nextRunAt), asc(sessionWakeups.createdAt)); +} + +/** Active first, then the most recent terminal rows for history. */ +export function listSessionWakeups( + conversationId: string, + options: { includeTerminal?: boolean; limit?: number } = {}, + tx: DatabaseOrTransaction = db, +): Promise { + const limit = options.limit ?? 50; + return tx + .select() + .from(sessionWakeups) + .where( + options.includeTerminal + ? eq(sessionWakeups.conversationId, conversationId) + : and( + eq(sessionWakeups.conversationId, conversationId), + eq(sessionWakeups.status, 'active'), + ), + ) + .orderBy( + sql`case when ${sessionWakeups.status} = 'active' then 0 else 1 end`, + asc(sessionWakeups.nextRunAt), + desc(sessionWakeups.updatedAt), + ) + .limit(limit); +} + +export async function getSessionWakeupById( + id: string, + tx: DatabaseOrTransaction = db, +): Promise { + const [row] = await tx + .select() + .from(sessionWakeups) + .where(eq(sessionWakeups.id, id)) + .limit(1); + return row ?? null; +} + +export type InsertSessionWakeupInput = { + conversationId: string; + createdByUserId: string | null; + name: string; + prompt: string; + schedule: SessionWakeupSchedule; + reportPolicy: SessionWakeupReportPolicy; + maxRuns: number | null; + until: Date | null; + nextRunAt: Date; +}; + +export async function insertSessionWakeup( + input: InsertSessionWakeupInput, + tx: DatabaseOrTransaction = db, +): Promise { + const [row] = await tx + .insert(sessionWakeups) + .values({ + conversationId: input.conversationId, + createdByUserId: input.createdByUserId, + name: input.name, + prompt: input.prompt, + promptSignature: buildSessionWakeupPromptSignature(input.prompt), + schedule: input.schedule, + reportPolicy: input.reportPolicy, + status: 'active', + maxRuns: input.maxRuns, + until: input.until, + nextRunAt: input.nextRunAt, + }) + .returning(); + if (!row) { + throw new Error('Failed to insert session wakeup.'); + } + return row; +} + +/** + * Cancel one active wakeup in a conversation. Returns the row when it was + * active and is now cancelled, and null when it was missing, belonged to + * another conversation, or had already reached a terminal state. + */ +export async function cancelSessionWakeup( + params: { id: string; conversationId: string }, + tx: DatabaseOrTransaction = db, +): Promise { + const now = new Date(); + const [row] = await tx + .update(sessionWakeups) + .set({ + status: 'cancelled', + nextRunAt: null, + completedAt: now, + updatedAt: now, + }) + .where( + and( + eq(sessionWakeups.id, params.id), + eq(sessionWakeups.conversationId, params.conversationId), + eq(sessionWakeups.status, 'active'), + ), + ) + .returning(); + return row ?? null; +} + +/** Cancel every active wakeup in a conversation, e.g. when it is archived. */ +export async function cancelSessionWakeupsForConversation( + conversationId: string, + tx: DatabaseOrTransaction = db, +): Promise { + const now = new Date(); + const rows = await tx + .update(sessionWakeups) + .set({ + status: 'cancelled', + nextRunAt: null, + completedAt: now, + updatedAt: now, + }) + .where( + and( + eq(sessionWakeups.conversationId, conversationId), + eq(sessionWakeups.status, 'active'), + ), + ) + .returning({ id: sessionWakeups.id }); + return rows.length; +} + +export type ClaimSessionWakeupFireInput = { + id: string; + /** The occurrence the caller intends to fire; the claim fails if it moved. */ + expectedNextRunAt: Date; + /** The occurrence after this one, or null when this is the last run. */ + nextRunAt: Date | null; + firedAt: Date; +}; + +/** + * Compare-and-set claim of one occurrence. Two workers holding the same + * delayed job cannot both fire it, and a stale job that arrives after the + * row already advanced is a no-op. The row completes when there is no next + * occurrence. + */ +export async function claimSessionWakeupFire( + input: ClaimSessionWakeupFireInput, + tx: DatabaseOrTransaction = db, +): Promise { + const [row] = await tx + .update(sessionWakeups) + .set({ + runCount: sql`${sessionWakeups.runCount} + 1`, + lastFiredAt: input.firedAt, + nextRunAt: input.nextRunAt, + ...(input.nextRunAt + ? {} + : { status: 'completed' as const, completedAt: input.firedAt }), + updatedAt: input.firedAt, + }) + .where( + and( + eq(sessionWakeups.id, input.id), + eq(sessionWakeups.status, 'active'), + eq(sessionWakeups.nextRunAt, input.expectedNextRunAt), + ), + ) + .returning(); + return row ?? null; +} + +/** + * Record how the turn a wakeup admitted ended. A success clears the failure + * streak; enough consecutive failures retire a still-active wakeup. + */ +export async function recordSessionWakeupOutcome( + params: { id: string; status: 'succeeded' | 'failed'; error?: string }, + tx: DatabaseOrTransaction = db, +): Promise { + const now = new Date(); + if (params.status === 'succeeded') { + const [row] = await tx + .update(sessionWakeups) + .set({ consecutiveFailures: 0, lastError: null, updatedAt: now }) + .where(eq(sessionWakeups.id, params.id)) + .returning(); + return row ?? null; + } + + const [row] = await tx + .update(sessionWakeups) + .set({ + consecutiveFailures: sql`${sessionWakeups.consecutiveFailures} + 1`, + lastError: params.error ?? null, + updatedAt: now, + }) + .where(eq(sessionWakeups.id, params.id)) + .returning(); + if (!row) return null; + if ( + row.status !== 'active' || + row.consecutiveFailures < SESSION_WAKEUP_MAX_CONSECUTIVE_FAILURES + ) { + return row; + } + + const [retired] = await tx + .update(sessionWakeups) + .set({ + status: 'failed', + nextRunAt: null, + completedAt: now, + updatedAt: now, + }) + .where( + and( + eq(sessionWakeups.id, params.id), + eq(sessionWakeups.status, 'active'), + ), + ) + .returning(); + return retired ?? row; +} + +/** + * Active wakeups whose occurrence is due on or before `dueBy`. The recovery + * sweep uses this to re-add lost delayed jobs; the claim keeps duplicates + * harmless. + */ +export function listDueSessionWakeups( + params: { dueBy: Date; limit?: number }, + tx: DatabaseOrTransaction = db, +): Promise[]> { + return tx + .select({ id: sessionWakeups.id, nextRunAt: sessionWakeups.nextRunAt }) + .from(sessionWakeups) + .where( + and( + eq(sessionWakeups.status, 'active'), + lte(sessionWakeups.nextRunAt, params.dueBy), + ), + ) + .orderBy(asc(sessionWakeups.nextRunAt)) + .limit(params.limit ?? 500); +} diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 13a266481..b341fbb06 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -83,6 +83,9 @@ import type { FastAgentSurface, ReasoningEffort, SessionStatus, + SessionWakeupReportPolicy, + SessionWakeupSchedule, + SessionWakeupStatus, } from '@roomote/types'; import { DEFAULT_TASK_ARTIFACT_TYPE } from '@roomote/types'; @@ -3363,6 +3366,76 @@ export const fastAgentConversationsRelations = relations( }), ); +/** + * session_wakeups + * + * Messages a Fast conversation scheduled for itself. A row stays `active` + * until it has fired for the last time, was cancelled, or failed too many + * turns in a row; terminal rows are retained for history. `next_run_at` is + * the source of truth for firing: the BullMQ delayed job is only a wakeup + * hint, and claiming an occurrence is a compare-and-set on that column. + */ +export const sessionWakeups = pgTable( + 'session_wakeups', + { + id: uuid('id').primaryKey().defaultRandom(), + conversationId: uuid('conversation_id') + .notNull() + .references(() => fastAgentConversations.id, { onDelete: 'cascade' }), + createdByUserId: text('created_by_user_id').references(() => users.id, { + onDelete: 'set null', + }), + name: text('name').notNull(), + prompt: text('prompt').notNull(), + /** Whitespace-collapsed, lower-cased prompt used to detect duplicates. */ + promptSignature: text('prompt_signature').notNull(), + schedule: jsonb('schedule').notNull().$type(), + reportPolicy: text('report_policy') + .notNull() + .$type(), + status: text('status') + .notNull() + .default('active') + .$type(), + runCount: integer('run_count').notNull().default(0), + maxRuns: integer('max_runs'), + until: timestamp('until'), + consecutiveFailures: integer('consecutive_failures').notNull().default(0), + nextRunAt: timestamp('next_run_at'), + lastFiredAt: timestamp('last_fired_at'), + lastError: text('last_error'), + completedAt: timestamp('completed_at'), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + }, + (table) => [ + index('session_wakeups_due_idx').on(table.status, table.nextRunAt), + index('session_wakeups_conversation_idx').on( + table.conversationId, + table.status, + ), + check( + 'session_wakeups_status_check', + sql`${table.status} in ('active', 'completed', 'cancelled', 'failed')`, + ), + check( + 'session_wakeups_report_policy_check', + sql`${table.reportPolicy} in ('always', 'only_when_notable')`, + ), + ], +); + +export const sessionWakeupsRelations = relations(sessionWakeups, ({ one }) => ({ + conversation: one(fastAgentConversations, { + fields: [sessionWakeups.conversationId], + references: [fastAgentConversations.id], + }), + createdByUser: one(users, { + fields: [sessionWakeups.createdByUserId], + references: [users.id], + }), +})); + export const fastAgentParentEventsRelations = relations( fastAgentParentEvents, ({ one }) => ({ diff --git a/packages/db/src/server.ts b/packages/db/src/server.ts index 55248d726..9849ff0ea 100644 --- a/packages/db/src/server.ts +++ b/packages/db/src/server.ts @@ -59,6 +59,7 @@ export * from './lib/sync-task-state'; export * from './lib/cancel-task-run'; export * from './lib/automations'; export * from './lib/custom-automations'; +export * from './lib/session-wakeups'; export * from './lib/background-automation-slack-threads'; export * from './lib/task-run-events'; export * from './lib/declarative-environments'; @@ -201,6 +202,8 @@ export { fastAgentMessagesRelations, fastAgentParentEvents, fastAgentParentEventsRelations, + sessionWakeups, + sessionWakeupsRelations, fastAgentProviderMessages, fastAgentProviderMessagesRelations, fastAgentPrFeedbackDeliveries, diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 91546f2d4..f9ce2ec5f 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -64,6 +64,7 @@ import type { environmentRepositoryMappings, automations, customAutomations, + sessionWakeups, trackedMessages, } from './schema'; @@ -626,3 +627,14 @@ export type CreateCustomAutomation = Omit< typeof customAutomations.$inferInsert, Timestamp >; + +/** + * session_wakeups + */ + +export type SessionWakeup = typeof sessionWakeups.$inferSelect; + +export type CreateSessionWakeup = Omit< + typeof sessionWakeups.$inferInsert, + Timestamp +>; diff --git a/packages/sdk/src/server/index.ts b/packages/sdk/src/server/index.ts index 506c8b6de..c5faf9981 100644 --- a/packages/sdk/src/server/index.ts +++ b/packages/sdk/src/server/index.ts @@ -221,6 +221,15 @@ export { wakeFastAgentParentEventNow, type FastAgentParentEventQueueRequest, } from './lib/fast-agent-parent-event-queue'; +export { + SESSION_WAKEUP_FIRE_JOB_NAME, + SESSION_WAKEUP_QUEUE_NAME, + SESSION_WAKEUP_RECOVERY_LOOKAHEAD_MS, + fireSessionWakeup, + recoverPendingSessionWakeups, + type FireSessionWakeupResult, + type SessionWakeupFireJob, +} from './lib/session-wakeups'; export { admitFastAgentHumanFollowUp, persistFastAgentInlineHumanTurn, diff --git a/packages/sdk/src/server/lib/fast-agent-parent-event-queue.test.ts b/packages/sdk/src/server/lib/fast-agent-parent-event-queue.test.ts index 947c1bdcd..61c8e435d 100644 --- a/packages/sdk/src/server/lib/fast-agent-parent-event-queue.test.ts +++ b/packages/sdk/src/server/lib/fast-agent-parent-event-queue.test.ts @@ -33,6 +33,7 @@ const mocks = vi.hoisted(() => { deliver: vi.fn(), retryStartup: vi.fn(), recordAutomationOutcome: vi.fn(), + recordWakeupOutcome: vi.fn(), DeliveryError, }; }); @@ -87,6 +88,7 @@ vi.mock('@roomote/db/server', () => ({ lte: vi.fn((...values: unknown[]) => values), or: vi.fn((...values: unknown[]) => values), recordCustomAutomationRunOutcome: mocks.recordAutomationOutcome, + recordSessionWakeupOutcome: mocks.recordWakeupOutcome, sql: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ strings: [...strings], values, @@ -347,6 +349,75 @@ describe('Fast parent event durable queue', () => { ); }); + it('records the outcome of a scheduled wakeup turn once delivery settles', async () => { + const wakeupEvent = { + type: 'scheduled_wakeup' as const, + eventId: 'wakeup-1:3', + wakeupId: 'wakeup-1', + name: 'Check PR #85', + prompt: 'Check whether PR #85 merged.', + runNumber: 3, + maxRuns: null, + firedAt: '2026-09-04T17:10:00.000Z', + nextRunAt: '2026-09-04T17:20:00.000Z', + reportPolicy: 'only_when_notable' as const, + createdByUserId: 'user-1', + }; + const row = pendingRow('wakeup-event', wakeupEvent); + mocks.findPending + .mockResolvedValueOnce(row) + .mockResolvedValueOnce(row) + .mockResolvedValueOnce(undefined); + + await drainFastAgentParentEvents({ + conversationId: parent.sessionId, + eventKey: row.eventKey, + }); + + expect(mocks.recordWakeupOutcome).toHaveBeenCalledWith({ + id: 'wakeup-1', + status: 'succeeded', + }); + expect(mocks.recordAutomationOutcome).not.toHaveBeenCalled(); + }); + + it('counts a permanent delivery failure against the scheduled wakeup', async () => { + const wakeupEvent = { + type: 'scheduled_wakeup' as const, + eventId: 'wakeup-1:4', + wakeupId: 'wakeup-1', + name: 'Check PR #85', + prompt: 'Check whether PR #85 merged.', + runNumber: 4, + maxRuns: null, + firedAt: '2026-09-04T17:20:00.000Z', + nextRunAt: null, + reportPolicy: 'always' as const, + createdByUserId: 'user-1', + }; + const row = pendingRow('wakeup-event', wakeupEvent); + mocks.findPending + .mockResolvedValueOnce(row) + .mockResolvedValueOnce(row) + .mockResolvedValueOnce(undefined); + mocks.deliver.mockRejectedValueOnce( + new mocks.DeliveryError('parent session missing', { + replyPosted: false, + permanent: true, + }), + ); + + await drainFastAgentParentEvents({ + conversationId: parent.sessionId, + eventKey: row.eventKey, + }); + + expect(mocks.recordWakeupOutcome).toHaveBeenCalledWith({ + id: 'wakeup-1', + status: 'failed', + error: 'parent session missing', + }); + }); it('records success when delivery fails after a reply was posted', async () => { const launchClaimedAt = new Date('2026-09-01T15:18:41.782Z'); const automationEvent = { diff --git a/packages/sdk/src/server/lib/fast-agent-parent-event-queue.ts b/packages/sdk/src/server/lib/fast-agent-parent-event-queue.ts index 22d917531..e53e885d7 100644 --- a/packages/sdk/src/server/lib/fast-agent-parent-event-queue.ts +++ b/packages/sdk/src/server/lib/fast-agent-parent-event-queue.ts @@ -18,6 +18,7 @@ import { lte, or, recordCustomAutomationRunOutcome, + recordSessionWakeupOutcome, sql, taskRuns, } from '@roomote/db/server'; @@ -320,6 +321,22 @@ async function finalizeAutomationLaunch( }); } +async function finalizeScheduledWakeup( + event: FastAgentParentEvent, + status: 'succeeded' | 'failed', + error?: unknown, +) { + if (event.type !== 'scheduled_wakeup') return; + + await recordSessionWakeupOutcome({ + id: event.wakeupId, + status, + ...(status === 'failed' + ? { error: error instanceof Error ? error.message : String(error) } + : {}), + }); +} + /** Drain one parent's durable inbox in creation order under one turn lock. */ export async function drainFastAgentParentEvents( request: FastAgentParentEventQueueRequest, @@ -432,6 +449,7 @@ export async function drainFastAgentParentEvents( continue; } await finalizeAutomationLaunch(row.event, 'succeeded'); + await finalizeScheduledWakeup(row.event, 'succeeded'); await markDelivered(row.id); } catch (error) { if (findFastAgentDurableRetryScheduledError(error)) { @@ -447,11 +465,13 @@ export async function drainFastAgentParentEvents( error instanceof FastAgentParentEventDeliveryError ? error : null; if (deliveryError?.replyPosted) { await finalizeAutomationLaunch(row.event, 'succeeded'); + await finalizeScheduledWakeup(row.event, 'succeeded'); await markDelivered(row.id); continue; } if (deliveryError?.permanent) { await finalizeAutomationLaunch(row.event, 'failed', deliveryError); + await finalizeScheduledWakeup(row.event, 'failed', deliveryError); await markDiscarded(row.id, deliveryError); continue; } 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 e1c7ccd2f..81083174d 100644 --- a/packages/sdk/src/server/lib/fast-agent-parent-event.ts +++ b/packages/sdk/src/server/lib/fast-agent-parent-event.ts @@ -54,6 +54,7 @@ import { exitedRunStatuses, type FastAgentConversation, type FastAgentHumanFollowUpEvent, + type FastAgentScheduledWakeupEvent, type FastAgentSourceControlReplyTarget, type FastAgentParent, type PullRequestStatus, @@ -167,6 +168,7 @@ export type FastAgentPullRequestContext = { export type FastAgentParentEvent = | FastAgentHumanFollowUpEvent + | FastAgentScheduledWakeupEvent | { type: 'automation_triggered'; eventId: string; @@ -368,6 +370,8 @@ export function buildEventClientMessageSeed( return `fast-parent-human-follow-up:${event.eventId}`; case 'automation_triggered': return `fast-parent-automation:${event.eventId}`; + case 'scheduled_wakeup': + return `fast-parent-wakeup:${event.eventId}`; case 'child_message': return `fast-parent-child-message:${event.messageId}`; case 'artifact_published': @@ -2190,7 +2194,11 @@ export async function deliverFastAgentParentEventWithLock( const parentTurn = await createFastAgentParentTurn({ parent: params.parent, event: params.event, - ...(humanFollowUp ? { actorUserId: humanFollowUp.userId } : {}), + ...(humanFollowUp + ? { actorUserId: humanFollowUp.userId } + : params.event.type === 'scheduled_wakeup' + ? { actorUserId: params.event.createdByUserId } + : {}), onReplyPosted: () => { replyPosted = true; }, @@ -2277,14 +2285,18 @@ export async function deliverFastAgentParentEventWithLock( humanFollowUp?.platformEventVisibility ?? (params.event.type === 'pull_request_feedback' || params.event.type === 'pull_request_conflict_detected' || - params.event.type === 'automation_triggered' + params.event.type === 'automation_triggered' || + (params.event.type === 'scheduled_wakeup' && + params.event.reportPolicy === 'always') ? 'required' : 'optional'), platformEventKind: humanFollowUp?.platformEventKind ?? (params.event.type === 'automation_triggered' ? 'automation' - : 'delegated_task'), + : params.event.type === 'scheduled_wakeup' + ? 'scheduled_wakeup' + : 'delegated_task'), automationReport: params.event.type === 'task_settled' && Boolean(params.event.customAutomationId), diff --git a/packages/sdk/src/server/lib/session-wakeups.test.ts b/packages/sdk/src/server/lib/session-wakeups.test.ts new file mode 100644 index 000000000..168c3bca7 --- /dev/null +++ b/packages/sdk/src/server/lib/session-wakeups.test.ts @@ -0,0 +1,244 @@ +const mocks = vi.hoisted(() => ({ + enqueueFire: vi.fn(), + findById: vi.fn(), + resolveNextRun: vi.fn(), + claimFire: vi.fn(), + getById: vi.fn(), + listDue: vi.fn(), + recordOutcome: vi.fn(), + enqueueParentEvent: vi.fn(), +})); + +vi.mock('@roomote/cloud-agents/server', () => ({ + SESSION_WAKEUP_FIRE_JOB_NAME: 'fire', + SESSION_WAKEUP_QUEUE_NAME: 'session-wakeups', + enqueueSessionWakeupFire: mocks.enqueueFire, + fastAgentConversationRepository: { findById: mocks.findById }, + resolveSessionWakeupNextRun: mocks.resolveNextRun, +})); + +vi.mock('@roomote/db/server', () => ({ + claimSessionWakeupFire: mocks.claimFire, + getSessionWakeupById: mocks.getById, + listDueSessionWakeups: mocks.listDue, + recordSessionWakeupOutcome: mocks.recordOutcome, +})); + +vi.mock('./fast-agent-parent-event-queue', () => ({ + enqueueFastAgentParentEvent: mocks.enqueueParentEvent, +})); + +import { + fireSessionWakeup, + recoverPendingSessionWakeups, +} from './session-wakeups'; + +const conversation = { + surface: 'web' as const, + workspaceId: 'deployment', + conversationId: 'conversation-1', +}; + +const nextRunAt = new Date('2026-09-04T17:10:00.000Z'); + +function activeRow(overrides: Record = {}) { + return { + id: 'wakeup-1', + conversationId: '11111111-1111-4111-8111-111111111111', + createdByUserId: 'user-1', + name: 'Check PR #85', + prompt: 'Check whether PR #85 merged.', + promptSignature: 'check whether pr #85 merged.', + schedule: { mode: 'interval', everyMinutes: 10 }, + reportPolicy: 'only_when_notable', + status: 'active', + runCount: 2, + maxRuns: null, + until: null, + consecutiveFailures: 0, + nextRunAt, + lastFiredAt: null, + lastError: null, + completedAt: null, + createdAt: new Date('2026-09-04T16:00:00.000Z'), + updatedAt: new Date('2026-09-04T16:00:00.000Z'), + ...overrides, + }; +} + +describe('fireSessionWakeup', () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.spyOn(console, 'log').mockImplementation(() => {}); + mocks.findById.mockResolvedValue({ userId: 'owner-1', conversation }); + mocks.enqueueParentEvent.mockResolvedValue({ eventKey: 'k', queued: true }); + mocks.enqueueFire.mockResolvedValue(undefined); + mocks.recordOutcome.mockResolvedValue(null); + }); + + it('admits the event before claiming, then schedules the next occurrence', async () => { + const row = activeRow(); + const following = new Date('2026-09-04T17:20:00.000Z'); + mocks.getById.mockResolvedValue(row); + mocks.resolveNextRun.mockReturnValue(following); + mocks.claimFire.mockResolvedValue({ ...row, runCount: 3 }); + + const result = await fireSessionWakeup({ + wakeupId: row.id, + runAt: nextRunAt.getTime(), + }); + + expect(result).toEqual({ + outcome: 'fired', + eventId: 'wakeup-1:3', + nextRunAt: following, + }); + expect(mocks.enqueueParentEvent).toHaveBeenCalledWith({ + parent: { sessionId: row.conversationId, conversation }, + event: expect.objectContaining({ + type: 'scheduled_wakeup', + eventId: 'wakeup-1:3', + wakeupId: 'wakeup-1', + runNumber: 3, + prompt: row.prompt, + reportPolicy: 'only_when_notable', + createdByUserId: 'user-1', + nextRunAt: following.toISOString(), + }), + }); + expect(mocks.enqueueParentEvent.mock.invocationCallOrder[0]).toBeLessThan( + mocks.claimFire.mock.invocationCallOrder[0]!, + ); + expect(mocks.claimFire).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'wakeup-1', + expectedNextRunAt: nextRunAt, + nextRunAt: following, + }), + ); + expect(mocks.enqueueFire).toHaveBeenCalledWith({ + wakeupId: 'wakeup-1', + runAt: following.getTime(), + }); + }); + + it('skips a stale job whose occurrence already advanced', async () => { + mocks.getById.mockResolvedValue(activeRow()); + + const result = await fireSessionWakeup({ + wakeupId: 'wakeup-1', + runAt: nextRunAt.getTime() - 60_000, + }); + + expect(result.outcome).toBe('skipped'); + expect(mocks.enqueueParentEvent).not.toHaveBeenCalled(); + expect(mocks.claimFire).not.toHaveBeenCalled(); + }); + + it('skips terminal wakeups without touching the conversation', async () => { + mocks.getById.mockResolvedValue( + activeRow({ status: 'cancelled', nextRunAt: null }), + ); + + const result = await fireSessionWakeup({ + wakeupId: 'wakeup-1', + runAt: nextRunAt.getTime(), + }); + + expect(result).toEqual({ + outcome: 'skipped', + reason: 'Wakeup is cancelled.', + }); + expect(mocks.findById).not.toHaveBeenCalled(); + }); + + it('does not schedule a follow-up when another worker won the claim', async () => { + mocks.getById.mockResolvedValue(activeRow()); + mocks.resolveNextRun.mockReturnValue(new Date('2026-09-04T17:20:00.000Z')); + mocks.claimFire.mockResolvedValue(null); + + const result = await fireSessionWakeup({ + wakeupId: 'wakeup-1', + runAt: nextRunAt.getTime(), + }); + + expect(result.outcome).toBe('skipped'); + expect(mocks.enqueueParentEvent).toHaveBeenCalledOnce(); + expect(mocks.enqueueFire).not.toHaveBeenCalled(); + }); + + it('completes a once wakeup after its only run', async () => { + const row = activeRow({ + schedule: { mode: 'once', at: nextRunAt.toISOString() }, + reportPolicy: 'always', + runCount: 0, + }); + mocks.getById.mockResolvedValue(row); + mocks.resolveNextRun.mockReturnValue(null); + mocks.claimFire.mockResolvedValue({ ...row, status: 'completed' }); + + const result = await fireSessionWakeup({ + wakeupId: row.id, + runAt: nextRunAt.getTime(), + }); + + expect(result).toEqual({ + outcome: 'fired', + eventId: 'wakeup-1:1', + nextRunAt: null, + }); + expect(mocks.enqueueParentEvent).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ nextRunAt: null, maxRuns: null }), + }), + ); + expect(mocks.enqueueFire).not.toHaveBeenCalled(); + }); + + it('records a failure and advances when the conversation is gone', async () => { + const row = activeRow(); + mocks.getById.mockResolvedValue(row); + mocks.findById.mockResolvedValue(null); + mocks.resolveNextRun.mockReturnValue(new Date('2026-09-04T17:20:00.000Z')); + mocks.claimFire.mockResolvedValue(row); + + const result = await fireSessionWakeup({ + wakeupId: row.id, + runAt: nextRunAt.getTime(), + }); + + expect(result.outcome).toBe('skipped'); + expect(mocks.enqueueParentEvent).not.toHaveBeenCalled(); + expect(mocks.recordOutcome).toHaveBeenCalledWith( + expect.objectContaining({ id: 'wakeup-1', status: 'failed' }), + ); + expect(mocks.enqueueFire).toHaveBeenCalledOnce(); + }); +}); + +describe('recoverPendingSessionWakeups', () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + it('re-adds a hint for every due row and survives one failure', async () => { + mocks.listDue.mockResolvedValue([ + { id: 'a', nextRunAt: new Date('2026-09-04T17:00:00.000Z') }, + { id: 'b', nextRunAt: new Date('2026-09-04T17:01:00.000Z') }, + { id: 'c', nextRunAt: null }, + ]); + mocks.enqueueFire + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('redis restarting')); + + await expect( + recoverPendingSessionWakeups(new Date('2026-09-04T17:00:30.000Z')), + ).resolves.toBe(1); + + expect(mocks.listDue).toHaveBeenCalledWith({ + dueBy: new Date('2026-09-04T17:02:30.000Z'), + }); + expect(mocks.enqueueFire).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/sdk/src/server/lib/session-wakeups.ts b/packages/sdk/src/server/lib/session-wakeups.ts new file mode 100644 index 000000000..a55b9f437 --- /dev/null +++ b/packages/sdk/src/server/lib/session-wakeups.ts @@ -0,0 +1,190 @@ +import { + enqueueSessionWakeupFire, + fastAgentConversationRepository, + resolveSessionWakeupNextRun, + type SessionWakeupFireJob, +} from '@roomote/cloud-agents/server'; +import { + claimSessionWakeupFire, + getSessionWakeupById, + listDueSessionWakeups, + recordSessionWakeupOutcome, +} from '@roomote/db/server'; +import { + FAST_AGENT_SCHEDULED_WAKEUP_EVENT_TYPE, + type FastAgentScheduledWakeupEvent, +} from '@roomote/types'; + +import { enqueueFastAgentParentEvent } from './fast-agent-parent-event-queue'; + +export { + SESSION_WAKEUP_FIRE_JOB_NAME, + SESSION_WAKEUP_QUEUE_NAME, + type SessionWakeupFireJob, +} from '@roomote/cloud-agents/server'; + +const LOG_PREFIX = '[SessionWakeups]'; +/** + * The recovery sweep re-adds delayed hints for rows due within this window, + * so a hint lost to a Redis restart shortly before its time still fires on + * time rather than one sweep late. + */ +export const SESSION_WAKEUP_RECOVERY_LOOKAHEAD_MS = 2 * 60_000; + +export type FireSessionWakeupResult = + | { outcome: 'fired'; eventId: string; nextRunAt: Date | null } + | { outcome: 'skipped'; reason: string }; + +/** + * Fire one occurrence of a wakeup: admit the platform event into the + * conversation's durable inbox, then claim the occurrence on the row and + * schedule the next one. + * + * The event is admitted before the claim. Its key derives from the wakeup id + * and run number, so a crash between the two steps leaves a pending event + * and an unadvanced row; the recovery sweep re-fires, the duplicate + * admission is a no-op on its key, and the claim then succeeds. The reverse + * order could lose an occurrence. + */ +export async function fireSessionWakeup( + job: SessionWakeupFireJob, +): Promise { + const row = await getSessionWakeupById(job.wakeupId); + if (!row) return { outcome: 'skipped', reason: 'Wakeup no longer exists.' }; + if (row.status !== 'active' || !row.nextRunAt) { + return { outcome: 'skipped', reason: `Wakeup is ${row.status}.` }; + } + if (row.nextRunAt.getTime() !== job.runAt) { + return { + outcome: 'skipped', + reason: 'Occurrence already advanced past this job.', + }; + } + + const firedAt = new Date(); + const runNumber = row.runCount + 1; + const nextRunAt = resolveSessionWakeupNextRun({ + schedule: row.schedule, + firedAt, + runCountAfterFire: runNumber, + maxRuns: row.maxRuns, + until: row.until, + }); + + const record = await fastAgentConversationRepository.findById({ + id: row.conversationId, + }); + if (!record) { + // Claim first so the row does not fire again every sweep, then count + // the failure so a conversation that stays missing retires the wakeup. + await claimSessionWakeupFire({ + id: row.id, + expectedNextRunAt: row.nextRunAt, + nextRunAt, + firedAt, + }); + await recordSessionWakeupOutcome({ + id: row.id, + status: 'failed', + error: 'The Fast conversation for this wakeup was not found.', + }); + if (nextRunAt) { + await enqueueSessionWakeupFire({ + wakeupId: row.id, + runAt: nextRunAt.getTime(), + }); + } + return { outcome: 'skipped', reason: 'Conversation not found.' }; + } + + const event: FastAgentScheduledWakeupEvent = { + type: FAST_AGENT_SCHEDULED_WAKEUP_EVENT_TYPE, + eventId: `${row.id}:${runNumber}`, + wakeupId: row.id, + name: row.name, + prompt: row.prompt, + runNumber, + maxRuns: row.maxRuns, + firedAt: firedAt.toISOString(), + nextRunAt: nextRunAt?.toISOString() ?? null, + reportPolicy: row.reportPolicy, + createdByUserId: row.createdByUserId ?? record.userId ?? '', + }; + if (!event.createdByUserId) { + await claimSessionWakeupFire({ + id: row.id, + expectedNextRunAt: row.nextRunAt, + nextRunAt: null, + firedAt, + }); + await recordSessionWakeupOutcome({ + id: row.id, + status: 'failed', + error: 'No user remains to act on this wakeup.', + }); + return { outcome: 'skipped', reason: 'No acting user.' }; + } + + await enqueueFastAgentParentEvent({ + parent: { + sessionId: row.conversationId, + conversation: record.conversation, + }, + event, + }); + + const claimed = await claimSessionWakeupFire({ + id: row.id, + expectedNextRunAt: row.nextRunAt, + nextRunAt, + firedAt, + }); + if (!claimed) { + return { + outcome: 'skipped', + reason: 'Another worker claimed this occurrence.', + }; + } + + if (nextRunAt) { + await enqueueSessionWakeupFire({ + wakeupId: row.id, + runAt: nextRunAt.getTime(), + }); + } + + console.log( + `${LOG_PREFIX} Fired "${row.name}" (${row.id}) run #${runNumber}; next ${nextRunAt ? nextRunAt.toISOString() : 'none'}.`, + ); + return { outcome: 'fired', eventId: event.eventId, nextRunAt }; +} + +/** + * Recreate delayed hints for active rows that are due or nearly due. Runs on + * the queue's recovery schedule and at worker start so Redis restarts and + * lost jobs cannot strand a wakeup; the compare-and-set claim keeps a + * duplicate hint harmless. + */ +export async function recoverPendingSessionWakeups( + now = new Date(), +): Promise { + const rows = await listDueSessionWakeups({ + dueBy: new Date(now.getTime() + SESSION_WAKEUP_RECOVERY_LOOKAHEAD_MS), + }); + let recovered = 0; + for (const row of rows) { + if (!row.nextRunAt) continue; + try { + await enqueueSessionWakeupFire({ + wakeupId: row.id, + runAt: row.nextRunAt.getTime(), + }); + recovered += 1; + } catch (error) { + console.error( + `${LOG_PREFIX} Failed to re-add hint for wakeup ${row.id}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + return recovered; +} diff --git a/packages/types/src/fast-agent-tool-catalog.ts b/packages/types/src/fast-agent-tool-catalog.ts index a3f2ce5af..19dffb3cf 100644 --- a/packages/types/src/fast-agent-tool-catalog.ts +++ b/packages/types/src/fast-agent-tool-catalog.ts @@ -12,6 +12,7 @@ export const FAST_AGENT_NATIVE_TOOL_NAMES = { ignoreEvent: 'ignore_event', inspectImages: 'inspect_images', launchTask: 'launch_task', + manageWakeups: 'manage_wakeups', retryTaskStart: 'retry_task_start', saveMemory: 'save_memory', sendChatReaction: 'send_chat_reaction', @@ -56,6 +57,10 @@ export const FAST_AGENT_NATIVE_TOOL_CATALOG = [ kind: ACP_TOOL_KINDS.read, }, { name: FAST_AGENT_NATIVE_TOOL_NAMES.launchTask, kind: ACP_TOOL_KINDS.task }, + { + name: FAST_AGENT_NATIVE_TOOL_NAMES.manageWakeups, + kind: ACP_TOOL_KINDS.task, + }, { name: FAST_AGENT_NATIVE_TOOL_NAMES.retryTaskStart, kind: ACP_TOOL_KINDS.task, diff --git a/packages/types/src/fast-agent.ts b/packages/types/src/fast-agent.ts index 7bc116136..fc2418c88 100644 --- a/packages/types/src/fast-agent.ts +++ b/packages/types/src/fast-agent.ts @@ -223,6 +223,7 @@ export const fastAgentPlatformEventKindSchema = z.enum([ 'automation', 'setup', 'input_response', + 'scheduled_wakeup', ]); export const fastAgentPlatformEventVisibilitySchema = z.enum([ diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 407742f77..0fa1776fd 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -12,6 +12,7 @@ export * from './cloud-agents'; export * from './pr-review-action'; export * from './task-runs'; export * from './sessions'; +export * from './session-wakeups'; export * from './fast-agent'; export * from './fast-agent-tool-catalog'; export * from './integration-tool-lookup'; diff --git a/packages/types/src/session-wakeups.test.ts b/packages/types/src/session-wakeups.test.ts new file mode 100644 index 000000000..38455666f --- /dev/null +++ b/packages/types/src/session-wakeups.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest'; + +import { + FAST_AGENT_NATIVE_TOOL_NAMES, + getFastAgentNativeAcpKind, +} from './fast-agent-tool-catalog'; +import { + MANAGE_WAKEUPS_ACTIONS, + MANAGE_WAKEUPS_TOOL, + fastAgentScheduledWakeupEventSchema, + manageWakeupsInputSchema, + sessionWakeupScheduleInputSchema, +} from './session-wakeups'; + +describe('manage wakeups tool contract', () => { + it('keeps every supported action in the shared Zod schema', () => { + for (const action of MANAGE_WAKEUPS_ACTIONS) { + expect(manageWakeupsInputSchema.parse({ action })).toEqual({ action }); + } + }); + + it('publishes the canonical descriptor and is a Fast native tool', () => { + expect(MANAGE_WAKEUPS_TOOL.name).toBe('manage_wakeups'); + expect(FAST_AGENT_NATIVE_TOOL_NAMES.manageWakeups).toBe( + MANAGE_WAKEUPS_TOOL.name, + ); + expect(getFastAgentNativeAcpKind('manage_wakeups')).toBe('task'); + expect(MANAGE_WAKEUPS_TOOL.description).toContain('mode "once"'); + expect(MANAGE_WAKEUPS_TOOL.description).toContain('There is no pause.'); + expect(MANAGE_WAKEUPS_TOOL.description).toContain( + 'Never poll, sleep, or wait', + ); + }); + + it('rejects a schedule that mixes modes', () => { + expect( + sessionWakeupScheduleInputSchema.safeParse({ + mode: 'once', + inMinutes: 4, + everyMinutes: 10, + }).success, + ).toBe(false); + expect( + sessionWakeupScheduleInputSchema.safeParse({ + mode: 'interval', + everyMinutes: 10, + at: '2026-09-04T15:00:00Z', + }).success, + ).toBe(false); + }); + + it('accepts each schedule mode on its own', () => { + expect( + sessionWakeupScheduleInputSchema.parse({ mode: 'once', inMinutes: 4 }), + ).toEqual({ mode: 'once', inMinutes: 4 }); + expect( + sessionWakeupScheduleInputSchema.parse({ + mode: 'interval', + everyMinutes: 15, + }), + ).toEqual({ mode: 'interval', everyMinutes: 15 }); + expect( + sessionWakeupScheduleInputSchema.parse({ + mode: 'cron', + expression: '0 9 * * 1-5', + timezone: 'America/New_York', + }), + ).toEqual({ + mode: 'cron', + expression: '0 9 * * 1-5', + timezone: 'America/New_York', + }); + }); + + it('validates the scheduled wakeup platform event', () => { + expect( + fastAgentScheduledWakeupEventSchema.safeParse({ + type: 'scheduled_wakeup', + eventId: 'wakeup-1:3', + wakeupId: 'wakeup-1', + name: 'Check PR #85', + prompt: 'Check whether PR #85 merged.', + runNumber: 3, + maxRuns: null, + firedAt: '2026-09-04T17:10:00.000Z', + nextRunAt: '2026-09-04T17:20:00.000Z', + reportPolicy: 'only_when_notable', + createdByUserId: 'user-1', + }).success, + ).toBe(true); + }); +}); diff --git a/packages/types/src/session-wakeups.ts b/packages/types/src/session-wakeups.ts new file mode 100644 index 000000000..69a4da055 --- /dev/null +++ b/packages/types/src/session-wakeups.ts @@ -0,0 +1,290 @@ +import { z } from 'zod'; + +/** + * Session wakeups let a Fast conversation schedule a message back to itself. + * When a wakeup fires, the platform injects a `scheduled_wakeup` event into + * the same conversation and the agent handles it as a normal turn with the + * full conversation still in context. A one-shot wakeup is a reminder; a + * recurring wakeup is a monitor. + */ + +export const SESSION_WAKEUP_NAME_MAX_LENGTH = 80; +export const SESSION_WAKEUP_NAME_MIN_LENGTH = 3; +export const SESSION_WAKEUP_PROMPT_MAX_LENGTH = 4_000; +export const SESSION_WAKEUP_PROMPT_MIN_LENGTH = 10; +export const SESSION_WAKEUP_CRON_MAX_LENGTH = 120; +/** Active wakeups one conversation may hold at once. */ +export const MAX_ACTIVE_SESSION_WAKEUPS = 10; +export const SESSION_WAKEUP_MIN_INTERVAL_MINUTES = 1; +/** Seven days. */ +export const SESSION_WAKEUP_MAX_INTERVAL_MINUTES = 7 * 24 * 60; +/** + * Recurring wakeups tighter than this must carry `maxRuns` or `until`, so a + * tight polling loop cannot run forever. + */ +export const SESSION_WAKEUP_UNCAPPED_MIN_INTERVAL_MINUTES = 5; +/** Thirty days. A one-shot wakeup may not be scheduled further out. */ +export const SESSION_WAKEUP_MAX_ONCE_HORIZON_MINUTES = 30 * 24 * 60; +export const SESSION_WAKEUP_MAX_RUNS_LIMIT = 1_000; +/** A recurring wakeup whose turns fail this many times in a row is retired. */ +export const SESSION_WAKEUP_MAX_CONSECUTIVE_FAILURES = 5; + +export const SESSION_WAKEUP_STATUSES = [ + 'active', + 'completed', + 'cancelled', + 'failed', +] as const; +export type SessionWakeupStatus = (typeof SESSION_WAKEUP_STATUSES)[number]; + +export const SESSION_WAKEUP_REPORT_POLICIES = [ + 'always', + 'only_when_notable', +] as const; +export type SessionWakeupReportPolicy = + (typeof SESSION_WAKEUP_REPORT_POLICIES)[number]; + +const isoDateTimeSchema = z + .string() + .trim() + .min(1) + .refine((value) => !Number.isNaN(Date.parse(value)), { + message: 'Must be an ISO 8601 date-time.', + }); + +/** + * The schedule as the agent supplies it. `once` accepts either a relative + * delay or an absolute time; the relative form is preferred because it does + * not require the model to do clock arithmetic. + */ +export const sessionWakeupScheduleInputSchema = z.discriminatedUnion('mode', [ + z + .object({ + mode: z + .literal('once') + .describe( + 'Fire one time. Use this for every reminder or delayed follow-up ("in 4 minutes", "at 3pm", "tomorrow morning").', + ), + inMinutes: z + .number() + .int() + .min(1) + .max(SESSION_WAKEUP_MAX_ONCE_HORIZON_MINUTES) + .optional() + .describe( + 'Minutes from now. Preferred over "at" for relative requests. Provide exactly one of inMinutes or at.', + ), + at: isoDateTimeSchema + .optional() + .describe( + 'Absolute ISO 8601 date-time with a UTC offset, for example 2026-09-04T15:00:00-04:00. Provide exactly one of inMinutes or at.', + ), + }) + .strict(), + z + .object({ + mode: z + .literal('interval') + .describe( + 'Fire repeatedly on a fixed interval measured from each run.', + ), + everyMinutes: z + .number() + .int() + .min(SESSION_WAKEUP_MIN_INTERVAL_MINUTES) + .max(SESSION_WAKEUP_MAX_INTERVAL_MINUTES) + .describe( + `Minutes between runs (${SESSION_WAKEUP_MIN_INTERVAL_MINUTES}-${SESSION_WAKEUP_MAX_INTERVAL_MINUTES}). Intervals under ${SESSION_WAKEUP_UNCAPPED_MIN_INTERVAL_MINUTES} minutes require maxRuns or until.`, + ), + }) + .strict(), + z + .object({ + mode: z + .literal('cron') + .describe('Fire repeatedly on a calendar schedule.'), + expression: z + .string() + .trim() + .min(9) + .max(SESSION_WAKEUP_CRON_MAX_LENGTH) + .describe( + 'Standard five-field cron expression (minute hour day-of-month month day-of-week), for example "0 9 * * 1-5" for 9am on weekdays.', + ), + timezone: z + .string() + .trim() + .min(1) + .optional() + .describe( + 'IANA timezone for the cron expression, for example "America/New_York". Defaults to the deployment timezone.', + ), + }) + .strict(), +]); + +export type SessionWakeupScheduleInput = z.infer< + typeof sessionWakeupScheduleInputSchema +>; + +/** The normalized schedule persisted with a wakeup. */ +export const sessionWakeupScheduleSchema = z.discriminatedUnion('mode', [ + z.object({ mode: z.literal('once'), at: z.string() }).strict(), + z + .object({ mode: z.literal('interval'), everyMinutes: z.number().int() }) + .strict(), + z + .object({ + mode: z.literal('cron'), + expression: z.string(), + timezone: z.string(), + }) + .strict(), +]); + +export type SessionWakeupSchedule = z.infer; + +export function isSessionWakeupRecurring( + schedule: Pick, +): boolean { + return schedule.mode !== 'once'; +} + +export const MANAGE_WAKEUPS_ACTIONS = [ + 'create', + 'list', + 'get', + 'cancel', +] as const; +export type ManageWakeupsAction = (typeof MANAGE_WAKEUPS_ACTIONS)[number]; + +export const manageWakeupsFieldSchemas = { + action: z + .enum(MANAGE_WAKEUPS_ACTIONS) + .describe( + 'create schedules a new wakeup; list shows the active wakeups in this conversation; get shows one by id; cancel stops one. Cancel is the only stop action.', + ), + wakeupId: z + .string() + .trim() + .min(1) + .optional() + .describe('Required for get and cancel.'), + name: z + .string() + .trim() + .min(SESSION_WAKEUP_NAME_MIN_LENGTH) + .max(SESSION_WAKEUP_NAME_MAX_LENGTH) + .optional() + .describe( + '[create] Short label for the wakeup, for example "Check PR #85 for merge".', + ), + prompt: z + .string() + .trim() + .min(SESSION_WAKEUP_PROMPT_MIN_LENGTH) + .max(SESSION_WAKEUP_PROMPT_MAX_LENGTH) + .optional() + .describe( + '[create] What to do when the wakeup fires. This conversation will still be in context, so keep it short and concrete. Say what to check, what counts as done, and what to tell the user.', + ), + schedule: sessionWakeupScheduleInputSchema + .optional() + .describe( + '[create] Exactly one schedule mode. Reminders must use mode "once"; monitors use "interval" or "cron".', + ), + maxRuns: z + .number() + .int() + .min(1) + .max(SESSION_WAKEUP_MAX_RUNS_LIMIT) + .optional() + .describe( + '[create] Stop after this many runs. Only for interval or cron schedules.', + ), + until: isoDateTimeSchema + .optional() + .describe( + '[create] Stop after this ISO 8601 date-time. Only for interval or cron schedules.', + ), + reportPolicy: z + .enum(SESSION_WAKEUP_REPORT_POLICIES) + .optional() + .describe( + '[create] "always" replies to the user on every run (default for once). "only_when_notable" stays silent unless there is news or the condition resolved (default for interval and cron).', + ), +} satisfies z.ZodRawShape; + +export const manageWakeupsInputSchema = z.object(manageWakeupsFieldSchemas); + +export type ManageWakeupsInput = z.infer; + +export const MANAGE_WAKEUPS_TOOL_NAME = 'manage_wakeups' as const; + +export const MANAGE_WAKEUPS_TOOL_DESCRIPTION = `Schedule this conversation to wake itself up later, once or on a cadence. When a wakeup fires, you receive a scheduled_wakeup platform event in this same conversation with the full history still in context, so the prompt can be brief and refer to things discussed here. Use it for reminders ("remind me in 20 minutes", "ping me at 3pm") and for monitors ("check every 10 minutes whether CI is green", "every weekday at 9am summarize open PRs"). + +Choose the schedule deliberately: +- One-shot reminders and delayed follow-ups must use schedule.mode "once". Prefer inMinutes for relative times; use at only for an explicit absolute time and include the UTC offset. +- Repeating monitors use mode "interval" or mode "cron". Pick an interval that matches how fast the monitored thing actually changes, not how soon you want an answer. Intervals under ${SESSION_WAKEUP_UNCAPPED_MIN_INTERVAL_MINUTES} minutes need maxRuns or until. +- A monitor keeps running until the user cancels it or the condition definitively resolves. A run that finds nothing new is still useful. When a monitored condition resolves, tell the user and cancel the wakeup. +- Results arrive automatically as a new turn in this conversation. Never poll, sleep, or wait for a wakeup inside a turn. +- When the user says stop, cancel, remove, delete, or end a wakeup, use cancel. There is no pause. +- Creating a wakeup that matches an active one (same prompt and schedule) returns the existing wakeup instead of a duplicate. At most ${MAX_ACTIVE_SESSION_WAKEUPS} wakeups may be active per conversation. +- After creating a wakeup, confirm what will happen and when in one short sentence using the returned nextRunAt.`; + +export const MANAGE_WAKEUPS_TOOL = { + name: MANAGE_WAKEUPS_TOOL_NAME, + title: 'Manage Wakeups', + description: MANAGE_WAKEUPS_TOOL_DESCRIPTION, + inputSchema: manageWakeupsFieldSchemas, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: false, + }, +} as const; + +/** Tool-facing view of a wakeup row. */ +export type SessionWakeupSummary = { + id: string; + name: string; + prompt: string; + schedule: SessionWakeupSchedule; + scheduleDescription: string; + reportPolicy: SessionWakeupReportPolicy; + status: SessionWakeupStatus; + runCount: number; + maxRuns: number | null; + until: string | null; + nextRunAt: string | null; + lastFiredAt: string | null; + lastError: string | null; + createdAt: string; +}; + +export const FAST_AGENT_SCHEDULED_WAKEUP_EVENT_TYPE = + 'scheduled_wakeup' as const; + +/** + * The parent event a firing wakeup admits into its conversation. The prompt + * is repeated here so the turn does not depend on the row still existing. + */ +export const fastAgentScheduledWakeupEventSchema = z.object({ + type: z.literal(FAST_AGENT_SCHEDULED_WAKEUP_EVENT_TYPE), + /** `${wakeupId}:${runNumber}`; one admission per occurrence. */ + eventId: z.string().min(1), + wakeupId: z.string().min(1), + name: z.string().min(1), + prompt: z.string().min(1), + runNumber: z.number().int().min(1), + maxRuns: z.number().int().nullable(), + firedAt: z.string().min(1), + nextRunAt: z.string().nullable(), + reportPolicy: z.enum(SESSION_WAKEUP_REPORT_POLICIES), + createdByUserId: z.string().min(1), +}); + +export type FastAgentScheduledWakeupEvent = z.infer< + typeof fastAgentScheduledWakeupEventSchema +>; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e37dd3d25..e11e5dd4c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1164,6 +1164,12 @@ importers: ai: specifier: ^6.0.116 version: 6.0.116(zod@3.25.76) + bullmq: + specifier: ^5.78.0 + version: 5.78.0 + cron-parser: + specifier: 5.6.1 + version: 5.6.1 dompurify: specifier: 3.4.13 version: 3.4.13 From 6fec4bdec216c0c09645ae070d63263a935ee31f Mon Sep 17 00:00:00 2001 From: Daniel <57051444+daniel-lxs@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:21:09 -0500 Subject: [PATCH 02/11] fix: make session wakeup creation resilient to model placeholders and BullMQ job ids - BullMQ rejects custom job ids containing ':'; use '-' between the wakeup id and occurrence time so delayed fire jobs actually enqueue. - Strip empty strings, null, 'none'-style placeholders, and non-positive caps from manage_wakeups arguments before validation. Models fill every optional field, and each strict rejection cost a retry. - A once schedule ignores stray maxRuns/until and prefers inMinutes when a computed 'at' is sent alongside it, instead of failing. --- .../server/fast-agent/fast-agent-service.ts | 9 ++- .../src/server/session-wakeups/args.test.ts | 57 +++++++++++++++++++ .../src/server/session-wakeups/args.ts | 51 +++++++++++++++++ .../src/server/session-wakeups/index.ts | 1 + .../src/server/session-wakeups/queue.ts | 3 +- .../server/session-wakeups/schedule.test.ts | 15 +++-- .../src/server/session-wakeups/schedule.ts | 17 +++--- .../src/server/session-wakeups/service.ts | 8 +-- 8 files changed, 138 insertions(+), 23 deletions(-) create mode 100644 packages/cloud-agents/src/server/session-wakeups/args.test.ts create mode 100644 packages/cloud-agents/src/server/session-wakeups/args.ts diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts index f56c6d03d..774b68068 100644 --- a/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts @@ -58,7 +58,10 @@ import { z } from 'zod'; import packageJson from '../../../../../package.json'; import { appendAttachmentTextsToPromptText } from '../../file-attachments'; -import { handleManageWakeupsToolCall } from '../session-wakeups'; +import { + handleManageWakeupsToolCall, + normalizeManageWakeupsArgs, +} from '../session-wakeups'; import { buildSlackThreadPromptBlocks, wrapSlackMessage, @@ -4208,7 +4211,9 @@ export async function answerFastAgentQuestion({ } case FAST_AGENT_NATIVE_TOOL_NAMES.manageWakeups: { - const args = manageWakeupsInputSchema.parse(call.args); + const args = manageWakeupsInputSchema.parse( + normalizeManageWakeupsArgs(call.args), + ); throwIfTurnCancelled(); diff --git a/packages/cloud-agents/src/server/session-wakeups/args.test.ts b/packages/cloud-agents/src/server/session-wakeups/args.test.ts new file mode 100644 index 000000000..2862f40e1 --- /dev/null +++ b/packages/cloud-agents/src/server/session-wakeups/args.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; +import { manageWakeupsInputSchema } from '@roomote/types'; + +import { normalizeManageWakeupsArgs } from './args'; + +describe('normalizeManageWakeupsArgs', () => { + it('drops empty-string and null placeholders at every depth', () => { + expect( + normalizeManageWakeupsArgs({ + action: 'create', + wakeupId: '', + name: 'Check the deploy', + prompt: 'Tell the user to check the deploy.', + schedule: { mode: 'once', inMinutes: 2, at: '' }, + until: null, + reportPolicy: '', + }), + ).toEqual({ + action: 'create', + name: 'Check the deploy', + prompt: 'Tell the user to check the deploy.', + schedule: { mode: 'once', inMinutes: 2 }, + }); + }); + + it('makes a model-shaped once schedule pass the strict contract', () => { + const parsed = manageWakeupsInputSchema.parse( + normalizeManageWakeupsArgs({ + action: 'create', + wakeupId: '', + name: 'Check the deploy', + prompt: 'Tell the user to check the deploy.', + schedule: { mode: 'once', inMinutes: 2, at: '' }, + until: '', + }), + ); + expect(parsed.schedule).toEqual({ mode: 'once', inMinutes: 2 }); + expect(parsed.wakeupId).toBeUndefined(); + expect(parsed.until).toBeUndefined(); + }); + + it('drops placeholder strings and non-positive caps but keeps real values', () => { + expect( + normalizeManageWakeupsArgs({ + action: 'cancel', + wakeupId: 'abc', + maxRuns: 0, + until: 'none', + schedule: { mode: 'cron', expression: '0 9 * * *', timezone: 'UTC' }, + }), + ).toEqual({ + action: 'cancel', + wakeupId: 'abc', + schedule: { mode: 'cron', expression: '0 9 * * *', timezone: 'UTC' }, + }); + }); +}); diff --git a/packages/cloud-agents/src/server/session-wakeups/args.ts b/packages/cloud-agents/src/server/session-wakeups/args.ts new file mode 100644 index 000000000..e55a20686 --- /dev/null +++ b/packages/cloud-agents/src/server/session-wakeups/args.ts @@ -0,0 +1,51 @@ +/** + * Models routinely fill every optional tool argument, sending "", null, or a + * literal "none" for the ones they do not mean to use, and a non-positive + * number for an unused cap. Dropping those before validation keeps the + * "exactly one of" rules and ISO date-time checks honest instead of failing + * on placeholders and burning a retry. + */ +export function normalizeManageWakeupsArgs( + args: Record, +): Record { + return stripEmpty(args) as Record; +} + +const PLACEHOLDER_STRINGS = new Set(['null', 'none', 'undefined', 'n/a']); +const NON_POSITIVE_NUMERIC_KEYS = new Set([ + 'maxRuns', + 'inMinutes', + 'everyMinutes', +]); + +function stripEmpty(value: unknown, key?: string): unknown { + if (Array.isArray(value)) { + return value.map((item) => stripEmpty(item)); + } + if (value && typeof value === 'object') { + const entries = Object.entries(value as Record) + .map( + ([nestedKey, nested]) => + [nestedKey, stripEmpty(nested, nestedKey)] as const, + ) + .filter(([, nested]) => nested !== undefined); + return Object.fromEntries(entries); + } + if (value === null) return undefined; + if (typeof value === 'string') { + const trimmed = value.trim(); + if (trimmed === '' || PLACEHOLDER_STRINGS.has(trimmed.toLowerCase())) { + return undefined; + } + return value; + } + if ( + typeof value === 'number' && + key !== undefined && + NON_POSITIVE_NUMERIC_KEYS.has(key) && + !(value > 0) + ) { + return undefined; + } + return value; +} diff --git a/packages/cloud-agents/src/server/session-wakeups/index.ts b/packages/cloud-agents/src/server/session-wakeups/index.ts index c5d37a706..45cc4bcb2 100644 --- a/packages/cloud-agents/src/server/session-wakeups/index.ts +++ b/packages/cloud-agents/src/server/session-wakeups/index.ts @@ -1,3 +1,4 @@ +export { normalizeManageWakeupsArgs } from './args'; export { SESSION_WAKEUP_FIRE_JOB_NAME, SESSION_WAKEUP_QUEUE_NAME, diff --git a/packages/cloud-agents/src/server/session-wakeups/queue.ts b/packages/cloud-agents/src/server/session-wakeups/queue.ts index 7802c5e59..226eaa581 100644 --- a/packages/cloud-agents/src/server/session-wakeups/queue.ts +++ b/packages/cloud-agents/src/server/session-wakeups/queue.ts @@ -39,7 +39,8 @@ function getSessionWakeupQueue(): Queue { } export function buildSessionWakeupFireJobId(job: SessionWakeupFireJob): string { - return `${job.wakeupId}:${job.runAt}`; + // BullMQ rejects custom job ids containing ":". + return `${job.wakeupId}-${job.runAt}`; } /** diff --git a/packages/cloud-agents/src/server/session-wakeups/schedule.test.ts b/packages/cloud-agents/src/server/session-wakeups/schedule.test.ts index 1a235f627..b40b25550 100644 --- a/packages/cloud-agents/src/server/session-wakeups/schedule.test.ts +++ b/packages/cloud-agents/src/server/session-wakeups/schedule.test.ts @@ -33,13 +33,16 @@ describe('normalizeSessionWakeupSchedule', () => { expect(result.firstRunAt.toISOString()).toBe('2026-09-04T19:00:00.000Z'); }); - it('rejects a once schedule with both or neither time fields', () => { - expect(() => + it('prefers inMinutes when a computed at is sent alongside it', () => { + expect( normalizeSessionWakeupSchedule( { mode: 'once', inMinutes: 5, at: '2026-09-04T18:00:00Z' }, options, - ), - ).toThrow(SessionWakeupValidationError); + ).firstRunAt.toISOString(), + ).toBe('2026-09-04T17:05:00.000Z'); + }); + + it('rejects a once schedule with neither time field', () => { expect(() => normalizeSessionWakeupSchedule({ mode: 'once' }, options), ).toThrow(SessionWakeupValidationError); @@ -158,7 +161,7 @@ describe('resolveSessionWakeupNextRun', () => { }); describe('validateSessionWakeupCaps', () => { - it('rejects caps on a once schedule', () => { + it('ignores stray caps on a once schedule', () => { expect(() => validateSessionWakeupCaps({ schedule: { mode: 'once', at: now.toISOString() }, @@ -166,7 +169,7 @@ describe('validateSessionWakeupCaps', () => { maxRuns: 2, until: null, }), - ).toThrow(/only apply/); + ).not.toThrow(); }); it('requires a cap on tight intervals', () => { diff --git a/packages/cloud-agents/src/server/session-wakeups/schedule.ts b/packages/cloud-agents/src/server/session-wakeups/schedule.ts index 923e6988a..37c7aedc7 100644 --- a/packages/cloud-agents/src/server/session-wakeups/schedule.ts +++ b/packages/cloud-agents/src/server/session-wakeups/schedule.ts @@ -74,11 +74,13 @@ export function normalizeSessionWakeupSchedule( const { now } = options; switch (input.mode) { case 'once': { + // Models often send a computed `at` alongside `inMinutes`; the relative + // form is authoritative because it cannot be off by a clock skew. const hasDelay = input.inMinutes !== undefined; const hasAt = input.at !== undefined; - if (hasDelay === hasAt) { + if (!hasDelay && !hasAt) { throw new SessionWakeupValidationError( - 'A once schedule needs exactly one of inMinutes or at.', + 'A once schedule needs inMinutes or at.', ); } const at = hasDelay @@ -198,14 +200,9 @@ export function validateSessionWakeupCaps(params: { until: Date | null; }): void { const { schedule } = params; - if (schedule.mode === 'once') { - if (params.maxRuns !== null || params.until !== null) { - throw new SessionWakeupValidationError( - 'maxRuns and until only apply to interval and cron schedules.', - ); - } - return; - } + // A once schedule is inherently a single run; a stray maxRuns or until from + // the model is ignored rather than rejected. + if (schedule.mode === 'once') return; if (params.until && params.until.getTime() <= params.firstRunAt.getTime()) { throw new SessionWakeupValidationError( `until must be later than the first occurrence at ${params.firstRunAt.toISOString()}.`, diff --git a/packages/cloud-agents/src/server/session-wakeups/service.ts b/packages/cloud-agents/src/server/session-wakeups/service.ts index 03e01a39b..7e7a9c51b 100644 --- a/packages/cloud-agents/src/server/session-wakeups/service.ts +++ b/packages/cloud-agents/src/server/session-wakeups/service.ts @@ -116,8 +116,9 @@ export async function createSessionWakeup( input.schedule, { now, defaultTimeZone: timeZone }, ); - const maxRuns = input.maxRuns ?? null; - const until = input.until ? new Date(input.until) : null; + const recurring = isSessionWakeupRecurring(schedule); + const maxRuns = recurring ? (input.maxRuns ?? null) : null; + const until = recurring && input.until ? new Date(input.until) : null; if (until && Number.isNaN(until.getTime())) { throw new SessionWakeupValidationError( 'until must be an ISO 8601 date-time.', @@ -125,8 +126,7 @@ export async function createSessionWakeup( } validateSessionWakeupCaps({ schedule, firstRunAt, maxRuns, until }); const reportPolicy: SessionWakeupReportPolicy = - input.reportPolicy ?? - (isSessionWakeupRecurring(schedule) ? 'only_when_notable' : 'always'); + input.reportPolicy ?? (recurring ? 'only_when_notable' : 'always'); // Reuse an equivalent active wakeup instead of stacking duplicates; a model // that retries a create call must not double-schedule. From 80a398b365908cc7edc6a519f218dcf008a81298 Mon Sep 17 00:00:00 2001 From: Daniel <57051444+daniel-lxs@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:44:55 -0500 Subject: [PATCH 03/11] refactor: take the wakeup schedule as one string instead of a structured union Models fill every optional structured field with placeholders, and each rejected placeholder cost a retry. The tool now takes a single required schedule string ("in 20m", "at ", "every 10m x3", "every 10m until ", "cron 0 9 * * 1-5 America/New_York") parsed server-side into the same stored schedule. This removes the discriminated union and the maxRuns/until fields from the tool surface; the create action is now name, prompt, schedule, and an optional reportPolicy. --- .../fast-agent-native-tool-bridge.ts | 29 +-- .../server/fast-agent/fast-agent-prompt.ts | 2 +- .../src/server/session-wakeups/args.test.ts | 34 ++-- .../src/server/session-wakeups/args.ts | 30 +-- .../src/server/session-wakeups/index.ts | 5 + .../src/server/session-wakeups/parse.test.ts | 110 +++++++++++ .../src/server/session-wakeups/parse.ts | 178 ++++++++++++++++++ .../src/server/session-wakeups/schedule.ts | 7 +- .../src/server/session-wakeups/service.ts | 21 +-- packages/types/src/session-wakeups.test.ts | 59 +++--- packages/types/src/session-wakeups.ts | 134 +++---------- 11 files changed, 378 insertions(+), 231 deletions(-) create mode 100644 packages/cloud-agents/src/server/session-wakeups/parse.test.ts create mode 100644 packages/cloud-agents/src/server/session-wakeups/parse.ts diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-native-tool-bridge.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-native-tool-bridge.ts index fb83f89fc..e4f1538c2 100644 --- a/packages/cloud-agents/src/server/fast-agent/fast-agent-native-tool-bridge.ts +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-native-tool-bridge.ts @@ -28,12 +28,10 @@ import { INTEGRATION_TOOL_LOOKUP_MAX_LIMIT, REASONING_EFFORT_VALUES, MANAGE_WAKEUPS_TOOL_DESCRIPTION, - MAX_ACTIVE_SESSION_WAKEUPS, - SESSION_WAKEUP_MAX_INTERVAL_MINUTES, - SESSION_WAKEUP_MAX_ONCE_HORIZON_MINUTES, - SESSION_WAKEUP_MAX_RUNS_LIMIT, SESSION_WAKEUP_NAME_MAX_LENGTH, SESSION_WAKEUP_PROMPT_MAX_LENGTH, + SESSION_WAKEUP_SCHEDULE_GRAMMAR, + SESSION_WAKEUP_SCHEDULE_MAX_LENGTH, type FastAgentSurface, FAST_EXECUTION, } from '@roomote/types'; @@ -403,28 +401,11 @@ export default { description: ${JSON.stringify(MANAGE_WAKEUPS_TOOL_DESCRIPTION)}, args: { action: z.enum(["create", "list", "get", "cancel"]).describe("create schedules a wakeup; list shows active wakeups in this conversation; get shows one; cancel stops one. Cancel is the only stop action."), - wakeupId: z.string().optional().describe("Required for get and cancel"), + wakeupId: z.string().optional().describe("Required for get and cancel. Omit otherwise."), name: z.string().min(3).max(${SESSION_WAKEUP_NAME_MAX_LENGTH}).optional().describe("[create] Short label, e.g. 'Check PR #85 for merge'"), prompt: z.string().min(10).max(${SESSION_WAKEUP_PROMPT_MAX_LENGTH}).optional().describe("[create] What to do when it fires. This conversation stays in context, so keep it short: what to check, what counts as done, what to tell the user."), - schedule: z.discriminatedUnion("mode", [ - z.object({ - mode: z.literal("once").describe("Fire one time. Use for every reminder or delayed follow-up."), - inMinutes: z.number().int().min(1).max(${SESSION_WAKEUP_MAX_ONCE_HORIZON_MINUTES}).optional().describe("Minutes from now. Preferred for relative requests. Provide exactly one of inMinutes or at."), - at: z.string().optional().describe("Absolute ISO 8601 date-time with UTC offset, e.g. 2026-09-04T15:00:00-04:00. Provide exactly one of inMinutes or at."), - }), - z.object({ - mode: z.literal("interval").describe("Fire repeatedly on a fixed interval measured from each run."), - everyMinutes: z.number().int().min(1).max(${SESSION_WAKEUP_MAX_INTERVAL_MINUTES}).describe("Minutes between runs. Intervals under 5 minutes require maxRuns or until."), - }), - z.object({ - mode: z.literal("cron").describe("Fire repeatedly on a calendar schedule."), - expression: z.string().describe("Five-field cron expression, e.g. '0 9 * * 1-5'"), - timezone: z.string().optional().describe("IANA timezone, e.g. 'America/New_York'. Defaults to the deployment timezone."), - }), - ]).optional().describe("[create] Exactly one schedule mode. Reminders must use mode 'once'; monitors use 'interval' or 'cron'."), - maxRuns: z.number().int().min(1).max(${SESSION_WAKEUP_MAX_RUNS_LIMIT}).optional().describe("[create] Stop after this many runs. Interval and cron only."), - until: z.string().optional().describe("[create] Stop after this ISO 8601 date-time. Interval and cron only."), - reportPolicy: z.enum(["always", "only_when_notable"]).optional().describe("[create] 'always' replies on every run (default for once); 'only_when_notable' stays silent unless there is news (default for interval and cron). At most ${MAX_ACTIVE_SESSION_WAKEUPS} wakeups may be active per conversation."), + schedule: z.string().max(${SESSION_WAKEUP_SCHEDULE_MAX_LENGTH}).optional().describe(${JSON.stringify(`[create] ${SESSION_WAKEUP_SCHEDULE_GRAMMAR}`)}), + reportPolicy: z.enum(["always", "only_when_notable"]).optional().describe("[create] 'always' replies on every run (default for one-shots); 'only_when_notable' stays silent unless there is news (default for repeating schedules). Omit to use the default."), }, execute: (args, context) => invoke("manage_wakeups", args, context), } 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 283f6674b..56030aa96 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 @@ -350,7 +350,7 @@ ${reactionGuidance} - Use "cancel_task" only when the user explicitly asks to stop an active task. - Call a deployment MCP tool when it can answer the request. Fast receives the same actor-authorized remote and deployment-proxied MCP tool catalog as delegated tasks; local stdio servers remain sandbox-only. Servers listed with a tool prefix expose each tool individually with its native JSON schema. On-demand servers are reached through \`find_integration_tools\` (fetch the schema by server id and tool name, or search by keywords) followed by \`call_integration_tool\`; the same acknowledgement, duplicate, and audit rules apply to both paths. - Use \`roomote_manage_custom_automations\` for custom automation lifecycle requests. It uses the current user's deployment authorization, is admin-only, and is unavailable to advisor and judge subagents. List before modifying an existing automation, use "list_models" before setting a model override, use update with "enabled" to enable or disable, and use "run_now" rather than "launch_task" to test an automation. Communicate first on a human-authored turn; platform events remain exempt. Delete only when the user explicitly requests it, and after creating an automation ask whether they want to run it now. -- Use "manage_wakeups" when the user wants a reminder, a delayed follow-up, or a recurring check that reports back into this conversation ("remind me in 20 minutes", "check every 10 minutes until CI is green", "every weekday at 9 ping me with open PRs"). Reminders use schedule mode "once" (prefer inMinutes); recurring checks use "interval" or "cron". It is scoped to this conversation and available to every participant. Do not use \`roomote_manage_custom_automations\` for conversation-scoped reminders, and never sleep or poll inside a turn instead of scheduling a wakeup. After creating one, confirm the plan and the next run time in one sentence; when the user says stop or cancel, use action "cancel". +- Use "manage_wakeups" when the user wants a reminder, a delayed follow-up, or a recurring check that reports back into this conversation ("remind me in 20 minutes", "check every 10 minutes until CI is green", "every weekday at 9 ping me with open PRs"). The schedule is one short string: "in 20m" for a reminder, "every 10m" or "every 1m x3" for a repeating check, "cron 0 9 * * 1-5" for a calendar schedule. Send only the fields the action needs. It is scoped to this conversation and available to every participant. Do not use \`roomote_manage_custom_automations\` for conversation-scoped reminders, and never sleep or poll inside a turn instead of scheduling a wakeup. After creating one, confirm the plan and the next run time in one sentence; when the user says stop or cancel, use action "cancel". ${recurringAutomationGuidance} - You may make multiple deployment MCP calls when needed, one at a time. Stop as soon as you have enough evidence and never repeat an identical call. diff --git a/packages/cloud-agents/src/server/session-wakeups/args.test.ts b/packages/cloud-agents/src/server/session-wakeups/args.test.ts index 2862f40e1..96fb08da5 100644 --- a/packages/cloud-agents/src/server/session-wakeups/args.test.ts +++ b/packages/cloud-agents/src/server/session-wakeups/args.test.ts @@ -4,54 +4,50 @@ import { manageWakeupsInputSchema } from '@roomote/types'; import { normalizeManageWakeupsArgs } from './args'; describe('normalizeManageWakeupsArgs', () => { - it('drops empty-string and null placeholders at every depth', () => { + it('drops empty-string, null, and placeholder values', () => { expect( normalizeManageWakeupsArgs({ action: 'create', wakeupId: '', name: 'Check the deploy', prompt: 'Tell the user to check the deploy.', - schedule: { mode: 'once', inMinutes: 2, at: '' }, - until: null, - reportPolicy: '', + schedule: 'in 2m', + reportPolicy: null, }), ).toEqual({ action: 'create', name: 'Check the deploy', prompt: 'Tell the user to check the deploy.', - schedule: { mode: 'once', inMinutes: 2 }, + schedule: 'in 2m', }); + expect( + normalizeManageWakeupsArgs({ action: 'list', wakeupId: 'none' }), + ).toEqual({ action: 'list' }); }); - it('makes a model-shaped once schedule pass the strict contract', () => { + it('makes a model-shaped create call pass the contract', () => { const parsed = manageWakeupsInputSchema.parse( normalizeManageWakeupsArgs({ action: 'create', wakeupId: '', name: 'Check the deploy', prompt: 'Tell the user to check the deploy.', - schedule: { mode: 'once', inMinutes: 2, at: '' }, - until: '', + schedule: 'in 2m', + reportPolicy: '', }), ); - expect(parsed.schedule).toEqual({ mode: 'once', inMinutes: 2 }); + expect(parsed.schedule).toBe('in 2m'); expect(parsed.wakeupId).toBeUndefined(); - expect(parsed.until).toBeUndefined(); + expect(parsed.reportPolicy).toBeUndefined(); }); - it('drops placeholder strings and non-positive caps but keeps real values', () => { + it('keeps real values untouched', () => { expect( normalizeManageWakeupsArgs({ action: 'cancel', wakeupId: 'abc', - maxRuns: 0, - until: 'none', - schedule: { mode: 'cron', expression: '0 9 * * *', timezone: 'UTC' }, + reportPolicy: 'always', }), - ).toEqual({ - action: 'cancel', - wakeupId: 'abc', - schedule: { mode: 'cron', expression: '0 9 * * *', timezone: 'UTC' }, - }); + ).toEqual({ action: 'cancel', wakeupId: 'abc', reportPolicy: 'always' }); }); }); diff --git a/packages/cloud-agents/src/server/session-wakeups/args.ts b/packages/cloud-agents/src/server/session-wakeups/args.ts index e55a20686..b3e5bdd00 100644 --- a/packages/cloud-agents/src/server/session-wakeups/args.ts +++ b/packages/cloud-agents/src/server/session-wakeups/args.ts @@ -1,9 +1,8 @@ /** * Models routinely fill every optional tool argument, sending "", null, or a - * literal "none" for the ones they do not mean to use, and a non-positive - * number for an unused cap. Dropping those before validation keeps the - * "exactly one of" rules and ISO date-time checks honest instead of failing - * on placeholders and burning a retry. + * literal "none" for the ones they do not mean to use. Dropping those before + * validation keeps the contract honest instead of failing on placeholders + * and burning a retry. */ export function normalizeManageWakeupsArgs( args: Record, @@ -12,22 +11,14 @@ export function normalizeManageWakeupsArgs( } const PLACEHOLDER_STRINGS = new Set(['null', 'none', 'undefined', 'n/a']); -const NON_POSITIVE_NUMERIC_KEYS = new Set([ - 'maxRuns', - 'inMinutes', - 'everyMinutes', -]); -function stripEmpty(value: unknown, key?: string): unknown { +function stripEmpty(value: unknown): unknown { if (Array.isArray(value)) { - return value.map((item) => stripEmpty(item)); + return value.map(stripEmpty); } if (value && typeof value === 'object') { const entries = Object.entries(value as Record) - .map( - ([nestedKey, nested]) => - [nestedKey, stripEmpty(nested, nestedKey)] as const, - ) + .map(([key, nested]) => [key, stripEmpty(nested)] as const) .filter(([, nested]) => nested !== undefined); return Object.fromEntries(entries); } @@ -37,15 +28,6 @@ function stripEmpty(value: unknown, key?: string): unknown { if (trimmed === '' || PLACEHOLDER_STRINGS.has(trimmed.toLowerCase())) { return undefined; } - return value; - } - if ( - typeof value === 'number' && - key !== undefined && - NON_POSITIVE_NUMERIC_KEYS.has(key) && - !(value > 0) - ) { - return undefined; } return value; } diff --git a/packages/cloud-agents/src/server/session-wakeups/index.ts b/packages/cloud-agents/src/server/session-wakeups/index.ts index 45cc4bcb2..ddee3a6c8 100644 --- a/packages/cloud-agents/src/server/session-wakeups/index.ts +++ b/packages/cloud-agents/src/server/session-wakeups/index.ts @@ -1,4 +1,8 @@ export { normalizeManageWakeupsArgs } from './args'; +export { + parseSessionWakeupSchedule, + type ParsedSessionWakeupSchedule, +} from './parse'; export { SESSION_WAKEUP_FIRE_JOB_NAME, SESSION_WAKEUP_QUEUE_NAME, @@ -16,6 +20,7 @@ export { resolveSessionWakeupNextRun, validateSessionWakeupCaps, type NormalizedSessionWakeupSchedule, + type SessionWakeupScheduleInput, } from './schedule'; export { cancelSessionWakeupForConversation, diff --git a/packages/cloud-agents/src/server/session-wakeups/parse.test.ts b/packages/cloud-agents/src/server/session-wakeups/parse.test.ts new file mode 100644 index 000000000..2f18fdd7f --- /dev/null +++ b/packages/cloud-agents/src/server/session-wakeups/parse.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest'; + +import { parseSessionWakeupSchedule } from './parse'; +import { SessionWakeupValidationError } from './schedule'; + +const now = new Date('2026-09-04T17:00:00.000Z'); +const options = { now, defaultTimeZone: 'America/New_York' }; + +describe('parseSessionWakeupSchedule', () => { + it('reads one-shot delays in several spellings', () => { + for (const text of ['in 2m', 'in 2 minutes', 'IN 2min', 'once in 2m']) { + const parsed = parseSessionWakeupSchedule(text, options); + expect(parsed.schedule).toEqual({ + mode: 'once', + at: '2026-09-04T17:02:00.000Z', + }); + expect(parsed.maxRuns).toBeNull(); + expect(parsed.until).toBeNull(); + } + expect( + parseSessionWakeupSchedule('in 3h', options).firstRunAt.toISOString(), + ).toBe('2026-09-04T20:00:00.000Z'); + expect( + parseSessionWakeupSchedule('in 2 days', options).firstRunAt.toISOString(), + ).toBe('2026-09-06T17:00:00.000Z'); + }); + + it('reads an absolute one-shot time', () => { + expect( + parseSessionWakeupSchedule( + 'at 2026-09-04T15:00:00-04:00', + options, + ).firstRunAt.toISOString(), + ).toBe('2026-09-04T19:00:00.000Z'); + }); + + it('reads intervals with run counts and end times in either order', () => { + const plain = parseSessionWakeupSchedule('every 10m', options); + expect(plain.schedule).toEqual({ mode: 'interval', everyMinutes: 10 }); + expect(plain.maxRuns).toBeNull(); + + for (const text of [ + 'every 1m x3', + 'every 1m x 3', + 'every 1 minute 3 times', + 'every minute for 3 runs', + ]) { + const parsed = parseSessionWakeupSchedule(text, options); + expect(parsed.schedule).toEqual({ mode: 'interval', everyMinutes: 1 }); + expect(parsed.maxRuns).toBe(3); + } + + const both = parseSessionWakeupSchedule( + 'every 10m until 2026-09-04T18:00:00Z x5', + options, + ); + expect(both.maxRuns).toBe(5); + expect(both.until?.toISOString()).toBe('2026-09-04T18:00:00.000Z'); + expect( + parseSessionWakeupSchedule('every 6 hours', options).schedule, + ).toEqual({ mode: 'interval', everyMinutes: 360 }); + }); + + it('reads cron with an optional timezone', () => { + expect( + parseSessionWakeupSchedule('cron 0 9 * * 1-5', options).schedule, + ).toEqual({ + mode: 'cron', + expression: '0 9 * * 1-5', + timezone: 'America/New_York', + }); + expect( + parseSessionWakeupSchedule('cron 0 9 * * 1-5 Europe/Berlin', options) + .schedule, + ).toEqual({ + mode: 'cron', + expression: '0 9 * * 1-5', + timezone: 'Europe/Berlin', + }); + }); + + it('enforces the tight-interval cap through the string form', () => { + expect(() => parseSessionWakeupSchedule('every 1m', options)).toThrow( + /x|until/, + ); + expect(() => + parseSessionWakeupSchedule('every 1m x3', options), + ).not.toThrow(); + }); + + it('rejects unreadable schedules with the grammar in the message', () => { + for (const text of ['', 'tomorrow', '2m', 'every', 'in two minutes']) { + expect(() => parseSessionWakeupSchedule(text, options)).toThrow( + SessionWakeupValidationError, + ); + } + expect(() => parseSessionWakeupSchedule('2m', options)).toThrow( + /say "in \.\.\."/, + ); + expect(() => parseSessionWakeupSchedule('soon', options)).toThrow( + /"in m\|h\|d"/, + ); + expect(() => + parseSessionWakeupSchedule('every 10m until soon', options), + ).toThrow(/ISO 8601/); + expect(() => + parseSessionWakeupSchedule('every 10m banana', options), + ).toThrow(/unexpected "banana"/); + }); +}); diff --git a/packages/cloud-agents/src/server/session-wakeups/parse.ts b/packages/cloud-agents/src/server/session-wakeups/parse.ts new file mode 100644 index 000000000..f39074e96 --- /dev/null +++ b/packages/cloud-agents/src/server/session-wakeups/parse.ts @@ -0,0 +1,178 @@ +import { + SESSION_WAKEUP_MAX_RUNS_LIMIT, + SESSION_WAKEUP_SCHEDULE_GRAMMAR, + type SessionWakeupSchedule, +} from '@roomote/types'; + +import { + SessionWakeupValidationError, + normalizeSessionWakeupSchedule, + validateSessionWakeupCaps, +} from './schedule'; + +export type ParsedSessionWakeupSchedule = { + schedule: SessionWakeupSchedule; + firstRunAt: Date; + maxRuns: number | null; + until: Date | null; +}; + +const UNIT_MINUTES: Record = { + m: 1, + min: 1, + mins: 1, + minute: 1, + minutes: 1, + h: 60, + hr: 60, + hrs: 60, + hour: 60, + hours: 60, + d: 24 * 60, + day: 24 * 60, + days: 24 * 60, +}; + +const DURATION = String.raw`(\d+)\s*(m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days)`; +const DURATION_RE = new RegExp(`^${DURATION}$`, 'i'); +const IN_RE = new RegExp(`^(?:once\\s+)?in\\s+${DURATION}$`, 'i'); +const AT_RE = /^(?:once\s+)?at\s+(\S+)$/i; +const EVERY_RE = new RegExp( + `^every\\s+(?:${DURATION}|(minute|hour|day))(?:\\s+(.*))?$`, + 'i', +); +const CRON_RE = /^cron\s+(\S+\s+\S+\s+\S+\s+\S+\s+\S+)(?:\s+(\S+))?$/i; +const COUNT_RE = + /^(?:x\s*(\d+)|(\d+)\s*(?:x|times|runs)|for\s+(\d+)\s+(?:runs|times))$/i; +const UNTIL_RE = /^until\s+(\S+)$/i; + +function invalid(text: string, detail?: string): SessionWakeupValidationError { + return new SessionWakeupValidationError( + `Could not read the schedule "${text}"${detail ? `: ${detail}` : ''}. ${SESSION_WAKEUP_SCHEDULE_GRAMMAR}`, + ); +} + +function durationMinutes(amount: string, unit: string): number { + const minutes = + Number.parseInt(amount, 10) * UNIT_MINUTES[unit.toLowerCase()]!; + if (!Number.isFinite(minutes) || minutes <= 0) { + throw new SessionWakeupValidationError('A duration must be positive.'); + } + return minutes; +} + +/** + * Split the trailing modifiers of an "every" schedule: any of "x3", + * "3 times", "for 3 runs", and "until ", in either order. + */ +function parseModifiers( + text: string, + rest: string | undefined, +): { maxRuns: number | null; until: Date | null } { + let maxRuns: number | null = null; + let until: Date | null = null; + const tokens = (rest ?? '').trim().split(/\s+/).filter(Boolean); + let index = 0; + while (index < tokens.length) { + const one = tokens[index]!; + const two = tokens.slice(index, index + 2).join(' '); + const three = tokens.slice(index, index + 3).join(' '); + const untilMatch = UNTIL_RE.exec(two); + if (untilMatch) { + const parsed = new Date(untilMatch[1]!); + if (Number.isNaN(parsed.getTime())) { + throw invalid(text, `"${untilMatch[1]}" is not an ISO 8601 date-time`); + } + until = parsed; + index += 2; + continue; + } + const countMatch = + COUNT_RE.exec(three) ?? COUNT_RE.exec(two) ?? COUNT_RE.exec(one); + if (countMatch) { + const count = Number.parseInt( + countMatch[1] ?? countMatch[2] ?? countMatch[3]!, + 10, + ); + if (!(count >= 1) || count > SESSION_WAKEUP_MAX_RUNS_LIMIT) { + throw invalid( + text, + `run count must be between 1 and ${SESSION_WAKEUP_MAX_RUNS_LIMIT}`, + ); + } + maxRuns = count; + index += COUNT_RE.exec(three) ? 3 : COUNT_RE.exec(two) ? 2 : 1; + continue; + } + throw invalid(text, `unexpected "${one}"`); + } + return { maxRuns, until }; +} + +/** + * Parse the single schedule string the tool accepts into the normalized + * stored schedule plus its caps, resolving relative delays against `now`. + */ +export function parseSessionWakeupSchedule( + input: string, + options: { now: Date; defaultTimeZone: string }, +): ParsedSessionWakeupSchedule { + const text = input.trim().replace(/\s+/g, ' '); + if (!text) throw invalid(input, 'it is empty'); + + const inMatch = IN_RE.exec(text); + if (inMatch) { + const inMinutes = durationMinutes(inMatch[1]!, inMatch[2]!); + const normalized = normalizeSessionWakeupSchedule( + { mode: 'once', inMinutes }, + options, + ); + return { ...normalized, maxRuns: null, until: null }; + } + + const atMatch = AT_RE.exec(text); + if (atMatch) { + const normalized = normalizeSessionWakeupSchedule( + { mode: 'once', at: atMatch[1]! }, + options, + ); + return { ...normalized, maxRuns: null, until: null }; + } + + const everyMatch = EVERY_RE.exec(text); + if (everyMatch) { + const everyMinutes = everyMatch[3] + ? UNIT_MINUTES[everyMatch[3].toLowerCase()]! + : durationMinutes(everyMatch[1]!, everyMatch[2]!); + const normalized = normalizeSessionWakeupSchedule( + { mode: 'interval', everyMinutes }, + options, + ); + const caps = parseModifiers(text, everyMatch[4]); + validateSessionWakeupCaps({ ...normalized, ...caps }); + return { ...normalized, ...caps }; + } + + const cronMatch = CRON_RE.exec(text); + if (cronMatch) { + const normalized = normalizeSessionWakeupSchedule( + { + mode: 'cron', + expression: cronMatch[1]!, + ...(cronMatch[2] ? { timezone: cronMatch[2] } : {}), + }, + options, + ); + return { ...normalized, maxRuns: null, until: null }; + } + + // A bare duration ("2m", "10 minutes") is ambiguous between a delay and a + // cadence; say so rather than guess. + if (DURATION_RE.test(text)) { + throw invalid( + text, + 'say "in ..." for a one-shot delay or "every ..." for a repeating interval', + ); + } + throw invalid(text); +} diff --git a/packages/cloud-agents/src/server/session-wakeups/schedule.ts b/packages/cloud-agents/src/server/session-wakeups/schedule.ts index 37c7aedc7..013723111 100644 --- a/packages/cloud-agents/src/server/session-wakeups/schedule.ts +++ b/packages/cloud-agents/src/server/session-wakeups/schedule.ts @@ -6,9 +6,14 @@ import { SESSION_WAKEUP_MIN_INTERVAL_MINUTES, SESSION_WAKEUP_UNCAPPED_MIN_INTERVAL_MINUTES, type SessionWakeupSchedule, - type SessionWakeupScheduleInput, } from '@roomote/types'; +/** The structured form the schedule-string parser produces before validation. */ +export type SessionWakeupScheduleInput = + | { mode: 'once'; inMinutes?: number; at?: string } + | { mode: 'interval'; everyMinutes: number } + | { mode: 'cron'; expression: string; timezone?: string }; + const MINUTE_MS = 60_000; /** A schedule or option the agent supplied that cannot be honoured. */ diff --git a/packages/cloud-agents/src/server/session-wakeups/service.ts b/packages/cloud-agents/src/server/session-wakeups/service.ts index 7e7a9c51b..9b2b6cdb2 100644 --- a/packages/cloud-agents/src/server/session-wakeups/service.ts +++ b/packages/cloud-agents/src/server/session-wakeups/service.ts @@ -16,17 +16,15 @@ import { isSessionWakeupRecurring, type ManageWakeupsInput, type SessionWakeupReportPolicy, - type SessionWakeupScheduleInput, type SessionWakeupSummary, } from '@roomote/types'; import { enqueueSessionWakeupFireBestEffort } from './queue'; +import { parseSessionWakeupSchedule } from './parse'; import { SessionWakeupValidationError, describeSessionWakeupSchedule, - normalizeSessionWakeupSchedule, normalizeSessionWakeupTimeZone, - validateSessionWakeupCaps, } from './schedule'; const DEFAULT_DEPLOYMENT_SETTINGS_ID = 'default'; @@ -40,9 +38,8 @@ export type SessionWakeupActor = { export type CreateSessionWakeupInput = { name: string; prompt: string; - schedule: SessionWakeupScheduleInput; - maxRuns?: number | null; - until?: string | null; + /** One schedule string, e.g. "in 20m", "every 10m x3", "cron 0 9 * * 1-5". */ + schedule: string; reportPolicy?: SessionWakeupReportPolicy | null; }; @@ -112,19 +109,11 @@ export async function createSessionWakeup( if (!prompt) throw new SessionWakeupValidationError('prompt is required.'); const timeZone = await resolveSessionWakeupTimeZone(); - const { schedule, firstRunAt } = normalizeSessionWakeupSchedule( + const { schedule, firstRunAt, maxRuns, until } = parseSessionWakeupSchedule( input.schedule, { now, defaultTimeZone: timeZone }, ); const recurring = isSessionWakeupRecurring(schedule); - const maxRuns = recurring ? (input.maxRuns ?? null) : null; - const until = recurring && input.until ? new Date(input.until) : null; - if (until && Number.isNaN(until.getTime())) { - throw new SessionWakeupValidationError( - 'until must be an ISO 8601 date-time.', - ); - } - validateSessionWakeupCaps({ schedule, firstRunAt, maxRuns, until }); const reportPolicy: SessionWakeupReportPolicy = input.reportPolicy ?? (recurring ? 'only_when_notable' : 'always'); @@ -242,8 +231,6 @@ export async function handleManageWakeupsToolCall( name: input.name, prompt: input.prompt, schedule: input.schedule, - maxRuns: input.maxRuns ?? null, - until: input.until ?? null, reportPolicy: input.reportPolicy ?? null, }); return { diff --git a/packages/types/src/session-wakeups.test.ts b/packages/types/src/session-wakeups.test.ts index 38455666f..b6bd4b954 100644 --- a/packages/types/src/session-wakeups.test.ts +++ b/packages/types/src/session-wakeups.test.ts @@ -9,7 +9,6 @@ import { MANAGE_WAKEUPS_TOOL, fastAgentScheduledWakeupEventSchema, manageWakeupsInputSchema, - sessionWakeupScheduleInputSchema, } from './session-wakeups'; describe('manage wakeups tool contract', () => { @@ -25,53 +24,41 @@ describe('manage wakeups tool contract', () => { MANAGE_WAKEUPS_TOOL.name, ); expect(getFastAgentNativeAcpKind('manage_wakeups')).toBe('task'); - expect(MANAGE_WAKEUPS_TOOL.description).toContain('mode "once"'); + expect(MANAGE_WAKEUPS_TOOL.description).toContain('"in 20m"'); expect(MANAGE_WAKEUPS_TOOL.description).toContain('There is no pause.'); expect(MANAGE_WAKEUPS_TOOL.description).toContain( 'Never poll, sleep, or wait', ); + expect(MANAGE_WAKEUPS_TOOL.inputSchema.schedule.description).toContain( + 'cron 0 9 * * 1-5 America/New_York', + ); }); - it('rejects a schedule that mixes modes', () => { + it('takes the schedule as one string and nothing else schedule-shaped', () => { + expect(Object.keys(MANAGE_WAKEUPS_TOOL.inputSchema).sort()).toEqual([ + 'action', + 'name', + 'prompt', + 'reportPolicy', + 'schedule', + 'wakeupId', + ]); expect( - sessionWakeupScheduleInputSchema.safeParse({ - mode: 'once', - inMinutes: 4, - everyMinutes: 10, - }).success, - ).toBe(false); + manageWakeupsInputSchema.parse({ + action: 'create', + name: 'Check the deploy', + prompt: 'Tell the user to check the deploy.', + schedule: ' in 2m ', + }).schedule, + ).toBe('in 2m'); expect( - sessionWakeupScheduleInputSchema.safeParse({ - mode: 'interval', - everyMinutes: 10, - at: '2026-09-04T15:00:00Z', + manageWakeupsInputSchema.safeParse({ + action: 'create', + schedule: { mode: 'once', inMinutes: 2 }, }).success, ).toBe(false); }); - it('accepts each schedule mode on its own', () => { - expect( - sessionWakeupScheduleInputSchema.parse({ mode: 'once', inMinutes: 4 }), - ).toEqual({ mode: 'once', inMinutes: 4 }); - expect( - sessionWakeupScheduleInputSchema.parse({ - mode: 'interval', - everyMinutes: 15, - }), - ).toEqual({ mode: 'interval', everyMinutes: 15 }); - expect( - sessionWakeupScheduleInputSchema.parse({ - mode: 'cron', - expression: '0 9 * * 1-5', - timezone: 'America/New_York', - }), - ).toEqual({ - mode: 'cron', - expression: '0 9 * * 1-5', - timezone: 'America/New_York', - }); - }); - it('validates the scheduled wakeup platform event', () => { expect( fastAgentScheduledWakeupEventSchema.safeParse({ diff --git a/packages/types/src/session-wakeups.ts b/packages/types/src/session-wakeups.ts index 69a4da055..ba9d7ba16 100644 --- a/packages/types/src/session-wakeups.ts +++ b/packages/types/src/session-wakeups.ts @@ -6,21 +6,27 @@ import { z } from 'zod'; * the same conversation and the agent handles it as a normal turn with the * full conversation still in context. A one-shot wakeup is a reminder; a * recurring wakeup is a monitor. + * + * The tool takes the schedule as one short string ("in 2m", "every 10m x3", + * "cron 0 9 * * 1-5 America/New_York") rather than a structured union. + * Models fill every optional structured field with placeholders, and each + * rejected placeholder costs a retry; a single required string has nothing to + * pad. */ export const SESSION_WAKEUP_NAME_MAX_LENGTH = 80; export const SESSION_WAKEUP_NAME_MIN_LENGTH = 3; export const SESSION_WAKEUP_PROMPT_MAX_LENGTH = 4_000; export const SESSION_WAKEUP_PROMPT_MIN_LENGTH = 10; -export const SESSION_WAKEUP_CRON_MAX_LENGTH = 120; +export const SESSION_WAKEUP_SCHEDULE_MAX_LENGTH = 160; /** Active wakeups one conversation may hold at once. */ export const MAX_ACTIVE_SESSION_WAKEUPS = 10; export const SESSION_WAKEUP_MIN_INTERVAL_MINUTES = 1; /** Seven days. */ export const SESSION_WAKEUP_MAX_INTERVAL_MINUTES = 7 * 24 * 60; /** - * Recurring wakeups tighter than this must carry `maxRuns` or `until`, so a - * tight polling loop cannot run forever. + * Recurring wakeups tighter than this must carry a run count or an end time, + * so a tight polling loop cannot run forever. */ export const SESSION_WAKEUP_UNCAPPED_MIN_INTERVAL_MINUTES = 5; /** Thirty days. A one-shot wakeup may not be scheduled further out. */ @@ -44,89 +50,6 @@ export const SESSION_WAKEUP_REPORT_POLICIES = [ export type SessionWakeupReportPolicy = (typeof SESSION_WAKEUP_REPORT_POLICIES)[number]; -const isoDateTimeSchema = z - .string() - .trim() - .min(1) - .refine((value) => !Number.isNaN(Date.parse(value)), { - message: 'Must be an ISO 8601 date-time.', - }); - -/** - * The schedule as the agent supplies it. `once` accepts either a relative - * delay or an absolute time; the relative form is preferred because it does - * not require the model to do clock arithmetic. - */ -export const sessionWakeupScheduleInputSchema = z.discriminatedUnion('mode', [ - z - .object({ - mode: z - .literal('once') - .describe( - 'Fire one time. Use this for every reminder or delayed follow-up ("in 4 minutes", "at 3pm", "tomorrow morning").', - ), - inMinutes: z - .number() - .int() - .min(1) - .max(SESSION_WAKEUP_MAX_ONCE_HORIZON_MINUTES) - .optional() - .describe( - 'Minutes from now. Preferred over "at" for relative requests. Provide exactly one of inMinutes or at.', - ), - at: isoDateTimeSchema - .optional() - .describe( - 'Absolute ISO 8601 date-time with a UTC offset, for example 2026-09-04T15:00:00-04:00. Provide exactly one of inMinutes or at.', - ), - }) - .strict(), - z - .object({ - mode: z - .literal('interval') - .describe( - 'Fire repeatedly on a fixed interval measured from each run.', - ), - everyMinutes: z - .number() - .int() - .min(SESSION_WAKEUP_MIN_INTERVAL_MINUTES) - .max(SESSION_WAKEUP_MAX_INTERVAL_MINUTES) - .describe( - `Minutes between runs (${SESSION_WAKEUP_MIN_INTERVAL_MINUTES}-${SESSION_WAKEUP_MAX_INTERVAL_MINUTES}). Intervals under ${SESSION_WAKEUP_UNCAPPED_MIN_INTERVAL_MINUTES} minutes require maxRuns or until.`, - ), - }) - .strict(), - z - .object({ - mode: z - .literal('cron') - .describe('Fire repeatedly on a calendar schedule.'), - expression: z - .string() - .trim() - .min(9) - .max(SESSION_WAKEUP_CRON_MAX_LENGTH) - .describe( - 'Standard five-field cron expression (minute hour day-of-month month day-of-week), for example "0 9 * * 1-5" for 9am on weekdays.', - ), - timezone: z - .string() - .trim() - .min(1) - .optional() - .describe( - 'IANA timezone for the cron expression, for example "America/New_York". Defaults to the deployment timezone.', - ), - }) - .strict(), -]); - -export type SessionWakeupScheduleInput = z.infer< - typeof sessionWakeupScheduleInputSchema ->; - /** The normalized schedule persisted with a wakeup. */ export const sessionWakeupScheduleSchema = z.discriminatedUnion('mode', [ z.object({ mode: z.literal('once'), at: z.string() }).strict(), @@ -150,6 +73,12 @@ export function isSessionWakeupRecurring( return schedule.mode !== 'once'; } +export const SESSION_WAKEUP_SCHEDULE_GRAMMAR = `One of: +- "in m|h|d" for a one-shot delay, e.g. "in 2m", "in 90m", "in 3h" (preferred for reminders and delayed follow-ups) +- "at " for a one-shot at an absolute time, e.g. "at 2026-09-04T15:00:00-04:00" +- "every m|h|d" for a repeating interval, e.g. "every 10m", "every 6h"; add "x" to stop after that many runs ("every 1m x3") or "until " to stop after a time ("every 10m until 2026-09-04T18:00:00Z") +- "cron [IANA timezone]" for a calendar schedule, e.g. "cron 0 9 * * 1-5 America/New_York" (timezone defaults to the deployment timezone)`; + export const MANAGE_WAKEUPS_ACTIONS = [ 'create', 'list', @@ -169,7 +98,7 @@ export const manageWakeupsFieldSchemas = { .trim() .min(1) .optional() - .describe('Required for get and cancel.'), + .describe('Required for get and cancel. Omit otherwise.'), name: z .string() .trim() @@ -188,30 +117,18 @@ export const manageWakeupsFieldSchemas = { .describe( '[create] What to do when the wakeup fires. This conversation will still be in context, so keep it short and concrete. Say what to check, what counts as done, and what to tell the user.', ), - schedule: sessionWakeupScheduleInputSchema - .optional() - .describe( - '[create] Exactly one schedule mode. Reminders must use mode "once"; monitors use "interval" or "cron".', - ), - maxRuns: z - .number() - .int() + schedule: z + .string() + .trim() .min(1) - .max(SESSION_WAKEUP_MAX_RUNS_LIMIT) - .optional() - .describe( - '[create] Stop after this many runs. Only for interval or cron schedules.', - ), - until: isoDateTimeSchema + .max(SESSION_WAKEUP_SCHEDULE_MAX_LENGTH) .optional() - .describe( - '[create] Stop after this ISO 8601 date-time. Only for interval or cron schedules.', - ), + .describe(`[create] ${SESSION_WAKEUP_SCHEDULE_GRAMMAR}`), reportPolicy: z .enum(SESSION_WAKEUP_REPORT_POLICIES) .optional() .describe( - '[create] "always" replies to the user on every run (default for once). "only_when_notable" stays silent unless there is news or the condition resolved (default for interval and cron).', + '[create] "always" replies to the user on every run (default for one-shots). "only_when_notable" stays silent unless there is news or the condition resolved (default for repeating schedules). Omit to use the default.', ), } satisfies z.ZodRawShape; @@ -223,14 +140,13 @@ export const MANAGE_WAKEUPS_TOOL_NAME = 'manage_wakeups' as const; export const MANAGE_WAKEUPS_TOOL_DESCRIPTION = `Schedule this conversation to wake itself up later, once or on a cadence. When a wakeup fires, you receive a scheduled_wakeup platform event in this same conversation with the full history still in context, so the prompt can be brief and refer to things discussed here. Use it for reminders ("remind me in 20 minutes", "ping me at 3pm") and for monitors ("check every 10 minutes whether CI is green", "every weekday at 9am summarize open PRs"). -Choose the schedule deliberately: -- One-shot reminders and delayed follow-ups must use schedule.mode "once". Prefer inMinutes for relative times; use at only for an explicit absolute time and include the UTC offset. -- Repeating monitors use mode "interval" or mode "cron". Pick an interval that matches how fast the monitored thing actually changes, not how soon you want an answer. Intervals under ${SESSION_WAKEUP_UNCAPPED_MIN_INTERVAL_MINUTES} minutes need maxRuns or until. +The schedule is one short string. Reminders and delayed follow-ups use "in m" ("in 20m"); use "at " only for an explicit absolute time. Repeating checks use "every m" or "cron ...", optionally with "x" or "until ". Pick an interval that matches how fast the monitored thing actually changes, not how soon you want an answer; intervals under ${SESSION_WAKEUP_UNCAPPED_MIN_INTERVAL_MINUTES} minutes need "x" or "until". + - A monitor keeps running until the user cancels it or the condition definitively resolves. A run that finds nothing new is still useful. When a monitored condition resolves, tell the user and cancel the wakeup. - Results arrive automatically as a new turn in this conversation. Never poll, sleep, or wait for a wakeup inside a turn. - When the user says stop, cancel, remove, delete, or end a wakeup, use cancel. There is no pause. - Creating a wakeup that matches an active one (same prompt and schedule) returns the existing wakeup instead of a duplicate. At most ${MAX_ACTIVE_SESSION_WAKEUPS} wakeups may be active per conversation. -- After creating a wakeup, confirm what will happen and when in one short sentence using the returned nextRunAt.`; +- Only send the fields the action needs; omit the rest. After creating a wakeup, confirm what will happen and when in one short sentence using the returned nextRunAt.`; export const MANAGE_WAKEUPS_TOOL = { name: MANAGE_WAKEUPS_TOOL_NAME, From 887d0eda19ba299ca24f13b18979298cb83b1663 Mon Sep 17 00:00:00 2001 From: Daniel <57051444+daniel-lxs@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:09:51 -0500 Subject: [PATCH 04/11] fix: honour cancellation for already-admitted wakeups and cap high-frequency cron - Delivery of a scheduled_wakeup event now re-reads the row and skips when the wakeup was cancelled or failed after its occurrence was admitted, so cancel and archive keep their guarantee even against an in-flight event. A row that completed at claim time (one-shot or final run) still runs. - Cron schedules are held to the same tight-interval cap as intervals by sampling the gap between upcoming occurrences; "cron * * * * *" now needs "x" or "until ", which the cron grammar accepts alongside an optional timezone. --- .../src/server/session-wakeups/parse.test.ts | 34 ++++++++++- .../src/server/session-wakeups/parse.ts | 15 ++++- .../server/session-wakeups/schedule.test.ts | 2 +- .../src/server/session-wakeups/schedule.ts | 50 +++++++++++++++- .../lib/fast-agent-parent-event.test.ts | 57 +++++++++++++++++++ .../src/server/lib/fast-agent-parent-event.ts | 16 ++++++ packages/types/src/session-wakeups.ts | 2 +- 7 files changed, 167 insertions(+), 9 deletions(-) diff --git a/packages/cloud-agents/src/server/session-wakeups/parse.test.ts b/packages/cloud-agents/src/server/session-wakeups/parse.test.ts index 2f18fdd7f..2d7a7867c 100644 --- a/packages/cloud-agents/src/server/session-wakeups/parse.test.ts +++ b/packages/cloud-agents/src/server/session-wakeups/parse.test.ts @@ -79,9 +79,41 @@ describe('parseSessionWakeupSchedule', () => { }); }); + it('holds high-frequency cron to the same cap as intervals', () => { + expect(() => parseSessionWakeupSchedule('cron * * * * *', options)).toThrow( + /Cron schedules that fire more often/, + ); + expect(() => + parseSessionWakeupSchedule('cron */2 * * * * UTC', options), + ).toThrow(/Cron schedules that fire more often/); + const bounded = parseSessionWakeupSchedule('cron * * * * * x3', options); + expect(bounded.maxRuns).toBe(3); + expect(bounded.schedule).toEqual({ + mode: 'cron', + expression: '* * * * *', + timezone: 'America/New_York', + }); + const withTz = parseSessionWakeupSchedule( + 'cron * * * * * UTC until 2026-09-04T18:00:00Z', + options, + ); + expect(withTz.schedule).toEqual({ + mode: 'cron', + expression: '* * * * *', + timezone: 'UTC', + }); + expect(withTz.until?.toISOString()).toBe('2026-09-04T18:00:00.000Z'); + expect(() => + parseSessionWakeupSchedule('cron */10 * * * *', options), + ).not.toThrow(); + expect(() => + parseSessionWakeupSchedule('cron 0 9 * * 1-5 Europe/Berlin x2', options), + ).not.toThrow(); + }); + it('enforces the tight-interval cap through the string form', () => { expect(() => parseSessionWakeupSchedule('every 1m', options)).toThrow( - /x|until/, + /run count|end time/, ); expect(() => parseSessionWakeupSchedule('every 1m x3', options), diff --git a/packages/cloud-agents/src/server/session-wakeups/parse.ts b/packages/cloud-agents/src/server/session-wakeups/parse.ts index f39074e96..f403d139b 100644 --- a/packages/cloud-agents/src/server/session-wakeups/parse.ts +++ b/packages/cloud-agents/src/server/session-wakeups/parse.ts @@ -41,7 +41,9 @@ const EVERY_RE = new RegExp( `^every\\s+(?:${DURATION}|(minute|hour|day))(?:\\s+(.*))?$`, 'i', ); -const CRON_RE = /^cron\s+(\S+\s+\S+\s+\S+\s+\S+\s+\S+)(?:\s+(\S+))?$/i; +const CRON_RE = /^cron\s+(\S+\s+\S+\s+\S+\s+\S+\s+\S+)(?:\s+(.*))?$/i; +// A timezone token is anything that is not a modifier keyword or number. +const MODIFIER_START_RE = /^(?:x\s*\d*|\d+|for|until)$/i; const COUNT_RE = /^(?:x\s*(\d+)|(\d+)\s*(?:x|times|runs)|for\s+(\d+)\s+(?:runs|times))$/i; const UNTIL_RE = /^until\s+(\S+)$/i; @@ -155,15 +157,22 @@ export function parseSessionWakeupSchedule( const cronMatch = CRON_RE.exec(text); if (cronMatch) { + const trailing = (cronMatch[2] ?? '').trim().split(/\s+/).filter(Boolean); + const timezone = + trailing[0] && !MODIFIER_START_RE.test(trailing[0]) + ? trailing.shift() + : undefined; const normalized = normalizeSessionWakeupSchedule( { mode: 'cron', expression: cronMatch[1]!, - ...(cronMatch[2] ? { timezone: cronMatch[2] } : {}), + ...(timezone ? { timezone } : {}), }, options, ); - return { ...normalized, maxRuns: null, until: null }; + const caps = parseModifiers(text, trailing.join(' ')); + validateSessionWakeupCaps({ ...normalized, ...caps }); + return { ...normalized, ...caps }; } // A bare duration ("2m", "10 minutes") is ambiguous between a delay and a diff --git a/packages/cloud-agents/src/server/session-wakeups/schedule.test.ts b/packages/cloud-agents/src/server/session-wakeups/schedule.test.ts index b40b25550..798798173 100644 --- a/packages/cloud-agents/src/server/session-wakeups/schedule.test.ts +++ b/packages/cloud-agents/src/server/session-wakeups/schedule.test.ts @@ -180,7 +180,7 @@ describe('validateSessionWakeupCaps', () => { maxRuns: null, until: null, }), - ).toThrow(/maxRuns or until/); + ).toThrow(/run count|end time/); expect(() => validateSessionWakeupCaps({ schedule: { mode: 'interval', everyMinutes: 2 }, diff --git a/packages/cloud-agents/src/server/session-wakeups/schedule.ts b/packages/cloud-agents/src/server/session-wakeups/schedule.ts index 013723111..cc55f870f 100644 --- a/packages/cloud-agents/src/server/session-wakeups/schedule.ts +++ b/packages/cloud-agents/src/server/session-wakeups/schedule.ts @@ -205,6 +205,7 @@ export function validateSessionWakeupCaps(params: { until: Date | null; }): void { const { schedule } = params; + const uncapped = params.maxRuns === null && params.until === null; // A once schedule is inherently a single run; a stray maxRuns or until from // the model is ignored rather than rejected. if (schedule.mode === 'once') return; @@ -216,13 +217,56 @@ export function validateSessionWakeupCaps(params: { if ( schedule.mode === 'interval' && schedule.everyMinutes < SESSION_WAKEUP_UNCAPPED_MIN_INTERVAL_MINUTES && - params.maxRuns === null && - params.until === null + uncapped ) { throw new SessionWakeupValidationError( - `Intervals under ${SESSION_WAKEUP_UNCAPPED_MIN_INTERVAL_MINUTES} minutes need maxRuns or until so they cannot run forever.`, + `Intervals under ${SESSION_WAKEUP_UNCAPPED_MIN_INTERVAL_MINUTES} minutes need a run count ("x") or an end time ("until ") so they cannot run forever.`, ); } + // A cron expression can fire as often as every minute; hold it to the same + // bound as an interval by sampling the gap between upcoming occurrences. + if ( + schedule.mode === 'cron' && + uncapped && + estimateCronMinGapMinutes(schedule, params.firstRunAt) < + SESSION_WAKEUP_UNCAPPED_MIN_INTERVAL_MINUTES + ) { + throw new SessionWakeupValidationError( + `Cron schedules that fire more often than every ${SESSION_WAKEUP_UNCAPPED_MIN_INTERVAL_MINUTES} minutes need a run count ("x") or an end time ("until ") so they cannot run forever.`, + ); + } +} + +const CRON_GAP_SAMPLES = 24; + +/** + * The smallest gap, in minutes, between the next `CRON_GAP_SAMPLES` + * occurrences after `from`. A sample is enough to catch every-minute and + * every-few-minutes patterns, which are the ones the cap exists for. + */ +export function estimateCronMinGapMinutes( + schedule: Extract, + from: Date, +): number { + const interval = CronExpressionParser.parse(schedule.expression, { + currentDate: from, + tz: schedule.timezone, + }); + let previous: number | null = null; + let minGap = Number.POSITIVE_INFINITY; + for (let index = 0; index < CRON_GAP_SAMPLES; index += 1) { + let next: number; + try { + next = interval.next().getTime(); + } catch { + break; + } + if (previous !== null) { + minGap = Math.min(minGap, (next - previous) / MINUTE_MS); + } + previous = next; + } + return minGap; } function formatMinutes(minutes: number): string { diff --git a/packages/sdk/src/server/lib/fast-agent-parent-event.test.ts b/packages/sdk/src/server/lib/fast-agent-parent-event.test.ts index 2af0427c8..35ce84fd3 100644 --- a/packages/sdk/src/server/lib/fast-agent-parent-event.test.ts +++ b/packages/sdk/src/server/lib/fast-agent-parent-event.test.ts @@ -17,6 +17,7 @@ const mocks = vi.hoisted(() => ({ findCustomAutomation: vi.fn(), findArtifacts: vi.fn(), findTaskRun: vi.fn(), + findWakeup: vi.fn(), findTaskRuns: vi.fn(), getConversationLookupIds: vi.fn(), findTaskPullRequests: vi.fn(), @@ -138,6 +139,7 @@ vi.mock('@roomote/db/server', () => ({ eq: vi.fn((...args: unknown[]) => args), inArray: vi.fn((...args: unknown[]) => args), getCustomAutomationById: mocks.findCustomAutomation, + getSessionWakeupById: mocks.findWakeup, slackInstallations: { isActive: 'slack_installations.is_active', teamId: 'slack_installations.team_id', @@ -2735,6 +2737,61 @@ describe('deliverFastAgentParentEvent', () => { expect(mocks.releaseTurnLock).toHaveBeenCalledOnce(); }); + it('skips a scheduled wakeup that was cancelled after its occurrence was admitted', async () => { + mocks.findWakeup.mockResolvedValueOnce({ status: 'cancelled' }); + const result = await deliverFastAgentParentEvent({ + parent, + event: { + type: 'scheduled_wakeup', + eventId: 'wakeup-1:1', + wakeupId: 'wakeup-1', + name: 'Check the deploy', + prompt: 'Tell the user to check the deploy.', + runNumber: 1, + maxRuns: null, + firedAt: '2026-09-04T17:10:00.000Z', + nextRunAt: null, + reportPolicy: 'always', + createdByUserId: 'user-1', + }, + }); + + expect(result).toBe('skipped'); + expect(mocks.answerQuestion).not.toHaveBeenCalled(); + expect(mocks.releaseTurnLock).toHaveBeenCalledOnce(); + }); + + it('still runs a scheduled wakeup whose one-shot row completed at claim time', async () => { + mocks.findWakeup.mockResolvedValueOnce({ status: 'completed' }); + const result = await deliverFastAgentParentEvent({ + parent, + event: { + type: 'scheduled_wakeup', + eventId: 'wakeup-1:1', + wakeupId: 'wakeup-1', + name: 'Check the deploy', + prompt: 'Tell the user to check the deploy.', + runNumber: 1, + maxRuns: null, + firedAt: '2026-09-04T17:10:00.000Z', + nextRunAt: null, + reportPolicy: 'always', + createdByUserId: 'user-1', + }, + }); + + expect(result).toBe('delivered'); + expect(mocks.answerQuestion).toHaveBeenCalledOnce(); + expect(mocks.answerQuestion).toHaveBeenCalledWith( + expect.objectContaining({ + turnSource: 'platform_event', + platformEventKind: 'scheduled_wakeup', + platformEventVisibility: 'required', + userId: 'user-1', + }), + ); + }); + it('answers a pull request mention routed into a Slack Session on both the thread and the pull request', async () => { mocks.buildSourceControlFastDelivery.mockResolvedValue({ postComment: async (input: unknown) => { 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 81083174d..f556c1898 100644 --- a/packages/sdk/src/server/lib/fast-agent-parent-event.ts +++ b/packages/sdk/src/server/lib/fast-agent-parent-event.ts @@ -22,6 +22,7 @@ import { customAutomations, eq, getCustomAutomationById, + getSessionWakeupById, inArray, slackInstallations, taskArtifacts, @@ -2188,6 +2189,21 @@ export async function deliverFastAgentParentEventWithLock( return 'skipped'; } } + // A wakeup can be cancelled (or its Session archived) after its + // occurrence was admitted here but before this turn runs. The row is + // authoritative: a cancelled or failed wakeup must not speak. A row that + // is already `completed` is fine, because the claim that completes a + // one-shot or final run happens before delivery. + if (params.event.type === 'scheduled_wakeup') { + const wakeup = await getSessionWakeupById(params.event.wakeupId); + if ( + !wakeup || + wakeup.status === 'cancelled' || + wakeup.status === 'failed' + ) { + return 'skipped'; + } + } const humanFollowUp = params.event.type === 'human_follow_up' ? params.event : null; diff --git a/packages/types/src/session-wakeups.ts b/packages/types/src/session-wakeups.ts index ba9d7ba16..e216ad6ef 100644 --- a/packages/types/src/session-wakeups.ts +++ b/packages/types/src/session-wakeups.ts @@ -77,7 +77,7 @@ export const SESSION_WAKEUP_SCHEDULE_GRAMMAR = `One of: - "in m|h|d" for a one-shot delay, e.g. "in 2m", "in 90m", "in 3h" (preferred for reminders and delayed follow-ups) - "at " for a one-shot at an absolute time, e.g. "at 2026-09-04T15:00:00-04:00" - "every m|h|d" for a repeating interval, e.g. "every 10m", "every 6h"; add "x" to stop after that many runs ("every 1m x3") or "until " to stop after a time ("every 10m until 2026-09-04T18:00:00Z") -- "cron [IANA timezone]" for a calendar schedule, e.g. "cron 0 9 * * 1-5 America/New_York" (timezone defaults to the deployment timezone)`; +- "cron [IANA timezone]" for a calendar schedule, e.g. "cron 0 9 * * 1-5 America/New_York" (timezone defaults to the deployment timezone); "x" and "until " work here too, and a cron that fires more often than every 5 minutes requires one of them`; export const MANAGE_WAKEUPS_ACTIONS = [ 'create', From 5ed9662fe5d98dece388e80915355c4d07d783cc Mon Sep 17 00:00:00 2001 From: Daniel <57051444+daniel-lxs@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:15:04 -0500 Subject: [PATCH 05/11] fix: keep the wakeup schedule parser linear and the cron gap helper private The parser already collapses whitespace and the contract caps the string length, so the patterns use literal single spaces instead of \s+ runs that CodeQL flagged as polynomial. estimateCronMinGapMinutes is only used inside the schedule module. --- .../src/server/session-wakeups/parse.ts | 23 +++++++++++-------- .../src/server/session-wakeups/schedule.ts | 2 +- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/packages/cloud-agents/src/server/session-wakeups/parse.ts b/packages/cloud-agents/src/server/session-wakeups/parse.ts index f403d139b..0ac578aea 100644 --- a/packages/cloud-agents/src/server/session-wakeups/parse.ts +++ b/packages/cloud-agents/src/server/session-wakeups/parse.ts @@ -33,20 +33,23 @@ const UNIT_MINUTES: Record = { days: 24 * 60, }; -const DURATION = String.raw`(\d+)\s*(m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days)`; +// Every pattern below runs on text whose whitespace has already been +// collapsed to single spaces and whose length is capped by the contract, so +// the patterns use literal single spaces and stay linear. +const DURATION = String.raw`(\d+) ?(m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days)`; const DURATION_RE = new RegExp(`^${DURATION}$`, 'i'); -const IN_RE = new RegExp(`^(?:once\\s+)?in\\s+${DURATION}$`, 'i'); -const AT_RE = /^(?:once\s+)?at\s+(\S+)$/i; +const IN_RE = new RegExp(`^(?:once )?in ${DURATION}$`, 'i'); +const AT_RE = /^(?:once )?at (\S+)$/i; const EVERY_RE = new RegExp( - `^every\\s+(?:${DURATION}|(minute|hour|day))(?:\\s+(.*))?$`, + `^every (?:${DURATION}|(minute|hour|day))(?: (.*))?$`, 'i', ); -const CRON_RE = /^cron\s+(\S+\s+\S+\s+\S+\s+\S+\s+\S+)(?:\s+(.*))?$/i; +const CRON_RE = /^cron ((?:\S+ ){4}\S+)(?: (.*))?$/i; // A timezone token is anything that is not a modifier keyword or number. -const MODIFIER_START_RE = /^(?:x\s*\d*|\d+|for|until)$/i; +const MODIFIER_START_RE = /^(?:x ?\d*|\d+|for|until)$/i; const COUNT_RE = - /^(?:x\s*(\d+)|(\d+)\s*(?:x|times|runs)|for\s+(\d+)\s+(?:runs|times))$/i; -const UNTIL_RE = /^until\s+(\S+)$/i; + /^(?:x ?(\d+)|(\d+) ?(?:x|times|runs)|for (\d+) (?:runs|times))$/i; +const UNTIL_RE = /^until (\S+)$/i; function invalid(text: string, detail?: string): SessionWakeupValidationError { return new SessionWakeupValidationError( @@ -73,7 +76,7 @@ function parseModifiers( ): { maxRuns: number | null; until: Date | null } { let maxRuns: number | null = null; let until: Date | null = null; - const tokens = (rest ?? '').trim().split(/\s+/).filter(Boolean); + const tokens = (rest ?? '').trim().split(' ').filter(Boolean); let index = 0; while (index < tokens.length) { const one = tokens[index]!; @@ -157,7 +160,7 @@ export function parseSessionWakeupSchedule( const cronMatch = CRON_RE.exec(text); if (cronMatch) { - const trailing = (cronMatch[2] ?? '').trim().split(/\s+/).filter(Boolean); + const trailing = (cronMatch[2] ?? '').trim().split(' ').filter(Boolean); const timezone = trailing[0] && !MODIFIER_START_RE.test(trailing[0]) ? trailing.shift() diff --git a/packages/cloud-agents/src/server/session-wakeups/schedule.ts b/packages/cloud-agents/src/server/session-wakeups/schedule.ts index cc55f870f..1e94a1e26 100644 --- a/packages/cloud-agents/src/server/session-wakeups/schedule.ts +++ b/packages/cloud-agents/src/server/session-wakeups/schedule.ts @@ -244,7 +244,7 @@ const CRON_GAP_SAMPLES = 24; * occurrences after `from`. A sample is enough to catch every-minute and * every-few-minutes patterns, which are the ones the cap exists for. */ -export function estimateCronMinGapMinutes( +function estimateCronMinGapMinutes( schedule: Extract, from: Date, ): number { From 95578548e3f3e3b97c722b80933217454c0c1b3e Mon Sep 17 00:00:00 2001 From: Daniel <57051444+daniel-lxs@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:28:15 -0500 Subject: [PATCH 06/11] fix: never deliver a wakeup into an archived Session Archiving cancels a Session's wakeups, but that cancellation is best-effort after the archive itself. Delivery now also checks the Session and skips a scheduled_wakeup whose Session is archived, so a failed cancellation cannot make an archived Session speak. --- .../lib/fast-agent-parent-event.test.ts | 29 +++++++++++++++++++ .../src/server/lib/fast-agent-parent-event.ts | 11 +++++-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/packages/sdk/src/server/lib/fast-agent-parent-event.test.ts b/packages/sdk/src/server/lib/fast-agent-parent-event.test.ts index 35ce84fd3..57f490d41 100644 --- a/packages/sdk/src/server/lib/fast-agent-parent-event.test.ts +++ b/packages/sdk/src/server/lib/fast-agent-parent-event.test.ts @@ -18,6 +18,7 @@ const mocks = vi.hoisted(() => ({ findArtifacts: vi.fn(), findTaskRun: vi.fn(), findWakeup: vi.fn(), + findWakeupSession: vi.fn(), findTaskRuns: vi.fn(), getConversationLookupIds: vi.fn(), findTaskPullRequests: vi.fn(), @@ -140,6 +141,7 @@ vi.mock('@roomote/db/server', () => ({ inArray: vi.fn((...args: unknown[]) => args), getCustomAutomationById: mocks.findCustomAutomation, getSessionWakeupById: mocks.findWakeup, + getSessionForFastConversation: mocks.findWakeupSession, slackInstallations: { isActive: 'slack_installations.is_active', teamId: 'slack_installations.team_id', @@ -2761,8 +2763,35 @@ describe('deliverFastAgentParentEvent', () => { expect(mocks.releaseTurnLock).toHaveBeenCalledOnce(); }); + it('skips a scheduled wakeup whose Session was archived even if the row is still active', async () => { + mocks.findWakeup.mockResolvedValueOnce({ status: 'active' }); + mocks.findWakeupSession.mockResolvedValueOnce({ + archivedAt: new Date('2026-09-04T17:05:00.000Z'), + }); + const result = await deliverFastAgentParentEvent({ + parent, + event: { + type: 'scheduled_wakeup', + eventId: 'wakeup-1:1', + wakeupId: 'wakeup-1', + name: 'Check the deploy', + prompt: 'Tell the user to check the deploy.', + runNumber: 1, + maxRuns: null, + firedAt: '2026-09-04T17:10:00.000Z', + nextRunAt: null, + reportPolicy: 'always', + createdByUserId: 'user-1', + }, + }); + + expect(result).toBe('skipped'); + expect(mocks.answerQuestion).not.toHaveBeenCalled(); + }); + it('still runs a scheduled wakeup whose one-shot row completed at claim time', async () => { mocks.findWakeup.mockResolvedValueOnce({ status: 'completed' }); + mocks.findWakeupSession.mockResolvedValueOnce({ archivedAt: null }); const result = await deliverFastAgentParentEvent({ parent, event: { 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 f556c1898..ff0215f55 100644 --- a/packages/sdk/src/server/lib/fast-agent-parent-event.ts +++ b/packages/sdk/src/server/lib/fast-agent-parent-event.ts @@ -22,6 +22,7 @@ import { customAutomations, eq, getCustomAutomationById, + getSessionForFastConversation, getSessionWakeupById, inArray, slackInstallations, @@ -2195,11 +2196,17 @@ export async function deliverFastAgentParentEventWithLock( // is already `completed` is fine, because the claim that completes a // one-shot or final run happens before delivery. if (params.event.type === 'scheduled_wakeup') { - const wakeup = await getSessionWakeupById(params.event.wakeupId); + const [wakeup, session] = await Promise.all([ + getSessionWakeupById(params.event.wakeupId), + getSessionForFastConversation(db, params.parent.sessionId), + ]); if ( !wakeup || wakeup.status === 'cancelled' || - wakeup.status === 'failed' + wakeup.status === 'failed' || + // Archiving cancels wakeups, but if that cancellation failed the + // archived Session must still stay quiet. + session?.archivedAt ) { return 'skipped'; } From 8ea587baad8cf93d0eb6e16de17a093b9846e435 Mon Sep 17 00:00:00 2001 From: Daniel <57051444+daniel-lxs@users.noreply.github.com> Date: Sat, 5 Sep 2026 03:24:50 -0500 Subject: [PATCH 07/11] fix: revalidate a wakeup right before it replies A cancel or archive that lands while the wake turn is generating must still win. The wakeup turn's postReply is now guarded: it re-checks the wakeup row and the Session immediately before posting, drops the reply if either was superseded, and aborts the turn's signal so no further tool calls run. The next drain of the event settles it as skipped. --- .../lib/fast-agent-parent-event.test.ts | 47 +++++++ .../src/server/lib/fast-agent-parent-event.ts | 118 +++++++++++++++--- 2 files changed, 149 insertions(+), 16 deletions(-) diff --git a/packages/sdk/src/server/lib/fast-agent-parent-event.test.ts b/packages/sdk/src/server/lib/fast-agent-parent-event.test.ts index 57f490d41..12003c85f 100644 --- a/packages/sdk/src/server/lib/fast-agent-parent-event.test.ts +++ b/packages/sdk/src/server/lib/fast-agent-parent-event.test.ts @@ -2789,6 +2789,53 @@ describe('deliverFastAgentParentEvent', () => { expect(mocks.answerQuestion).not.toHaveBeenCalled(); }); + it('drops the reply and cancels the turn when the wakeup is cancelled mid-turn', async () => { + // Deliverable at the start of the turn, cancelled by the time the model + // wants to post. + mocks.findWakeup + .mockResolvedValueOnce({ status: 'active' }) + .mockResolvedValueOnce({ status: 'cancelled' }); + mocks.findWakeupSession.mockResolvedValue({ archivedAt: null }); + let signalAbortedAfterPost: boolean | undefined; + mocks.answerQuestion.mockImplementationOnce( + async ({ + adapter, + signal, + }: { + adapter: { postReply: (reply: unknown) => Promise }; + signal: AbortSignal; + }) => { + expect(signal.aborted).toBe(false); + await adapter.postReply({ + purpose: 'closeout', + message: 'Time to check the deploy.', + }); + signalAbortedAfterPost = signal.aborted; + return 'Time to check the deploy.'; + }, + ); + + await deliverFastAgentParentEvent({ + parent, + event: { + type: 'scheduled_wakeup', + eventId: 'wakeup-1:1', + wakeupId: 'wakeup-1', + name: 'Check the deploy', + prompt: 'Tell the user to check the deploy.', + runNumber: 1, + maxRuns: null, + firedAt: '2026-09-04T17:10:00.000Z', + nextRunAt: null, + reportPolicy: 'always', + createdByUserId: 'user-1', + }, + }); + + expect(mocks.postMessage).not.toHaveBeenCalled(); + expect(signalAbortedAfterPost).toBe(true); + }); + it('still runs a scheduled wakeup whose one-shot row completed at claim time', async () => { mocks.findWakeup.mockResolvedValueOnce({ status: 'completed' }); mocks.findWakeupSession.mockResolvedValueOnce({ archivedAt: null }); 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 ff0215f55..fef0e0e5b 100644 --- a/packages/sdk/src/server/lib/fast-agent-parent-event.ts +++ b/packages/sdk/src/server/lib/fast-agent-parent-event.ts @@ -2173,12 +2173,98 @@ export async function deliverFastAgentParentEvent( * conversation. The caller owns lock release and may invoke this repeatedly * to preserve durable queue order without letting another turn interleave. */ +/** + * Whether a scheduled wakeup may still speak. The row is authoritative: a + * cancelled or failed wakeup must not run, and an archived Session must stay + * quiet even if its cancel-on-archive step failed. A row that is already + * `completed` is fine, because the claim that completes a one-shot or final + * run happens before delivery. + */ +async function isScheduledWakeupDeliverable(params: { + wakeupId: string; + conversationId: string; +}): Promise { + const [wakeup, session] = await Promise.all([ + getSessionWakeupById(params.wakeupId), + getSessionForFastConversation(db, params.conversationId), + ]); + return Boolean( + wakeup && + wakeup.status !== 'cancelled' && + wakeup.status !== 'failed' && + !session?.archivedAt, + ); +} + +type ScheduledWakeupReplyGuard = { + signal: AbortSignal; + guardPostReply: ( + postReply: FastAgentTurnAdapter['postReply'], + ) => FastAgentTurnAdapter['postReply']; +}; + +/** + * Wrap a wakeup turn's reply path so the wakeup is re-checked immediately + * before anything user-visible goes out. If it was cancelled or its Session + * archived while the model was working, the post is dropped and the turn's + * signal is aborted so no further tool calls run. The next drain of the + * event sees the same state and settles it as skipped. + */ +function createScheduledWakeupReplyGuard(params: { + wakeupId: string; + conversationId: string; + upstream: AbortSignal; +}): ScheduledWakeupReplyGuard { + const controller = new AbortController(); + const abortFromUpstream = () => controller.abort(params.upstream.reason); + if (params.upstream.aborted) { + abortFromUpstream(); + } else { + params.upstream.addEventListener('abort', abortFromUpstream, { + once: true, + }); + } + return { + signal: controller.signal, + guardPostReply: (postReply) => async (reply) => { + if ( + !controller.signal.aborted && + (await isScheduledWakeupDeliverable(params)) + ) { + return postReply(reply); + } + if (!controller.signal.aborted) { + console.warn( + `[SessionWakeups] Dropped a reply for wakeup ${params.wakeupId}: it was cancelled or its Session archived while the turn was running.`, + ); + controller.abort( + new Error( + 'Scheduled wakeup was cancelled or its Session archived while the turn was running.', + ), + ); + } + return undefined; + }, + }; +} + export async function deliverFastAgentParentEventWithLock( params: FastAgentParentEventDeliveryParams, turnLock: FastAgentTurnLockHandle, ): Promise<'delivered' | 'skipped'> { let replyPosted = false; - const turnSignal = turnLock.signal; + // A wakeup turn revalidates at reply time as well as at start: a cancel or + // archive that lands while the model is generating must still win, so the + // guard suppresses the post and cancels the rest of the turn. + const wakeupGuard = + params.event.type === 'scheduled_wakeup' + ? createScheduledWakeupReplyGuard({ + wakeupId: params.event.wakeupId, + conversationId: params.parent.sessionId, + upstream: turnLock.signal, + }) + : null; + const turnSignal = wakeupGuard?.signal ?? turnLock.signal; try { if (params.event.type === 'pull_request_opened') { @@ -2195,21 +2281,14 @@ export async function deliverFastAgentParentEventWithLock( // authoritative: a cancelled or failed wakeup must not speak. A row that // is already `completed` is fine, because the claim that completes a // one-shot or final run happens before delivery. - if (params.event.type === 'scheduled_wakeup') { - const [wakeup, session] = await Promise.all([ - getSessionWakeupById(params.event.wakeupId), - getSessionForFastConversation(db, params.parent.sessionId), - ]); - if ( - !wakeup || - wakeup.status === 'cancelled' || - wakeup.status === 'failed' || - // Archiving cancels wakeups, but if that cancellation failed the - // archived Session must still stay quiet. - session?.archivedAt - ) { - return 'skipped'; - } + if ( + params.event.type === 'scheduled_wakeup' && + !(await isScheduledWakeupDeliverable({ + wakeupId: params.event.wakeupId, + conversationId: params.parent.sessionId, + })) + ) { + return 'skipped'; } const humanFollowUp = @@ -2344,6 +2423,13 @@ export async function deliverFastAgentParentEventWithLock( createArtifact: buildFastAgentArtifactCreator(params.parent.sessionId), ...parentTurn.adapter, launchTask: parentTurn.adapter.launchTask, + ...(wakeupGuard + ? { + postReply: wakeupGuard.guardPostReply( + parentTurn.adapter.postReply, + ), + } + : {}), resolveMcpServerConfigs: () => resolveUserMcpServerConfigs({ userId: parentTurn.userId, From 5431cd533df5aca3557e8deb69050c516571ef7a Mon Sep 17 00:00:00 2001 From: "@daniel-lxs" <57051444+daniel-lxs@users.noreply.github.com> Date: Sat, 5 Sep 2026 14:47:46 +0000 Subject: [PATCH 08/11] fix: serialize session wakeup creation and enforce the active cap --- .../src/server/session-wakeups/service.ts | 59 +++++------------ .../src/lib/__tests__/session-wakeups.test.ts | 65 ++++++++++++++++++- packages/db/src/lib/session-wakeups.ts | 39 ++++++++++- 3 files changed, 120 insertions(+), 43 deletions(-) diff --git a/packages/cloud-agents/src/server/session-wakeups/service.ts b/packages/cloud-agents/src/server/session-wakeups/service.ts index 9b2b6cdb2..43bfef638 100644 --- a/packages/cloud-agents/src/server/session-wakeups/service.ts +++ b/packages/cloud-agents/src/server/session-wakeups/service.ts @@ -1,13 +1,10 @@ import { - buildSessionWakeupPromptSignature, + admitSessionWakeup, cancelSessionWakeup, - countActiveSessionWakeups, db, deploymentSettings, eq, getSessionWakeupById, - insertSessionWakeup, - listActiveSessionWakeups, listSessionWakeups, type SessionWakeup, } from '@roomote/db/server'; @@ -90,13 +87,6 @@ export function toSessionWakeupSummary( }; } -function schedulesMatch( - left: SessionWakeup['schedule'], - right: SessionWakeup['schedule'], -): boolean { - return JSON.stringify(left) === JSON.stringify(right); -} - export async function createSessionWakeup( actor: SessionWakeupActor, input: CreateSessionWakeupInput, @@ -117,31 +107,7 @@ export async function createSessionWakeup( const reportPolicy: SessionWakeupReportPolicy = input.reportPolicy ?? (recurring ? 'only_when_notable' : 'always'); - // Reuse an equivalent active wakeup instead of stacking duplicates; a model - // that retries a create call must not double-schedule. - const promptSignature = buildSessionWakeupPromptSignature(prompt); - const active = await listActiveSessionWakeups(actor.conversationId); - const existing = active.find( - (row) => - row.promptSignature === promptSignature && - schedulesMatch(row.schedule, schedule), - ); - if (existing) { - return { - wakeup: toSessionWakeupSummary(existing), - duplicate: true, - timeZone, - }; - } - - const activeCount = await countActiveSessionWakeups(actor.conversationId); - if (activeCount >= MAX_ACTIVE_SESSION_WAKEUPS) { - throw new SessionWakeupValidationError( - `This conversation already has ${MAX_ACTIVE_SESSION_WAKEUPS} active wakeups. Cancel one before creating another.`, - ); - } - - const row = await insertSessionWakeup({ + const result = await admitSessionWakeup({ conversationId: actor.conversationId, createdByUserId: actor.userId, name, @@ -152,12 +118,23 @@ export async function createSessionWakeup( until, nextRunAt: firstRunAt, }); - enqueueSessionWakeupFireBestEffort({ - wakeupId: row.id, - runAt: firstRunAt.getTime(), - }); + if (result.outcome === 'cap_reached') { + throw new SessionWakeupValidationError( + `This conversation already has ${MAX_ACTIVE_SESSION_WAKEUPS} active wakeups. Cancel one before creating another.`, + ); + } + if (result.outcome === 'created') { + enqueueSessionWakeupFireBestEffort({ + wakeupId: result.wakeup.id, + runAt: firstRunAt.getTime(), + }); + } - return { wakeup: toSessionWakeupSummary(row), duplicate: false, timeZone }; + return { + wakeup: toSessionWakeupSummary(result.wakeup), + duplicate: result.outcome === 'duplicate', + timeZone, + }; } export async function listSessionWakeupsForConversation( diff --git a/packages/db/src/lib/__tests__/session-wakeups.test.ts b/packages/db/src/lib/__tests__/session-wakeups.test.ts index 667ba7696..d35ea38af 100644 --- a/packages/db/src/lib/__tests__/session-wakeups.test.ts +++ b/packages/db/src/lib/__tests__/session-wakeups.test.ts @@ -2,9 +2,13 @@ // next_run_at is load-bearing: it is what keeps a duplicate delayed job, or // two workers holding the same job, from firing one occurrence twice. -import { SESSION_WAKEUP_MAX_CONSECUTIVE_FAILURES } from '@roomote/types'; +import { + MAX_ACTIVE_SESSION_WAKEUPS, + SESSION_WAKEUP_MAX_CONSECUTIVE_FAILURES, +} from '@roomote/types'; import { + admitSessionWakeup, cancelSessionWakeup, cancelSessionWakeupsForConversation, claimSessionWakeupFire, @@ -60,6 +64,65 @@ afterEach(async () => { }); describe('session wakeup helpers', () => { + it('deduplicates concurrent recurring creates in one conversation', async () => { + const { user, conversation } = await makeConversation(); + const results = await Promise.all( + Array.from({ length: 12 }, (_, index) => + admitSessionWakeup({ + conversationId: conversation.id, + createdByUserId: user.id, + name: `Check ${index}`, + prompt: index % 2 ? ' CHECK PR ' : 'check pr', + schedule: { mode: 'interval', everyMinutes: 10 }, + reportPolicy: 'only_when_notable', + maxRuns: null, + until: null, + nextRunAt: new Date(firstRunAt.getTime() + index), + }), + ), + ); + + expect(results.filter((r) => r.outcome === 'created')).toHaveLength(1); + expect(results.filter((r) => r.outcome === 'duplicate')).toHaveLength(11); + const rows = await listSessionWakeups(conversation.id); + expect(rows).toHaveLength(1); + for (const result of results) { + expect(result).toMatchObject({ wakeup: { id: rows[0]!.id } }); + } + }); + + it('caps concurrent distinct creates at ten and still admits duplicates', async () => { + const { user, conversation } = await makeConversation(); + const input = { + conversationId: conversation.id, + createdByUserId: user.id, + name: 'Check PR', + prompt: 'check pr', + schedule: { mode: 'interval' as const, everyMinutes: 10 }, + reportPolicy: 'only_when_notable' as const, + maxRuns: null, + until: null, + nextRunAt: firstRunAt, + }; + const results = await Promise.all( + Array.from({ length: MAX_ACTIVE_SESSION_WAKEUPS + 5 }, (_, index) => + admitSessionWakeup({ ...input, prompt: `check pr ${index}` }), + ), + ); + + expect(results.filter((r) => r.outcome === 'created')).toHaveLength( + MAX_ACTIVE_SESSION_WAKEUPS, + ); + expect(results.filter((r) => r.outcome === 'cap_reached')).toHaveLength(5); + expect(await countActiveSessionWakeups(conversation.id)).toBe( + MAX_ACTIVE_SESSION_WAKEUPS, + ); + const rows = await listSessionWakeups(conversation.id); + expect( + await admitSessionWakeup({ ...input, prompt: rows[0]!.prompt }), + ).toMatchObject({ outcome: 'duplicate', wakeup: { id: rows[0]!.id } }); + }); + it('claims an occurrence exactly once and advances the row', async () => { const { user, conversation } = await makeConversation(); const row = await makeWakeup(conversation.id, user.id); diff --git a/packages/db/src/lib/session-wakeups.ts b/packages/db/src/lib/session-wakeups.ts index 04db0099d..f3a960323 100644 --- a/packages/db/src/lib/session-wakeups.ts +++ b/packages/db/src/lib/session-wakeups.ts @@ -1,14 +1,16 @@ import { and, asc, count, desc, eq, lte, sql } from 'drizzle-orm'; import { + MAX_ACTIVE_SESSION_WAKEUPS, SESSION_WAKEUP_MAX_CONSECUTIVE_FAILURES, type SessionWakeupReportPolicy, type SessionWakeupSchedule, } from '@roomote/types'; import { type DatabaseOrTransaction, db } from '../db'; -import { sessionWakeups } from '../schema'; +import { fastAgentConversations, sessionWakeups } from '../schema'; import type { SessionWakeup } from '../types'; +import { runInTransactionIfAvailable } from './transaction-utils'; /** * Collapse whitespace and case so two prompts that read the same dedupe to @@ -100,6 +102,41 @@ export type InsertSessionWakeupInput = { nextRunAt: Date; }; +export type AdmitSessionWakeupResult = + | { outcome: 'created' | 'duplicate'; wakeup: SessionWakeup } + | { outcome: 'cap_reached' }; + +/** Serialize create admission even when tools run concurrently within a turn. */ +export async function admitSessionWakeup( + input: InsertSessionWakeupInput, + database: DatabaseOrTransaction = db, +): Promise { + return runInTransactionIfAvailable(database, async (tx) => { + const [conversation] = await tx + .select({ id: fastAgentConversations.id }) + .from(fastAgentConversations) + .where(eq(fastAgentConversations.id, input.conversationId)) + .for('update'); + if (!conversation) { + throw new Error(`Conversation ${input.conversationId} does not exist.`); + } + + const promptSignature = buildSessionWakeupPromptSignature(input.prompt); + const active = await listActiveSessionWakeups(input.conversationId, tx); + const existing = active.find( + (row) => + row.promptSignature === promptSignature && + JSON.stringify(row.schedule) === JSON.stringify(input.schedule), + ); + if (existing) return { outcome: 'duplicate', wakeup: existing }; + if (active.length >= MAX_ACTIVE_SESSION_WAKEUPS) { + return { outcome: 'cap_reached' }; + } + + return { outcome: 'created', wakeup: await insertSessionWakeup(input, tx) }; + }); +} + export async function insertSessionWakeup( input: InsertSessionWakeupInput, tx: DatabaseOrTransaction = db, From e2fea3ca774860dccfe26483051fe1e3d4f5909a Mon Sep 17 00:00:00 2001 From: "@daniel-lxs" <57051444+daniel-lxs@users.noreply.github.com> Date: Sat, 5 Sep 2026 14:54:33 +0000 Subject: [PATCH 09/11] fix: deduplicate wakeup schedules independent of JSONB key order --- .../src/lib/__tests__/session-wakeups.test.ts | 35 +++++++++++++++++++ packages/db/src/lib/session-wakeups.ts | 4 ++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/db/src/lib/__tests__/session-wakeups.test.ts b/packages/db/src/lib/__tests__/session-wakeups.test.ts index d35ea38af..805055301 100644 --- a/packages/db/src/lib/__tests__/session-wakeups.test.ts +++ b/packages/db/src/lib/__tests__/session-wakeups.test.ts @@ -64,6 +64,41 @@ afterEach(async () => { }); describe('session wakeup helpers', () => { + it.each([ + { mode: 'once' as const, at: firstRunAt.toISOString() }, + { mode: 'cron' as const, expression: '*/10 * * * *', timezone: 'UTC' }, + ])( + 'deduplicates $mode schedules after JSONB reorders keys', + async (schedule) => { + const { user, conversation } = await makeConversation(); + const input = { + conversationId: conversation.id, + createdByUserId: user.id, + name: 'Check PR', + prompt: 'check pr', + schedule, + reportPolicy: 'only_when_notable' as const, + maxRuns: null, + until: null, + nextRunAt: firstRunAt, + }; + expect(await admitSessionWakeup(input)).toMatchObject({ + outcome: 'created', + }); + const [persisted] = await listSessionWakeups(conversation.id); + expect(persisted!.schedule).toEqual(schedule); + expect(Object.keys(persisted!.schedule)).not.toEqual( + Object.keys(schedule), + ); + + expect(await admitSessionWakeup(input)).toMatchObject({ + outcome: 'duplicate', + wakeup: { id: persisted!.id }, + }); + expect(await listSessionWakeups(conversation.id)).toHaveLength(1); + }, + ); + it('deduplicates concurrent recurring creates in one conversation', async () => { const { user, conversation } = await makeConversation(); const results = await Promise.all( diff --git a/packages/db/src/lib/session-wakeups.ts b/packages/db/src/lib/session-wakeups.ts index f3a960323..15b0c2111 100644 --- a/packages/db/src/lib/session-wakeups.ts +++ b/packages/db/src/lib/session-wakeups.ts @@ -1,3 +1,5 @@ +import { isDeepStrictEqual } from 'node:util'; + import { and, asc, count, desc, eq, lte, sql } from 'drizzle-orm'; import { @@ -126,7 +128,7 @@ export async function admitSessionWakeup( const existing = active.find( (row) => row.promptSignature === promptSignature && - JSON.stringify(row.schedule) === JSON.stringify(input.schedule), + isDeepStrictEqual(row.schedule, input.schedule), ); if (existing) return { outcome: 'duplicate', wakeup: existing }; if (active.length >= MAX_ACTIVE_SESSION_WAKEUPS) { From d3ad6c2af1532b0313688b33d68a6835356a89ba Mon Sep 17 00:00:00 2001 From: "@daniel-lxs" <57051444+daniel-lxs@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:04:47 +0000 Subject: [PATCH 10/11] fix: preserve relative reminder identity across create retries --- .../src/server/session-wakeups/parse.test.ts | 23 ++++++ .../server/session-wakeups/schedule.test.ts | 1 + .../src/server/session-wakeups/schedule.ts | 6 +- .../src/lib/__tests__/session-wakeups.test.ts | 71 +++++++++++++++++++ packages/db/src/lib/session-wakeups.ts | 7 +- packages/types/src/session-wakeups.test.ts | 14 ++++ packages/types/src/session-wakeups.ts | 14 +++- 7 files changed, 133 insertions(+), 3 deletions(-) diff --git a/packages/cloud-agents/src/server/session-wakeups/parse.test.ts b/packages/cloud-agents/src/server/session-wakeups/parse.test.ts index 2d7a7867c..149ed07fd 100644 --- a/packages/cloud-agents/src/server/session-wakeups/parse.test.ts +++ b/packages/cloud-agents/src/server/session-wakeups/parse.test.ts @@ -13,6 +13,7 @@ describe('parseSessionWakeupSchedule', () => { expect(parsed.schedule).toEqual({ mode: 'once', at: '2026-09-04T17:02:00.000Z', + inMinutes: 2, }); expect(parsed.maxRuns).toBeNull(); expect(parsed.until).toBeNull(); @@ -34,6 +35,28 @@ describe('parseSessionWakeupSchedule', () => { ).toBe('2026-09-04T19:00:00.000Z'); }); + it('keeps relative identity across changed clocks and equivalent units', () => { + const first = parseSessionWakeupSchedule('in 1h', options); + const retry = parseSessionWakeupSchedule('in 60m', { + ...options, + now: new Date(now.getTime() + 5_000), + }); + expect(first.schedule).toEqual({ + mode: 'once', + inMinutes: 60, + at: first.firstRunAt.toISOString(), + }); + expect(retry.schedule).toEqual({ + mode: 'once', + inMinutes: 60, + at: retry.firstRunAt.toISOString(), + }); + expect(retry.firstRunAt.getTime() - first.firstRunAt.getTime()).toBe(5_000); + expect( + parseSessionWakeupSchedule('at 2026-09-04T18:00:00Z', options).schedule, + ).toEqual({ mode: 'once', at: first.firstRunAt.toISOString() }); + }); + it('reads intervals with run counts and end times in either order', () => { const plain = parseSessionWakeupSchedule('every 10m', options); expect(plain.schedule).toEqual({ mode: 'interval', everyMinutes: 10 }); diff --git a/packages/cloud-agents/src/server/session-wakeups/schedule.test.ts b/packages/cloud-agents/src/server/session-wakeups/schedule.test.ts index 798798173..4dd45aadf 100644 --- a/packages/cloud-agents/src/server/session-wakeups/schedule.test.ts +++ b/packages/cloud-agents/src/server/session-wakeups/schedule.test.ts @@ -22,6 +22,7 @@ describe('normalizeSessionWakeupSchedule', () => { expect(result.schedule).toEqual({ mode: 'once', at: '2026-09-04T17:20:00.000Z', + inMinutes: 20, }); }); diff --git a/packages/cloud-agents/src/server/session-wakeups/schedule.ts b/packages/cloud-agents/src/server/session-wakeups/schedule.ts index 1e94a1e26..d5336fbc0 100644 --- a/packages/cloud-agents/src/server/session-wakeups/schedule.ts +++ b/packages/cloud-agents/src/server/session-wakeups/schedule.ts @@ -105,7 +105,11 @@ export function normalizeSessionWakeupSchedule( ); } return { - schedule: { mode: 'once', at: at.toISOString() }, + schedule: { + mode: 'once', + at: at.toISOString(), + ...(hasDelay ? { inMinutes: input.inMinutes } : {}), + }, firstRunAt: at, }; } diff --git a/packages/db/src/lib/__tests__/session-wakeups.test.ts b/packages/db/src/lib/__tests__/session-wakeups.test.ts index 805055301..6cd48a8fb 100644 --- a/packages/db/src/lib/__tests__/session-wakeups.test.ts +++ b/packages/db/src/lib/__tests__/session-wakeups.test.ts @@ -64,6 +64,77 @@ afterEach(async () => { }); describe('session wakeup helpers', () => { + it('deduplicates relative retries with changed resolved times, not distinct delays or prompts', async () => { + const { user, conversation } = await makeConversation(); + const input = { + conversationId: conversation.id, + createdByUserId: user.id, + name: 'Reminder', + prompt: 'Check the deploy.', + schedule: { + mode: 'once' as const, + at: firstRunAt.toISOString(), + inMinutes: 2, + }, + reportPolicy: 'always' as const, + maxRuns: null, + until: null, + nextRunAt: firstRunAt, + }; + const first = await admitSessionWakeup(input); + expect(first.outcome).toBe('created'); + const later = new Date(firstRunAt.getTime() + 5_000); + const retry = { + ...input, + prompt: ' CHECK THE DEPLOY. ', + schedule: { ...input.schedule, at: later.toISOString() }, + nextRunAt: later, + }; + expect(await admitSessionWakeup(retry)).toEqual({ + ...first, + outcome: 'duplicate', + }); + const [persisted] = await listSessionWakeups(conversation.id); + expect(persisted!.schedule).toEqual(input.schedule); + expect(persisted!.nextRunAt).toEqual(firstRunAt); + expect( + await admitSessionWakeup({ + ...retry, + schedule: { ...retry.schedule, inMinutes: 3 }, + }), + ).toMatchObject({ outcome: 'created' }); + expect( + await admitSessionWakeup({ ...retry, prompt: 'Check another deploy.' }), + ).toMatchObject({ outcome: 'created' }); + expect(await listSessionWakeups(conversation.id)).toHaveLength(3); + }); + + it('does not infer relative identity from legacy or absolute one-shots', async () => { + const { user, conversation } = await makeConversation(); + const legacy = await makeWakeup(conversation.id, user.id, { + schedule: { mode: 'once', at: firstRunAt.toISOString() }, + }); + const input = { ...legacy, nextRunAt: firstRunAt }; + expect(await admitSessionWakeup(input)).toMatchObject({ + outcome: 'duplicate', + wakeup: { id: legacy.id }, + }); + expect( + await admitSessionWakeup({ + ...input, + schedule: { mode: 'once', at: firstRunAt.toISOString(), inMinutes: 2 }, + }), + ).toMatchObject({ outcome: 'created' }); + const later = new Date(firstRunAt.getTime() + 5_000); + expect( + await admitSessionWakeup({ + ...input, + schedule: { mode: 'once', at: later.toISOString() }, + nextRunAt: later, + }), + ).toMatchObject({ outcome: 'created' }); + }); + it.each([ { mode: 'once' as const, at: firstRunAt.toISOString() }, { mode: 'cron' as const, expression: '*/10 * * * *', timezone: 'UTC' }, diff --git a/packages/db/src/lib/session-wakeups.ts b/packages/db/src/lib/session-wakeups.ts index 15b0c2111..6fce84958 100644 --- a/packages/db/src/lib/session-wakeups.ts +++ b/packages/db/src/lib/session-wakeups.ts @@ -128,7 +128,12 @@ export async function admitSessionWakeup( const existing = active.find( (row) => row.promptSignature === promptSignature && - isDeepStrictEqual(row.schedule, input.schedule), + (row.schedule.mode === 'once' && + input.schedule.mode === 'once' && + row.schedule.inMinutes !== undefined && + input.schedule.inMinutes !== undefined + ? row.schedule.inMinutes === input.schedule.inMinutes + : isDeepStrictEqual(row.schedule, input.schedule)), ); if (existing) return { outcome: 'duplicate', wakeup: existing }; if (active.length >= MAX_ACTIVE_SESSION_WAKEUPS) { diff --git a/packages/types/src/session-wakeups.test.ts b/packages/types/src/session-wakeups.test.ts index b6bd4b954..7c98901c8 100644 --- a/packages/types/src/session-wakeups.test.ts +++ b/packages/types/src/session-wakeups.test.ts @@ -9,9 +9,23 @@ import { MANAGE_WAKEUPS_TOOL, fastAgentScheduledWakeupEventSchema, manageWakeupsInputSchema, + sessionWakeupScheduleSchema, } from './session-wakeups'; describe('manage wakeups tool contract', () => { + it('accepts optional persisted relative identity without requiring it on legacy rows', () => { + const absolute = { mode: 'once', at: '2026-09-04T17:02:00.000Z' }; + expect(sessionWakeupScheduleSchema.parse(absolute)).toEqual(absolute); + expect( + sessionWakeupScheduleSchema.parse({ ...absolute, inMinutes: 2 }), + ).toEqual({ ...absolute, inMinutes: 2 }); + for (const inMinutes of [0, -1, 1.5, 43_201, '2', null]) { + expect( + sessionWakeupScheduleSchema.safeParse({ ...absolute, inMinutes }) + .success, + ).toBe(false); + } + }); it('keeps every supported action in the shared Zod schema', () => { for (const action of MANAGE_WAKEUPS_ACTIONS) { expect(manageWakeupsInputSchema.parse({ action })).toEqual({ action }); diff --git a/packages/types/src/session-wakeups.ts b/packages/types/src/session-wakeups.ts index e216ad6ef..9546409c2 100644 --- a/packages/types/src/session-wakeups.ts +++ b/packages/types/src/session-wakeups.ts @@ -52,7 +52,19 @@ export type SessionWakeupReportPolicy = /** The normalized schedule persisted with a wakeup. */ export const sessionWakeupScheduleSchema = z.discriminatedUnion('mode', [ - z.object({ mode: z.literal('once'), at: z.string() }).strict(), + z + .object({ + mode: z.literal('once'), + at: z.string(), + // Stable relative identity for retries; at remains the firing time. + inMinutes: z + .number() + .int() + .positive() + .max(SESSION_WAKEUP_MAX_ONCE_HORIZON_MINUTES) + .optional(), + }) + .strict(), z .object({ mode: z.literal('interval'), everyMinutes: z.number().int() }) .strict(), From e92ccc18734037f93bb8e941f27349ecb3d9bf4e Mon Sep 17 00:00:00 2001 From: "@daniel-lxs" <57051444+daniel-lxs@users.noreply.github.com> Date: Sat, 5 Sep 2026 16:54:44 +0000 Subject: [PATCH 11/11] fix: serialize session archival with Fast reply delivery --- .../trpc/commands/sessions/archive.test.ts | 232 ++++++++++++++++++ apps/web/src/trpc/commands/sessions/index.ts | 71 ++++-- 2 files changed, 287 insertions(+), 16 deletions(-) create mode 100644 apps/web/src/trpc/commands/sessions/archive.test.ts diff --git a/apps/web/src/trpc/commands/sessions/archive.test.ts b/apps/web/src/trpc/commands/sessions/archive.test.ts new file mode 100644 index 000000000..34b819b4a --- /dev/null +++ b/apps/web/src/trpc/commands/sessions/archive.test.ts @@ -0,0 +1,232 @@ +import { + db, + fastAgentConversations, + sessionFactory, + userFactory, +} from '@roomote/db/server'; +import { + acquireFastAgentTurnLock, + fastAgentConversationRepository, +} from '@roomote/cloud-agents/server'; +import * as sessionQueries from '@/lib/server/sessions'; +import type { UserAuthSuccess } from '@/types'; +import { archiveSessionCommand } from './index'; + +const mocks = vi.hoisted(() => ({ + acquireRedisLock: vi.fn(), + cancelWakeups: vi.fn(), +})); + +vi.mock('@roomote/redis', async (importOriginal) => ({ + ...(await importOriginal()), + acquireRedisLock: mocks.acquireRedisLock, +})); +vi.mock('@roomote/db/server', async (importOriginal) => ({ + ...(await importOriginal()), + cancelSessionWakeupsForConversation: mocks.cancelWakeups, +})); +vi.mock('@roomote/sdk/server', () => ({ + syncFastAgentSlackTitleBestEffort: vi.fn(), +})); +vi.mock('@roomote/telemetry/server', () => ({ captureEvent: vi.fn() })); + +describe('archiveSessionCommand turn serialization', () => { + const locks = new Set(); + let contention: ReturnType>; + + beforeEach(() => { + locks.clear(); + contention = Promise.withResolvers(); + mocks.cancelWakeups.mockReset().mockResolvedValue(0); + mocks.acquireRedisLock + .mockReset() + .mockImplementation(async (key: string) => { + if (locks.has(key)) { + contention.resolve(); + return null; + } + locks.add(key); + return Object.assign( + async () => { + locks.delete(key); + }, + { + renewDetailed: async () => 'renewed', + }, + ); + }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + expect(locks.size).toBe(0); + }); + + async function fixture(fast = true) { + const owner = await userFactory.create(); + const [record] = fast + ? await db + .insert(fastAgentConversations) + .values({ + userId: owner.id, + surface: 'web', + workspaceId: owner.id, + conversationId: crypto.randomUUID(), + }) + .returning() + : []; + const session = await sessionFactory.create({ + ownerKind: 'user', + ownerUserId: owner.id, + fastConversationId: record?.id ?? null, + }); + const auth = { userId: owner.id, isAdmin: false } as UserAuthSuccess; + return { auth, session, record }; + } + + it('cannot commit archive while reply is in flight, then archives after the turn releases', async () => { + const { auth, session, record } = await fixture(); + const conversation = await fastAgentConversationRepository.findById({ + id: record!.id, + }); + vi.useFakeTimers({ + toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'], + }); + const turn = await acquireFastAgentTurnLock({ + conversation: conversation!.conversation, + }); + const reply = Promise.withResolvers(); + const replyFinished = reply.promise.finally(() => turn!()); + const archive = archiveSessionCommand(auth, session.id); + await contention.promise; + expect( + (await sessionQueries.findAccessibleSession(auth, session.id)) + ?.archivedAt, + ).toBeNull(); + expect(mocks.cancelWakeups).not.toHaveBeenCalled(); + reply.resolve(); + await replyFinished; + await vi.advanceTimersByTimeAsync(500); + expect(await archive).toMatchObject({ + id: session.id, + archivedAt: expect.any(Date), + }); + expect( + (await sessionQueries.findAccessibleSession(auth, session.id)) + ?.archivedAt, + ).toBeInstanceOf(Date); + expect(mocks.cancelWakeups).toHaveBeenCalledWith(record!.id); + }); + + it('fails retryably without archival when the turn stays busy', async () => { + const { auth, session, record } = await fixture(); + const conversation = await fastAgentConversationRepository.findById({ + id: record!.id, + }); + vi.useFakeTimers({ + toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'], + }); + const turn = await acquireFastAgentTurnLock({ + conversation: conversation!.conversation, + }); + try { + const result = expect( + archiveSessionCommand(auth, session.id), + ).rejects.toMatchObject({ code: 'CONFLICT' }); + await contention.promise; + await vi.advanceTimersByTimeAsync(2_000); + await result; + expect( + (await sessionQueries.findAccessibleSession(auth, session.id)) + ?.archivedAt, + ).toBeNull(); + expect(mocks.cancelWakeups).not.toHaveBeenCalled(); + } finally { + await turn!(); + } + }); + + it('releases the lock when the metadata update fails', async () => { + const { auth, session } = await fixture(); + vi.spyOn(sessionQueries, 'updateSessionMetadata').mockRejectedValueOnce( + new Error('database unavailable'), + ); + await expect(archiveSessionCommand(auth, session.id)).rejects.toThrow( + 'database unavailable', + ); + expect( + (await sessionQueries.findAccessibleSession(auth, session.id)) + ?.archivedAt, + ).toBeNull(); + expect(locks.size).toBe(0); + expect(await archiveSessionCommand(auth, session.id)).toMatchObject({ + archivedAt: expect.any(Date), + }); + }); + + it('does not archive when lock acquisition fails', async () => { + const { auth, session } = await fixture(); + mocks.acquireRedisLock.mockRejectedValueOnce( + new Error('redis unavailable'), + ); + await expect(archiveSessionCommand(auth, session.id)).rejects.toThrow( + 'redis unavailable', + ); + expect( + (await sessionQueries.findAccessibleSession(auth, session.id)) + ?.archivedAt, + ).toBeNull(); + expect(mocks.cancelWakeups).not.toHaveBeenCalled(); + }); + + it('releases the lock and preserves archival if wakeup cancellation fails', async () => { + const { auth, session } = await fixture(); + mocks.cancelWakeups.mockRejectedValueOnce(new Error('cancel failed')); + vi.spyOn(console, 'error').mockImplementation(() => {}); + expect(await archiveSessionCommand(auth, session.id)).toMatchObject({ + archivedAt: expect.any(Date), + }); + }); + + it('checks archive permission before conversation lookup or locking and preserves missing behavior', async () => { + const { auth, session } = await fixture(); + const stranger = await userFactory.create(); + const lookup = vi.spyOn(fastAgentConversationRepository, 'findById'); + expect( + await archiveSessionCommand({ ...auth, userId: stranger.id }, session.id), + ).toBeNull(); + expect(await archiveSessionCommand(auth, crypto.randomUUID())).toBeNull(); + expect(lookup).not.toHaveBeenCalled(); + expect(mocks.acquireRedisLock).not.toHaveBeenCalled(); + expect( + await archiveSessionCommand( + { ...auth, userId: stranger.id, isAdmin: true }, + session.id, + ), + ).toMatchObject({ archivedAt: expect.any(Date) }); + }); + + it('archives task-only sessions without a turn lock', async () => { + const { auth, session } = await fixture(false); + expect(await archiveSessionCommand(auth, session.id)).toMatchObject({ + archivedAt: expect.any(Date), + }); + expect(mocks.acquireRedisLock).not.toHaveBeenCalled(); + }); + + it('fails closed if the conversation cannot be resolved', async () => { + const { auth, session } = await fixture(); + vi.spyOn(fastAgentConversationRepository, 'findById').mockResolvedValueOnce( + null, + ); + await expect(archiveSessionCommand(auth, session.id)).rejects.toMatchObject( + { code: 'CONFLICT' }, + ); + expect( + (await sessionQueries.findAccessibleSession(auth, session.id)) + ?.archivedAt, + ).toBeNull(); + expect(mocks.acquireRedisLock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/trpc/commands/sessions/index.ts b/apps/web/src/trpc/commands/sessions/index.ts index 3aef47429..1bee6bb66 100644 --- a/apps/web/src/trpc/commands/sessions/index.ts +++ b/apps/web/src/trpc/commands/sessions/index.ts @@ -1,4 +1,9 @@ import { z } from 'zod'; +import { TRPCError } from '@trpc/server'; +import { + acquireFastAgentTurnLock, + fastAgentConversationRepository, +} from '@roomote/cloud-agents/server'; import { SESSION_STATUSES } from '@roomote/types'; import { advanceSessionReadCursor, @@ -131,26 +136,60 @@ export async function archiveSessionCommand( auth: UserAuthSuccess, sessionId: string, ) { - const archived = await updateSessionMetadata(auth, sessionId, { - archivedAt: new Date(), - }); - if (archived) { - if (archived.fastConversationId) { - // An archived session must not wake itself up later. - await cancelSessionWakeupsForConversation( - archived.fastConversationId, - ).catch((error) => { - console.error( - `[sessions] Failed to cancel wakeups for archived session ${sessionId}: ${error instanceof Error ? error.message : String(error)}`, - ); + const session = await findAccessibleSession(auth, sessionId); + if (!session || (!auth.isAdmin && session.ownerUserId !== auth.userId)) { + return null; + } + + let releaseTurnLock; + if (session.fastConversationId) { + const record = await fastAgentConversationRepository.findById({ + id: session.fastConversationId, + }); + if (!record) { + throw new TRPCError({ + code: 'CONFLICT', + message: 'Session is unavailable. Please try archiving again.', }); } - void captureEvent('session_archived', { - userId: auth.userId, - properties: { surface: 'web', outcome: 'archived' }, + // Serialize with reply delivery, without holding a DB transaction over I/O. + releaseTurnLock = await acquireFastAgentTurnLock({ + conversation: record.conversation, + maxWaitMs: 2_000, }); + if (!releaseTurnLock) { + throw new TRPCError({ + code: 'CONFLICT', + message: 'Session is busy. Please try archiving again shortly.', + }); + } + } + + try { + releaseTurnLock?.signal.throwIfAborted(); + const archived = await updateSessionMetadata(auth, sessionId, { + archivedAt: new Date(), + }); + if (archived) { + if (archived.fastConversationId) { + // An archived session must not wake itself up later. + await cancelSessionWakeupsForConversation( + archived.fastConversationId, + ).catch((error) => { + console.error( + `[sessions] Failed to cancel wakeups for archived session ${sessionId}: ${error instanceof Error ? error.message : String(error)}`, + ); + }); + } + void captureEvent('session_archived', { + userId: auth.userId, + properties: { surface: 'web', outcome: 'archived' }, + }); + } + return archived; + } finally { + await releaseTurnLock?.(); } - return archived; } export {